From 9a8b01a8641caed82ac9ffeeb6cb3650a42f0d3b Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 17 Aug 2026 19:50:58 +0200 Subject: [PATCH] fix(agents): treat sibling checkouts as separate roots Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 16 +++++++++------- .claude/hooks/lib-guarded-verb.sh | 10 ++++++---- .claude/hooks/test-hooks.sh | 5 +++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index c22a82b40b..c624920060 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -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 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index 10d6280c78..10e0072dea 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -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:` for one strictly inside the git working tree at , 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 ($1) runs as a command word anywhere in ($2). Mirrors how a Bash diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index e770760b7f..b4fdfc4468 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -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; }