🤖 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.
- Set repo secret
MY_TOKEN to a value containing a " (e.g. ab"cd).
gh aw compile, then run the workflow with engine: claude (or any non-Copilot engine).
- 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.
🤖 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:This reproduced in a real run on the Claude engine,
gh-aw v0.80.9, with a stdio/container custom MCP server whoseenv:referenced a repo secret containing a"character.Root cause
pkg/workflow/secret_extraction.go:pkg/workflow/mcp_config_custom.gocalls this for non-Copilot engines:The resulting
"KEY": "${SECRET}"is written inside an unquoted heredoc: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
renderMCPEnvMapinmcp_config_custom.gobackslash-escapes (\${VAR}) any env var that's also present inheaderSecrets(i.e. used in an HTTP-type MCP server'sAuthorizationheader), andReplaceSecretsWithEnvVarsdoes the same for Copilot generally:\${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/containerenv: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, whilesafeoutputs(also stdio/container) andtsb-mcp-qa(http, secret also used in a header) are backslash-escaped and fine — same file, same compiler run, different code paths.Reproduction
MY_TOKENto a value containing a"(e.g.ab"cd).gh aw compile, then run the workflow withengine: claude(or any non-Copilot engine).ERROR: Configuration is not valid JSONbefore 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/containerenv:blocks, regardless of engine — i.e. haverenderCustomMCPEnvVars's non-Copilot branch call the backslash-escaping path (ReplaceSecretsWithEnvVars-style) instead ofReplaceSecretsWithBashVars. 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.