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
29 changes: 29 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,35 @@ expect allow 'grep -rn "he said \"sed -i\" once" .claude/'
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 (#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
50 changes: 45 additions & 5 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@
# 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.
# 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 (#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
Expand Down Expand Up @@ -138,10 +147,19 @@ strip_heredocs() {

# --- split the command into shell segments, honouring quotes ---------------------------
# A separator inside '…' or "…" does NOT split, so writing *about* the ban is never caught
# by the ban.
# 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 (#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
Expand All @@ -156,9 +174,20 @@ split_segments() {
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 @@ -168,6 +197,9 @@ split_segments() {
# The whole argument list matters here, 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 (#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 @@ -187,6 +219,14 @@ tokenize() {
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