From 62fa58a8c0deb6606dc989c5f6c507a91029dcf8 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 9 Sep 2026 13:57:49 -0700 Subject: [PATCH] hooks: refuse a write whose effect you cannot see 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///pulls/` for base/title/body and `gh api -X POST repos///issues//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 origin/`. 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) Claude-Session: https://claude.ai/code/session_01F43CBUnsDEs6J2zEC1r8a8 --- engine/hooks/gh-write-verification/README.md | 136 ++++++++ .../gh-write-verification/claude.hook.json | 28 ++ .../claude_pretooluse.py | 57 ++++ .../claude_stop_check.py | 35 ++ engine/hooks/gh-write-verification/detect.py | 314 ++++++++++++++++++ .../install_claude_hook.py | 51 +++ .../gh-write-verification/tests/test_hooks.py | 250 ++++++++++++++ .../verify_pr_landed_on_trunk.sh | 57 ++++ install.sh | 2 + tests/test_install.py | 11 + 10 files changed, 941 insertions(+) create mode 100644 engine/hooks/gh-write-verification/README.md create mode 100644 engine/hooks/gh-write-verification/claude.hook.json create mode 100644 engine/hooks/gh-write-verification/claude_pretooluse.py create mode 100644 engine/hooks/gh-write-verification/claude_stop_check.py create mode 100644 engine/hooks/gh-write-verification/detect.py create mode 100644 engine/hooks/gh-write-verification/install_claude_hook.py create mode 100644 engine/hooks/gh-write-verification/tests/test_hooks.py create mode 100755 engine/hooks/gh-write-verification/verify_pr_landed_on_trunk.sh diff --git a/engine/hooks/gh-write-verification/README.md b/engine/hooks/gh-write-verification/README.md new file mode 100644 index 0000000..e0d30e1 --- /dev/null +++ b/engine/hooks/gh-write-verification/README.md @@ -0,0 +1,136 @@ +# gh-write-verification + +One principle, three detectors: **a write's report is not the write's +effect.** A command that changes remote state has to leave behind evidence +the agent actually looked at, and that evidence has to be the effect itself +— not the tool's own claim about it. + +## 1. `gh pr edit` is refused on every flag (PreToolUse) + +`gh pr edit` eagerly queries `repository.pullRequest.projectCards`, a sunset +Projects-classic GraphQL field, and exits 1 before writing anything — +`--base`, `--add-label`, `--title`, `--body`, all of them. The equivalent +REST calls work. + +**Fires on:** any `gh pr edit ...` in a shell tool call. +**Stays silent on:** `gh pr view`, `gh api` reads, and the `gh api` writes it +redirects to. + +The block names the replacements: + +```sh +gh api -X PATCH repos///pulls/ -f base= +gh api -X POST repos///issues//labels -f 'labels[]=