Skip to content

Non-Copilot stdio/container MCP server env secrets are raw-substituted into gateway JSON config, breaking on quote/backslash characters #49424

Description

@yskopets

🤖 This issue has been generated by Claude Code.

Summary

For non-Copilot engines (Claude, Codex, etc.), a secret placed in a stdio/container-type custom MCP server's env: block is substituted into the MCP Gateway JSON config via raw, unescaped bash variable expansion inside an unquoted heredoc. If the secret's value contains a " or \ character, the substitution corrupts the JSON and the Start MCP Gateway step fails before the agent ever runs:

ERROR: Configuration is not valid JSON
JSON validation error:
Expected ',' or '}' after property value in JSON at position 464 (line 16 column 48)

This reproduced in a real run on the Claude engine, gh-aw v0.80.9, with a stdio/container custom MCP server whose env: referenced a repo secret containing a " character.

Root cause

pkg/workflow/secret_extraction.go:

// ReplaceSecretsWithBashVars replaces secret expressions in a value with bash env var references.
// Example: "${{ secrets.DD_API_KEY }}" -> "${DD_API_KEY}"
// Unlike ReplaceSecretsWithEnvVars, this does NOT add a backslash prefix, so bash expands
// the variable at runtime. Used for non-Copilot MCP server env blocks where the step env block
// already holds the corresponding env vars (injected by collectMCPEnvironmentVariables),
// preventing direct secret interpolation in run blocks (RGS-008 compliance).
func ReplaceSecretsWithBashVars(value string) string {
	result := value
	secrets := ExtractSecretsFromValue(value)
	for _, varName := range sliceutil.SortedKeys(secrets) {
		secretExpr := secrets[varName]
		result = strings.ReplaceAll(result, secretExpr, "${"+varName+"}")
	}
	return result
}

pkg/workflow/mcp_config_custom.go calls this for non-Copilot engines:

} else {
    // For non-Copilot engines, replace secrets with ${VAR} bash expansion so
    // they are never directly interpolated in the run block (RGS-008). The
    // env vars are injected into the step env block by collectMCPEnvironmentVariables.
    envValue = ReplaceSecretsWithBashVars(envValue)
}

The resulting "KEY": "${SECRET}" is written inside an unquoted heredoc:

cat << GH_AW_MCP_CONFIG_xxx_EOF | "$GH_AW_NODE" ".../start_mcp_gateway.cjs"
{
  ...
  "prometheus-mcp-qa": {
    "type": "stdio",
    "container": "ghcr.io/pab1it0/prometheus-mcp-server:latest",
    "env": {
      "PROMETHEUS_PASSWORD": "${PROMETHEUS_QA_PASSWORD}",
      "PROMETHEUS_USERNAME": "${PROMETHEUS_QA_USERNAME}"
    }
  }
  ...
}
GH_AW_MCP_CONFIG_xxx_EOF

Because the delimiter is unquoted, bash performs full parameter expansion on the whole block before it reaches start_mcp_gateway.cjs, splicing the raw secret bytes directly into JSON text with no JSON-string escaping. A password/token containing ", \, or a control character breaks the JSON outright.

Compare: the already-correct pattern used elsewhere in the very same generated file

renderMCPEnvMap in mcp_config_custom.go backslash-escapes (\${VAR}) any env var that's also present in headerSecrets (i.e. used in an HTTP-type MCP server's Authorization header), and ReplaceSecretsWithEnvVars does the same for Copilot generally:

for varName := range headerSecrets {
    if _, exists := renderedEnv[varName]; !exists {
        renderedEnv[varName] = "\\${" + varName + "}"
    }
}

\${VAR} survives the unquoted heredoc as the literal, valid-JSON string ${VAR} (bash only strips the backslash), and the actual value substitution happens safely downstream, after the config is already valid JSON. This is exactly the mechanism needed to avoid the corruption — it's just not applied to plain stdio/container env: secrets on non-Copilot engines.

In our failing run's own generated lock file, this inconsistency is directly visible: prometheus-mcp-qa (stdio/container, env-only secret) is unescaped and broken, while safeoutputs (also stdio/container) and tsb-mcp-qa (http, secret also used in a header) are backslash-escaped and fine — same file, same compiler run, different code paths.

Reproduction

---
on:
  workflow_dispatch:
engine: claude
permissions:
  contents: read
mcp-servers:
  my-server:
    type: stdio
    container: "some/image:latest"
    env:
      MY_TOKEN: "${{ secrets.MY_TOKEN }}"
---

# Repro
Do nothing.
  1. Set repo secret MY_TOKEN to a value containing a " (e.g. ab"cd).
  2. gh aw compile, then run the workflow with engine: claude (or any non-Copilot engine).
  3. Start MCP Gateway fails with ERROR: Configuration is not valid JSON before the agent starts — silently, for any secret rotation that happens to introduce a quote/backslash.

Suggested fix

Apply the same \${VAR} passthrough already used for header secrets / Copilot to all secret substitutions in stdio/container env: blocks, regardless of engine — i.e. have renderCustomMCPEnvVars's non-Copilot branch call the backslash-escaping path (ReplaceSecretsWithEnvVars-style) instead of ReplaceSecretsWithBashVars. This satisfies the RGS-008 concern just as well (no literal secret text is embedded in the run block either way — only the token name is) while producing JSON that's valid regardless of what characters the secret contains.

Impact

Any Claude/Codex/etc. (non-Copilot) workflow with a stdio/container-type custom MCP server that reads a secret via env: is exposed to a full job failure (agent never starts) triggered purely by a secret rotation, with no compiler-time warning and a cryptic runtime JSON-parse error that gives no indication which secret is at fault.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions