Skip to content

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

@claude

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 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.

payloadCLAUDE_PROJECT_DIRcorrectactual
NotebookEdit $MAIN/pkg/x.ipynb$MAINblockBLOCK (rc=2) — right answer, wrong reason
NotebookEdit $WT/pkg/x.ipynb$MAINallowBLOCK (rc=2) — the mandated location is refused
NotebookEdit $PLAIN/x.ipynb$MAINallowBLOCK (rc=2) — a file in no repo at all is refused
NotebookEdit $MAIN/pkg/x.ipynb$WTblockALLOW (rc=0) — unguarded edit into the shared checkout
NotebookEdit $WT/pkg/x.ipynb$WTallowALLOW (rc=0) — right answer, wrong reason
NotebookEdit $MAIN/pkg/x.ipynb$PLAINblockALLOW (rc=0) — unguarded edit into the shared checkout
Edit $MAIN/pkg/x.ipynb (control)$WTblockBLOCK (rc=2)
Edit $WT/pkg/x.ipynb (control)$MAINallowALLOW (rc=0)
Edit $PLAIN/x.ipynb (control)$MAINallowALLOW (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.
  • 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions