perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan#44454
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…t in run-block expression scan
The `walkRunBlockLines` text scanner visited all lines in multiline run
blocks including lines inside heredocs. The compiled YAML for any workflow
with MCP tools contains:
`${{ toJSON(steps.determine-automatic-lockdown.outputs.visibility) }}`
inside a `<< GH_AW_MCP_CONFIG_..._EOF` heredoc. Because this expression
is not in `allowedRunScriptExpressionRegex`, the scan set `hasDisallowed=true`
on every compilation, triggering a full `yaml.Unmarshal` on the compiled YAML
(Path B fallback in `validateTemplateInjection`).
`validateNoGitHubExpressionsInRunScriptsFromParsed` (the parsed path) already
calls `removeHeredocContent` before checking expressions, so the expression
never caused a real validation failure — it was purely a false-positive in the
text scan that forced an expensive yaml.Unmarshal on every call.
Fix: add `detectHeredocDelimiter` and heredoc-state tracking to
`walkRunBlockLines` so that lines inside heredocs are skipped, matching the
behavior of the validated parsed path.
Benchmark results (BenchmarkCompileSimpleWorkflow):
Before: ~11ms/op 71,945 allocs/op 6.1 MB/op
After: ~3.8ms/op 6,230 allocs/op 1.2 MB/op (3x faster, 92% fewer allocs)
Historical average: 5.67ms/op (now 33% faster than baseline)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
✅ Test Quality Sentinel completed test quality analysis. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
|
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. |
There was a problem hiding this comment.
Pull request overview
Fixes a performance regression in the compiler’s Path B (skipValidation) template-injection pre-scan by skipping heredoc bodies when scanning run: block text, avoiding unnecessary yaml.Unmarshal fallbacks triggered by expressions embedded in heredoc payloads.
Changes:
- Add heredoc delimiter detection + heredoc-state tracking to
walkRunBlockLinesso heredoc bodies are skipped during run-block expression scanning. - Add unit tests covering heredoc skipping behavior and delimiter detection.
- Update a generated workflow lock file (container/tag bump and some runtime/env/script wiring changes).
Show a summary per file
| File | Description |
|---|---|
| pkg/workflow/template_injection_validation.go | Adds heredoc-aware run-block line walking to prevent false positives that trigger expensive YAML parsing. |
| pkg/workflow/template_injection_validation_test.go | Adds tests for heredoc skipping and delimiter parsing behavior. |
| .github/workflows/daily-elixir-credo-snippet-audit.lock.yml | Regenerated lock file with unrelated workflow/runtime changes (scope expansion vs PR description). |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Low
| rest := strings.TrimSpace(after) | ||
| // Handle <<- (strip-tab variant). | ||
| rest = strings.TrimLeft(rest, "-") | ||
| rest = strings.TrimSpace(rest) |
| // Unquoted delimiter: take the first whitespace-delimited token. | ||
| fields := strings.Fields(rest) | ||
| if len(fields) == 0 { | ||
| return "", false | ||
| } | ||
| delim := strings.TrimRight(fields[0], "|;&") | ||
| if delim == "" { | ||
| return "", false | ||
| } | ||
| return delim, true |
| { | ||
| name: "strip-tab variant", | ||
| line: `cat <<- EOF`, | ||
| wantDelim: "EOF", | ||
| wantOK: true, | ||
| }, | ||
| { | ||
| name: "no heredoc", | ||
| line: `echo hello`, | ||
| wantDelim: "", | ||
| wantOK: false, | ||
| }, |
| # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6cf2dc5cf3f6169a8fc2ae8262681f558655296a60ef8bc1a96550c0f7b3520f","body_hash":"2f1463b9044ab5b109d77da64d2959d9e4394f83c383cfd35a015e481cef9acb","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.201"}} | ||
| # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.0","digest":"sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.0@sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} | ||
| # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} | ||
| # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md |
🧪 Test Quality Sentinel Report✅ Test Quality Score: 90/100 — Excellent
📊 Metrics (2 tests)
Verdict
Highlights:
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Design Decision Gate: ADR RequiredThis PR makes significant changes to core business logic (238 new lines in Draft ADR committed: This PR cannot merge until an ADR is linked in the PR body. What to do next
Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision. Why ADRs MatterADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you. Michael Nygard ADR Format ReferenceAn ADR must contain these four sections to be considered complete:
All ADRs are stored in
|
|
@copilot run pr-limiter skill |
Review SummaryApplied 🔑 Must-address before merge
📋 Test coverage gaps to fill
@copilot please address the review comments above.
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnosing-bugs and /tdd — requesting changes for one correctness issue and several test-coverage gaps.
📋 Key Themes & Highlights
Key Themes
- Correctness risk — bitshift false positive (blocking):
strings.Cut(trimmed, "<<")matches shell arithmetic bitshift (echo $((x << 2))), which would setinHeredoc=trueand silently suppress expression scanning for the rest of the run block. The existing test"less-than operator is not a heredoc"uses a single<, so it does not catch this case. TrimLeftover-strips dashes (low risk):strings.TrimLeft(rest, "-")should bestrings.TrimPrefixto strip exactly one dash for the<<-form.- Closing-delimiter leniency:
strings.TrimSpace(line) == heredocDelimiterworks for<<-but is overly permissive for standard heredocs, where the POSIX spec requires the delimiter to be at column 1 (no leading whitespace). In practice this only matters if heredoc content is a bare word equal to the delimiter, which is rare — but worth a targeted test. - Test coverage gaps: Missing cases for sequential heredocs, unclosed heredoc, and the actual bitshift (
<<in arithmetic) scenario.
Positive Highlights
- ✅ Root cause is correctly identified and the fix is surgical — aligns the text-scan path with
removeHeredocContenton the parsed path. - ✅
detectHeredocDelimiteris well-factored as a standalone, unit-testable function. - ✅ Comprehensive docstring on
detectHeredocDelimiterdocumenting all four heredoc forms. - ✅ The opening line of a heredoc is still visited (so shell context like
cat << EOF | node script.cjscan be checked) while the body is skipped — this is the right trade-off. - ✅ 13 new tests cover the happy path thoroughly.
- ✅ Performance numbers in the PR description (3.8 ms/op, 6,230 allocs/op) provide a clear regression guard.
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 119.7 AIC · ⌖ 6.12 AIC · ⊞ 6.6K
Comment /matt to run again
| // | ||
| // Returns ("", false) when no heredoc opening is found on the line. | ||
| func detectHeredocDelimiter(trimmed string) (string, bool) { | ||
| _, after, found := strings.Cut(trimmed, "<<") |
There was a problem hiding this comment.
[/diagnosing-bugs] strings.Cut(trimmed, "<<") matches the bitshift operator << in arithmetic contexts like echo $((x << 2)) — this would set inHeredoc=true and silently skip the rest of the run block's expression scanning, suppressing real security detections.
💡 Suggested guard: validate delimiter is an identifier
Heredoc delimiters must be identifiers (letter or _ start) or quoted strings. After extracting rest, reject strings that start with a digit or operator character:
// Guard: rest must start with a letter, underscore, or quote;
// digits/operators indicate bitshift, not a heredoc.
if rest[0] != '\'' && rest[0] != '"' {
if rest[0] < 'A' || (rest[0] > 'Z' && rest[0] < '_') || (rest[0] > '_' && rest[0] < 'a') || rest[0] > 'z' {
return "", false
}
}A simpler approach is to align with removeHeredocContent, which uses pre-compiled regexes enforcing [A-Za-z_][A-Za-z0-9_]* as the delimiter format. Consider extracting a shared isValidHeredocDelimiter(s string) bool helper.
@copilot please address this.
| } else { | ||
| // If we are inside a heredoc, look for the closing delimiter. | ||
| if inHeredoc { | ||
| if strings.TrimSpace(line) == heredocDelimiter { |
There was a problem hiding this comment.
[/diagnosing-bugs] The closing-delimiter check uses strings.TrimSpace(line) but the heredocDelimiter was extracted from trimmed (leading whitespace already stripped). In POSIX heredocs with <<-, tab-indented close delimiters like \t\tEOF should match EOF — but here strings.TrimSpace strips leading tabs, so that part works. The real risk is on non-<<- heredocs: the spec says the closing delimiter must appear at the start of the line (column 1, no leading whitespace), so this TrimSpace check is overly lenient and could close a heredoc prematurely on a content line that happens to equal the delimiter after trimming (e.g. a JSON string "EOF" won't match, but a bare EOF in JSON content would).
💡 Suggested fix
Track whether the heredoc was opened with <<- and use different matching:
// For <<-, trim only tabs (POSIX spec).
if heredocStrip {
if strings.TrimLeft(line, "\t") == heredocDelimiter {
inHeredoc = false
}
} else {
// Standard heredoc: delimiter at column 1, no leading whitespace.
if line == heredocDelimiter {
inHeredoc = false
}
}This also adds a test case for <<- closing with a tab-indented delimiter.
@copilot please address this.
| } | ||
| rest := strings.TrimSpace(after) | ||
| // Handle <<- (strip-tab variant). | ||
| rest = strings.TrimLeft(rest, "-") |
There was a problem hiding this comment.
[/diagnosing-bugs] strings.TrimLeft(rest, "-") strips all leading dashes, so <<----EOF would be accepted and extract EOF. While multiple dashes aren't valid shell syntax, over-stripping could also transform a string like <<-3 (invalid but present in user-written content) into 3, then fail the empty-check and eventually match 3 as a delimiter. A tighter trim is strings.TrimPrefix(rest, "-") — strip exactly one dash for the <<- form.
💡 One-line fix
// Handle <<- (strip-tab variant): exactly one leading dash.
rest = strings.TrimPrefix(rest, "-")@copilot please address this.
| wantOK: false, | ||
| }, | ||
| { | ||
| name: "less-than operator is not a heredoc", |
There was a problem hiding this comment.
[/tdd] The test "less-than operator is not a heredoc" uses $x < 5 which, after strings.Cut(trimmed, "<<"), does NOT match because < is a single character — << (two less-than signs) is needed to trigger the bug. The test as written would pass even without any fix, so it doesn't actually exercise the bitshift case (echo $((x << 2))). Add a test for the true false-positive case.
💡 More precise test case
{
name: "arithmetic bitshift is not a heredoc",
line: `echo $((x << 2))`,
wantDelim: "",
wantOK: false,
},Note that this test will currently fail with the existing implementation (because strings.Cut finds << and then tries to parse 2))), confirming the bug described in the adjacent review comment.
@copilot please address this.
| // are not flagged as disallowed – this is the core fix for the CompileSimpleWorkflow | ||
| // performance regression where ${{ toJSON(steps.determine-automatic-lockdown...) }} | ||
| // inside a heredoc was triggering a full yaml.Unmarshal on every compilation. | ||
| func TestScanRunContentExpressionsHeredoc(t *testing.T) { |
There was a problem hiding this comment.
[/tdd] There is no test for a run block containing multiple heredocs in sequence — once the first heredoc closes, the scanner should resume visiting lines and correctly open/close the second heredoc. Without this test, a state-reset bug (e.g. heredocDelimiter not being cleared on inRunBlock exit for nested steps) could go undetected.
💡 Suggested test case
{
name: "two sequential heredocs: only expressions outside both are flagged",
yaml: `jobs:
test:
steps:
- run: |
cat << EOF1
${{ github.event.issue.title }}
EOF1
echo ${{ github.actor }}
cat << EOF2
${{ github.event.comment.body }}
EOF2`,
wantHasUnsafe: true, // github.actor line is visible
wantHasDisallowed: true,
},@copilot please address this.
| if len(fields) == 0 { | ||
| return "", false | ||
| } | ||
| delim := strings.TrimRight(fields[0], "|;&") |
There was a problem hiding this comment.
[/diagnosing-bugs] strings.TrimRight(fields[0], "|;&") strips trailing pipe/semicolon/ampersand characters from the unquoted delimiter token. This handles cat << EOF | node correctly. However, it would also strip from a legitimate delimiter named e.g. MY_DELIMITER& — though such delimiters are extremely unlikely in practice. More importantly, it does not strip trailing > or ), which can appear in unquoted pipeline fragments like cat <<EOF>output.txt. This is minor and unlikely to affect compiler-generated content, but worth a comment noting the intentional scope limitation.
💡 Suggestion
Add a brief inline comment explaining why only |;& are trimmed and that this is tuned for compiler-generated run blocks:
// Trim shell operators that can follow the delimiter in a pipeline/command.
// This list is intentionally minimal: compiler-generated run blocks use | and ; only.
delim := strings.TrimRight(fields[0], "|;&")@copilot please address this.
| } | ||
| } | ||
|
|
||
| // TestDetectHeredocDelimiter verifies the heredoc delimiter extraction function. |
There was a problem hiding this comment.
[/tdd] TestScanRunContentExpressionsHeredoc doesn't have a test for a heredoc that is never closed (the run block ends without the closing delimiter). The current code exits the heredoc state when indent <= runBlockIndent, so an unclosed heredoc effectively suppresses all subsequent line visits in that run block step — but there's no test asserting this is safe (neither a false positive nor a missed detection).
💡 Suggested test case
{
name: "unclosed heredoc: content after opening not flagged",
yaml: `jobs:
test:
steps:
- run: |
cat << EOF
${{ github.event.issue.title }}`,
wantHasUnsafe: false,
wantHasDisallowed: false,
},This documents the intended behaviour (skip unclosed heredoc content) and prevents future regressions if the exit path changes.
@copilot please address this.
There was a problem hiding this comment.
Review: perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan
The performance fix is well-structured and the benchmark numbers are compelling (~3x improvement). The approach of tracking heredoc state in walkRunBlockLines is conceptually sound. However, one blocking correctness/security issue was found.
Blocking issue
<<< here-strings are misidentified as heredoc openers. strings.Cut(trimmed, "<<") matches the first two < of a here-string (<<<), so after = "< value". The unquoted-token path then returns "<" as the delimiter. With inHeredoc=true and heredocDelimiter="<", no subsequent line in the run block will ever match, so all lines after any <<< are silently skipped — including any ${{ }} expressions on those lines.
This is confirmed real: architecture-guardian.lock.yml and daily-performance-summary.lock.yml both contain <<< inside multiline run blocks. The fix is a one-line guard in detectHeredocDelimiter:
_, after, found := strings.Cut(trimmed, "<<")
if !found {
return "", false
}
// <<< is a here-string (bash herestring), not a heredoc opener.
if strings.HasPrefix(after, "<") {
return "", false
}A corresponding test in TestDetectHeredocDelimiter for cmd <<< "$var" expecting ("", false) should accompany this.
Non-blocking observation
The TestDetectHeredocDelimiter test for "less-than operator is not a heredoc" (if [ $x < 5 ]) passes correctly because it contains only a single <. The <<- strip-tab case is handled correctly by TrimSpace on closing lines (tabs are stripped), though the TrimLeft(rest, "-") could theoretically consume a --prefixed delimiter name — low-severity, see inline comment.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
proxy.golang.org
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "proxy.golang.org"See Network Configuration for more information.
🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 133.1 AIC · ⌖ 6.35 AIC · ⊞ 4.8K
| // | ||
| // Returns ("", false) when no heredoc opening is found on the line. | ||
| func detectHeredocDelimiter(trimmed string) (string, bool) { | ||
| _, after, found := strings.Cut(trimmed, "<<") |
There was a problem hiding this comment.
<<< here-string is misidentified as a heredoc opener, silently skipping the rest of the run block.
strings.Cut(trimmed, "<<") matches the first two < characters of a here-string (<<<), leaving < ...value... as after. After TrimSpace, rest starts with <, which is not a quote character, so the unquoted-token path returns "<" as the delimiter.
Once inHeredoc=true and heredocDelimiter="<", every subsequent line in the run block is skipped because no line will ever equal "<" exactly. Any ${{ }} expressions that follow the here-string line will not be scanned.
This is real: both architecture-guardian.lock.yml and daily-performance-summary.lock.yml contain <<< inside multiline run blocks. A workflow that places a ${{ github.event.issue.title }} expression after a <<< line would bypass this scan.
Fix: Reject the <<< case before processing:
_, after, found := strings.Cut(trimmed, "<<")
if !found {
return "", false
}
// <<< is a here-string, not a heredoc — do not treat it as one.
if strings.HasPrefix(after, "<") {
return "", false
}@copilot please address this.
| } else { | ||
| // If we are inside a heredoc, look for the closing delimiter. | ||
| if inHeredoc { | ||
| if strings.TrimSpace(line) == heredocDelimiter { |
There was a problem hiding this comment.
Heredoc closing delimiter comparison ignores leading indentation (<<- strip-tab).
The heredoc close check uses strings.TrimSpace(line) == heredocDelimiter. For a regular heredoc (<<), the closing delimiter appears at the original indentation level of the run block, so TrimSpace works. However, for <<- (strip-tab heredocs), the shell strips leading tabs from every body line including the closing delimiter. The closing line may appear as \t\tEOF in the YAML literal block, not as EOF at column 0.
strings.TrimSpace strips both spaces and tabs, so it accidentally handles this case. But detectHeredocDelimiter strips any leading - characters with strings.TrimLeft(rest, "-"), which also strips multiple dashes. A delimiter that legitimately starts with a - character (e.g., <<- -MY_DELIM-) would have its leading - consumed.
This is lower severity (strip-tab heredocs are rare in generated workflows and the space/tab trimming covers the common case), but worth a follow-up test for the <<- path in TestScanRunContentExpressionsHeredoc.
@copilot please address this.
…nd multi-dash Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done. Addressed all three review concerns in commit
|
|
🎉 This pull request is included in a new release. Release: |
CompileSimpleWorkflowregressed ~3x (5.67ms → 14.6ms, ~12x more allocations) because the expression text scanner invalidateTemplateInjectionPath B triggered a fullyaml.Unmarshalon every compilation.Root cause
walkRunBlockLinesvisited every line in multiline run blocks, including lines inside shell heredocs. Any workflow with MCP tools generates a run step containing:toJSON(steps.determine-automatic-lockdown.outputs.visibility)isn't inallowedRunScriptExpressionRegex, soscanRunContentExpressionssethasDisallowed=trueon every call — unconditionally falling through toyaml.Unmarshal(~434 MB of allocations per benchmark run, 100 iterations).The actual validated path (
validateNoGitHubExpressionsInRunScriptsFromParsed) already callsremoveHeredocContentand never raises an error for this expression. Theyaml.Unmarshalwas pure waste from a false positive.Changes
pkg/workflow/template_injection_validation.go— addeddetectHeredocDelimiterto extract the closing delimiter from a heredoc-opening line (handles unquoted,<<-, single/double-quoted forms), and wired heredoc-state tracking intowalkRunBlockLinesso lines inside heredocs are skipped. This aligns the text scan with the behavior ofremoveHeredocContenton the parsed path.pkg/workflow/template_injection_validation_test.go— addedTestScanRunContentExpressionsHeredoc(6 cases covering heredoc skipping, expressions before/after heredoc close) andTestDetectHeredocDelimiter(7 cases).Result