diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh index 9c628e6555..747e8bd3ff 100755 --- a/.claude/hooks/guard-shared-stash.selftest.sh +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -85,6 +85,28 @@ 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 (#11738) ==" +# The measured hole: segmentation read the escaped `"` as OPENING a quoted region that never +# closed. Every separator behind it went inert, the whole command collapsed into a single +# `echo` segment, and the real `git stash pop` was just another argument. Same repair +# guard-main-checkout-bash.sh's split_segments() took in #11131. Every case below was +# ALLOWED before that branch existed; the bare forms next door are the controls that say +# the guard was reached at all. +expect block 'echo \" ; git stash' +expect block 'echo \" ; git stash pop' +expect block 'echo \" && git stash pop' +expect block 'printf \" ; git stash drop' +expect block 'echo \" ; git -C ../objectstack-issue-11738 stash pop' +expect block "$(printf 'echo \\"\ngit stash pop\n')" +# Precision twins: the escape must not manufacture a block where nothing touches the stack, +# and the read-only and SHA-pinned forms stay allowed behind one. +expect allow 'echo \" ; echo hello' +expect allow 'echo \" ; git status' +expect allow 'echo \" ; git stash list' +expect allow 'echo \" ; git stash apply abc1234' +expect allow 'echo a\ b' +expect allow 'echo \\ ; git status' + 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 78b097ca61..0f685dc91d 100755 --- a/.claude/hooks/guard-shared-stash.sh +++ b/.claude/hooks/guard-shared-stash.sh @@ -63,7 +63,7 @@ # 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 +# Self-test (44 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh set -uo pipefail @@ -89,6 +89,18 @@ 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 that branch this pass 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 the harmless one, and let a real `git stash pop` ride through +# as a mere argument of `echo` (#11738). Measured on this repo's copy before the fix: +# `echo \" ; git stash pop` was ALLOWED while a bare `git stash pop` was blocked. +# +# This is the same repair guard-main-checkout-bash.sh's split_segments() took in #11131, +# in the same shape; that guard's `word` bookkeeping has no analogue here because this pass +# has no comment rule to track word starts for. Both characters are kept verbatim — this +# pass only SPLITS, and check_segment() re-reads the words afterwards. segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} @@ -100,6 +112,10 @@ split_segments() { continue fi case "$ch" in + '\') + 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" ;;