From e006924df462ef434f42368f3a572bcc9da28701 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 17 Aug 2026 19:58:52 +0200 Subject: [PATCH] fix(agents): prove where a directory-form cp or mv actually lands Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 39 ++++++++++++++++++++++----- .claude/hooks/test-hooks.sh | 6 +++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index c624920060..c1accbb367 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -81,9 +81,9 @@ 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. +# Prints the root class of a charset-safe path token, then the path it resolved to on a second +# line, 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. operand_class() { local t="$1" canon alt cls alt_cls="" # Globs never auto-allow: bash expands them only after this hook has decided, so realpath @@ -107,7 +107,9 @@ operand_class() { [ -n "$canon" ] || return 1 cls=$(path_class "$canon") || return 1 [ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1 - printf '%s' "$cls" + # Class and resolved path together: a caller runs this in a command substitution, so a global + # set here would be set in that subshell and lost. + printf '%s\n%s' "$cls" "$canon" } # 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive @@ -194,8 +196,9 @@ check_archive_segment() { # Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens # are in SEG_TOKS. check_fileops_segment() { - local verb="$1" takes_mode ok_opts t cls seen_class="" - local path_operand=0 seen_mode=0 end_opts=0 i=1 + local verb="$1" takes_mode ok_opts t cls resolved seen_class="" derived src dest + local path_operand=0 seen_mode=0 end_opts=0 i=1 recursive=0 + local -a ops=() # Options are an allowlist per command, so anything that changes how symlinks are followed # defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while # recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch @@ -220,6 +223,7 @@ check_fileops_segment() { -?*) # Allowlist: long options and the dereferencing flags leave a residue and defer. [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" + case "${t#-}" in *[rRa]*) recursive=1 ;; esac continue ;; esac @@ -235,14 +239,35 @@ check_fileops_segment() { continue fi - cls=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME" + resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME" + cls="${resolved%%$'\n'*}" # Every operand of one operation stays in one root: see the exfiltration note above. [ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots" seen_class="$cls" + ops+=("${resolved#*$'\n'}") path_operand=1 done [ "$path_operand" = 1 ] || defer "no path operand" + + # In directory form the destination is not what the command writes: `cp x dir` writes + # `dir/x`, and `cp` follows that child when it is a symlink. This checkout is full of them — + # every `*_ee.rs` points into the sibling EE repo — so the derived path is proved too. Under + # `-r` the derived paths run the depth of the source tree, past what this can enumerate. + case "$verb" in + cp | mv) + [ "${#ops[@]}" -ge 2 ] || return 0 + dest="${ops[-1]}" + [ -d "$dest" ] || return 0 + [ "$recursive" = 0 ] || defer "recursive $verb into the existing directory \`$dest\`" + for src in "${ops[@]:0:${#ops[@]}-1}"; do + derived=$(realpath -m -- "$dest/${src##*/}" 2>/dev/null) + [ -n "$derived" ] || defer "cannot resolve where \`$src\` lands in \`$dest\`" + cls=$(path_class "$derived") || defer "\`$src\` lands on \`$derived\`, outside every root" + [ "$cls" = "$seen_class" ] || defer "\`$src\` lands on \`$derived\`, in another root" + done + ;; + esac } split_segments "$cmd" diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index b4fdfc4468..7a76adbd3a 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -191,6 +191,12 @@ run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b' EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs" run $A none "cp $EE/README.md $CWD/README.copy" +# Directory form writes DEST/basename(SRC), and `cp` follows that child when it is a symlink — +# every `*_ee.rs` in this checkout is one, pointing into the EE repo. +run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp frontend/apps_ee.rs backend/windmill-api/src" +run $A none "cp -r frontend/x backend" # derived paths run the depth of the source tree +run $A allow "cp frontend/a.ts backend" # ... an ordinary child still proves echo [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }