feat(ci): rebuild claude-code-review with inline comments and a diff gate - #3024
Conversation
…gate `claude-code-review.yml` has been disabled since 2026-05-20. This rebuilds it around a prompt that was measured against real human review comments, and changes how findings are delivered. The prompt now lives in `.github/prompts/code-review.md` so it is diffable and so the review gate can hash it. It was selected by an offline harness scoring reviewer prompts against a frozen dataset of 122 hyperdx PRs where a human left a substantive inline review comment. On the held-out test split (Opus, 49 PRs / 87 gold items) it recovers 40% of what humans flagged, against 31% for a lifted-budget variant of the old prompt. Delivery changes: - Findings post as INLINE review comments on the changed lines. Measured on the dataset, 82% of findings anchor to a diff line; the rest reference files the diff never touched and fall back to the sticky summary rather than being dropped, because that class is a large share of the useful output. - Repeat comments are suppressed across pushes by a hidden per-finding fingerprint keyed on path plus normalized title, so a reworded body does not repost. - Minor-severity findings are folded behind <details>, not filtered. On the dataset minor findings carry 29-57% of everything a human independently flagged, so dropping them would cost roughly half the recall. Severity is used for ordering only. Cost and correctness controls: - A review gate hashes the effective diff (merge-base..HEAD) and the prompt, and skips when both are unchanged. `synchronize` fires on every "Update branch" merge, which advances the merge-base while leaving the diff byte-identical, so without this a large share of runs are pure waste at ~$3-5/PR. - The state marker is only stamped when the run produced parseable output. Otherwise the gate fail-opens and the next push retries, rather than caching a zero-finding review against that diff forever. - `concurrency` with cancel-in-progress, so two quick pushes cannot race the sticky comment or pay twice. - The model is pinned to opus. The prompt gained ~13 points from Opus where the previous one gained ~5; an action-default change must not silently swap it. Context and tool grants follow the pattern already established in deep-review.yml: a read-only git/gh prefix allowlist, with `gh api` withheld because it accepts --method POST and prefix allowlists cannot constrain flags. Prior review comments are materialized in trusted shell and passed in fenced and capped, and the prompt instructs the reviewer not to re-report them. `.github/scripts/review-comments.cjs` holds the comment routing so it can be tested without triggering a PR event; the workflow runs its tests before the review step, as pr-triage.yml does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🟢 Tier 1 — TrivialDocs, images, lock files, a dependency bump, or an automated release. No functional code changes detected. Why this tier:
Review process: Auto-merge once CI passes. No human review required. Stats
|
Greptile SummaryThe PR rebuilds the disabled Claude review workflow around a versioned prompt, trusted helper checkout, effective-diff gate, and inline-comment delivery.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| .github/workflows/claude-code-review.yml | Rebuilds the review workflow with trusted execution inputs, fork-safe checkout, diff gating, output validation, resilient inline delivery, and sticky-summary updates. |
| .github/scripts/code-review/review-comments.cjs | Implements diff-line parsing, finding deduplication, bounded rendering, fallback routing, and credential-canary checks. |
| .github/scripts/code-review/tests/review-comments.test.mjs | Adds regression coverage for line mapping, deduplication, fail-open markers, fallback details, count consistency, truncation, and canary scanning. |
| .github/scripts/code-review/trusted-hash.sh | Centralizes the trusted-input hash used before and after review execution. |
| .github/prompts/code-review.md | Adds the versioned single-agent review prompt and its repository trust-boundary instructions. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
E[PR event or manual dispatch] --> F[Resolve PR metadata]
F --> U[Checkout PR head without persisted credentials]
F --> T[Checkout trusted prompt and helpers]
U --> D[Stage effective diff]
T --> H[Hash trusted review inputs]
D --> G{Diff and prompt unchanged?}
H --> G
G -->|Yes| S[Skip review]
G -->|No| R[Run Claude review]
R --> V[Validate structured output and trusted tree]
V --> I[Route findings to inline comments]
I --> M[Update sticky summary]
Reviews (15): Last reviewed commit: "Merge branch 'main' into mike/code-revie..." | Re-trigger Greptile
E2E Test Results✅ All tests passed • 323 passed • 1 skipped • 1096s
Tests ran across 4 shards in parallel. |
Deep Review✅ No critical issues found. This workflow has been through seven prior review rounds (greptile, deep-review, and the author's own self-runs); the P0/P1 class — fork code execution in a privileged step, the 🟡 P2 — recommended
🔵 P3 nitpicks (2)
Reviewers (8): correctness, security, adversarial, reliability, testing, maintainability, previous-comments, project-standards. Testing gaps: The binary-diff / |
… review
Addresses the P1/P2 findings from deep-review and greptile on this PR. The two
security issues were both real and both introduced by this PR.
Security:
- The PR title was interpolated raw into a `run:` shell inside a command
substitution, so a title containing `$(...)` or backticks executed arbitrary
code in a pull_request_target job holding ANTHROPIC_API_KEY and a write token.
The title is no longer substituted into the prompt at all -- only the numeric
PR number is -- and it reaches the reviewer inside the fenced untrusted block
in .hdx/context.md, which also removes the prompt-injection path where a title
could steer the reviewer.
- The job checked out the fork head and then ran the fork's test file and
`require()`d the fork's review-comments.cjs inside privileged steps, executing
PR-authored code with secrets. Prompt, helper and tests now come from a
separate base-repo checkout pinned to base_sha.
- Materialized context is now defused: values arrive via `env` rather than
interpolation, and runs of backticks are collapsed so a comment body cannot
close the fence and have the following text read as instructions.
Correctness:
- `JSON.parse('${{ ... }}')` embedded JSON in a single-quoted JS literal, and
JSON does not escape `'`. Any finding containing an apostrophe threw in the
summary step, so no comment and no state marker were written and the gate
re-paid on every push. Now interpolated with toJSON.
- The healthy state marker was stamped whenever the review parsed, even if the
inline post step failed outright -- caching a lost review against that diff
forever, the exact case fail-open exists to prevent. It now also requires the
delivery step to have succeeded.
- `gh ... | head -c N` ran under pipefail with no `|| true`, so a PR body or
comment set over the cap aborted the step instead of truncating.
- The per-comment retry replaced the finding with a `see summary` stub, so a
finding that failed to anchor became an empty line pointing at a summary that
did not contain it. Inline entries now carry their originating finding.
- The retry only fires on 422 (a rejected anchor). On 403/429 it no longer
hammers the API N more times; findings route to the summary instead.
- The gate hash covered only the prompt, so changing the model, schema, tool
grants or routing helper left every open PR on a stale review. It now covers
the prompt, the workflow and the helper.
- parseCommentableLines consumed no hunk length, so an added line whose content
read `+++ b/x` or `@@ ...` reset parser state and mis-routed anchors; and
`+++ /dev/null` did not match the file regex, leaving a deleted file's hunk
attributed to the previously seen file. Both are now covered by tests.
- fingerprint guarded the title but not the path, so two file-less findings
collapsed onto one id and one was dropped.
- The summary claimed "no findings could be anchored" when the real reason was
that all findings had already been posted on an earlier push.
Testing:
- The suite's gate regex was a hand-copied duplicate of the workflow's `sed`, so
editing the marker format passed the tests while the gate silently stopped
parsing its own marker. It now lifts the `RE=` line out of the workflow and
runs the real `sed`, pinning emitter and parser together.
- Added regressions for both parser bugs, the file-less fingerprint collision,
and the delivery-message cases. 16 tests.
- The test step now runs under the repo's .nvmrc Node, as pr-triage.yml does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bot review triage — all fixed in 3efc4af, with two severity disagreementsBoth reviewers were right on substance. No false positives from either, which is worth noting given this PR is itself about review quality. The two security findings were real and both were introduced by this PR. Fixed — security (deep-review P1)
Fixed — correctness
Fixed — testing (deep P2, and the sharpest finding here)
Correct, and it undercut the whole point of that file. The test now lifts the Two severity disagreementsgreptile rated both of its findings P1; deep-review rated the same two issues P2, and I agree with deep-review. Neither is a ship-blocker:
Calling those P1 alongside a shell-injection RCE flattens a distinction that matters when triaging. Not fixed, deliberately
One caveat for whoever reviews this: the fixes touched the two |
Second round of bot review findings. The first one broke the workflow outright.
- The `Test review helpers` step ran `node --test .trusted/...` before the
`Checkout trusted prompt and helpers` step created `.trusted/`, and before
`setup-node`, so the job failed at that step on every run. Reordered:
trusted checkout, setup-node, then the test. Caught by both reviewers.
- `{{PR_TITLE}}` was left in the prompt after the substitution was removed, so
the literal token shipped in the reviewer's instructions. Dropped it; the
title reaches the reviewer through the fenced untrusted block instead.
- The sticky marker `<!-- claude-code-review -->` was duplicated between
`renderSummary` and the workflow's `body-includes` with nothing binding them.
A drift posts a new sticky comment on every push instead of updating in place,
and the gate stops finding prior state. Now tested by lifting the real
`body-includes` value out of the workflow, the same way the gate regex is.
- `prompt_hash` hashed a hand-maintained three-file list, so a future
behaviour-affecting trusted file would not invalidate the gate. It now hashes
the whole trusted tree.
17 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 — all four fixed in d7a17b1Both reviewers independently caught that my previous fix broke the workflow outright, which is a fair hit: I introduced the trusted checkout to stop fork code execution and put it after the step that loads from it.
17 tests, all passing. Worth calling outTwo of these are the same class of defect — a value duplicated between YAML and JS with nothing binding them. deep-review had already flagged that pattern for the gate regex last round, I fixed that one instance, and it correctly came back for the sticky marker. The generalisable fix is what's now in place twice: read the real value out of the workflow in the test rather than restating it. Also: the ordering bug is one a test suite structurally cannot catch — it's step sequencing, not logic. Only actually running the workflow, or a reviewer reading it, finds it. Worth remembering when weighing "add a test" against "read it again". One fidelity note for the recordDropping Correction to something I said earlierI predicted this PR would land tier-3 or tier-4 and pull deep-review in on itself. It classified as Tier 1 — Trivial, because |
…ew's patterns Third round, from running this PR's own reviewer against this PR. It found a critical hole neither greptile nor deep-review reported. Where deep-review.yml already solved a problem, its approach is adopted rather than reinvented. Security: - `--strict-mcp-config` was missing. `.mcp.json` is tracked, `--setting-sources user` does not cover it, and `-p` mode skips the trust dialog -- so a fork PR could declare an MCP server and have it started inside a job holding ANTHROPIC_API_KEY and a write token. deep-review.yml documents exactly this gap; the same flag and the same "neutralize by config, never by deleting the files" reasoning now apply here. - The fork checkout kept `persist-credentials: true`, writing the write-scoped token into .git/config where the reviewer's own Read tool could retrieve it. - Added `allow-unsafe-pr-checkout: true`, which checkout@v6 requires for a fork PR ref, matching deep-review.yml. Fork PRs were broken outright: - The base was fetched from `origin`, which on a fork PR is the fork and need not contain the base ref, so `git merge-base` aborted the job. Now fetched from the base repo by URL with a single deepen retry, and the job refuses to review without a merge-base rather than silently widening the diff to the base tip. Adopted from deep-review.yml, including its file-list cross-check against the API as defense in depth. Correctness: - The CLI is pinned to the pre-regression version with an integrity check, single-sourced with the same env vars deep-review.yml uses. Unpinned, the >=2.1.216 bwrap regression silently kills every git/gh call the prompt tells the reviewer to make, and it reviews the diff blind while reporting success. - Checkout is pinned to head_sha rather than the branch ref, so the diff, the line map and the posted comments all describe one commit. - An unhealthy run no longer overwrites the sticky comment, which destroyed the previous review's out-of-diff findings. The stale body also keeps its old diff hash, so the gate still re-reviews. - The gate hash covers the prompt, the helper and this workflow, and nothing else. Hashing all of .github (the previous round's fix) made an unrelated workflow edit re-pay for a review of every open PR. - Prior-comment context is paginated and now includes conversation comments, so the prompt's promise of "every review comment already left on this PR" holds. - setup-node reads .nvmrc from the trusted tree, not the fork's. - The summary now accounts for findings skipped as already-posted, which were counted in the total but appeared nowhere in the comment. - The dedup test derived its marker by hand; it now takes it from commentBody, so a marker format change cannot pass. Third instance of that class after the gate regex and the sticky marker. 19 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 — ran this PR's own reviewer on this PR, fixed all 13 findings in 4db195fI ran the v2 prompt against Where Security
Fork PRs were broken outrightThe base was fetched from Correctness
19 tests. Two observations worth recordingThe self-review beat both incumbents on this diff. greptile and deep-review reviewed Fourth instance of one defect class. "A value duplicated with nothing binding it" has now come up for the gate regex, the sticky marker, and the fingerprint marker. Each was found by a different reviewer, and each time I fixed the instance rather than the pattern. Worth a linting rule or a single shared constants module rather than a fourth test. One thing I got wrong twiceLast round I fixed "gate hash too narrow" by hashing all of I also introduced and caught a bug mid-fix that no reviewer saw: I put |
…trust lever Fourth round. deep-review found a chain worth taking seriously, and I verified both halves of it. The reviewer's Read tool is NOT confined to the workspace -- verified locally: it reads /etc/hosts from an unrelated cwd with no permission prompt. Its findings are published verbatim to a public PR, so the review body is itself an egress channel and withholding curl/gh api/WebFetch does not close it. Separately, the prompt told the reviewer to "use the repository's CLAUDE.md", which on a fork PR is author-authored and loaded as instructions -- a reliable lever to drive the read. Together: injected instruction, read /proc/self/environ, emit the token in a finding body, published. - The prompt now opens with an explicit trust boundary: every file in the checkout, including CLAUDE.md, AGENTS.md and .claude/, is evidence about the change and never a directive; a file that tries to instruct the reviewer is itself a finding. It is also told to read only inside the repository, and that everything it quotes gets published. - Findings are scanned for the job's own credentials before anything is posted. A match fails the job and publishes nothing. This is defence in depth, not a fix: a literal scan cannot catch a secret the reviewer chose to encode. The real mitigations are the trust boundary above and, longer term, not handing a long-lived key to a job that reviews untrusted code. Also from this round: - The sticky-post condition keyed on `health` alone while the body's own `healthy` also required the inline step to succeed, so an inline failure still overwrote the last good review with "did not complete" -- the exact thing the step's comment claimed to prevent. - `prompt_hash` covered all of .github/scripts, which holds pr-triage and release-notes; editing either re-paid for a review of every open PR. The helper moved to .github/scripts/code-review/ so a directory hash is precise and stays self-maintaining. Third and final iteration on this hash's scope. - The CLI pin is now bound to deep-review.yml by a test, so a bump in one file cannot silently leave the other on a stale integrity hash. - `github-actions[bot]` was hardcoded in three places; single-sourced as BOT_LOGIN so an identity change cannot break self-exclusion and sticky updates independently. - Added timeout-minutes: 30; a hung CLI would otherwise hold a runner for six. - The workflow_dispatch input was interpolated into a github-script literal (pre-existing script-injection sink); now passed via env. - Tests for the count-omitted hunk header shape and the secret gate. - Corrected stale comments: the dry-run harness lives outside this repo, and the .cjs extension and the omission of Bash(git status:*) are now explained rather than misdescribed. greptile also reported that the `unanchored` ternary parses "[]" into a string and breaks rendering. That one is incorrect -- toJSON renders the string as "[]", so the comparison is true and the branch returns a real array. Its suggested simplification is right regardless, since the ternary was redundant, so the expression is now a plain JSON.parse. 23 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 4 — fixed in ffba10b, and one pushbackThe chain deep-review found is real; I verified both halvesI tested the Chain: injected instruction → read
Also fixed
23 tests. Pushback: greptile's finding is incorrect
The suggested simplification is right regardless — the ternary was redundant — so the expression is now a plain Pattern worth notingFour rounds, and the gate-hash scope has now been wrong in three different ways: too narrow, too broad, and structurally unscopable. Each critique was individually correct. Moving the helper into its own directory is the first change that makes the shape of the problem go away rather than re-tuning a list. |
…token Round 5. Both reviewers cleared must-fix on the previous head; these are the P2 hardening items worth taking. - `Check review health` only asserted the output was a findings array, so a run whose sandbox broke every git/gh Bash call could return a schema-valid empty result, stamp a state marker, and make the gate skip that diff on every future push. The transcript is now scanned for bwrap tool_result failures. The CLI pin only dodges the known regression; deep-review.yml argues this guard is the permanent one, and the same reasoning applies here. - `findingsLeakingSecrets` joined fields with a newline before testing `includes`, so a credential split across title and body evaded the scan while still rendering adjacent and reconstructable in the posted comment. Both the separated and unseparated joins are now checked, and the title/file paths and the needle-length boundary have tests. - Dropped `id-token: write`. `github_token` already bypasses OIDC, so the permission bought nothing while injecting ACTIONS_ID_TOKEN_REQUEST_TOKEN/_URL into the reviewer's environment -- credentials outside the canary set that a prompt-injected reviewer reading /proc/self/environ could publish. - The per-comment retry caught every error uniformly, so a stale anchor followed by a rate limit kept firing requests and demoted anchorable findings. It now breaks on non-422 like the batch path, routing the remainder to the summary. - A second finding with the same file and title within one run was bucketed as `skipped`, which the summary renders as "unchanged from an earlier push". Only fingerprints recovered from existing comments mean that now; same-run repeats are counted separately. - Bound the reviewer's --json-schema to the renderer: a test parses the schema out of the workflow and asserts its severity enum matches the icons the renderer knows. Renaming an enum value previously passed every test while severityOf silently degraded every finding to minor. 27 tests. Not taken, deliberately: the unanchored JSON round-trip runs only in YAML and cannot be exercised from the suite; ICON/renderFinding have no in-repo consumer but are used by the out-of-tree eval harness that generated this prompt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 5 — b0489e1. Must-fix is clear; took the P2s that were security-relevant or cheapBoth reviewers cleared blocking findings on Taken from the P2 list:
27 tests. Not taken, with reasons
Where this leaves the PRFive rounds, three reviewers, ~40 findings. The two most serious — fork code execution in a privileged step, and the I'd treat must-fix as closed. Remaining items are hardening, and the honest next validation is shadow mode on live PRs, not another review round. |
A dispatch attempt failed with 'An expression was expected' at line 82. The cause was a comment inside the github-script block that contained a literal empty expression as prose -- Actions evaluates expressions wherever they appear in a scalar, including inside what reads as a JS comment, so the comment warning about expression injection was itself an invalid expression. Neither the YAML parse nor the 27 tests caught this; only an actual dispatch did. Added a test that scans the workflow for empty expressions so the same class cannot recur. 28 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| const existingComments = await github.paginate(github.rest.pulls.listReviewComments, { | ||
| owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, per_page: 100, | ||
| }); | ||
| const { inline, unanchored, skipped } = helpers.buildInlineComments({ |
There was a problem hiding this comment.
🔵 minor — Same-run duplicate findings are counted in the summary total but never posted or accounted for
buildInlineComments returns duplicates (findings the reviewer emitted twice in one run, collapsed to one comment — see review-comments.cjs:890/921 and the test at review-comments.test.mjs:701), but the inline step destructures only { inline, unanchored, skipped } and never surfaces duplicates. renderSummary prints findings.length (the full raw list, including duplicates) as the total, while posted excludes the collapsed copies and skipped/unanchored don't cover them. So if the model emits two anchorable findings with the same path+normalized-title, the summary reads e.g. "2 finding(s) … 1 posted as inline comment(s)" with the second copy silently unexplained — the exact "totals look like they lost something" failure the nSkipped accounting comment guards against. Fix: output duplicates.length from the inline step and either subtract it from the displayed total or add a "N duplicate(s) collapsed" clause in renderSummary.
PR Review1 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 1 minor 1 posted as inline comment(s) on the changed lines. Severity is the reviewer's own estimate and is used for ordering, not filtering. |
Found by this workflow reviewing its own PR in a live dispatch run. Splitting same-run duplicates out of `skipped` last round fixed the wording but left them in `findings.length`, so the headline advertised findings that appeared nowhere in the comment. The count is now distinct findings, with any collapsed duplicates noted alongside it. The `duplicates` bucket was also never wired from the inline step to the renderer -- an earlier edit to that block was lost when a later assertion in the same patch failed before the write. 29 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live test run of the workflow — it works, and it found a bug in itselfEnabled the workflow, dispatched the branch version against this PR, then disabled it again. Full green run, every step: It posted one inline comment and a correctly-formed sticky with a parseable state marker. Its single finding:
That is a real bug, and one I introduced in the previous round. Splitting duplicates out of Fixing it also surfaced that the The dispatch also caught a parse error nothing else couldThe first attempt failed outright: A comment inside the Neither 29 tests. What this run does not prove
TallyTwo of the last three defects in this PR were found by running it, not by reviewing or testing it: a workflow that could not parse, and a count that lied. The other three reviewers had all passed the parse-error commit. |
… new findings Round 5's commit message claimed four workflow fixes. Only one line reached the file. My patch script asserted every replacement and wrote once at the end, so a late failing assertion silently discarded the earlier edits, and I reported success from the script's own log line rather than checking the result. Each edit is now written individually and verified by grep. Actually landed now (claimed in b0489e1, absent from it): - Dropped `id-token: write`. Unused because the action authenticates via github_token, while leaving ACTIONS_ID_TOKEN_REQUEST_TOKEN/_URL in the reviewer's environment, outside the credential canary set. - Sandbox health guard. `Check review health` declared EXECUTION_FILE but never read it, so a run whose Bash calls all failed still returned a schema-valid empty result, stamped a state marker, and made the gate skip that diff on every future push. The transcript is now scanned for bwrap tool_result failures. - The per-comment retry now breaks on non-422 instead of firing N more requests into a rate limit and demoting anchorable findings. New in this round: - `npm install` for the pinned CLI ran with the fork checkout as cwd, so a fork-committed .npmrc was read while installing into a job holding ANTHROPIC_API_KEY. It now runs from RUNNER_TEMP with --userconfig /dev/null. Worth checking deep-review.yml for the same exposure. - The `listReviewComments` pagination sat outside the try guarding the review post, so a transient fetch error threw the step and discarded a completed review. Guarded; losing dedup is survivable because the fingerprint markers keep a re-post idempotent. - Per-severity counts ran over all findings while the total showed distinct, so the numbers did not sum once duplicates collapsed. Both now run over the same deduped set. - `prompt_hash` covered the whole code-review directory including __tests__, so a test-only edit forced a paid re-review of every open PR. Fourth and hopefully final correction to this hash's scope. - Trigger types gained `reopened` and `edited`: a base retarget or reopen changes the effective diff with no push, leaving a stale sticky and stale anchors. - setup-node moved to @v6, matching the repo; corrected a stale path in a comment. 30 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 7 — 169e9dc. Correction first: round 5 did not do what I said it didMy round-5 commit message and my comment above claimed four workflow fixes. Exactly one line reached the file. deep-review caught it — "the health step declares Cause: my patch script asserted every replacement and wrote the file once at the end, so a late failing assertion discarded all earlier edits. I read "workflow patched" from the script's own output and moved on. I noticed this failure mode once before in this PR, fixed that single symptom, and did not change the process. Every edit is now written individually and verified by Landed now, having been claimed before:
New findings this round, all valid
30 tests. Pushback
Standinggreptile: 5/5, safe to merge on the previous head. deep-review: "✅ No critical issues found", gate/fail-open state machine verified under tracing. No merge blockers from either; everything above was P2. The uncomfortable pattern is mine, not the reviewers': across seven rounds the recurring defect has been me reporting a fix that did not land or did not do what I described. Two of those were caught by review, one by actually running the workflow. That is the argument for shadow mode over more review rounds. |
…ssing transcript Two P1s from deep-review, both real and both verified locally. `git log --output=<path>` is an arbitrary-file-write primitive that a prefix allowlist cannot constrain, and the content written is the commit message -- which on a fork PR the author controls. Verified: `git log --output=/tmp/x` writes the subject line to an arbitrary path. Because the steps after the review `require()` the trusted helper, a prompt-injected reviewer could overwrite it with crafted JS and have this job execute it while holding the write token and the API key, bypassing findingsLeakingSecrets entirely. deep-review.yml notes this residual and calls it "no longer an exec path" for its own design; in this workflow it is one. The trusted tree's hash is now recomputed after the review and compared to the value the gate recorded before it. A mismatch fails the job and stamps no marker, so the next push re-reviews. The hash is also directory-based now (excluding __tests__) rather than three named files, so a helper added later is covered automatically -- the previous comment claimed that and the find did not. The health check reached healthy=true whenever the execution transcript was missing, empty, or reshaped, and `|| echo 0` swallowed jq failures, so a blind review caused by a rate limit or turn limit stamped a clean marker and that revision was never re-reviewed. A transcript that cannot be analysed is now unhealthy rather than "no evidence of a problem", matching deep-review.yml. Also: - The summary body had no length cap. Over GitHub's 65536-character limit the post 422s, and because the state marker lives inside that body the gate re-reviews and re-pays on every push without converging. Truncated with the marker re-emitted. - The file-list cross-check used git's default core.quotepath and rename detection while the API returns raw UTF-8 and the staged diff uses --no-renames, so a non-ASCII path or a rename hard-failed the job on every push. - The summary steps used always(), so a run cancelled by cancel-in-progress raced the superseding run's sticky write. Now !cancelled(). - A finding both already-posted and repeated within one run was pushed to `skipped` twice, inflating the "unchanged from an earlier push" count. 32 tests. Declined: fingerprint title-collision (deliberate trade, and deep-review's own note marks it as such); --no-renames anchoring (degrades to a summary line, not data loss); the `edited` trigger running the preamble before the gate (~30s of runner time on a title edit, against leaving a stale review after a retarget). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 8 — 8704b27. Both P1s were real; I verified the write primitiveP1:
|
…d for secrets Both reviewers report no must-fix findings on the previous head. These are the two P2s worth taking; the third is declined below. - `Materialize review context` ran its paginated `gh api` sweep of every review and issue comment BEFORE the gate decided whether to review, so every gate-skipped synchronize -- each "Update branch" merge, the exact case the gate exists to make free -- still paid for it. Moved after the gate and guarded on should_review. - `findingsLeakingSecrets` guessed a field order (title, body, file), but the summary renders `file` before `title`, so a credential split across that boundary evaded the scan while rendering adjacent and reconstructable. It now scans the strings that are actually published -- the inline comment body and the summary line -- and also compares them with everything outside credential-shaped characters removed, so intervening markup cannot hide a split secret. Needles are >= 16 characters, so collapsing punctuation does not realistically create a false match. 33 tests. Declined: hashing only the semantically-relevant parts of this workflow file so a comment-only edit does not invalidate the gate. Distinguishing a comment-only edit from a behavioural one requires parsing and normalizing YAML, which is more risk than the saving is worth -- and this is the fifth pass over this hash's scope, with each previous narrowing introducing a different bug (missed files, included tests, a hand-listed set that drifted). A workflow edit is rare and re-reviewing after one is defensible. Stopping the churn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 9 — 5dc39f9. Must-fix is clear on both reviewers; took 2 of 3 P2sStanding on TakenThe context sweep ran before the gate. The secret scan guessed a field order. It joined Worth noting my first attempt at that fix failed its own test: stripping whitespace wasn't enough, because the markup between the halves is 33 tests. DeclinedHashing only the semantically-relevant parts of the workflow, so a comment-only edit doesn't invalidate the gate for open PRs. Valid as stated, but I'm stopping here. Telling a comment-only edit from a behavioural one requires parsing and normalizing YAML, which is more risk than the saving is worth. And this is the fifth pass over this one hash's scope — each previous narrowing introduced a different bug: three hand-listed files that missed new ones, then all of Where this endsNine rounds. The reviewers have converged: 12 P2s → 3 P2s → 2 taken, 1 declined with reasons. No blocker has survived a round since round 4. The remaining unknowns are not reachable by more review, and I'd rather name them than let another round imply they're covered:
Those want shadow mode on live PRs, not a tenth review. |
…allowlisted deep-review escalated the round-8 P1 correctly. The round-8 integrity check hashed only .trusted, but the file-write primitive can target Actions command files that sit outside it -- writing NODE_OPTIONS or a PATH entry into $GITHUB_ENV/$GITHUB_PATH gains execution in a later github-script step holding the write token, and the bytes written are commit-message content a fork author controls. Checking each granted verb made it worse than reported: log, show, diff AND blame all accept --output. And any Bash grant permits shell redirection, so the primitive is inherent to granting Bash at all rather than to a particular verb. There is no allowlist that removes it. So the grant is gone: back to Read/Grep/Glob. This reverses the earlier widening in this PR. The cost is real -- no git history, no blame -- but the prior-comment context those grants were mostly added for is already materialized in trusted shell, and this is the tool set the prompt was actually evaluated with, so it also removes an unevaluated variable. deep-review.yml can accept the same residual because nothing in that job loads a workspace file after its reviewer runs; this job does. Also: - The file-list verification used --no-renames locally against an API side that detects renames, so every rename-containing PR reported old+new paths against one new path and aborted the job. I added that flag last round to "match the staged diff"; that was the wrong side to match. deep-review.yml deliberately omits it. - On a transient listReviewComments failure the catch continued with an empty list, so seenFingerprints was empty and every previously-posted finding was re-anchored as a fresh comment. The markers are idempotent across runs, not within a run that could not read them. Now fails closed; the next push retries. - The covered-file list was duplicated between the gate's hash and the verify step with nothing binding them, so a trusted input added to one copy would fail verification on every run with a misleading message and re-pay forever. Both now call .github/scripts/code-review/trusted-hash.sh. - curl for the CLI tarball had no timeouts; a hung registry stalled to the job ceiling. Un-exported renderFinding (no in-repo consumer; ICON stays, the tests use it). Removed a comment block left stranded when the context step moved. The prompt no longer advertises git/gh, since it no longer has them. 33 tests. Deferred with reasons: moving the 422-retry routing out of YAML into the tested helper. The finding is right that it is the logic most likely to be subtly wrong and that node --test cannot reach it there. It is a real refactor of the step's control flow rather than a fix, and doing it under time pressure at the end of nine review rounds is how the last three regressions got introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 10 — f6fd692. The Bash grant is gone; the primitive can't be allowlisteddeep-review escalated the round-8 P1 correctly, and checking it made things worse than reported. My round-8 integrity check hashed only Then I tested each granted verb: And any So the grant is gone — back to
Also fixed
33 tests. Deferred, with reasonsMoving the 422-retry routing out of YAML into the tested helper. The finding is correct on both counts — it is the logic most likely to be subtly wrong, and Honest tally after ten roundsRounds 7–10 each found a defect in a fix from one or two rounds earlier. The reviewers aren't converging on a clean file so much as tracking my error rate, and my errors cluster in exactly the security plumbing added to close earlier findings. Three were caught by review, one by running the workflow, one by a test I'd just written. That's the argument for stopping: further rounds of me editing this under time pressure have a poor track record. The remaining unknowns — the gate's skip path, all fork-PR handling, the retry and body-cap paths — are unreachable by review and want shadow mode. |
Final round. Both reviewers report no must-fix findings on the previous head; these are the cheap P2s worth taking before stopping. - The credential scan canaried only ANTHROPIC_API_KEY and GITHUB_TOKEN. ACTIONS_RUNTIME_TOKEN is injected into every step and is readable from /proc/self/environ exactly like the others, so a jailbroken reviewer could publish it verbatim in a finding. Added to the canary set. - commentBody had no length cap while the summary path did, so a single oversized finding would 422 the whole inline batch and then 422 its individual retry, taking every other comment in the run with it. Capped, with the dedup marker preserved so a truncated comment still does not repost. - Removed a vestigial `if true; then ... fi` left behind when the transcript validation replaced its condition -- dead indent that read like scaffolding. - Added the two missing assertions the tests were carrying execution for without checking: the "None could be anchored" delivery sentence, and that the summary renders the finding BODY and not just the title. Unanchored findings are the only place a human reads the fix text in the summary, and every test asserted the title alone. 35 tests. Held, with reasons already on the PR: moving the 422-retry routing out of YAML (a control-flow refactor, not a fix, and a poor thing to attempt at the end of ten rounds); the fingerprint title-collision trade; and binding this workflow's security shell to deep-review.yml's copy, which needs a composite action rather than a comment. Worth noting the divergence runs in the safe direction: this file hardens `npm install` with --userconfig /dev/null and a cd subshell, and the sibling does neither. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 11 — d68ea61. Must-fix clear on both reviewers; stopping hereStanding on Took three cheap P2s:
And two assertions the tests were carrying execution for without actually checking: the "None could be anchored" delivery sentence, and that the summary renders the finding body at all. Unanchored findings are the only place a human reads the fix text in the summary, and every test asserted the title alone. That's the kind of gap that makes a suite look better than it is. 35 tests. Held, with reasons
Eleven rounds, closing summaryRoughly 60 findings across greptile, deep-review, and this PR's own reviewer run against itself. The two most serious were both mine and both introduced while fixing earlier findings: fork code execution in a privileged step (introduced by adding a test step), and the Where each defect class was caught:
One pattern recurred six times: a value duplicated with nothing binding it — gate regex, sticky marker, fingerprint marker, CLI pin, finding schema, covered-file list. I fixed the first five with tests and only the sixth by extracting a shared definition. The tests were the cheaper fix each time and the wrong one. What is still unvalidated, and cannot be validated by review: the gate's skip path, all fork-PR handling ( Merging does not enable anything — the workflow stays |
…ted context The reviewer had no access to this repo's conventions at all. Verified with a canary rather than assumed: with `--setting-sources user` the model receives neither CLAUDE.md nor AGENTS.md; without the flag it receives AGENTS.md via the `@AGENTS.md` import but still not agent_docs/code_style.md, because AGENTS.md links that file rather than @-importing it. So the conventions never reached the model by any path. Dropping the flag is not an option: the working tree is fork-authored and that flag is what stops a PR injecting instructions into a job holding ANTHROPIC_API_KEY and a write token. So the conventions are fed in from the base-pinned trusted checkout instead. - agent_docs/code_style.md is added to the trusted sparse-checkout. Explicitly: this checkout is non-cone (it was set that way for .nvmrc), so root and nested files do not come along for free. - `Materialize review context` appends a `## Repository conventions` section holding that file, deliberately OUTSIDE the untrusted fence and labelled authoritative, since it comes from the base branch rather than the PR. The prompt's trust boundary gains a matching carve-out, so the reviewer does not distrust it along with everything else in the checkout. - The prompt gains a `Project conventions` pass. Feeding a rules document nobody is asked to apply would have changed nothing. - trusted-hash.sh covers the conventions file, so editing the rules invalidates the gate. That does mean a rules change re-reviews every open PR, which is the right trade for a change in what "correct" means. AGENTS.md is deliberately NOT included. It is 19.5KB of process guidance -- dev setup, running tests, changelog generation, merge conflicts -- with very little a reviewer can act on, and adding it would roughly double the token cost for that. code_style.md is 17KB of directly reviewable rules, several marked REQUIRED, and maps onto misses measured on the eval set: an icon-only ActionIcon with no accessible label (#1609) and a hand-rolled control that should have used a Mantine variant (#2988) are both REQUIRED sections of it. Two caveats. This is unevaluated: the harness passes the same `--setting-sources user`, so the 40% figure was measured without conventions too -- this is a new variable, not a restored one, and prior rounds showed added context is not free. And a PR that edits the conventions is still reviewed against the base copy, which is correct for trust and slightly wrong for that PR. 35 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pin existed for one reason: anthropics/claude-code-action#1547, whose title is "Every Bash call fails under subprocess isolation since pinned CLI 2.1.216". Round 10 removed the Bash grant entirely, so there are no Bash calls left to break. The issue is still open upstream, but it no longer describes anything this workflow does. Keeping it was pure cost: - CI would run 2.1.215 while every measurement -- the whole eval ladder including the 40% recall figure, the three-PR dry run, and the self-review -- ran on 2.1.251 with exactly this Read/Grep/Glob grant. The pin made CI the odd one out. - It held a job with ANTHROPIC_API_KEY and a write token 36 versions behind on upstream fixes, including any security fixes. - Every bump required recomputing a SHA-512 by hand, and the test binding it to deep-review.yml existed only to catch that going stale. Also replaced the sandbox guard it was paired with. Grepping the transcript for `bwrap:` in Bash tool_results can no longer match anything, since no Bash is granted -- it was dead code protecting against the regression above. The replacement uses an invariant that still holds: the diff lives at .hdx/pr.diff and the only way to reach it is the Read tool, so a review that made zero tool calls never saw the diff and answered from the prompt alone. That covers a broken sandbox, a rate limit and a turn limit with one check. deep-review.yml should keep its pin -- it grants git and gh verbs, so the regression still applies there. 34 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deep Review✅ No critical (P0/P1) issues found. The workflow is careful about the fork/secret trust boundary and fails open on the review gate. The findings below are correctness and security-control gaps introduced by this diff, plus one robustness nit. 🟡 P2 -- recommended
🔵 P3 nitpicks (1)
Reviewers (6): correctness, security, reliability, testing, maintainability, previous-comments. Testing gaps: the |
Summary
claude-code-review.ymlhas been disabled since 2026-05-20, so this is a rebuild rather than a tweak. It replaces the prompt, moves it into a versioned file, and changes how findings reach the PR.The prompt was selected by measurement, not taste. I built an offline harness that scores reviewer prompts against a frozen dataset of 122 hyperdx PRs where a human left a substantive inline review comment, using those comments as ground truth. On the held-out test split (Opus, 49 PRs / 87 gold items):
deep-review(currently in production)On the larger shared slice this prompt and
deep-revieware within noise of each other, so the claim is parity-or-better at one agent instead of six-to-thirteen — not a 4× improvement. I'd rather understate that than have it quoted back later.What changes for reviewers
<details>, not filtered. On the dataset, minor-severity findings carry 29–57% of everything a human independently flagged — filtering them would cost roughly half the recall. Severity is used for ordering only.Cost and correctness controls
merge-base..HEAD) and the prompt; skips when both are unchanged.synchronizefires on every "Update branch" merge, which advances the merge-base while leaving the diff byte-identical — without this, a large share of runs are pure waste at ~$3–5/PR. Same idea asdeep-review.yml.concurrency+ cancel-in-progress, so two quick pushes can't race the sticky comment or pay twice.opus. This prompt gained ~13 points from Opus where the old one gained ~5 — the extra passes only pay off at that tier, and an action-default change shouldn't silently swap the reviewer.Tool grants
Follows the pattern already set in
deep-review.yml: a read-onlygit/ghprefix allowlist. Withheld deliberately, since this job checks out fork-authored code and holds secrets:gh api— accepts--method POST, and prefix allowlists can't constrain flags (your own note indeep-review.yml). Prior review threads are materialized in trusted shell instead, fenced and capped, and the prompt instructs the reviewer not to re-report them.Bash— would be arbitrary code execution on PR-author-controlled build scripts next toANTHROPIC_API_KEY.WebFetch/WebSearch/curl/wget— data egress from a job holding secrets.Testing
.github/scripts/review-comments.cjsholds the comment-routing logic so it's testable without triggering a PR event. 11 tests, run by this workflow before the review step (same pattern aspr-triage.yml). They pin the two silent failure modes — a wrong line map, and a fingerprint that reposts — plus the fail-open contract, so nobody "tidies" the marker format and quietly disables the gate.Dry run on live PRs
Ran against #2992, #2981, #2980 (none in the eval set) without posting: 17 findings, 14 inline, 3 in summary, $10.86. Full output on request. The standout was a
criticalon #2981 that neithergreptilenordeep-reviewreported — quoting every builder column makesORDER BY "count()"unresolvable for unaliased projections.Merging this does not turn it on
The workflow is
disabled_manually; merging the file doesn't change that. Enabling is a separate, deliberate step:I'd suggest enabling it alongside
deep-reviewfor a couple of weeks (two comments per PR, distinct markers) and comparing on live PRs before deciding whether this replaces it. If it does, expect a net spend decrease — one agent instead of 6–13.Known gaps, stated plainly
Read/Grep/Globonly. CI reads the diff from a file, and grants read-onlygit/ghplus materialized prior comments. Those should help, but the 40% figure doesn't cover them. Prior comments in particular are unevaluable in the harness — on the eval PRs those comments are the gold set.review/tier-Nlabels and only running this on tier-3/4.greptileanddeep-reviewindependently caught failures involving data persisted before the change (stale saved sort strings) and this prompt caught neither. It has no pass for that class. That's the next thing I'd add.🤖 Generated with Claude Code