From 0835db154effd0186f48e666bda8e21e92ba0d38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:21:08 +0000 Subject: [PATCH] fix(hooks): close the backslash fail-open in guard-shared-stash.sh split_segments() Outside quotes a backslash escapes the next character, so an escaped `\"` opens no quoted region at all. split_segments() had no branch for it: it read the `"` as opening a region that never closed, went inert for every separator behind it, collapsed the whole command into one segment whose head word was harmless, and waved a real `git stash` through as a mere argument of `echo`. Ports the split_segments() backslash branch from guard-main-checkout-bash.sh (objectstack#11131 / objectstack PR #11278, and this repo's PR #6046), adapted to this hook: it has no `word` bookkeeping and no tokenize() -- check_segment() uses `read -r -a`, which leaves the backslash literal, so both characters are kept verbatim and the pass only splits. Only the objectstack#11131 half applies. The objectstack#11133 comment-heredoc half has no analogue: this hook has no strip_heredocs() pass to carry it. Self-test matrix 32 -> 41 cases, and the hook header's own re-derivable count line is updated to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .claude/hooks/guard-shared-stash.selftest.sh | 18 +++++++++++++++++ .claude/hooks/guard-shared-stash.sh | 21 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh index fc7a59ebc3..94dd150bf0 100755 --- a/.claude/hooks/guard-shared-stash.selftest.sh +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -81,6 +81,24 @@ expect allow 'grep -rn "cd x && git stash pop" .claude/' expect allow 'echo "never run git stash pop in a shared checkout"' expect allow 'git grep -n "git stash"' +echo "== an UNQUOTED \\\" opens no quote, so the stash behind it is still seen (objectstack#11131) ==" +# The measured hole: segmentation read the escaped `"` as OPENING a quoted region that never +# closed. Every separator behind it went inert, the command collapsed into one `echo` +# segment, and the real `git stash` was just another argument. +expect block 'echo \" ; git stash' +expect block 'echo \" ; git stash pop' +expect block 'printf \" ; git stash drop' +expect block "$(printf 'echo \\"\ngit stash pop\n')" +# Precision twins: the escape must not manufacture a stash where none is run, and the forms +# this guard deliberately allows stay allowed when reached through the same escape. (Upstream's +# twin aims the command at a linked worktree; this hook reads no cwd, so the SHA-pinned and +# read-only allow-list is the analogous precision surface.) +expect allow 'echo \" ; echo hello' +expect allow 'echo \" ; cat README.md' +expect allow 'echo a\ b' +expect allow 'echo \\ ; grep -n worktree README.md' +expect allow 'echo \" ; git stash list' + echo "== escape hatch ==" expect allow 'git stash pop' OS_ALLOW_STASH=1 diff --git a/.claude/hooks/guard-shared-stash.sh b/.claude/hooks/guard-shared-stash.sh index 139cf1a4c3..4417fe22c5 100755 --- a/.claude/hooks/guard-shared-stash.sh +++ b/.claude/hooks/guard-shared-stash.sh @@ -46,8 +46,8 @@ # for anyone who means it. Widening it to string-match anywhere in the command would block # every `grep "git stash"` run against this very file. # -# Self-test (32 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh -# 32 = 30 `expect ` lines + 2 inline specials (empty-tool_input fail-open, no-jq fallback). +# Self-test (41 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh +# 41 = 39 `expect ` lines + 2 inline specials (empty-tool_input fail-open, no-jq fallback). # Re-derive when the matrix changes: `grep -c '^expect ' ` + 2, and the run's own # tail prints the total ("N passed, N failed") — keep this number equal to it. @@ -75,6 +75,14 @@ fi # A separator inside '…' or "…" does NOT split, so writing *about* the ban is never caught # by the ban: `grep -n "cd x && git stash pop" AGENTS.md` stays one segment whose first # word is grep. (objectstack#4890's lesson — the PR writing a rule must not trip it.) +# +# OUTSIDE quotes a backslash escapes the NEXT character, so an escaped `\"` opens no quoted +# region at all. Without a branch for it this pass read that `"` as OPENING a region that +# never closed, went inert for every separator behind it, collapsed the whole command into +# one segment whose head word was the harmless one, and waved a real `git stash` through as +# a mere argument of `echo`. That is a fail-OPEN in the backstop for the one rule whose +# breach silently corrupts ANOTHER agent's work (objectstack#11131, the same defect the +# sibling hook guard-main-checkout-bash.sh carried; objectui#6042). segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} @@ -86,6 +94,15 @@ split_segments() { continue fi case "$ch" in + '\') + # An escaped character opens no quote and separates nothing: consume BOTH characters + # and keep scanning outside quotes, so the separator behind a `\"` still splits + # (objectstack#11131). Both are kept verbatim because this pass only SPLITS — + # check_segment() re-reads the segment with `read -r -a`, and `-r` leaves the + # backslash literal, exactly as a real shell argument would carry it. + seg+="$ch" + if [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)) ; seg+="${s:i:1}" ; fi + ;; "'" | '"') q="$ch" ; seg+="$ch" ;; ';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;; *) seg+="$ch" ;;