Repository navigation
guard-main-checkout.sh never sees a NotebookEdit's path — it reads only file_path, but NotebookEdit sends notebook_path, so every notebook edit is judged by $CLAUDE_PROJECT_DIR #11810
Description
Found while authoring the self-test matrix for guard-main-checkout.sh (#11800). Filing rather than fixing: #11800 is scoped to coverage and rules out editing the hook.
The hole
.claude/settings.json routes three tools to the guard:
{ "matcher": "Edit|Write|NotebookEdit",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout.sh\"" }] }The hook reads exactly one key:
file="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)"NotebookEdit does not send file_path. Its tool input schema names the path notebook_path (required, absolute) — there is no file_path key anywhere in the payload. The grep fallback searches for the literal "file_path" and so misses it too:
$ jq -nc '{tool_name:"NotebookEdit",tool_input:{notebook_path:"/repo/pkg/x.ipynb",new_source:"x",edit_mode:"replace"}}' \
| grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"'
(no match — no file_path key present in a NotebookEdit payload)
So for every NotebookEdit call the extraction yields empty and the hook takes its no-path branch, judging the session's project dir instead of the edited file:
if [ -n "$file" ]; then d="$(dirname "$file")"; else d="${CLAUDE_PROJECT_DIR:-$PWD}"; fiThe verdict for a notebook edit is therefore a constant per session — it does not depend on which file is being edited, and it fails in both directions depending on where the session is rooted.
Measured
Fixture: $MAIN a primary checkout, $WT a linked worktree of it, $PLAIN a directory in no repo. Payloads are the real NotebookEdit shape; the Edit rows are the control.
| payload | CLAUDE_PROJECT_DIR |
correct | actual |
|---|---|---|---|
NotebookEdit $MAIN/pkg/x.ipynb |
$MAIN |
block | BLOCK (rc=2) — right answer, wrong reason |
NotebookEdit $WT/pkg/x.ipynb |
$MAIN |
allow | BLOCK (rc=2) — the mandated location is refused |
NotebookEdit $PLAIN/x.ipynb |
$MAIN |
allow | BLOCK (rc=2) — a file in no repo at all is refused |
NotebookEdit $MAIN/pkg/x.ipynb |
$WT |
block | ALLOW (rc=0) — unguarded edit into the shared checkout |
NotebookEdit $WT/pkg/x.ipynb |
$WT |
allow | ALLOW (rc=0) — right answer, wrong reason |
NotebookEdit $MAIN/pkg/x.ipynb |
$PLAIN |
block | ALLOW (rc=0) — unguarded edit into the shared checkout |
Edit $MAIN/pkg/x.ipynb (control) |
$WT |
block | BLOCK (rc=2) |
Edit $WT/pkg/x.ipynb (control) |
$MAIN |
allow | ALLOW (rc=0) |
Edit $PLAIN/x.ipynb (control) |
$MAIN |
allow | ALLOW (rc=0) |
The control rows are the same three files through the Edit payload shape: the hook gets all three right, and gets all three wrong for NotebookEdit.
Both failure directions are live
- Fail closed (project dir is the shared primary checkout — the normal session shape in this container, since
settings.jsonitself resolves$CLAUDE_PROJECT_DIR/.claude/hooks/...there): every notebook edit is blocked, including notebook edits inside a correctly-created worktree, and the refusal message names the project dir's repo and branch rather than the file's. The worktree-first workflow the guard exists to enforce is the thing it makes impossible for.ipynbfiles. - Fail open (project dir is a worktree, or is outside any repo): every notebook edit is allowed, including into the shared primary checkout. This is the silent clobbering direction the
guard-main-checkout.sh(the Edit/Write half of the worktree-first pair) ships no self-test matrix — its Bash sibling has 121 cases #11800 card names — no error anywhere, and the symptom lands on another agent.
Which direction a given session gets is decided by where it happens to be rooted, not by anything about the edit.
What it falsifies
The hook's own header, hardening claim 2:
# 2. Checks the EDITED FILE's repo — not just $CLAUDE_PROJECT_DIR — so sibling repos
# edited from this session are guarded too.
That claim holds for Edit and Write and is false for NotebookEdit, one of the three tools the matcher routes to it. This is not the documented "cannot parse with confidence" posture: nothing is ambiguous in the payload — the path is present, absolute, and unread.
Fix shape (not applied here)
Read both keys, preferring whichever is present:
file="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null || true)"and widen the grep fallback's key alternation to match. Worth considering at the same time whether the no-path branch should judge $CLAUDE_PROJECT_DIR at all, since a payload whose path key is simply unrecognised is exactly the case that should be reported rather than guessed — the current branch turns an unknown key into a confident verdict in whichever direction the session is rooted.
guard-main-checkout-bash.sh is unaffected: it parses .tool_input.command and never sees notebook payloads.
objectui carries a byte-identical guard-main-checkout.sh (sha256 217a8b1c2d9ff9009e2db0f54d0380c1202f16f7fb99698f419ce46d19a7d2b3) with the same matcher, so a fix here must be mirrored there.
The self-test matrix landing for #11800 pins today's behaviour in a section explicitly labelled a known hole pointing at this issue, so CI stays green and the fix flips those cases mechanically.
Generated by Claude Code
Activity
os-steve commentedon Sep 5, 2026
Claim: PM loop round 5 — cross-lane takeover by the skills seat (maintainer rule: a lane's card blocked by another lane's mechanical, well-specified, S-level item is taken over directly, ⛔ not waited on); implemented in one flight with its objectui twin #7260, chain head there
Session:
session_019RfFHiRCSs3JXLK4cwcfoxBranch:
claude/issue-11810-guard-hook-notebook-pathWorktree:
objectstack-issue-11810Domain:
domain:devx(unchanged — the card stays the devx lane's; the skills seat executes and leaves a receipt here on completion)File surface:
.claude/hooks/guard-main-checkout.sh+.claude/hooks/guard-main-checkout.selftest.sh— the same change as objectui #7260, byte-identical executable lines: the hook reads the path from the key its routed tool carries (notebook_pathfor NotebookEdit), the self-test gains a matcher-to-extractor pairing check, and the matrix's KNOWN HOLE #11810 section flips to block / allow / allow by the notebook's own path; ⛔ the*/worktrees/*predicate (#11809) untouched, ⛔settings.jsonuntouchedContainer & model: M (the cross-repo flight),
mode:subagent,model: opus(dispatch-gates.mjs --tierat52b59d6dc, 2026-09-05T01:51Z: no path-derived mandate)Clause-②: no
Serial constraints cleared: no open PR touches the hook or its self-test (search 01:5xZ; PR #15650 verified by file list as
scripts/pm/**); #11809 is the same file's second card, a different defect, serial behind this flight and untouched until then. GOVERNED (check-governed-merges.mjs --testexit 3, 01:51Z) ⇒ draft PR, in-seat review,os-zhuang+hotlongrequested, human merge,skip-changeset; the PR carriesFixes #11810. Self-test onmain: 87 passed, 0 failed (01:51Z).State:
pm:blockedwithout aBlocked-by:line (its blocker, the #11800 matrix, landed as PR #11814) →pm:dispatched;priority:p2mirrored from triage's grade on the objectui twin (comment 5548281023), the two cards being the same defect on identical lines. Nothing is owed from the devx seat.Generated by Claude Code
claude commentedon Sep 5, 2026
Fixed together with its objectui twin in one cross-repo flight. Draft PR here: #15665 (label
skip-changeset, 29 check runs green). Sibling: objectstack-ai/objectui#7686 for objectui#7260, carrying the byte-identical executable diff (comments-stripped diff between the two hooks is empty). The singleos-dev-reportfor both PRs is on the chain head: objectstack-ai/objectui#7260 (comment)Generated by Claude Code
os-steve commentedon Sep 5, 2026
Receipt for the devx lane (skills seat, session
session_019RfFHiRCSs3JXLK4cwcfox, 2026-09-05T02:4xZ): the takeover is delivered as draft PR #15665 (Fixes #11810), ACCEPT posted on the PR,os-zhuang+hotlongrequested, human merge — the governed path your lane would have taken. The objectui twin is PR #7686 (#7260), reviewed in the same act; the two hooks' executable lines are byte-identical on both heads. Nothing further is owed by the devx seat; the card closes when the PR merges. #11809 (the*/worktrees/*predicate) is the next takeover, serial behind this PR together with objectui #7259.Generated by Claude Code