Skip to content
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: 56 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,62 @@ expect allow "$(printf "cat > /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts
expect block "$(printf 'cat > %s/notes.md <<EOF\nhello\nEOF\n' "$MAIN")"
expect allow 'grep -q worktree <<<"$AGENTS"'

echo "== non-ASCII codepoints are never operators; ASCII redirects still are (objectstack#10247) =="
CWD="$MAIN"
# A pure-read `node -e` whose string literal carries U+2192. Nothing is redirected: the
# arrow is a display separator. Every byte of a non-ASCII codepoint is >= 0x80 and `>` is
# 0x3e, so no arrow can ever reach operator position.
expect allow "node -e \"console.log(steps.map(st=>st.a).join(' $(printf '\xe2\x86\x92') '))\""
expect allow "echo 'build $(printf '\xe2\x86\x92') test $(printf '\xe2\x86\x92') ship'"
expect allow "grep -n '$(printf '\xe2\x86\x92') the URL' AGENTS.md" # arrows fill this repo's prose
expect allow "echo 'a $(printf '\xe2\x87\x92') b'"
# The negative twins: an otherwise-similar command with a REAL ASCII redirect still blocks,
# so the allow side above is widened for non-ASCII only and not for redirection at large.
expect block "node -e \"console.log(1)\" > out.log"
expect block "echo 'build $(printf '\xe2\x86\x92') ship' > steps.txt"
expect block "echo 'a $(printf '\xe2\x86\x92') b' >> pkg/x.ts"

echo "== \\\" inside a double-quoted word does not end the quote (objectstack#10247 real cause) =="
CWD="$MAIN"
# The shape from the card: an escaped \" used to close the string, after which the JS arrow
# function `st=>` put a real `>` in operator position and the guard named the JS tail that
# followed it as a write target. Pure read — must be allowed.
expect allow 'node -e "const j=require(\"./a.json\"); console.log(j.x.map(st=>st.a).join(\" - \"))"'
expect allow 'node -e "console.log(\"a\", x.map(s=>s.t))"'
expect allow 'grep -rn "he said \"sed -i\" once" .claude/'
# Negative twins: a real write is still caught even when an escaped quote precedes it.
expect block 'node -e "console.log(\"hi\")" > pkg/out.json'
expect block 'sed -i "s/\"a\"/\"b\"/" pkg/x.ts'

