You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The compiler embeds user-authored Markdown prompt content into a bash heredoc using a static, predictable delimiter (GH_AW_PROMPT_EOF). If a workflow .md file contains a line matching that delimiter, the heredoc closes early and subsequent lines execute as shell commands with workflow-token privileges.
Because .lock.yml files are marked linguist-generated=true in .gitattributes, GitHub collapses their diff by default in PR review. This converts a low-scrutiny "edit a Markdown prompt" PR into arbitrary shell execution.
Upstream tracking: github/agentic-workflows#181 Severity: High — code-review bypass → RCE in the workflow runner
The delimiter is fully predictable from the name parameter. An attacker who knows the name (e.g., "PROMPT" → GH_AW_PROMPT_EOF) can embed that string in their .md prompt to close the heredoc and inject shell commands.
The YAML run: | block-scalar strips common leading indentation before bash sees the script. The user's line and the closing delimiter have identical indentation, so after stripping, both appear at column 0 and bash treats the injected line as the heredoc terminator.
Because delimiters are now random, each compilation produces different output even if nothing meaningful changed. The compiler's skip-write optimization (which avoids rewriting .lock.yml when content is unchanged) must normalize delimiters before comparing:
Use normalizeHeredocDelimiters() in writeWorkflowOutput() when comparing existing vs new content.
3. Update tests
All tests that check for exact heredoc delimiter strings need updating to use regex matching:
// Before (exact match — will break):assert.Contains(t, output, "GH_AW_PROMPT_EOF")
// After (regex match — works with randomized delimiters):delimRE:=regexp.MustCompile(`GH_AW_PROMPT_[0-9a-f]{16}_EOF`)
assert.True(t, delimRE.MatchString(output))
pkg/workflow/wasm_golden_test.go — normalize before golden comparison (already has normalization logic)
4. Regenerate golden files and lock files
go test -v ./pkg/workflow -run='^TestWasmGolden_CompileFixtures' -update # Regenerate golden files
make build && make recompile # Recompile all 178 lock files
The fix was part of a combined PR that also addressed shellEscapeArg bypass and YAML env injection. This issue covers only the heredoc delimiter injection.
Checklist
Update GenerateHeredocDelimiter() to use crypto/rand
Add normalizeHeredocDelimiters() to compiler skip-write comparison
Update ~10 test files to use regex-based delimiter matching
Regenerate wasm golden files (-update flag)
Recompile all workflow lock files (make recompile)
Summary
The compiler embeds user-authored Markdown prompt content into a bash heredoc using a static, predictable delimiter (
GH_AW_PROMPT_EOF). If a workflow.mdfile contains a line matching that delimiter, the heredoc closes early and subsequent lines execute as shell commands with workflow-token privileges.Because
.lock.ymlfiles are markedlinguist-generated=truein.gitattributes, GitHub collapses their diff by default in PR review. This converts a low-scrutiny "edit a Markdown prompt" PR into arbitrary shell execution.Upstream tracking: github/agentic-workflows#181
Severity: High — code-review bypass → RCE in the workflow runner
Affected Code
pkg/workflow/strings.goGenerateHeredocDelimiter()(~L287-291)pkg/workflow/unified_prompt_step.go~L429-493pkg/workflow/codex_engine.go,claude_engine.goRenderMCPConfigcallerspkg/workflow/safe_scripts.gobuildCustomScriptFilesStep().lock.ymlmarked linguist-generated.gitattributesRoot Cause
The delimiter is fully predictable from the
nameparameter. An attacker who knows the name (e.g.,"PROMPT"→GH_AW_PROMPT_EOF) can embed that string in their.mdprompt to close the heredoc and inject shell commands.The YAML
run: |block-scalar strips common leading indentation before bash sees the script. The user's line and the closing delimiter have identical indentation, so after stripping, both appear at column 0 and bash treats the injected line as the heredoc terminator.Steps to Reproduce
.github/workflows/poc.md:gh aw compile .github/workflows/poc.md.lock.yml— after indent-strip,echo "INJECTED..."executes as shell.Impact
GITHUB_TOKEN, secrets, runner filesystem.mdlooks like prose,.lock.ymldiff is hidden)Recommended Solution
Replace the static delimiter with a cryptographically random one using
crypto/rand. This was prototyped and validated in closed PR #22987.1. Randomize the delimiter (
pkg/workflow/strings.go)Format:
GH_AW_<NAME>_<16_HEX_CHARS>_EOF(e.g.,GH_AW_PROMPT_a1b2c3d4e5f6g7h8_EOF)2. Preserve compiler skip-write optimization (
pkg/workflow/compiler.go)Because delimiters are now random, each compilation produces different output even if nothing meaningful changed. The compiler's skip-write optimization (which avoids rewriting
.lock.ymlwhen content is unchanged) must normalize delimiters before comparing:Use
normalizeHeredocDelimiters()inwriteWorkflowOutput()when comparing existing vs new content.3. Update tests
All tests that check for exact heredoc delimiter strings need updating to use regex matching:
Test files requiring updates (from PR #22987):
pkg/workflow/strings_test.go— test uniqueness, not exact valuespkg/workflow/mcp_scripts_mode_test.go— regex matchingpkg/workflow/unified_prompt_creation_test.go— regex countingpkg/workflow/secure_markdown_rendering_test.go— regex matchingpkg/workflow/xml_comments_test.go— regex countingpkg/workflow/codex_engine_test.go— normalize before line comparisonpkg/workflow/engine_helpers_shared_test.go— substring matchingpkg/workflow/heredoc_interpolation_test.go— regex matchingpkg/workflow/safe_scripts_test.go— substring matchingpkg/workflow/wasm_golden_test.go— normalize before golden comparison (already has normalization logic)4. Regenerate golden files and lock files
Prior Work
make test-unit(all pass except 3 pre-existing failures).Checklist
GenerateHeredocDelimiter()to usecrypto/randnormalizeHeredocDelimiters()to compiler skip-write comparison-updateflag)make recompile)make agent-finishto validate