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
27 changes: 27 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,33 @@ 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 (#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\x9e\x9c') API:' boot.log" # the CLI banner glyph
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 (#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 "== 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
31 changes: 29 additions & 2 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,27 @@
# 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 (#4890's lesson). Two layers:
# Writing ABOUT the ban must never trip the ban (#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 (#10247).
# Single quotes take no escapes: inside '…' a backslash is literal, as in a real shell.
#
# 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 `➜` is in this repo's own CLI boot banner (`➜ API:`, `➜ Console:`), so echoing
# or grepping a banner must stay allowed. The self-test pins both directions.
#
# Exit-code contract, mirroring guard-main-checkout.sh: 0 = allow, 2 = block with the reason
# on stderr.
Expand Down Expand Up @@ -130,6 +145,12 @@ split_segments() {
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
Expand All @@ -155,6 +176,12 @@ 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
Expand Down
Loading