echo "== a shell COMMENT is text, not a command (objectstack#10570) =="
CWD="$MAIN"
# The measured false blocks: prose in a comment put a real `>` in operator position and the
# next word was named as a write target. Nothing in either command writes anything.
expect allow "$(printf '# rename foo -> bar\necho hello\n')"
expect allow "$(printf '# sitting 1 landed; card stays open for sitting 2 -> pm:dispatched goes, pm:queue returns\ncurl -s https://example.com/a\ncurl -s https://example.com/b\n')"
expect allow 'echo hi # then; tee pkg/x.ts would write it down'
expect allow "$(printf '# step one; then a -> b\n# 2> is not a redirect here either\ngit status\n')"
# Recall is untouched: a real redirect on a LATER line still blocks, and so does one on the
# SAME line ahead of an inline comment.
expect block "$(printf '# rename foo -> bar\necho x > pkg/x.ts\n')"
expect block 'echo x > pkg/x.ts # write it down'
expect block "$(printf '# a comment; with a separator\nsed -i s/a/b/ pkg/x.ts\n')"
# Word start is the whole rule: these `#`s are not comments and must not change a verdict.
expect block 'touch foo#bar'
expect block 'rm -rf pkg/x.ts#old'
expect allow 'curl -s "https://example.com/docs#frag"'
expect allow "curl -s https://example.com/docs#a-real-redirect-would-be > /dev/null"
expect allow "grep -n '#' README.md"
expect allow "sed 's/#//' README.md"
expect allow 'echo "# not a comment > pkg/x.ts"'
# `${x#y}` / `${#arr[@]}` — a parameter expansion, not a comment. Swallowing the line here
# would drop the redirect behind it, so the negative twin is the load-bearing case.
expect allow 'echo ${x#pkg/} '
expect block 'echo ${#TOK[@]} > pkg/x.ts'
expect block 'echo ${x#a} > pkg/x.ts'
# an escaped `\#` outside quotes is a literal, not a comment opener
expect allow 'echo \# not a comment'

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\""
Expand Down
82 changes: 75 additions & 7 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,37 @@
# for anyone who means to write there, and the target of this guard is the reflexive
# `sed -i` an agent reaches for mid-task, not a determined evader.
#
# Writing ABOUT the ban must never trip the ban (objectstack#4890's lesson). Two layers:
# Writing ABOUT the ban must never trip the ban (objectstack#4890's lesson). Three 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;
# 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.
# newlines) would happily read them as such;
# 3. a backslash-escaped `\"` inside a double-quoted word does NOT end the quote — POSIX
# says `\` keeps its meaning before `"`, `\`, `$` and a backtick there, and nowhere else.
# Layer 1 used to end the string at the `\"`, and everything after it — the rest of a
# `node -e "…"` program — was then read as bare shell. A JS arrow function `st=>st.x`
# in that tail put a REAL ASCII `>` in operator position, so the guard named the
# following JS fragment as a write "target" and blocked a pure-read command (objectstack#10247).
# Single quotes take no escapes: inside '…' a backslash is literal, as in a real shell.
# 4. shell COMMENTS are text, not commands — both quote-aware passes stop at an unquoted
# `#` that starts a WORD and resume at the next newline (objectstack#10570). A comment cannot
# write anything, so reading one as a command is a false BLOCK: prose arrows (`->`,
# `=>`) put a real `>` in operator position and the word after it was named as a write
# target, while a `;` in the same comment first split it into its own "segment".
# The word-start condition is the whole rule — `foo#bar`, `${x#y}`, `curl 'url/#frag'`,
# `sed 's/#//'` and `grep '#'` are NOT comments and are left exactly as they were. A
# comment never begins inside '…' or "…" (layer 1 owns those) nor inside a heredoc body
# (layer 2 has already dropped those lines before this pass runs).
#
# Redirection is recognised on ASCII operators ONLY — `>` `>>` `<` `<<` and their fd-prefixed
# forms. No non-ASCII codepoint is ever an operator or an operator boundary, and this is
# structural rather than a list to maintain: every byte of a non-ASCII UTF-8 codepoint is
# >= 0x80, while `>` is 0x3e and `<` is 0x3c, so `→` (e2 86 92), `⇒` (e2 87 92) and `➜`
# (e2 9e 9c) cannot collide with an operator byte-wise or character-wise. That matters
# because `→` runs all through this repo's own AGENTS.md — one per Prime Directive clause,
# and again in the state-classification rules — so echoing or grepping that prose must stay
# allowed. The self-test pins both directions.
#
# Exit-code contract, mirroring both sibling hooks: 0 = allow, 2 = block with the reason on
# stderr.
Expand Down Expand Up @@ -117,21 +142,47 @@ strip_heredocs() {

# --- split the command into shell segments, honouring quotes ---------------------------
# Verbatim from guard-shared-stash.sh. A separator inside '…' or "…" does NOT split, so
# writing *about* the ban is never caught by the ban.
# writing *about* the ban is never caught by the ban. The same goes for a separator inside
# a COMMENT: `word` tracks whether the next character would open a new WORD, which is the
# only position where an unquoted `#` begins a comment — the comment then runs to the next
# newline and never splits, tokenises or contributes a target (objectstack#10570).
#
# What resets `word` is exactly what delimits a word in the shell: start of input, blank,
# and the metacharacters `; | & ( ) newline > <`. `{` and `}` do NOT — they are reserved
# words rather than metacharacters, so `#` right after one continues the word. That is not
# cosmetic: this pass splits on `{`/`}` anyway, and treating the `#` of `${#arr[@]}` as a
# comment would swallow the rest of the line — including a real redirect behind it.
segments=()
split_segments() {
local s="$1" seg="" q="" ch i n=${#1}
local s="$1" seg="" q="" ch i n=${#1} word=0
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
fi
case "$ch" in
"'" | '"') q="$ch" ; seg+="$ch" ;;
';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;;
*) seg+="$ch" ;;
'#')
if [ "$word" = 0 ]; then
# comment: skip to (not past) the newline, which still separates as usual
while [ $((i + 1)) -lt "$n" ] && [ "${s:i+1:1}" != $'\n' ]; do i=$((i + 1)); done
continue
fi
seg+="$ch" # foo#bar, ${x#y}, url/#frag
;;
"'" | '"') q="$ch" ; seg+="$ch" ; word=1 ;;
';' | '|' | '&' | '(' | ')' | $'\n') segments+=("$seg") ; seg="" ; word=0 ;;
'{' | '}') segments+=("$seg") ; seg="" ; word=1 ;;
' ' | $'\t') seg+="$ch" ; word=0 ;;
'>' | '<') seg+="$ch" ; word=0 ;;
*) seg+="$ch" ; word=1 ;;
esac
done
segments+=("$seg")
Expand All @@ -142,6 +193,9 @@ split_segments() {
# enough there. Here the whole argument list matters, and `read -r -a` would promote a
# QUOTED ">" or "sed -i" to a real operator/command — exactly the false positive that makes
# a guard get disabled. So: quotes are stripped and their contents are inert.
# A comment is inert here too: `have` is already the "a word is open" flag, so an unquoted
# `#` with have=0 is at word start and begins a comment (objectstack#10570). `foo#bar` and
# `${x#y}` reach this point with have=1 and stay part of the token.
# TOK[] = token values (unquoted); TOP[] = "op" for an unquoted redirection operator.
TOK=(); TOP=()
tokenize() {
Expand All @@ -150,11 +204,25 @@ tokenize() {
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
'"' | '\' | '$' | '`')
i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;;
esac
fi
if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi
have=1
continue
fi
case "$ch" in
'#')
if [ "$have" = 0 ]; then
# word-start `#`: comment, inert to the next newline
while [ $((i + 1)) -lt "$n" ] && [ "${s:i+1:1}" != $'\n' ]; do i=$((i + 1)); done
continue
fi
tok+="$ch"
;;
"'" | '"') q="$ch" ; have=1 ;;
'\')
i=$((i + 1))
Expand Down
Loading