From bc9400bcd04e37fda34045d94e15fb32794b2690 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:04:30 +0000 Subject: [PATCH] fix(hooks): give guard-shared-stash's split_segments() the backslash branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside quotes a backslash escapes the next character, so an escaped `\"` opens no quoted region at all. split_segments() had no backslash branch, so it read the `"` as opening a region that never closed, went inert for every separator behind it, and collapsed the whole command into one segment whose head word was the harmless one — leaving a real `git stash pop` to be read as a mere argument of `echo`. This is the fail-OPEN backstop gap in the enforcement behind the ⛔ Never `git stash` Prime Directive, the rule whose violation swapped two parallel agents' in-flight work (objectui#3430). Measured on THIS repo's copy (the card's reading was taken on objectui's), fed as the PreToolUse payload shape {cwd, tool_name:"Bash", tool_input:{command}}: git stash (control) before: exit 2 blocked after: exit 2 blocked git stash pop (control) before: exit 2 blocked after: exit 2 blocked echo \" ; git stash before: exit 0 ALLOWED after: exit 2 blocked echo \" ; git stash pop before: exit 0 ALLOWED after: exit 2 blocked echo \" && git stash pop before: exit 0 ALLOWED after: exit 2 blocked printf \" ; git stash drop before: exit 0 ALLOWED after: exit 2 blocked 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: this pass has no comment rule, so there are no word starts to track. The #11133 comment-heredoc half does not apply either — there is no strip_heredocs() pass here to carry that defect. This fixes a parse that was WRONG, not one that was uncertain. The hook's documented fail-open posture for what it cannot parse (wrapped invocations: bash -c, xargs, ssh) is unchanged and deliberate. Self-test: 32 -> 44 passed, 0 failed. The six new block cases were run against the pre-fix hook and all six FAIL there (38 passed, 6 failed), so they are discriminating rather than decorative; the six precision twins pass in both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- .claude/hooks/guard-shared-stash.selftest.sh | 22 ++++++++++++++++++++ .claude/hooks/guard-shared-stash.sh | 18 +++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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" ;;