From d51de5b7bedd1fa80febedcbf18fef351d618507 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 17 Aug 2026 19:42:11 +0200 Subject: [PATCH] fix(agents): never prove a command carrying a substitution or relative cd Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 9 +++++++++ .claude/hooks/guard-rm-outside-tmp.sh | 9 +++++++++ .claude/hooks/lib-guarded-verb.sh | 12 +++++------- .claude/hooks/test-hooks.sh | 6 ++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 62dbbe18ad..c22a82b40b 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -70,6 +70,15 @@ defer() { exit 0 } +# A command substitution is concatenated into the word it sits in, and splitting the command on +# its opener cuts that word in half: `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/` +# and the traversal validated as an unrelated segment. Nothing here can evaluate the +# substitution, so a command carrying one is never proved — heredoc bodies excepted, since +# those are data the split already dropped. +case "$(strip_heredoc_bodies "$cmd")" in + *'$('* | *'`'*) defer "command substitution in the command line" ;; +esac + # Prints the root class of a charset-safe path token, resolving a relative one against the # tracked working directory. Fails, printing nothing, when the token is unsafe to reason about # or lands outside every root. diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 7c352dd3a7..feb6762e5d 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -45,6 +45,15 @@ defer() { exit 0 } +# A command substitution is concatenated into the word it sits in, and splitting the command on +# its opener cuts that word in half: `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/` +# and the traversal validated as an unrelated segment. Nothing here can evaluate the +# substitution, so a command carrying one is never proved — heredoc bodies excepted, since +# those are data the split already dropped. +case "$(strip_heredoc_bodies "$cmd")" in + *'$('* | *'`'*) defer "command substitution in the command line" ;; +esac + # Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative # operands against $seg_cwd. Returns only once every operand is an auto-allowable target; # anything it cannot prove defers instead. diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index c96f41e842..10d6280c78 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -168,7 +168,7 @@ segment_tokens() { # Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the # `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable, -# `-`, an option, no operand at all (`cd` alone is $HOME), or more than one. +# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one. # # Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and # `;` runs the next command anyway, leaving it in the directory it started in. So a caller may @@ -180,12 +180,10 @@ apply_cd() { [ "$#" -eq 1 ] || return 1 t="$1" [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 - case "$t" in -*) return 1 ;; esac - case "$t" in - /*) realpath -m -- "$t" 2>/dev/null ;; - *) [ -n "$cwd" ] || return 1 - realpath -m -- "$cwd/$t" 2>/dev/null ;; - esac + # Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first, + # so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out. + case "$t" in /*) ;; *) return 1 ;; esac + realpath -m -- "$t" 2>/dev/null } # Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, `repo` diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index d2ad4378c1..e770760b7f 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -132,6 +132,11 @@ run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub" run $G ask "rm -rf /tmp/clone/.git" # history is never in a class run $G ask "rm -rf /tmp/scratch/id_rsa.key" run $G none "cd /tmp >$OUT; rm -f /tmp/a" +# A substitution is concatenated into its word, so splitting on it would prove only the literal +# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first. +run $G ask 'rm -rf /tmp/a/`printf ../../etc`' +run $G ask 'rm -rf /tmp/a/$(printf ../../etc)' +run $G ask "cd ssh && rm -rf moduli" echo echo "== allow-fileops-in-tmp.sh ==" @@ -180,6 +185,7 @@ run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak" # what a later operand resolves to — neither may ride along on an allow. run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b" run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link" +run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b' echo [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }