You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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 everyNotebookEdit 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}";fi
The 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.json itself 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 .ipynb files.
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.
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.
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.jsonroutes 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)"NotebookEditdoes not sendfile_path. Its tool input schema names the pathnotebook_path(required, absolute) — there is nofile_pathkey anywhere in the payload. The grep fallback searches for the literal"file_path"and so misses it too:So for every
NotebookEditcall the extraction yields empty and the hook takes its no-path branch, judging the session's project dir instead of the edited file:The 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:
$MAINa primary checkout,$WTa linked worktree of it,$PLAINa directory in no repo. Payloads are the realNotebookEditshape; theEditrows are the control.CLAUDE_PROJECT_DIR$MAIN/pkg/x.ipynb$MAIN$WT/pkg/x.ipynb$MAIN$PLAIN/x.ipynb$MAIN$MAIN/pkg/x.ipynb$WT$WT/pkg/x.ipynb$WT$MAIN/pkg/x.ipynb$PLAIN$MAIN/pkg/x.ipynb(control)$WT$WT/pkg/x.ipynb(control)$MAIN$PLAIN/x.ipynb(control)$MAINThe control rows are the same three files through the
Editpayload shape: the hook gets all three right, and gets all three wrong forNotebookEdit.Both failure directions are live
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.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:
That claim holds for
EditandWriteand is false forNotebookEdit, 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_DIRat 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.shis unaffected: it parses.tool_input.commandand never sees notebook payloads.objectuicarries a byte-identicalguard-main-checkout.sh(sha256217a8b1c2d9ff9009e2db0f54d0380c1202f16f7fb99698f419ce46d19a7d2b3) 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