Summary
pkg/workflow/mcp_setup_generator.go:258 (generateSafeOutputsSetup) constructs the runtime safeoutputs/config.json by:
- Extracting
${{ secrets.* }}, ${{ github.* }}, and (since #30878) ${{ inputs.* }} from the config string into a step env: block.
- String-replacing each expression in the JSON template with
${VARNAME}.
- Writing the template via an unquoted heredoc so bash expands those shell variables at write time.
This is the GitHub Security Lab env-var indirection pattern applied correctly for the shell threat model — the shell never parses attacker-controlled bytes as code. But the destination of the heredoc is not the shell; it is a JSON file consumed by downstream steps. Bash ${VAR} expansion is byte-substitution, not JSON encoding, so attacker-controlled " and other JSON-special characters land verbatim inside a JSON string literal. The JSON parser is the new injection target, with the same root cause as the original CWE-94 pattern but at a different boundary.
The existing regression coverage at pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76 asserts the env-var/heredoc shape but never probes with ", so this case is uncovered.
Affected source
- Compiler emitter:
pkg/workflow/mcp_setup_generator.go:258-322 — generateSafeOutputsSetup
- Sibling emitter, same class:
pkg/workflow/mcp_setup_generator.go:345-358 — GH_AW_TOOLS_META_JSON block-scalar construction
- Run-step sanitizer (correct for its stated threat model, scope clarification recommended):
pkg/workflow/run_step_sanitizer.go:38-43, 92
- Incomplete regression coverage:
pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76
Threat model
The class of bug applies wherever the compiler interpolates a ${{ … }} expression into a structured-data literal:
${{ inputs.* }} for workflow_dispatch / workflow_call inputs (requires repo write to exploit)
${{ vars.* }} for repo/org variables (broader trust gradient — org admins set, many workflows consume)
${{ github.event.* }} if surfaced into safe-outputs config
The compiler cannot determine the trust level of inputs.* or vars.* at compile time because that depends on the workflow's trigger configuration. Safe default is to treat all templated values as untrusted at the data-format boundary.
Why the existing sanitizer does not catch this
run_step_sanitizer.go correctly extracts ${{ … }} from run: bodies and explicitly skips heredoc content on the documented basis (lines 38-43) that heredoc bodies are not executed as shell code. That reasoning is sound for the shell threat model. It does not generalize to the data-format threat model. The sanitizer's docstring should also be updated to scope its claim explicitly to shell injection.
Suggested remediation
JSON-encode templated values at the data-format boundary rather than relying on bash byte-substitution. Options, in order of preference:
- Build the JSON via
jq with --arg, which produces a properly-encoded JSON string regardless of input bytes:
env:
GH_AW_INPUT_TITLE_PREFIX: ${{ inputs.title_prefix }}
run: |
jq -n --arg title_prefix "$GH_AW_INPUT_TITLE_PREFIX" \
'{create_issue: {labels:["triage"], max:5, title_prefix:$title_prefix}, ...}' \
> "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- Construct the JSON in Go for the static portion with placeholders; merge templated values at runtime via
jq rather than shell-substituting them in.
- Less attractive: keep current shape but pre-encode each env var as a JSON string before bash expansion (e.g. a
printf '%s' "$VAR" | jq -Rs . pass). Adds a parser-aware layer but is harder to keep correct than handing off to jq upstream.
Same fix shape applies to GH_AW_TOOLS_META_JSON (mcp_setup_generator.go:346-350) — build the meta via json.Marshal in Go with templated values pulled from env at runtime, rather than emitting a templated YAML block-scalar.
Add regression coverage in safe_outputs_dynamic_allowed_repos_test.go that compiles a workflow with a "-containing input value and asserts the resulting config.json parses back to a structure where the input value occupies exactly one JSON string (i.e., no key/value injection).
References
Proof-of-concept (PoC field — separate)
Setup
Author this workflow as .github/workflows/repro.md:
---
on:
workflow_dispatch:
inputs:
title_prefix:
default: "PME "
safe-outputs:
create-issue:
title-prefix: ${{ inputs.title_prefix }}
labels: [triage]
max: 5
---
Test workflow body.
Compile: gh aw compile repro. The emitted Generate Safe Outputs Config step in repro.lock.yml:
- name: Generate Safe Outputs Config
env:
GH_AW_INPUT_TITLE_PREFIX: ${{ inputs.title_prefix }}
run: |
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << GH_AW_SAFE_OUTPUTS_CONFIG_<hash>_EOF
{"create_issue":{"labels":["triage"],"max":5,"title_prefix":"${GH_AW_INPUT_TITLE_PREFIX}"}, ...}
GH_AW_SAFE_OUTPUTS_CONFIG_<hash>_EOF
Exploit
Dispatch with title_prefix = evil","max":9999,"extra":"x
Result
Actions correctly sets GH_AW_INPUT_TITLE_PREFIX=evil","max":9999,"extra":"x as an env value (no shell parsing). Bash then expands ${GH_AW_INPUT_TITLE_PREFIX} byte-for-byte into the heredoc body, writing:
{"create_issue":{"labels":["triage"],"max":5,"title_prefix":"evil","max":9999,"extra":"x"}, ...}
The downstream consumer parses the corrupted structure — max is now 9999 (vs the compile-time intent of 5), and extra is an attacker-injected key. With more careful crafting, allowed, labels, or any other safe-outputs config field can be overridden.
The same payload shape works for the target_repo / base_branch paths covered by safe_outputs_dynamic_allowed_repos_test.go. Any safe-outputs string field templated from ${{ inputs.* }}, ${{ vars.* }}, or any other ${{ … }} source whose value reaches the JSON literal via bash ${VAR} expansion is vulnerable.
Impact (Impact field — what an attacker can do)
An actor able to set the input value can corrupt the safe-outputs runtime config:
- Raise
max rate limits, bypassing the agent's compile-time safe-output quotas (could enable mass-issue creation, mass-labeling, etc.)
- Add values to
allowed lists for labels / repos / base branches
- Inject fields the compiler intended to be statically constrained
This subverts the safety guarantees that safe-outputs is meant to enforce on the agent's behalf. Exploitation does not yield RCE on the runner — the shell is correctly defended by the existing env-var indirection — but it does break the compile-time-trusted constraints that gate what the agent's outputs can do.
Threshold today: workflow_dispatch rights (repo write) for the inputs.* path. Lower for vars.* consumers, since org/repo variables can be set by different actors than workflow dispatchers.
The structural concern is that the compiler treats env-var indirection as sufficient when the destination is structured data. That assumption will quietly extend to any future feature that templates user-controlled values into a JSON / YAML literal.
Summary
pkg/workflow/mcp_setup_generator.go:258(generateSafeOutputsSetup) constructs the runtimesafeoutputs/config.jsonby:${{ secrets.* }},${{ github.* }}, and (since #30878)${{ inputs.* }}from the config string into a stepenv:block.${VARNAME}.This is the GitHub Security Lab env-var indirection pattern applied correctly for the shell threat model — the shell never parses attacker-controlled bytes as code. But the destination of the heredoc is not the shell; it is a JSON file consumed by downstream steps. Bash
${VAR}expansion is byte-substitution, not JSON encoding, so attacker-controlled"and other JSON-special characters land verbatim inside a JSON string literal. The JSON parser is the new injection target, with the same root cause as the original CWE-94 pattern but at a different boundary.The existing regression coverage at
pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76asserts the env-var/heredoc shape but never probes with", so this case is uncovered.Affected source
pkg/workflow/mcp_setup_generator.go:258-322—generateSafeOutputsSetuppkg/workflow/mcp_setup_generator.go:345-358—GH_AW_TOOLS_META_JSONblock-scalar constructionpkg/workflow/run_step_sanitizer.go:38-43, 92pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76Threat model
The class of bug applies wherever the compiler interpolates a
${{ … }}expression into a structured-data literal:${{ inputs.* }}forworkflow_dispatch/workflow_callinputs (requires repo write to exploit)${{ vars.* }}for repo/org variables (broader trust gradient — org admins set, many workflows consume)${{ github.event.* }}if surfaced into safe-outputs configThe compiler cannot determine the trust level of
inputs.*orvars.*at compile time because that depends on the workflow's trigger configuration. Safe default is to treat all templated values as untrusted at the data-format boundary.Why the existing sanitizer does not catch this
run_step_sanitizer.gocorrectly extracts${{ … }}fromrun:bodies and explicitly skips heredoc content on the documented basis (lines 38-43) that heredoc bodies are not executed as shell code. That reasoning is sound for the shell threat model. It does not generalize to the data-format threat model. The sanitizer's docstring should also be updated to scope its claim explicitly to shell injection.Suggested remediation
JSON-encode templated values at the data-format boundary rather than relying on bash byte-substitution. Options, in order of preference:
jqwith--arg, which produces a properly-encoded JSON string regardless of input bytes:jqrather than shell-substituting them in.printf '%s' "$VAR" | jq -Rs .pass). Adds a parser-aware layer but is harder to keep correct than handing off tojqupstream.Same fix shape applies to
GH_AW_TOOLS_META_JSON(mcp_setup_generator.go:346-350) — build the meta viajson.Marshalin Go with templated values pulled from env at runtime, rather than emitting a templated YAML block-scalar.Add regression coverage in
safe_outputs_dynamic_allowed_repos_test.gothat compiles a workflow with a"-containing input value and asserts the resultingconfig.jsonparses back to a structure where the input value occupies exactly one JSON string (i.e., no key/value injection).References
Proof-of-concept (PoC field — separate)
Setup
Author this workflow as
.github/workflows/repro.md:Compile:
gh aw compile repro. The emittedGenerate Safe Outputs Configstep inrepro.lock.yml:Exploit
Dispatch with
title_prefix=evil","max":9999,"extra":"xResult
Actions correctly sets
GH_AW_INPUT_TITLE_PREFIX=evil","max":9999,"extra":"xas an env value (no shell parsing). Bash then expands${GH_AW_INPUT_TITLE_PREFIX}byte-for-byte into the heredoc body, writing:{"create_issue":{"labels":["triage"],"max":5,"title_prefix":"evil","max":9999,"extra":"x"}, ...}The downstream consumer parses the corrupted structure —
maxis now9999(vs the compile-time intent of5), andextrais an attacker-injected key. With more careful crafting,allowed,labels, or any other safe-outputs config field can be overridden.The same payload shape works for the
target_repo/base_branchpaths covered bysafe_outputs_dynamic_allowed_repos_test.go. Any safe-outputs string field templated from${{ inputs.* }},${{ vars.* }}, or any other${{ … }}source whose value reaches the JSON literal via bash${VAR}expansion is vulnerable.Impact (Impact field — what an attacker can do)
An actor able to set the input value can corrupt the safe-outputs runtime config:
maxrate limits, bypassing the agent's compile-time safe-output quotas (could enable mass-issue creation, mass-labeling, etc.)allowedlists for labels / repos / base branchesThis subverts the safety guarantees that safe-outputs is meant to enforce on the agent's behalf. Exploitation does not yield RCE on the runner — the shell is correctly defended by the existing env-var indirection — but it does break the compile-time-trusted constraints that gate what the agent's outputs can do.
Threshold today:
workflow_dispatchrights (repo write) for theinputs.*path. Lower forvars.*consumers, since org/repo variables can be set by different actors than workflow dispatchers.The structural concern is that the compiler treats env-var indirection as sufficient when the destination is structured data. That assumption will quietly extend to any future feature that templates user-controlled values into a JSON / YAML literal.