ci: carry the gh.sh digest in a step output so Write cannot forge it - #1445
ci: carry the gh.sh digest in a step output so Write cannot forge it#1445dwin-gharibi wants to merge 1 commit into
Conversation
Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
| if [ -z "${EXPECTED_GH_SH_SHA256}" ]; then | ||
| echo "ERROR: no scripts/gh.sh digest was recorded by the prefetch step; refusing to publish." | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
The fix is correct for the hole it targets: step outputs are captured by the runner when the prefetch step ends and are not filesystem-reachable from a later step, so Write can no longer forge the expected digest.
One residual gap worth being explicit about (pre-existing, and acknowledged in the PR description): this check only vouches for scripts/gh.sh's own bytes. scripts/gh.sh invokes bare gh, gh api, and python3 resolved via PATH (lines 89/95/112/175/206/218). Because Write is still unconfined, a compromised model could plant a malicious gh/python3 into a writable, PATH-fronted directory — on hosted runners /home/runner/.local/bin is first on PATH and writable by the runner user — and the untampered scripts/gh.sh would then execute it with GH_TOKEN in the environment while this check passes. That doesn't mean the fix is wrong; it means the integrity check narrows but does not eliminate the "attacker-controlled code runs with GH_TOKEN" outcome. The durable boundary remains confining Write to the workspace, or hashing the dependency chain gh.sh actually execs.
|
AI-generated review — produced by an automated review agent; no human approval is implied. OverviewPR #1445 hardens the VerdictThe fix is correct for the hole it targets. GitHub Actions step outputs are stored in the runner's internal job context at step completion; the model step runs after prefetch, and its Key residual risk (not introduced by this PR, but directly relevant to its goal)The check only vouches for Minor nits
Verification notesThe PR's test matrix (master "PWNED" row vs. branch failure rows) matches the expected behavior of the two mechanisms, and the YAML parses cleanly. No |
Closes #1444.
Motivation
code-review.ymlguardsscripts/gh.shwith a digest snapshot so the review model cannot swap out thescript that later runs with
GH_TOKEN. The snapshot lives at${RUNNER_TEMP}/gh.sh.sha256— outside theworkspace, but not outside
Write's reach.Writeis in--allowedTools, takes absolute paths, andRUNNER_TEMPis right there in the environment, so writing two files instead of one defeats the check.What this changes
The digest moves from a file on disk to a step output.
Prefetch step gains
id: prefetchand records the digest withecho "gh_sh_sha256=$(sha256sum scripts/gh.sh | cut -d" " -f1)" >> "${GITHUB_OUTPUT}".Publish step receives it via
env: EXPECTED_GH_SH_SHA256: ${{ steps.prefetch.outputs.gh_sh_sha256 }},recomputes the digest, and compares.
This works because Actions captures a step's outputs when that step ends and stores them in the workflow
context. By the time the model runs, the value is no longer read from disk — appending to
GITHUB_OUTPUTduring a later step affects only that step's own outputs, which nothing consumes. There is no longer any
file whose contents the check depends on.
Two smaller changes in the same block:
EXPECTED_GH_SH_SHA256is now an explicit failure rather than a comparison against"", so afuture refactor that drops the
id:or renames the output fails closed instead of silently.sha256sum -cdid not.The value is passed through
env:rather than interpolated into the script body. It is a hex digest from atrusted checkout so injection was never the concern, but
env:is the right habit in apull_request_targetworkflow.The comment on the snapshot line was rewritten because the old wording ("outside the workspace") describes a
mechanism this PR removes. It is the same two lines; no new commentary.
Testing
The workflow's own two
run:blocks were extracted from the YAML withyaml.safe_loadand executedverbatim against a simulated runner: real
RUNNER_TEMPandGITHUB_OUTPUT, a stubghonPATH, a realscripts/gh.shthat printsPUBLISHED, and a tamper step between them standing in for the model'sWritetool. Step-output capture is modelled faithfully — outputs are read when the prefetch step ends, and the
tamper attempts to append to
GITHUB_OUTPUTafterwards, exactly as the attacker would.Row 1 is the regression guard — the normal path still publishes. Row 2 shows the existing control still
works. Row 3 is the fix.
yaml.safe_loadon the workflow parses clean, and noRUNNER_TEMPreference remains in the file.CI gates:
.github/workflows/is not covered byfmt-checkorunit-test-check, and the repo has noactionlintoryamllintstep, so there is no gate to satisfy beyond the file being valid YAML. Thisworkflow only runs on
pull_request_target, so it cannot be exercised until the PR exists.Not fixed here
The stronger fix the issue mentions — dropping
Writeentirely and having the model return the review bodyas a step output — depends on what
anthropics/claude-code-action@v1supports for structured output. Thatis worth pursuing, and it would let both the digest check and the
review-input/summary.mdmarker checks goaway, but it is a redesign of the publish path rather than a fix to a broken control.
Also unchanged:
Writeis still unconfined, so it can still reach absolute paths underRUNNER_TEMPandelsewhere on the runner. This PR removes the one place where that mattered. If the action ever grows a
workspace-confinement option for
Write, that is the real boundary and should be turned on.Risk / rollout
Low. Same check, same failure message, one fewer file. The only behavioural difference on the happy path is
that the publish step prints two extra lines when the digest does not match.