Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Merged
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
56 changes: 55 additions & 1 deletion .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# copied from objectstack@7c2c5aedd94d7b0d94c91432bc607862e9c83c6c
# copied from objectstack@338297e46018ae96256262590a308c8be735f825
# Self-test for guard-main-checkout-bash.sh — run it after touching that hook:
#
# .claude/hooks/guard-main-checkout-bash.selftest.sh
Expand Down Expand Up @@ -304,12 +304,66 @@ expect allow "$(printf "grep '#' README.md <<EOF\nsed -i 's/a/b/' pkg/x.ts\nEOF\
expect block "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\nsed -i s/a/b/ pkg/x.ts\n")"
expect allow "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\ngit status\n")"

echo "== a \$(…) inside \"…\" is its own quoting context (objectstack#24812) =="
CWD="$MAIN"
# The measured false blocks: a `"` inside the substitution was read as CLOSING the outer string,
# so the `>` in a quoted JSON body became a redirect and the fragment after it a write target.
# Pure reads — nothing is written.
expect allow 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)"'
expect allow "echo \"a \$(curl -d '{\"c\":\"x > 1\"}' http://h)\""
expect allow 'echo "a `curl -d "{\"c\":\"x > 1\"}" http://h`"' # the backtick spelling
# Recall is untouched: a real write beside a quoted substitution still blocks — in the same
# command, behind a separator, and inside a sed -i script — and so does the card's control.
expect block 'echo hi > out.txt'
expect block 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)" > pkg/x.ts'
expect block 'X="$(git rev-parse HEAD)" ; sed -i s/a/b/ pkg/x.ts'
expect block 'sed -i "s/a/$(date +%F)/" pkg/x.ts'
# The fail-OPEN mirror: one `"` in the body used to leave the outer string hanging, so the
# separator behind it went inert and the real write rode through as text.
expect block "echo \"\$(printf '\"')\" ; sed -i s/a/b/ pkg/x.ts"
expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts" # nested
# The heredoc scan reads the body the same way: the ` #` inside it is no comment, so the
# introducer behind it is found and the body stays prose.
expect allow "$(printf 'echo "$(echo " #")" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"

echo "== a \${…} inside \"…\" is its own quoting context too (objectstack#24901) =="
CWD="$MAIN"
# The measured false block: the `"` that opens the default was read as CLOSING the outer string,
# so `b}` became a write target. Pure reads — nothing is written. Each one exercises a different
# pass: tokenisation (the first two), segmentation (the `;`), nesting both ways (the third).
expect allow 'echo "${x:-"a > b"}"'
expect allow 'echo "${x#"a > b"}"'
expect allow 'echo "${x:-"$(printf "a > b")"}"'
expect allow 'echo "${x:-"a; sed -i s/a/b/ pkg/x.ts"}"'
expect allow "echo \"\${x:-'a > b'}\"" # control: no nested `"`, allowed before and after
# The heredoc scan agrees: the ` #` inside the expansion is no comment, so the introducer
# behind it is found and the body stays prose.
expect allow "$(printf 'echo "${x:-" #"}" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
# Recall is untouched: a write behind, beside or inside the script of a quoted expansion. A
# '…' run holding no character the shells disagree on is skipped, so the write behind it stays.
expect block 'sed -i "s/a/${x:-"b"}/" pkg/x.ts'
expect block 'sed -i "s/a/${x}/" pkg/x.ts'
expect block 'echo "${HOME}" > pkg/x.ts'
expect block 'echo "${x:-"a"}" ; sed -i s/a/b/ pkg/x.ts'
expect block "echo \"\${x:-'a'}\" ; sed -i s/a/b/ pkg/x.ts"
# A `$(…)` inside the expansion is read by the same function, quotes and all: the `"` inside
# its single quotes closes nothing, so the separator behind the word still splits.
expect block "echo \"\${x:-\$(printf '\"')}\" ; sed -i s/a/b/ pkg/x.ts"

echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
CWD="$MAIN"
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""
expect allow 'echo pkg/x.ts | xargs sed -i s/a/b/'
expect allow 'node -e "fs.writeFileSync(\"pkg/x.ts\", \"y\")"'
expect allow 'python3 -c "open(\"pkg/x.ts\",\"w\").write(\"y\")"'
expect allow 'echo "$(sed -i s/a/b/ pkg/x.ts)"' # a QUOTED $(…) body is one inert word
expect allow 'echo "${x:-"$(sed -i s/a/b/ pkg/x.ts)"}"' # …and so is a QUOTED ${…} body
expect allow 'echo hi > "${x:-"a > b"}"' # a target holding an expansion
# A '…' run inside a quoted ${…} that holds `}` (or `"` `\` `$` and a backtick): bash 5.2 reads
# it as quoted and POSIX shells as literal, so they end the expansion in different places and
# everything after it is inert. Under bash the first is a pure read, under dash a real write.
expect allow "echo \"\${x:-'}\"; sed -i s/a/b/ pkg/x.ts'}\""
expect allow "echo \"\${x:-'}'}\" ; sed -i s/a/b/ pkg/x.ts"
expect allow 'sed -i s/a/b/ $SOMEDIR/x.ts'
expect allow 'sed -i s/a/b/ pkg/*.ts'

Expand Down
94 changes: 92 additions & 2 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# copied from objectstack@7c2c5aedd94d7b0d94c91432bc607862e9c83c6c
# copied from objectstack@338297e46018ae96256262590a308c8be735f825
# guard-main-checkout-bash.sh — PreToolUse guard: the SAME worktree-first rule as
# guard-main-checkout.sh, applied to file writes that arrive through Bash instead of
# through the Edit / Write / NotebookEdit tools.
Expand Down Expand Up @@ -32,11 +32,15 @@
#
# Fails open, deliberately, on:
# - wrapped invocations: bash -c '…', xargs, ssh host '…', make, a shell script that
# writes (same known boundary guard-shared-stash.sh documents for itself);
# writes, a command inside a QUOTED "$(…)" (same known boundary guard-shared-stash.sh
# documents for itself);
# - programs whose writes live inside their own source text: python -c "open(p,'w')",
# node -e, awk > (the target is not a shell token, so there is nothing to read);
# - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable
# without running it;
# - everything after a `'` inside a QUOTED "${…}" unless the quoted run holds none of
# `"` `\` `$` `}` or a backtick: bash and POSIX shells disagree on where such an expansion
# ends (subst_end());
# - relative targets when the payload carries no cwd.
# Each of those is a hole by choice, not by accident: OS_ALLOW_MAIN_EDITS=1 already exists
# for anyone who means to write there, and the target of this guard is the reflexive
Expand All @@ -45,6 +49,8 @@
# Writing ABOUT the ban must never trip the ban (objectstack#4890's lesson). Four layers:
# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is
# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass;
# a `$(…)`, a backtick or a `${…}` inside "…" is a quoting context of its own, so a `"` in
# its body nests instead of closing the outer string (subst_end(), shared by every pass);
# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
# body are documentation, not commands, and segmentation alone (which splits on
# newlines) would happily read them as such;
Expand Down Expand Up @@ -114,6 +120,81 @@ fi

[ -n "$cmd" ] || exit 0

# --- an expansion inside "…" is its own quoting context --------------------------------
# Inside "…", `$(`, a backtick or `${` opens a NEW quoting context: a `"` in the body starts a
# string of its own and does not close the outer one. Each scanner below used to read that `"`
# as the closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was
# taken for a redirect whose "target" was the next fragment — a false BLOCK (objectstack#24812);
# the same reading of `echo "${x:-"a > b"}"` named `b}` as a target (objectstack#24901). All
# three quote-aware scanners hand such a body to this ONE function, so they agree on where it
# ends. guard-shared-stash.sh and guard-tree-enum.sh segment commands the same way and carry a
# byte-identical copy; their self-tests fail when a copy differs from this one. (objectstack's
# guard-process-kill.sh carries one too; it has no twin in this repo.)
#
# subst_end S I: when S[I] opens such a context (the `$` of a `$(` or a `${`, or a backtick),
# sets SUBST_END to the index of the character that closes it and returns 0; otherwise returns 1
# and changes nothing. The body is read with its own quotes, escapes and nesting; when the input
# ends first, SUBST_END is the last index — the rest is one inert run (bash refuses such a
# command anyway). The body is kept verbatim as part of the word, so a target holding it still
# fails open on its `$`.
#
# A `${…}` body reads like "…" with one more closer: `"`, `$(`, `${` and a backtick nest in it,
# a backslash escapes, and `}` closes it. A `'` there is where shells part ways, measured: bash
# 5.2 reads `'…'` as a quoted run while it looks for the `}` (`"${x:-'}'}"` prints `'}'`), and
# `bash --posix` and dash read the `'` as a literal. The two readings agree only when the run
# holds none of `"` `\` `$` `}` or a backtick, so such a run is skipped, and any other `'` makes
# the rest one inert run: an extent that depends on the shell is not one this guard can read.
#
# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment, an unquoted `${…}`
# holding one) moves the end — too early, and the rest is read as it was before this function
# existed; too late, and the text swallowed with the body is inert, i.e. fails open.
SUBST_END=0
subst_end() {
local s="$1" i="$2" n=${#1} st ch top run
case "${s:i:2}" in
'$(') st=')' ; i=$((i + 1)) ;;
'${') st='}' ; i=$((i + 1)) ;;
'`'*) st='`' ;;
*) return 1 ;;
esac
for ((i = i + 1; i < n; i++)); do
ch="${s:i:1}" ; top="${st:${#st}-1:1}"
if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi
if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi
if [ "$top" = '"' ] || [ "$top" = '}' ]; then
case "$ch" in
'"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;;
'`') st+='`' ;;
'$')
case "${s:i+1:1}" in
'(') st+=')' ; i=$((i + 1)) ;;
'{') st+='}' ; i=$((i + 1)) ;;
esac ;;
'}') [ "$top" = '}' ] && st="${st%?}" ;;
"'")
if [ "$top" = '}' ]; then
run="${s:i+1}"
case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac
run="${run%%"'"*}"
case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac
i=$((i + ${#run} + 1))
fi ;;
esac
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
continue
fi
case "$ch" in
"'" | '"') st+="$ch" ;;
'(') st+=')' ;;
')') [ "$top" = ')' ] && st="${st%?}" ;;
'`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;;
esac
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
done
SUBST_END=$((n - 1))
return 0
}

