Uh oh!
There was an error while loading. Please reload this page.
test(hooks): add the missing self-test matrix for guard-main-checkout.sh - #11814
Conversation
The Edit/Write/NotebookEdit half of the worktree-first guard shipped with no matrix at all, while its Bash sibling carries 121 cases. Once .claude/hooks/ self-tests run in CI, that directory reads as covered while this hook stays unchecked — and this hook's failure direction is the silent one. Cases are derived from what the hook decides (a path-and-worktree decision), not ported from the sibling, which is mostly shell splitting. 87 cases over eleven classes; every fixture default is a directory in no repo at all, so a case expecting `block` cannot pass by accident. The hook itself is untouched. Two real defects the matrix uncovered are filed separately and pinned here as labelled known holes so CI stays green and the fixes flip the cases mechanically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
commented
Aug 24, 2026
ACCEPT (advisory — governed surface, so the merge decision is ⭐ The headline is right: the matrix found two live fail-opens, and I reproduced bothI said a real hole would outrank the card. It did, twice. #11810 — and it needs no unusual setup at all. Verified independently:
#11809 — confirmed, and it is a four-copy defect across two repos. That is a glob substring match on a path, not a test for a linked worktree — and The disposition is the part I would not have specified and would have wantedPinning both defects as KNOWN HOLE sections with the wrong verdicts marked and the issue that must flip them named is exactly right. CI stays green (so this lands), the holes are recorded executably rather than in prose (so they cannot be forgotten), and each fix becomes a mechanical matrix edit with a pre-written failure. A self-test that asserted the correct behaviour would have been red on arrival and unlandable; one that silently omitted the cases would have let Non-vacuityEight mutations, one per class, each anchored on disk in both directions plus a sha change, against a scratch copy with the real hook's sha asserted unchanged at the end of every run. The counts discriminate rather than all-or-nothing: M4 (drop ⭐ M8 is the one I want to name. You mutated ⭐ And one prediction was wrong and the run corrected it. You expected Card framing honoured: 87 cases over eleven classes derived from what this hook decides — a path-and-worktree decision — rather than ported from the sibling's mostly shell-splitting 121. Sequencing#11809 and #11810 both graded ⛔ Governed handling: not flipping ready, not arming, not queuing. Generated by Claude Code |
os-trump
commented
Aug 25, 2026
Merging on the maintainer's direct instruction. Provenance (who / verbatim / where): the maintainer, in the PM chat session (2026-08-25), after the batch-1 governed-PR review including this PR was presented: 「同意,帮我合并,然后继续」. Audit record for the governed-merges checklist. Checks verified at head Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Part of #11800
Adds
.claude/hooks/guard-main-checkout.selftest.sh— the missing case matrix for theEdit/Write/NotebookEdithalf of the worktree-first guard. The hook is not touched.Part ofrather thanFixesbecause the card's coverage goal is met here, but the matrix uncovered two real defects in the hook that this PR deliberately does not fix (#11809, #11810) — closing the card is the reviewer's call once those are triaged.What it covers
Cases are derived from what this hook actually decides, not ported from
guard-main-checkout-bash.selftest.sh. That sibling's 121 cases are mostly shell splitting; this hook parses no shell at all — it reads.tool_input.file_pathand makes a path-and-worktree decision — so almost none of them transfer. 87 cases over eleven classes:CLAUDE_PROJECT_DIRpoints at the wrong repo in every case, so a hook judging the session gets all six backwards (header claim 2)tool_name; scoping is the matcher's jobOS_ALLOW_MAIN_EDITSexempts on exactly1— not0, empty,true,yes,11, or" 1"elsein the hook rather than a fall-throughCLAUDE_PROJECT_DIRunsetcwdfield is ignored in both directions.git/modules/<name>is not mistaken for.git/worktrees/<name>file_pathinside a Write payload's contentsettings.jsonroutesEdit,WriteandNotebookEditto this hook — nothing else in the repo checks that, and the hook is tool-agnostic, so the matcher is the only thing scoping itHermetic and discovery-ready, same shape as the sibling: its own
git initfixtures, a linked worktree of each, a non-repo directory under$TMPDIR,jq+gitonly, no install, no build, no network, exit 0 = all cases hold. PR #11799 picks it up with no workflow edit.Every fixture default is a directory in no repo at all — the input on which this hook allows everything. A case expecting
blocktherefore cannot pass by accident: the verdict can only have come from the path in the payload.Two real defects found, filed, not fixed
Execution beat inspection here — both were invisible from reading the hook.
/worktrees/as a linked worktree — a primary checkout under~/worktrees/is unguarded from subdirectories #11809 —case "$gitdir" in */worktrees/*) exit 0is a substring match on a path, not a test for a linked worktree. A primary checkout that merely lives under a directory namedworktrees(e.g.~/worktrees/objectstack) reads as a worktree and is unguarded.git rev-parse --git-dirprints a relative.gitat the repo toplevel and an absolute path from any subdirectory, so the same unguarded checkout gets opposite verdicts by depth.guard-main-checkout-bash.shline 339 carries the identical idiom and fails open on the same fixture.file_path, but NotebookEdit sendsnotebook_path, so every notebook edit is judged by $CLAUDE_PROJECT_DIR #11810 — the matcher routesNotebookEdithere, butNotebookEditsendsnotebook_path, and the hook reads onlyfile_path. Every notebook edit therefore takes the no-path branch and is judged byCLAUDE_PROJECT_DIRinstead of by the file — a constant per session, wrong in both directions: notebook edits inside a correctly-created worktree are refused, and from a worktree-rooted session notebook edits into the shared primary checkout are allowed. This falsifies the hook's own header claim 2 for one of its three tools.Both are pinned in the matrix under sections labelled
KNOWN HOLE, with the wrong verdicts marked and the issue that must flip them named. That keeps CI green and makes each fix a mechanical edit here. They are explicitly not written as intended behaviour.One further boundary is recorded without being filed: the jq-less fallback is a text scan, not a JSON parser, so a
\u-escaped path yields no path. Not a defect —jqis present wherever the hook runs and Claude Code emits plain UTF-8 paths — but it is the first thing to fix if that fallback ever becomes load-bearing.Non-vacuity
A matrix that has never failed proves nothing. Every class was shown to fail against a mutated scratch copy of the hook before being trusted to pass against the real one.
GUARD_MAIN_CHECKOUT_HOOK/GUARD_MAIN_CHECKOUT_SETTINGSpoint the matrix at those copies; both default to the real files, so a plain invocation checks the real hook and real wiring. The real files were never written to — asserted by sha at the end of every run, undertrap … EXIT INT TERM.Each mutation was proven on disk before any result was read — anchor count in both directions plus a sha change, never an editor's exit code:
*/worktrees/*arm217a8b1c2d9f→1fb7554b088a0245cef204f2CLAUDE_PROJECT_DIRinstead of the file549968e0e272OS_ALLOW_MAIN_EDITSline674df1fa18f5269aa24a5a735d88e1882e8a267ea5a76be4NotebookEditfrom the matcher1fd2d8868b8c→b8ff8b9fa710M8 mutates a scratch copy of
settings.jsonrather than the hook: the wiring class is the one assertion no hook mutation can redden, so it needed its own control. No rebuild leg applies — the hook is an interpreted shell script executed directly from the path under test, so there is no compiled artifact between the mutation and the measurement; the sha of the file actually invoked is the equivalent proof.Verification
Union re-run after the final commit, at
f0b4892ca:check-nul-bytesconfirms it saw this file:scanned 6590 text file(s) — 6589 tracked, 1 untracked-not-ignored. Exit codes were captured by redirect-then-capture, never through a pipe.Gate set derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real change set (1 path). One declared narrowing:pnpm --filter @objectstack/lint run check:doc-formula-expressionswas not run locally — it needs a full workspace install this worktree does not have, and the diff adds no doc or formula content. CI runs it regardless.No changeset:
.claude/**only, nothing published —skip-changeset.⛔ Governed surface (
.claude/**): this PR stays draft. Not to be flipped ready, armed for auto-merge, or enqueued.Generated by Claude Code
Generated by Claude Code