diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh index 94dd150bf0..40ebfb5c52 100755 --- a/.claude/hooks/guard-shared-stash.selftest.sh +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -99,6 +99,27 @@ expect allow 'echo a\ b' expect allow 'echo \\ ; grep -n worktree README.md' expect allow 'echo \" ; git stash list' +echo "== an escaped \\\" INSIDE a double-quoted word does NOT close it ==" +# Inside "…" a backslash is special only before " \ $ ` — so an escaped `\"` is a literal +# quote and the quoted region stays OPEN. A pass that reads it as closing goes "outside +# quotes" while bash is still inside: separators behind it split where bash would not, and +# the tail of a pure READ becomes a segment of its own, judged on its own head word. The +# single-level control above (`grep -rn "cd x && git stash pop" .claude/`) is the same +# command without the nested escape, so it isolates that escape as the only difference. +expect allow 'grep -rn "he said \"cd x && git stash pop\" once" .claude/' +expect allow 'echo "he said \"x && git stash pop\" once"' +# Precision twins — this is not a blanket "ignore whatever follows a backslash". Once the +# escapes pair up the region really is closed, and the stash behind it is still caught. +expect block 'echo "he said \"x\"" && git stash pop' +expect block 'echo "he said \"x\"" ; git stash drop' +expect block 'echo "quoted" && git stash pop' +# `\\` inside "…" is an escaped backslash, so the NEXT `"` still closes the region; without +# the `\` arm of the escapee list that closing quote is eaten and the stash rides through. +expect block 'echo "a \\" ; git stash pop' +# Inside '…' nothing is special — that asymmetry is what the q='"' gate pins. Applied to +# single quotes the branch would swallow the closing `'` and pass the stash behind it. +expect block "echo 'a \\' ; git stash pop" + 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 4417fe22c5..c9b0b59bf1 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 (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). +# Self-test (48 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh +# 48 = 46 `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. @@ -83,12 +83,31 @@ fi # 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). +# +# INSIDE "…" the rule inverts: there a backslash is special only before " \ $ ` , and an +# escaped `\"` is a literal quote that leaves the region OPEN. A pass that reads it as +# CLOSING goes outside quotes while bash is still inside, so separators behind it split +# where bash would not: the tail of a pure READ becomes a segment of its own, judged on its +# own head word — a false BLOCK on a command that touches no stash, which is exactly what +# the paragraph at the top of this section promises can never happen. The same gap fails +# OPEN in the other direction: once the escapes pair up the quoted region is left hanging +# and a real `git stash` behind it rides through as a mere argument. Inside '…' nothing is +# special, hence the q='"' gate. This is the in-quote half of the backslash rule, in the +# same shape and with the same escapee list as guard-main-checkout-bash.sh's +# split_segments() carries; that guard's `word` bookkeeping has no analogue here because +# this pass has no comment rule to track word starts for. segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} for ((i = 0; i < n; i++)); do ch="${s:i:1}" if [ -n "$q" ]; then + if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then + case "${s:i+1:1}" in + '"' | '\' | '$' | '`') + seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; + esac + fi seg+="$ch" [ "$ch" = "$q" ] && q="" continue