# --- heredoc bodies are text, not commands ---------------------------------------------
# `cat > /tmp/notes.md <<EOF` … `EOF` carries prose over several lines. Segmentation splits
# on newlines, so without this pass a documented example inside the body ("sed -i … main
Expand Down Expand Up @@ -144,6 +225,9 @@ strip_line_comment() {
ch="${s:i:1}"
if [ -n "$q" ]; then
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
i=$SUBST_END ; continue
fi
[ "$ch" = "$q" ] && q=""
continue
fi
Expand Down Expand Up @@ -213,6 +297,9 @@ split_segments() {
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
esac
fi
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
fi
seg+="$ch"
[ "$ch" = "$q" ] && q=""
continue
Expand Down Expand Up @@ -271,6 +358,9 @@ tokenize() {
i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;;
esac
fi
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue
fi
if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi
have=1
continue
Expand Down
39 changes: 38 additions & 1 deletion .claude/hooks/guard-shared-stash.selftest.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# copied from objectstack@7c2c5aedd94d7b0d94c91432bc607862e9c83c6c
# copied from objectstack@338297e46018ae96256262590a308c8be735f825
# Self-test for guard-shared-stash.sh — run it after touching that hook:
#
# .claude/hooks/guard-shared-stash.selftest.sh
Expand Down Expand Up @@ -148,6 +148,43 @@ expect block 'echo "a \\" ; git stash pop'
# single quotes the branch would swallow the closing `'` and pass the stash behind it.
expect block "echo 'a \\' ; git stash pop"

echo "== a \$(…), backtick or \${…} inside \"…\" is its own quoting context (objectstack#24901) =="
# Reading the `"` inside the body as CLOSING the outer string split the body at its `;`, so a
# pure read became a stash. The first three were BLOCKED before subst_end() existed here; the
# fourth (both kinds nested) was allowed then only because two misread quotes cancelled out,
# and the single-level `echo "x; git stash pop"` was allowed throughout.
expect allow 'echo "a $(echo "x; git stash pop")"'
expect allow 'echo "a `echo "x; git stash pop"`"'
expect allow 'echo "${x:-"a; git stash pop"}"'
expect allow 'echo "${x:-"$(echo "a; git stash pop")"}"'
expect allow 'echo "x; git stash pop"'
# Recall: a stash behind or beside a quoted expansion is still seen.
expect block 'echo "${x:-"a"}" ; git stash pop'
expect block 'X="${Y:-"z"}" && git stash push -m wip'
expect block 'git stash push -m "${msg:-"wip; x"}"'
# The fail-OPEN mirror: the `"` inside the single quotes left the outer string hanging, so the
# separator behind it went inert and the real stash rode through as text. Both were ALLOWED
# before subst_end() existed here.
expect block "echo \"\$(printf '\"')\" ; git stash pop"
expect block "echo \"\${x:-\$(printf '\"')}\" ; git stash pop"
# Deliberately not claimed, as before: a stash INSIDE a quoted substitution is one inert word.
expect allow 'echo "$(git stash pop)"'

echo "== the shared quoting rule is guard-main-checkout-bash.sh's, byte for byte (objectstack#24901) =="
# subst_end() lives in three hooks here. A copy that drifts is a quoting rule the guards no longer
# share, and nothing else notices: each hook's own cases keep passing on its own dialect.
fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; }
same_fn() { # same_fn <function> <file holding the copy this hook must equal>
local mine theirs
mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")"
if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then
pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}"
else
fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}"
fi
}
same_fn subst_end "$here/guard-main-checkout-bash.sh"

