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

Closed

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 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}"; 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.
  • 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

  1. self-assigned this
    on Sep 5, 2026
  2. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    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_019RfFHiRCSs3JXLK4cwcfox
    Branch: claude/issue-11810-guard-hook-notebook-path
    Worktree: objectstack-issue-11810
    Domain: 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_path for 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.json untouched
    Container & model: M (the cross-repo flight), mode:subagent, model: opus (dispatch-gates.mjs --tier at 52b59d6dc, 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 --test exit 3, 01:51Z) ⇒ draft PR, in-seat review, os-zhuang + hotlong requested, human merge, skip-changeset; the PR carries Fixes #11810. Self-test on main: 87 passed, 0 failed (01:51Z).

    State: pm:blocked without a Blocked-by: line (its blocker, the #11800 matrix, landed as PR #11814) → pm:dispatched; priority:p2 mirrored 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

  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    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 single os-dev-report for both PRs is on the chain head: objectstack-ai/objectui#7260 (comment)


    Generated by Claude Code

  4. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    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 + hotlong requested, 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions