fix(agents): treat sibling checkouts as separate roots

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-17 19:50:58 +02:00
co-authored by Claude Opus 5
parent d51de5b7be
commit 9a8b01a864
3 changed files with 20 additions and 11 deletions
+9 -7
View File
@@ -17,12 +17,14 @@
# they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix,
# and requiring every operand is the point.
#
# One operation may not straddle the two roots, sources included. A copy out of a checkout into
# /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)`
# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back; keeping
# every operand of one operation inside a single root closes that without restating the deny
# list here. The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable:
# deleting a file there has never prompted, and moving or chmod-ing one is not the graver act.
# One operation may not straddle two roots, sources included, and a sibling checkout is a
# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout
# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)`
# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one
# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every
# operand of one operation inside a single root closes both without restating those rules here.
# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a
# file there has never prompted, and moving or chmod-ing one is not the graver act.
#
# Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token
# must consist only of alphanumerics and `. _ / -`. That set contains none of the characters
@@ -235,7 +237,7 @@ check_fileops_segment() {
cls=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME"
# 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 both /tmp and a checkout"
[ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots"
seen_class="$cls"
path_operand=1
done
+6 -4
View File
@@ -186,9 +186,11 @@ apply_cd() {
realpath -m -- "$t" 2>/dev/null
}
# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, `repo`
# for one strictly inside a git working tree located under $HOME. Fails, printing nothing, for
# anything else — those are the only two roots the guards are willing to touch unprompted.
# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or
# `repo:<root>` for one strictly inside the git working tree at <root>, itself under $HOME.
# Fails, printing nothing, for anything else — those are the only roots the guards are willing
# to touch unprompted. The root is part of the class so that a caller pairing two operands can
# tell one checkout from another: sibling repos are separate permission boundaries, not one.
#
# The `repo` class trades on "this is a project under version control" being lower-stakes than
# the same act elsewhere — NOT on full recoverability: committed content is restorable via git,
@@ -227,7 +229,7 @@ path_class() {
if [ "$canon" = "$root" ]; then
[ -f "$root/.git" ] || return 1
fi
printf 'repo'
printf 'repo:%s' "$root"
}
# 0 iff <verb> ($1) runs as a command word anywhere in <command> ($2). Mirrors how a Bash
+5
View File
@@ -186,6 +186,11 @@ run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak"
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'
# A sibling checkout is a different root: its files are outside what the Read tool is confined
# to, and copying them in would hand back what that confinement withholds.
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"
echo
[ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }