diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index 2dc939487c..8ebe430e86 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -27,7 +27,12 @@ trap 'rm -rf "$tmp"' EXIT MAIN="$tmp/mainrepo" WT="$tmp/wt" PLAIN="$tmp/plain" -mkdir -p "$MAIN/pkg" "$PLAIN" +# ODD: a PRIMARY checkout whose own path carries a literal `worktrees` segment. Every write +# into it must BLOCK — it is a primary checkout, and the verdict comes from the git-dir vs +# git-common-dir structure, never from the spelling of the path (#7259). $WT is its positive +# twin: a real linked worktree at a path with no such segment. +ODD="$tmp/worktrees/oddrepo" +mkdir -p "$MAIN/pkg" "$PLAIN" "$ODD/pkg" ( cd "$MAIN" || exit 1 git init -q . @@ -38,6 +43,14 @@ mkdir -p "$MAIN/pkg" "$PLAIN" git add -A git commit -qm init git worktree add -q "$WT" -b selftest-wt + cd "$ODD" || exit 1 + git init -q . + git config user.email selftest@example.com + git config user.name selftest + : > README.md + : > pkg/x.ts + git add -A + git commit -qm init ) >/dev/null 2>&1 || { echo "could not build the git fixture" >&2; exit 1; } CWD="$MAIN" # payload cwd for the cases that follow; reassigned per section @@ -62,6 +75,7 @@ expect() { # expect [env…] local got; got="$(verdict "$cmd" "$@")" local shown="${cmd//$'\n'/ ⏎ }" shown="${shown//$MAIN/\$MAIN}"; shown="${shown//$WT/\$WT}"; shown="${shown//$PLAIN/\$PLAIN}" + shown="${shown//$ODD/\$ODD}" if [ "$got" = "$want" ]; then pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$shown" else @@ -109,6 +123,19 @@ expect allow 'touch pkg/new.ts' expect allow 'cp /tmp/a.txt pkg/a.txt' expect allow "cd $WT && tee pkg/a.ts" +echo "== a PRIMARY checkout whose own path carries a 'worktrees' segment is BLOCKED ==" +# Every target here is ABSOLUTE, so the verdict can only have come from the path's own repo. +# The two $ODD SUBDIRECTORY cases were `allow` under the `*/worktrees/*` substring test this +# replaced — unguarded writes into a primary checkout — because git prints an ABSOLUTE +# git-dir from a subdirectory and a RELATIVE one at the toplevel, so one checkout got +# opposite verdicts by depth (#7259). The structural test is spelling-independent. +CWD="$PLAIN" +expect block "sed -i s/a/b/ $ODD/pkg/x.ts" # a SUBDIRECTORY — the depth the substring test lost +expect block "echo x > $ODD/pkg/x.ts" # same depth, reached through redirection +expect block "sed -i s/a/b/ $ODD/README.md" # the toplevel, which blocked only by accident +expect block "sed -i s/a/b/ $MAIN/pkg/x.ts" # control: an ordinary shared primary checkout +expect allow "sed -i s/a/b/ $WT/pkg/x.ts" # control: a real linked worktree still allows + echo "== writes outside any repo are fine (/tmp, scratchpad, \$HOME dotfiles) ==" CWD="$MAIN" expect allow 'echo x > /tmp/os-selftest-out.log' diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index 94bb98fc86..117e48f364 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -17,9 +17,9 @@ # # Repo predicate — lifted verbatim from guard-main-checkout.sh so the two hooks can never # disagree about what "shared checkout" means: -# resolve the target's nearest EXISTING ancestor dir -> `git rev-parse --git-dir` +# resolve the target's nearest EXISTING ancestor dir -> `git rev-parse` # * not a git repo at all (/tmp, $HOME dotfiles, the scratchpad) -> allow -# * git-dir matches */worktrees/* (a linked worktree) -> allow +# * git-dir differs from git-common-dir (a linked worktree) -> allow # * anything else (the shared PRIMARY checkout, any sibling repo) -> BLOCK # # PRECISION OVER RECALL. Recognising a write target inside an arbitrary shell command is @@ -308,9 +308,14 @@ tokenize() { } # --- the repo predicate, identical to guard-main-checkout.sh's ------------------------ +# Canonicalise an existing directory to its physical absolute path, so both sides of the +# comparison below are spelled the same way: git prints the common-dir RELATIVE, and some +# hosts hand out symlinked temp dirs. +canon_dir() { ( cd "$1" 2>/dev/null && pwd -P ) || printf '%s' "$1"; } + # 0 = this target lands in a shared primary checkout (block it), 1 = fine / unknowable. target_is_shared_checkout() { - local p="$1" d gitdir + local p="$1" d gitdir commondir [ -n "$p" ] || return 1 [ "$p" = "-" ] && return 1 # stdout, not a file @@ -330,10 +335,15 @@ target_is_shared_checkout() { while [ -n "$d" ] && [ "$d" != "/" ] && [ ! -d "$d" ]; do d="$(dirname "$d")"; done [ -d "$d" ] || return 1 - gitdir="$(git -C "$d" rev-parse --git-dir 2>/dev/null)" || return 1 - case "$gitdir" in - */worktrees/*) return 1 ;; - esac + # A linked worktree's git-dir (.git/worktrees/NAME) differs from its git-COMMON-dir + # (.git); a primary checkout has the two equal, and so does a submodule. Structural, so it + # holds whatever the path is spelled like — the `*/worktrees/*` substring match it replaces + # did not (#7259). --git-common-dir prints RELATIVE to $d, so resolve it against $d first or + # the guard fails open at EVERY depth. + gitdir="$(git -C "$d" rev-parse --absolute-git-dir 2>/dev/null)" || return 1 + commondir="$(git -C "$d" rev-parse --git-common-dir 2>/dev/null)" || return 1 + case "$commondir" in /*) ;; *) commondir="$d/$commondir" ;; esac + [ "$(canon_dir "$gitdir")" != "$(canon_dir "$commondir")" ] && return 1 return 0 } diff --git a/.claude/hooks/guard-main-checkout.selftest.sh b/.claude/hooks/guard-main-checkout.selftest.sh index 4844703e2a..e8bab6d282 100755 --- a/.claude/hooks/guard-main-checkout.selftest.sh +++ b/.claude/hooks/guard-main-checkout.selftest.sh @@ -14,15 +14,14 @@ # all — it picks the payload's path key from the tool that sent it and makes a # PATH-AND-WORKTREE decision — so these cases are derived from what this hook actually # decides, not ported from the sibling. -# # PORTED from objectstack's copy of this matrix (objectstack-ai/objectstack, .claude/hooks/ # guard-main-checkout.selftest.sh @ d63c8a2) under objectui#6451, and the port is VERBATIM: -# the two repos' guard-main-checkout.sh differ by 9 diff lines that are all inside one -# comment block, no executable line differs, and both settings.json route the identical +# the two repos' guard-main-checkout.sh differ by 10 diff lines that are all inside comment +# blocks, no executable line differs, and both settings.json route the identical # Edit|Write|NotebookEdit matcher — so the sibling file was first run here BYTE-FOR-BYTE # unmodified (via the two env vars below) and returned 87 passed, 0 failed. Not one case -# needed adapting. The only edit below the header is the one remaining KNOWN HOLE section, -# whose issue reference is re-pointed at this repo's own card for the same defect. +# needed adapting. The only edit below the header is the `worktrees`-segment section, whose +# issue reference is re-pointed at this repo's own card for the same defect. # ⛔ Keep the two copies converged: a case that has to differ is evidence the HOOKS have # drifted, and that drift is the finding — not something to paper over here. # @@ -134,6 +133,31 @@ expect() { # expect [env…] — the common case check "$want" "$f" "$(payload "$f")" "$@" } +stderr_of() { # stderr_of [env…] -> the refusal text an agent actually reads + local payload="$1"; shift + ( cd "$CWD" && printf '%s' "$payload" | env CLAUDE_PROJECT_DIR="$PROJ" "$@" "$hook" 2>&1 >/dev/null ) +} + +says() { # says