hooks: refuse a write whose effect you cannot see - #322
Merged
Conversation
Adds engine/hooks/gh-write-verification, a Claude PreToolUse + Stop hook with three detectors that share one principle: a write's report is not the write's effect. 1. `gh pr edit` is refused on every flag. It eagerly queries the sunset `repository.pullRequest.projectCards` GraphQL field and exits 1 before writing anything, so the block redirects to the REST calls that work: `gh api -X PATCH repos/<owner>/<repo>/pulls/<n>` for base/title/body and `gh api -X POST repos/<owner>/<repo>/issues/<n>/labels` for labels. `GH_WRITE_VERIFICATION_TRUST_PR_EDIT=1` lifts it once the CLI is fixed. 2. A state-changing command whose stdout and stderr both go to /dev/null with no exit-code check is refused. This is fail-fast (Jim Shore, IEEE Software 21(5) 2004): a discarded failure resurfaces later with the diagnostic evidence already gone. The mutating set is an explicit allowlist, so read-only commands with discarded output -- `grep -q`, `command -v`, `git cat-file -e` -- stay silent by construction rather than by exclusion rule, as do `|| exit 1`, `if ! cmd`, `set -e`, a single discarded stream, and a redirect belonging to an earlier segment. 3. A turn that ran `gh pr merge` cannot end until it has checked where the merge commit landed. `MERGED` only means the PR closed against its own base ref; a PR whose base was never retargeted merges into its own stack branch and reports the identical state. This is the end-to-end argument (Saltzer, Reed and Clark, ACM TOCS 2(4) 1984): an intermediate acknowledgement cannot stand in for the end-to-end property. Ships verify_pr_landed_on_trunk.sh, which resolves the merge commit through `gh api` and asserts `git merge-base --is-ancestor <sha> origin/<trunk>`. Not folded into pr-schema-gate: that hook returns early unless `repo_root_with_create_pr_tool()` finds scripts/create-pr.mjs, because its redirect target is that script. catstack has none, so it fails open in the repo where all three failures happened. Its own `gh pr edit --body` block is left untouched -- it survives a `gh` fix, this one does not. Both entrypoints only write to stderr and exit 2. PreToolUse positive-lists shell-like tool names, so a Write/Edit whose content mentions these shapes is never blocked. Stop returns early on stop_hook_active and on a missing, unreadable, or malformed transcript, and every uncaught detector exception is trapped and reported as allowing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F43CBUnsDEs6J2zEC1r8a8
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_37d0f9c1-f5aa-47b2-a652-6d51c6afde3b) |
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New Claude hook,
gh-write-verification. Four guards, one principle: a command's report is not the state it claims.gh pr editis broken in this repo on every flag. It eagerly queries a sunset Projects-classic GraphQL field and exits 1 before writing anything.Three separate sessions hit that in one day. One of them ran it with
>/dev/null 2>&1attached.So the error went to
/dev/null, and a stale read-back reported success.Four PRs then merged. GitHub reported
MERGEDfor all four, because each merged into its own un-retargeted stack branch. Recovering them cost a cherry-pick PR.So the hook refuses
gh pr editand names thegh apicall that works. It refuses a mutation with both streams discarded and no exit-code check.And it stops a turn that merged a PR without checking where the commit landed.
The fourth guard points that at a read.
pgrep -fcompares full command lines, so it matches the shell asking the question.A wait negated on it never exits. One agent armed three, yielded, and was never resumed; two were still spinning 45 minutes later.
Review Claim
One hook with four detectors —
PreToolUseblocks ongh pr edit, on an unchecked both-streams-discarded mutation, and on apgrep -f/pkill -fthat matches its own command line, plus aStopblock on agh pr mergewith no landing check — each proven to fire on the real incident payload and stay silent on the legitimate neighbours it is most likely to be confused with.Review Lane
behavior
Review Unit
engine-runtime
Safety Invariant
Both entrypoints are advisory-to-the-model only: they write to stderr and exit 2, and have no other write path — no file mutation, no network, no state file. The
PreToolUsehalf positive-lists shell-like tool names, so aWrite/Editwhose content merely mentions these shapes is never blocked. TheStophalf returns early onstop_hook_activeand on a missing, unreadable, or malformed transcript, and every uncaught exception in either entrypoint is trapped and reported as "allowing", so a detector bug can only under-block.Detector 2 is an explicit allowlist of state-changing commands rather than a blocklist of safe ones, so every read-only command with discarded output — the common, legitimate, expensive-to-break case — is silent by construction, not by exclusion rule. Detector 3 decides statically from the command text alone, runs no process lookup of its own, and stays silent whenever the pattern is a variable or command substitution it cannot resolve.
GH_WRITE_VERIFICATION_TRUST_PR_EDIT=1lifts detector 1 once the CLI stops erroring.Assumptions:this Safety Invariant is unconfirmed — drafted under a non-interactive delegation with no user available to confirm it, perdraft-pr's headless mode.Slice Rationale
The first, second and fourth detectors are one causal chain from a single incident, not independent ideas: the broken command produced the failure, the discarded output hid it, and the trusted
MERGEDreport let it through. The third is the same class caught in the recovery session itself. Splitting them would ship a guard whose motivating evidence lives in another PR.install.shandtests/test_install.pycannot be split off — a hook whose installer is never called and never asserted is dead code, which is the failure mode this repo has already hit.lint-diff-atomicity.mjsraises one advisoryunrelated-areaswarning for that spread (engine,install.sh,tests). Confirmed intentional: it is the standard shape of every hook underengine/hooks/.Non-goals
pr-schema-gate, which was the first candidate. That hook returns early unlessrepo_root_with_create_pr_tool()findsscripts/create-pr.mjs; catstack has none, so it fails open in the very repo where all three failures happened. Itsgh pr edit --bodyblock stays as-is — it survives aghfix, this one does not.gh pr edit. It refuses it and names the working REST call.Stopfires after the fact. It blocks the turn from ending until the landing is checked.verify_pr_landed_on_trunk.shexits non-zero on a bad landing, and detector 2 stops that exit code being discarded.README.mdhooks table — that path classifies asdocsand cannot ship alongside abehavior/engine-runtimeslice.gh-write-verificationis now narrower than its scope — detector 3 is not aghwrite. Renaming would rewrite every file plus theinstall.shwiring and the settings marker on an otherwise green branch; recommended as a follow-up, noted in the README.wait-needs-wakeup, which is what pushes agents into these polling loops in the first place. Detector 3 only makes the loop terminate; deciding whether the loop should exist stays with that hook.Test Plan
Test Plan
Repro before, with the guard absent:
gh pr edit 295 --base main→GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards), exit 1 — whilegh api repos/EdbertChan/catstack/pulls/295 --jq .base.ref→main, exit 0BashPreToolUsehook fed the five incident payloads (gh pr edit --add-label,gh pr edit --base,gh pr edit "$1" --base main >/dev/null 2>&1,git push ... >/dev/null 2>&1,gh pr merge 291 --squash --admin) → all exit 0, none caughtpr-schema-gatefed its owngh pr edit --body-filecase withcwdin catstack → exit 0 (noscripts/create-pr.mjs); the identical payload withcwdin a repo that has one → exit 2merged=true;git merge-base --is-ancestor <merge_commit> origin/mainfails for all four, andgit branch -r --containsshows each on its ownorigin/stack/...branchRepro after:
gh pr edit 295 --add-label admin-bypassandgh pr edit 295 --base main→ exit 2, message namesgh api -X PATCH repos/<owner>/<repo>/pulls/<n>andgh api -X POST repos/<owner>/<repo>/issues/<n>/labelsgh pr edit "$1" --base main >/dev/null 2>&1,git push origin HEAD >/dev/null 2>&1,gh pr merge 291 --squash --admin &>/dev/null→ exit 2grep -q ... 2>/dev/null,command -v x >/dev/null 2>&1,git cat-file -e ... 2>/dev/null,git status --porcelain >/dev/null 2>&1,git init/commit/npm ci/rm -rf/pkill ... >/dev/null 2>&1,... || exit 1,if ! ...; then,2>&1 >/dev/null,grep -q ... 2>/dev/null && git push ...,gh api -X PATCH ...→ all exit 0gh pr merge 291+gh pr merge 292and no check → exit 2 naming both; same transcript plusverify_pr_landed_on_trunk.sh 291→ silent for hook: ui-input-guard blocks synthetic input at the user's live session #291bash engine/hooks/gh-write-verification/verify_pr_landed_on_trunk.sh 291→FAIL: PR #291 reports MERGED but 314f0447... is not on origin/main, exit 1bash engine/hooks/gh-write-verification/verify_pr_landed_on_trunk.sh 318→OK: f19319b2... is an ancestor of origin/main, exit 0Failure 4 (self-matching process wait), repro before:
pgrep -f zzz_selfmatch_demo_ghwv.shfor a token no process on the box uses →RUNNING;pgrep -afshowed it matched the argv of the shell askingpgrepargument only → stillRUNNING, so a "pattern appears elsewhere in the command" test would miss the canonical looppkill -f vvvv_selfkill_ghwvinsidebash -c→ the followingecho SURVIVEDnever ran; the wrapper was killed, harness reported exit 144! pgrephalf kept it alive — killed, confirmed goneabsent; the same bracket form in a command that also spells the token plainly →RUNNING, which is why the detector re-checks for a plain mentionFailure 4, repro after:
pkill -f run_all_tests.sh,pgrep -af my_worker.py,pgrep --full …,pgrep -f -u edbert …, and a bracket pattern undone by a plain mentionpgrep run_all_tests.sh/pgrep -x bash/pkill -x node(no-f),kill -0 "$PID",pgrep -f "$PATTERN", backtick-substituted patternGates:
python3 -m unittest discover -s engine/hooks/gh-write-verification/tests -v— 34 tests, OKpython3 engine/skills/make-pr/scripts/preflight.py --base origin/main—unit engine-runtime: 9 file(s),neutral 1 file(s): tests/test_install.py,ok preflight passedbash scripts/run_all_tests.sh— 37 suites,EXIT4=0, noFAILED/ERROR:linespython3 scripts/check_hook_test_coverage.py—OK (26 hook(s) checked)python3 scripts/check_no_new_comments.py --base origin/main—ok no new commentspython3 scripts/check_no_dated_provenance.py --base origin/main—ok no dated provenancepython3 scripts/check_no_tracked_local_artifacts.py,check_skills_three_harnesses.py,check_ecosystem_boundaries.py,check_skill_file_refs.py,check_skill_test_coverage.py,check_skill_test_debt_no_growth.py,check_skill_trigger_mechanism.py,check_dora_baseline.py— all OKruff check . --select E9,F— All checks passedshellcheck install.shandshellcheck engine/hooks/gh-write-verification/verify_pr_landed_on_trunk.sh— cleannode engine/skills/draft-pr/scripts/validate-pr-body.mjsandlint-diff-atomicity.mjs --base origin/main --review-lane behavior— both passedRevert Plan
Revert Plan
git revert <sha>then./install.sh./install.shrewrites~/.claude/settings.json;scripts/prune_dead_hook_entries.pyruns inside it and drops the now-danglinggh-write-verificationentries. A stale entry left behind is inert — the hook file is gone, so the harness logs a missing-script error and the tool call proceeds.🤖 Generated with Claude Code
https://claude.ai/code/session_01F43CBUnsDEs6J2zEC1r8a8