From cc21dd7f146b3fa3406b71788c12c98bbecbb3c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 03:33:31 +0000 Subject: [PATCH 1/2] test(hooks): pin the in-quote backslash rule of guard-shared-stash's split_segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the paired cases to guard-shared-stash.selftest.sh ahead of the fix, so the matrix is on record failing for the reason the missing branch explains. Inside "…" a backslash is special only before " \ $ ` , so an escaped \" is a literal quote and the quoted region stays OPEN. split_segments() reads it as closing, which breaks in both directions: a pure read whose quoted text carries a nested escape is falsely BLOCKED, and once the escapes pair up the region is left hanging and a real stash rides through as a mere argument. Run against the unfixed hook (41 -> 48 cases, 44 passed / 4 failed): == an escaped \" INSIDE a double-quoted word does NOT close it == FAIL want=allow got=block grep -rn "he said \"cd x && git stash pop\" once" .claude/ FAIL want=allow got=block echo "he said \"x && git stash pop\" once" FAIL want=block got=allow echo "he said \"x\"" && git stash pop FAIL want=block got=allow echo "he said \"x\"" ; git stash drop ok block echo "quoted" && git stash pop ok block echo "a \\" ; git stash pop ok block echo 'a \' ; git stash pop 44 passed, 4 failed The three ok rows are the precision twins. They hold on both sides of the fix and pin the branch against degrading into "ignore whatever follows a backslash". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --- .claude/hooks/guard-shared-stash.selftest.sh | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From c424f18ff81c8f8b69b121fc55743499085d6656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 03:34:50 +0000 Subject: [PATCH 2/2] fix(hooks): an escaped quote inside a double-quoted word does not close it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guard-shared-stash.sh's split_segments() had no backslash branch on its in-quote path. Inside "…" a backslash is special only before " \ $ ` , so an escaped \" is a literal quote and the quoted region stays OPEN; the pass read it as CLOSING, went outside quotes while bash was still inside, and separators behind it split where bash would not. The tail of a pure READ became a segment of its own, judged on its own head word — a false BLOCK on a command that touches no stash, which is the one failure the hook's own header promises can never happen. The same gap also fails OPEN in the other direction: once the escapes pair up the quoted region is left hanging and a real stash command behind it rides through as a mere argument. Ports the in-quote branch this repo's guard-main-checkout-bash.sh split_segments() already carries, in the same shape and with the same escapee list (" \ $ `), gated on q='"' because inside '…' nothing is special. That guard and guard-main-checkout.sh are the precedent here and are untouched. Self-test: 48 passed, 0 failed. Against the unfixed hook one commit ago the same matrix read 44 passed, 4 failed — the four that flipped are the two nested-escape reads (false BLOCK) and the two fail-open twins; the three precision twins blocked on both sides. Header case count updated 41 -> 48. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --- .claude/hooks/guard-shared-stash.sh | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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