fix(review-gate): refuse a re-anchored review whose body attests a different head (PEN-3413) - #1984
allyblockcast[bot] wants to merge 1 commit into
Conversation
…fferent head (PEN-3413) `githubHasReviewerEvidenceForPr` credited a formal review on `commit_id === headSha` alone. That stamp is not an immutable record of what was reviewed: GitHub rewrites it onto the new head when a force-push orphans the reviewed commit, hours after the review was submitted. So the check designed to prove "a reviewer looked at this exact tree" was crediting approvals of trees the reviewer never read. Measured on Blockcast/paperclip 2026-09-21: - #1936 review 5258246839, submitted 2026-09-19T23:12:46Z, body attests 3a01993; `commit_id` reads f6bbb95 — a commit not authored until 2026-09-20T22:23:44Z and force-pushed at 22:33:50Z (+23h11m). - #1937 review 5261008302, submitted 2026-09-20T16:03:47Z, body attests 3d9cfb2; `commit_id` reads c890164, authored 2026-09-21T02:19:58Z and force-pushed at 02:21:25Z (+10h16m). Both APPROVED, both by the App identity; #1936 was in the `master` merge queue carrying one. A commit that did not exist at submit time cannot have been stamped at submit time, which refutes the competing hypothesis that Ally raced a push — and means no producer-side pin can prevent a rewrite that lands hours after the review does. Only a consumer can refuse it. A review at the head whose body attests a *different* head — or several distinct heads — no longer satisfies the predicate. Narrow on purpose: a body carrying no attestation still passes on `commit_id` alone, because requiring an attestation to be present would fail every bodyless or off-template App review and reproduce the BLO-28920 false `pr_review_output_missing` retry loop. `extractAllyReviewedHeadShas` is added alongside the singular form rather than replacing it: the singular form collapses "no attestation" and "several" into null, and telling those apart is exactly what this consumer needs. Its three existing callers are untouched. The agent-facing copy of the same rule (`gateSignals`, which IS the implementation for the hand-performed gate re-check) is corrected to match, with a guard test so a size-motivated trim fails loudly. Refs PEN-2847, PEN-2865, BLO-28920, BLO-32844. Signed-off-by: Cto <cto@paperclip.blockcast.net>
|
✅ All checks passing — ready for Greptile review and maintainer approval. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (applied directly; nested CLI unavailable in this runtime).
Reviewed head: 6d3ad29
CI at this head has produced no test verdict: workflow run 35648952860 ("PR" — General tests, Typecheck, verify) is cancelled and unsuperseded, so the body's "160 passed" is a local result, not a CI one. Per the repo's own rule the remedy is gh api -X POST repos/Blockcast/paperclip/actions/runs/35648952860/rerun, not a push — a push moves the head and voids any at-head attestation. commitperclip PR Review (35648953271) is also red.
Critical Issues (0)
Important Issues (0)
I tried to break this and could not. Verified against the head tree rather than the patch:
headShaHex(github-app-auth.ts:392) lowercases, andextractAllyReviewedHeadShaslowercases each capture, so the newattested[0] === headcomparison cannot fail on casing even though the pattern carries theiflag.body: r.body ?? ""atgithub-app-auth.ts:652means thestringparameter is never null at the call site.- The early
if (attested.length === 0) return falseis load-bearing, not redundant — without it a zero-attestation body falls to!(0 === 1 && …)and reads as contradicting. matchAllon the sharedgipattern is safe (the spec clones the regex, solastIndexis not shared), matching what the singular form already does.- Consecutive attestation lines both match: the trailing
(?=\n|$)is a lookahead, so the newline stays available to the next iteration's(?:^|\n). The "several distinct heads" test depends on this and pins it. github-app-auth.ts:935is the only site in the file that creditscommit_id === headSha, so the guard is not half-applied. I confirmed the PR's "remaining consumer" claim directly:evidence-truth.ts:213already keys CLEAN on the attestation andpr-review-head-attestation.ts:39already refuses the stamp.
Suggestions (3)
- [native-codex]
server/src/services/github-app-auth.ts:537— the documented residual is that an App review carrying no attestation still rides on the stamp alone, so a re-anchored bodyless review still credits the gate. Agreed that requiring presence would reproduce BLO-28920, so the narrowness is right for this PR. Worth noting for follow-up: the measurement in this very header supplies a stamp-independent discriminator that also covers bodyless reviews — a review whosesubmitted_atprecedes the head commit'scommitter.dateprovably cannot have been stamped at submit time (+23h11m and +10h16m in the two measured cases). Costs one extra read, which is why it does not belong on this hot path today, but it closes the part this fix cannot. - [pr-review-toolkit:tests]
server/src/__tests__/github-app-auth.test.ts:455— the guard creates a control-flow path that could not previously exist: a review stamping at head now falls through tolistReviewerComments. Before, surface 1 short-circuited and comments were never fetched. The new behaviour is correct (a refused review owes nofound:true, and a comment-fetch failure there is a genuine inability to ask), but it interacts with the BLO-28920 note at:744about{error}vs{found:true}, so it deserves a pin: a re-anchored review plus a valid comment at head should yield{found:true, via:"comment"}. - [gstack/review]
server/src/services/github-app-auth.ts:492— the plural form de-duplicates and the singular keys on raw count, so the two surfaces now disagree on one input: a body attesting the same head twice is credited viareviewBodyContradictsHeadbut refused bycommentAttestsHead. The doc atally-review-detection.ts:199explains why the singular was left alone, which is the right call; it just does not flag that the surfaces diverge here. One clause at:492would stop a later reader "harmonising" them in the unsafe direction.
Strengths
- The mechanism is measured, not assumed, and the measurement is what selects the fix site. Showing that the stamped commit did not exist at submit time refutes the submit-race hypothesis outright, and that is precisely what rules out every producer-side remedy — so "this must be a consumer-side guard" is derived rather than asserted.
- The narrowness is defended against the specific regression it would otherwise cause, with the BLO-28920 loop named and quantified, and then pinned by a test ("attests no head at all") rather than left to a comment. That test is the one most likely to be deleted by someone tightening the predicate, which is exactly why it earns its place.
- A real positive control sits beside the rejection case, differing only in which head the body names — so the rejection cannot pass by having broken every bodied review. The "does not mask a sibling review" case covers the
.some()semantics that a narrower reading would have missed. - Adding
extractAllyReviewedHeadShasalongside the singular form, instead of refactoring it, keeps three fail-closed callers untouched; deriving the singular from the plural would have silently changed a doubly-attested body fromnullto a credited SHA. - The
gateSignalscopy is updated in the same change, and the validator test asserts the invariants rather than the prose — all four of its patterns match the new description, so a size-motivated trim fails loudly instead of quietly reinstating the bypass in the agent-performed half of the check. - Scope discipline: the dismiss/supersede suggestion from the ticket is explicitly deferred rather than bundled.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Thinking Path
Linked Issues or Issue Description
Closes PEN-3413.
The bypass
githubHasReviewerEvidenceForPrcredited a formal review oncommit_id === headShaalone. That stamp is not an immutable record of what was reviewed, so the check designed to prove "a reviewer looked at this exact tree" was crediting approvals of trees the reviewer never read.commit_id5258246839f6bbb9593a0199315261008302c890164a3d9cfb2bBoth
APPROVED, both byallyblockcast[bot](App identity). #1936 was sitting in themastermerge queue carrying one.The mechanism, settled
PEN-3413 named two candidates it could not separate and asked for the distinction in writing, because they have different fixes. Measured 2026-09-21:
commit_idauthored5258246839f6bbb959@ 2026-09-20T22:23:44Z5261008302c890164a@ 2026-09-21T02:19:58ZCandidate 1 — "Ally submits without pinning a SHA, so GitHub stamps the then-current head (a TOCTOU between fetch and submit)" — is refuted. In both cases the stamped commit did not exist when the review was submitted. A commit authored 23 hours later cannot have been the head at submit time.
Candidate 2 — GitHub re-anchors
commit_idafter the fact — is confirmed. Corroborating detail:divergedfrom the new head (compare/3a019931...f6bbb959→diverged, 4 behind / 27 ahead;3d9cfb2b...c890164a→diverged, 9 behind / 35 ahead), i.e. orphaned by the force-push.eb445a62; fix(claude-k8s): cap tool-spawned children with RLIMIT_DATA so an orphaned Bash child cannot OOM-kill the run (BLO-34477) #193719ff304f/fac058ce/f6a323e9×2 /6eba574a) all kept stamps equal to their own attestations.reviewedentry in the issue timeline renders the same rewritten field, so it corroborates nothing — it is not an independent record.This is the part that decides where the fix belongs. Since the rewrite lands hours after submission, the producer-side remedy PEN-3413 floated — "re-read the head immediately before submit and abort if it moved" — cannot prevent this class. Only a consumer can refuse it. I have therefore not built it.
What I have not established: GitHub's internal rule. "Re-anchors the old-tip review onto the new tip on force-push" fits both cases (n=2) and is inferred from review ordering — the
head_ref_force_pushedtimeline event carries only the new SHA, so the pre-push tip is not readable directly. The fix does not depend on the rule being exactly that; it depends only oncommit_idbeing mutable, which is now measured rather than assumed.This corroborates a ruling the repo had already made twice on other reads —
pr-review-head-attestation.ts("GitHub rewritescommit_idwhen a branch is updated") andevidence-truth.ts(CLEAN keyed on the attestation, never oncommit_id).githubHasReviewerEvidenceForPrwas the remaining consumer that still trusted the stamp. Distinct from BLO-32844, which is a review submitted to the wrong PR and is refused producer-side.What Changed
A review at the head whose body attests a different head — or several distinct heads — no longer satisfies the predicate.
Narrow on purpose. PEN-3413's suggested shape was to require both the attestation and
commit_idto equal the head. That would fail every bodyless or off-template App review and reproduce the BLO-28920 falsepr_review_output_missingretry loop (~66 runs / 3h) that this function's header exists to prevent. So a body carrying no attestation still passes oncommit_idalone; only a body that names a different head is refused. Several distinct heads is refused too, on the same fail-closed rule the grammar's exactly-one check already encodes.extractAllyReviewedHeadShasis added alongsideextractAllyReviewedHeadSharather than replacing it. The singular form collapses "no attestation" and "several" intonull— correct for its three existing callers, which must fail closed on ambiguity, and exactly the bit this consumer needs separated. Those callers are untouched.The agent-facing copy of the same rule (
gateSignals, which is the implementation for the hand-performedpr:…:reviewgate re-check) is corrected to match, with a guard test so a size-motivated trim fails loudly rather than silently reinstating the bypass.Not addressed here, deliberately: PEN-3413's third suggestion, that a re-review should dismiss or supersede the prior approval. That is a separate change and closest to PEN-2865.
Verification
Failing-before / passing-after, run both ways:
Exactly the two rejection tests flip. The three PEN-3413 positive controls — attests-the-same-head, attests-no-head-at-all, and a re-anchored review not masking a clean sibling — pass in both runs, which is what shows the rejection is not just the guard breaking every bodied review.
pnpm typecheck— exit 0evidence-truth,pr-review-head-attestation,pr-comment-review-gate,pr-comment-review-gate-check,heartbeat-reviewer-evidence-live-head,github-review-posted-metricDisposition of #1936 / #1937
Untouched, as PEN-3413 left them. Both have since been re-reviewed at their current heads (#1936
5263242106, #19375263421083), each attesting the head it is stamped at — so both now hold a review this predicate accepts on its own merits.Human-merge gated; not self-merging.
Risks
gateSignalscopy is corrected in step with the implementation and guarded by a test.Model Used
Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code