diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index 28ededaf8b..c4d5f5ac9a 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -15,13 +15,106 @@ set -f # the command splits on `; & |` and newlines, and a leading env assignment or process wrapper # (`timeout 5 rm`, `xargs rm`) is skipped before the command word is read. # -# The split set also carries the characters that open a nested command — `$(`, backticks, `( )` -# and `{ }` — because a rule matches the verb inside one (`echo $(rm -rf ~)` prompts), and a -# separator that only ends statements would read that as an `echo`. -# -# The scan is textual, so a heredoc body that merely contains the verb (`cat > s.sh < ($1) starts with a command that only reads its input. An allowlist, because the +# opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`, +# `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only +# costs a prompt. Text with no command word in it is not evidence of a reader either. +reads_only() { + local w + for w in $1; do + w="${w//[\"\'\\]/}" + w="${w%%<<*}" # a redirect needs no space: `cat<'* | '<'*) continue ;; esac + case "${w##*/}" in + cat | tee | head | tail | grep | sed | awk | sort | uniq | wc | cut | diff | tr \ + | jq | yq | gh | git | base64 | column | envsubst | python | python3 | node \ + | psql | mysql | sqlite3 | wmill) return 0 ;; + esac + return 1 + done + return 1 +} + +# A heredoc body is data rather than commands only when its delimiter is quoted and nothing +# executes it; a rule doesn't match a verb inside such a body, and a PR body would otherwise +# prompt for every `rm` in its text. Dropping one needs all of that, a delimiter that could +# really open a heredoc, and a terminator line — failing any part, nothing is dropped. +strip_heredoc_bodies() { + local -a lines=() + local line delim rest after trimmed piped quoted i j n + while IFS= read -r line; do lines+=("$line"); done <<< "$1" + n=${#lines[@]} + i=0 + while [ "$i" -lt "$n" ]; do + line="${lines[$i]}" + printf '%s\n' "$line" + i=$((i + 1)) + # A `#` opens a comment, and a comment opens no heredoc — including mid-line, as in + # `echo hi # cat < f`); prose after it means the `<<` sits + # inside a string (`echo "cat < f"` ends its redirect-looking text with the + # closing quote. That also refuses `cat < "f"`, a real heredoc, which only over-prompts. + after="${rest#"$delim"}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in + *[\"\'\\]*) continue ;; + "" | '>'* | '<'* | '|'* | [0-9]'>'* | [0-9]'<'*) ;; + *) continue ;; + esac + # A real delimiter is a bare word or one wholly quoted (`<<'EOF'`, `<<\EOF`); a stray quote + # left in it means the `<<` was quoted prose. + quoted=0 + case "$delim" in + \'*\' | \"*\") delim="${delim:1:${#delim}-2}" quoted=1 ;; + \\?*) delim="${delim#\\}" quoted=1 ;; + esac + case "$delim" in + [A-Za-z_]*) ;; + *) continue ;; + esac + case "$delim" in *[!A-Za-z0-9_]*) continue ;; esac + # Only a quoted delimiter makes the body inert. Unquoted, the shell expands it before the + # consumer ever sees it, so a `$(rm -rf ~)` written in the body runs whatever reads it. + [ "$quoted" = 1 ] || continue + # Two commands can see this body: the one the `<<` belongs to, and anything it is then piped + # into. The first is whatever was started last before the `<<`, so splitting the text there + # on separators and substitution openers and taking the final piece finds `cat` in + # `--title "fix(agents): …" --body "$(cat <<`, without the title's parenthesis standing in + # for it. A line continuation (`bash \` then `<<'EOF'`) leaves that piece empty, which is + # not evidence of a reader and so keeps the body. + reads_only "$(printf '%s' "${line%%<<*}" | tr ';&|()`' '\n' | grep -v '^[[:space:]]*$' | tail -1)" || continue + piped="$after" + while :; do + case "$piped" in *'|'*) ;; *) break ;; esac + piped="${piped#*|}" + reads_only "${piped%%|*}" || continue 2 + done + j="$i" + while [ "$j" -lt "$n" ]; do + trimmed="${lines[$j]#"${lines[$j]%%[![:space:]]*}"}" + [ "$trimmed" = "$delim" ] && break + j=$((j + 1)) + done + [ "$j" -lt "$n" ] && i=$((j + 1)) + done +} + runs_verb() { local verb="$1" seg w wrapped while IFS= read -r seg; do @@ -33,19 +126,24 @@ runs_verb() { case "$w" in "$verb" | */"$verb") return 0 ;; *=*) ;; # leading env assignment - -* | [0-9]*) ;; # a wrapper's own flag, or its duration - *'>'* | *'<'*) ;; # leading redirect, `>/dev/null rm ...` - '!' | if | then | elif | else | while | until | do) ;; # keywords, never the command - timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) wrapped=1 ;; + -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect + [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose + '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command + timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) + wrapped=1 ;; # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), - # so past a wrapper the whole segment is scanned instead of stopping at the first - # ordinary word. Before one, that word is the command and the verb cannot follow it. + # so past a wrapper the scan runs to the end of the segment instead of stopping at the + # first ordinary word. Before one, that word is the command and the verb cannot follow + # it. Nothing bounds the scan: a wrapper takes unboundedly many operands + # (`env -u A -u B …`), and any cutoff — a word count, or stopping at the first quoted + # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the + # price, and it only over-prompts. *) [ "$wrapped" = 1 ] || break ;; esac done # `tr` and not `${2//[...]}`: a `}` inside the bracket expression closes the expansion # itself, which silently leaves the command unsplit and every separator unseen. - done <<< "$(printf '%s' "$2" | tr ';&|(){}`' '\n')" + done <<< "$(strip_heredoc_bodies "$2" | tr ';&|()`' '\n')" return 1 } diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 905b251a4e..65631b6a48 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -19,10 +19,11 @@ run() { # run else got=$(printf '%s' "$out" | jq -r '.hookSpecificOutput.permissionDecision // "PARSE-ERROR"' 2>/dev/null || echo PARSE-ERROR) fi + local shown="${cmd//$'\n'/ ⏎ }" if [ "$got" = "$want" ]; then - printf ' ok %-5s %s\n' "$got" "$cmd" + printf ' ok %-5s %s\n' "$got" "$shown" else - printf 'FAIL want=%-5s got=%-5s %s\n %s\n' "$want" "$got" "$cmd" "$out" + printf 'FAIL want=%-5s got=%-5s %s\n %s\n' "$want" "$got" "$shown" "$out" fails=$((fails + 1)) fi } @@ -59,6 +60,48 @@ run $G ask 'r\m -rf /etc' run $G ask "! rm -rf /etc" run $G ask "if true; then rm -rf /etc; fi" run $G ask ">/dev/null rm -rf $OUT" +# Data that merely mentions a verb is not a command. Both of these prompted in the field. +run $G none "$(printf 'gh pr create --body "$(cat <<%sEOF%s\ndrop `rm` and `mv` from the ask list\nrm is now guarded here\nEOF\n)"' "'" "'")" +run $G none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')" +# A wrapper's own flags and assignments are unbounded, so they may not be charged against the +# scan that looks past it — these run rm and must prompt. +run $G ask "env -i HOME=/tmp PATH=/usr/bin LANG=C USER=root SHELL=/bin/sh rm -rf /etc" +run $G ask "sudo -E -H -u root FOO=1 BAR=2 rm -rf $OUT" +run $G ask "xargs -a f -d d -E e -I {} -L 1 -n 1 rm /etc" +run $G ask "env -u A -u B -u C -u D -u E -u F -u G rm -rf /etc" +run $G ask "sudo -u 'root' rm -rf /etc" +run $G ask "$(printf 'echo hi # cat < f"\nrm -rf /etc\nEOF')" +run $G ask "$(printf 'echo "cat < /tmp/a"\nrm -rf /etc\ntrue')" +run $G ask "$(printf "echo 'cat < /tmp/a\nrm -rf /etc\nEOF' "'" "'")" +run $G none "$(printf 'cat <<%sEOF%s 2>&1 | tee /tmp/a\nrm -rf /etc\nEOF' "'" "'")" +# An unquoted body is expanded before its consumer sees it, so it is code. +run $G ask "$(printf 'cat < /tmp/a\n$(rm -rf /etc)\nEOF')" +run $G ask "$(printf 'cat < /tmp/a\nrm -rf /etc\nEOF')" +# ... but a real command after a heredoc still is one. +run $G ask "$(printf 'cat < /tmp/s.sh\nhello\nEOF\nrm -rf %s' "$OUT")" +run $G ask "$(printf 'echo "a << b"\nrm -rf %s' "$OUT")" run $G none "git rm frontend/foo.ts" run $G none 'echo $(ls /tmp)' run $G none 'grep -rn "rm" backend/' @@ -80,6 +123,8 @@ run $A ask "timeout --signal KILL 5 mv /tmp/a /etc" run $A ask "time -f FORMAT chmod 777 $OUT" run $A ask "'mv' /tmp/a /etc" run $A ask 'ch\mod 777 /etc' +run $A none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')" +run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc" run $A none "cp $CWD/AGENTS.md /tmp/a" run $A none "tar -xzf /tmp/a.tar.gz -C $OUT" run $A none "cargo build"