Skip to content

finding(hooks): guard-main-checkout.sh is the only PreToolUse guard with no self-test — the one both CLAUDE.md files name by path as the enforcement mechanism #6451

Description

@os-warren

Found while porting the worktree-recipe repair (#6208 / PR #6449); out of scope for that card, filed rather than fixed.

The gap

This repo has three PreToolUse guards. Two carry hermetic self-test matrices that .github/workflows/hook-selftests.yml runs on every PR touching .claude/hooks/**:

guardself-testcases (measured 2026-08-26)
.claude/hooks/guard-main-checkout-bash.shguard-main-checkout-bash.selftest.sh121 passed, 0 failed
.claude/hooks/guard-shared-stash.shguard-shared-stash.selftest.sh41 passed, 0 failed
.claude/hooks/guard-main-checkout.shnone
$ ls .claude/hooks/
guard-main-checkout-bash.selftest.sh
guard-main-checkout-bash.sh
guard-main-checkout.sh
guard-shared-stash.selftest.sh
guard-shared-stash.sh
$ grep -n 'run:' .github/workflows/hook-selftests.yml
125: run: .claude/hooks/guard-main-checkout-bash.selftest.sh
129: run: .claude/hooks/guard-shared-stash.selftest.sh

Nothing in CI exercises guard-main-checkout.sh at all.

Why this one is the awkward omission

guard-main-checkout.sh is the original guard — the Edit/Write/NotebookEdit one. It is the hook both CLAUDE.md and AGENTS.md §9 name by path as the mechanism that enforces worktree-first. The Bash-side guard exists to cover the same rule expressed as a shell command, and guard-main-checkout-bash.sh's own header describes itself as the second line ("Same rule as guard-main-checkout.sh — that hook is registered on Edit|Write|NotebookEdit only, so the identical edit expressed as a shell command used to slip through in silence").

So the covered guard is the follow-on and the uncovered one is the primary. #5754 added the standing CI caller and wired it to the two self-tests that existed; nothing was left recording that the third guard had none.

What the absence actually permits

Both a false-negative and a false-positive path are unguarded:

  • fail-open — a parsing or path-resolution change that stops the guard classifying a shared checkout means edits land on the shared tree in silence, which is the whole failure this repo built worktree-first to stop.
  • fail-closed — an over-broad match blocks edits inside legitimate linked worktrees, which reads to an agent as a broken environment.

Neither has a test that would go red. Concretely: PR #6449 edited the recipe text this guard prints (guard-main-checkout.sh:57) and no gate in this repo could have noticed a mistake in it — the whole scripts/__tests__ tree stayed green at 2318 passed, correctly, because nothing there reads the file.

Not asserting the shape of the fix

The sibling self-test is a ready template (guard-main-checkout-bash.selftest.sh builds a throwaway repo plus a linked worktree and drives the hook through a case matrix, with a jq-less fallback lane). Whether the right move is a third matrix, folding the Edit-side cases into the existing one, or something narrower is for whoever picks this up. Note hook-selftests.yml's header states a per-file case-count-recipe convention that a new self-test would need to follow, and that its recorded counts (100 / 32) are already behind the tree's (121 / 41).

Filed unassigned; no domain:* or type label set.

Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repopm:queuetooling

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions