fix(agents): never prove a command carrying a substitution or relative cd

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-17 19:42:11 +02:00
parent a56f5c0dc2
commit d51de5b7be
4 changed files with 29 additions and 7 deletions
+9
View File
@@ -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.
+9
View File
@@ -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.
+5 -7
View File
@@ -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`
+6
View File
@@ -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; }