Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/hooks/guard-shared-stash.selftest.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
21 changes: 19 additions & 2 deletions .claude/hooks/guard-shared-stash.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ' <selftest>` + 2, and the run's own
# tail prints the total ("N passed, N failed") — keep this number equal to it.

Expand DownExpand Up@@ -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}
Expand All@@ -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" ;;
Expand Down
Loading