echo "== escape hatch =="
expect allow 'git stash pop' OS_ALLOW_STASH=1

Expand Down
76 changes: 66 additions & 10 deletions .claude/hooks/guard-shared-stash.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# copied from objectstack@7c2c5aedd94d7b0d94c91432bc607862e9c83c6c
# copied from objectstack@338297e46018ae96256262590a308c8be735f825
# guard-shared-stash.sh — PreToolUse guard: the stash stack is SHARED across worktrees.
# Blocks Bash commands that push to / pop from / drop the shared stash stack, and lets
# the read-only and SHA-pinned forms through.
Expand Down Expand Up @@ -47,10 +47,11 @@
# 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 (50 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh
# 50 = 46 `expect ` lines + 2 inline specials (empty-tool_input fail-open, no-jq fallback)
# + 2 hatch-message assertions (`lacks`/`says`, which are not `expect ` lines).
# Re-derive when the matrix changes: `grep -c '^expect ' <selftest>` + 4, and the run's own
# Self-test (62 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh
# 62 = 57 `expect ` lines + 2 inline specials (empty-tool_input fail-open, no-jq fallback)
# + 2 hatch-message assertions (`lacks`/`says`, which are not `expect ` lines) + 1 identity
# pin (`same_fn`, the subst_end() copy, which is not an `expect ` line either).
# Re-derive when the matrix changes: `grep -c '^expect ' <selftest>` + 5, and the run's own
# tail prints the total ("N passed, N failed") — keep this number equal to it.

set -uo pipefail
Expand Down Expand Up @@ -98,6 +99,63 @@ fi
# 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.
#
# INSIDE "…" a `$(`, a backtick or a `${` opens a quoting context of its own, so a `"` in its
# body nests instead of closing the outer string. Without that rule this pass read the inner
# `"` as the closer, the `;` inside the body split, and a pure read became a stash command —
# measured on this repo's copy before the fix: `echo "a $(echo "x; git stash pop")"` and
# `echo "${x:-"a; git stash pop"}"` were BLOCKED (objectstack#24901). The body is kept verbatim inside
# the segment, so a stash inside a QUOTED substitution stays the fail-open it always was.
#
# subst_end() is copied byte-for-byte from guard-main-checkout-bash.sh, whose comment above
# the function is its documentation; this hook's self-test fails when the copy differs.
SUBST_END=0
subst_end() {
local s="$1" i="$2" n=${#1} st ch top run
case "${s:i:2}" in
'$(') st=')' ; i=$((i + 1)) ;;
'${') st='}' ; i=$((i + 1)) ;;
'`'*) st='`' ;;
*) return 1 ;;
esac
for ((i = i + 1; i < n; i++)); do
ch="${s:i:1}" ; top="${st:${#st}-1:1}"
if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi
if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi
if [ "$top" = '"' ] || [ "$top" = '}' ]; then
case "$ch" in
'"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;;
'`') st+='`' ;;
'$')
case "${s:i+1:1}" in
'(') st+=')' ; i=$((i + 1)) ;;
'{') st+='}' ; i=$((i + 1)) ;;
esac ;;
'}') [ "$top" = '}' ] && st="${st%?}" ;;
"'")
if [ "$top" = '}' ]; then
run="${s:i+1}"
case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac
run="${run%%"'"*}"
case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac
i=$((i + ${#run} + 1))
fi ;;
esac
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
continue
fi
case "$ch" in
"'" | '"') st+="$ch" ;;
'(') st+=')' ;;
')') [ "$top" = ')' ] && st="${st%?}" ;;
'`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;;
esac
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
done
SUBST_END=$((n - 1))
return 0
}

segments=()
split_segments() {
local s="$1" seg="" q="" ch i n=${#1}
Expand All @@ -110,17 +168,15 @@ split_segments() {
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
esac
fi
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
fi
seg+="$ch"
[ "$ch" = "$q" ] && q=""
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
;;
Expand Down
Loading
Loading