From 539ca6a4fe8767fbcd3cbefedd5dd0c35091fdfd Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 19 Aug 2026 20:01:23 +0200 Subject: [PATCH] unbreak the scratch-dir permission guards on macOS (#10766) * fix(agents): unbreak the scratch-dir guards on macOS Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): fold case in the scratch-guard exclusion list Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): match the MCP cache roots exactly, not by prefix Co-Authored-By: Claude Opus 5 (1M context) * test(agents): pin the MCP cache class on the fileops guard Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 42 ++++++++------ .claude/hooks/guard-rm-outside-tmp.sh | 54 +++++++++++------- .claude/hooks/lib-guarded-verb.sh | 81 ++++++++++++++++++++++++--- .claude/hooks/test-hooks.sh | 21 +++++++ AGENTS.md | 18 +++--- 5 files changed, 163 insertions(+), 53 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 87ce6541aa..b6ab85fe85 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` / # `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes — -# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp. +# under /tmp, inside a git working tree under $HOME, or in an MCP browser cache — and +# `tar` / `unzip` confined to /tmp. # Anything else makes no decision (exit 0) and falls back to the normal permission flow, except # for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see # lib-guarded-verb.sh). @@ -28,9 +29,10 @@ # 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 +# must consist only of alphanumerics and `. _ / -`, the one exception being the leading `~/` or +# `$HOME/` that `expand_home_prefix` rewrites first. That set contains none of the characters # bash uses for quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), nor -# any glob character, so all of those forms fail by construction. `realpath -m` then resolves +# any glob character, so all of those forms fail by construction. `canon_path` then resolves # `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught. # # `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because @@ -52,7 +54,8 @@ # extracts. The archive itself must be under /tmp to get here, so this is a hazard only for # archives fetched from an untrusted source into the scratch dir. # -# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. +# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev +# env and macOS; with neither backend available it proves nothing and every op falls back. set -uo pipefail . "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh" @@ -90,16 +93,17 @@ literal_path() { # 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="" + local t canon alt cls alt_cls="" + t=$(expand_home_prefix "$1") literal_path "$t" || return 1 case "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + /*) canon=$(canon_path "$t") ;; *) # A `cd` may fail at runtime and leave the command where it started, so a relative # operand has to land in the same root either way. [ -n "$seg_cwd" ] || return 1 - canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + canon=$(canon_path "$seg_cwd/$t") if [ -n "$alt_cwd" ]; then - alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null) + alt=$(canon_path "$alt_cwd/$t") [ -n "$alt" ] || return 1 alt_cls=$(path_class "$alt") || return 1 fi @@ -116,13 +120,14 @@ operand_class() { # 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive # parser's stricter check; everything else goes through operand_class. under_tmp() { - local t="$1" canon + local t canon + t=$(expand_home_prefix "$1") literal_path "$t" || return 1 case "$t" in /*) ;; *) return 1 ;; esac - canon=$(realpath -m -- "$t" 2>/dev/null) + canon=$(canon_path "$t") [ -n "$canon" ] || return 1 # /tmp itself is never a target — only paths strictly inside it. - case "$canon" in /tmp/?*) return 0 ;; esac + case "$canon" in "$TMP_ROOT"/?*) return 0 ;; esac return 1 } @@ -191,7 +196,7 @@ 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 resolved seen_class="" + local verb="$1" takes_mode ok_opts t cls resolved dest seen_class="" local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0 local -a ops=() # Options are an allowlist per command, so anything that changes how symlinks are followed @@ -233,13 +238,15 @@ check_fileops_segment() { continue fi - resolved=$(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 the MCP caches, 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'}") - case "$t" in /*) ;; *) rel_operand=1 ;; esac + # Against the expanded token, since `~/a` is cwd-independent and only reads as relative + # before `expand_home_prefix` has run. + case "$(expand_home_prefix "$t")" in /*) ;; *) rel_operand=1 ;; esac path_operand=1 done @@ -260,8 +267,11 @@ check_fileops_segment() { # does not exist, while the one it actually ran in is a directory full of symlinks. [ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \ && defer "a relative operand after a \`cd\` lands in one of two directories" - [ -d "${ops[-1]}" ] \ - && defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name" + # Index arithmetic rather than `${ops[-1]}`: macOS ships bash 3.2, where a negative + # subscript is a fatal error and would abort the guard mid-decision. + dest="${ops[$((${#ops[@]} - 1))]}" + [ -d "$dest" ] \ + && defer "\`$dest\` already exists as a directory, so this $verb writes a path it does not name" ;; esac } diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 4d253ffc62..71657f031c 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target — -# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir). +# under /tmp, inside a git working tree located in $HOME (a version-controlled project dir), or +# in one of the browser-automation caches the MCP servers rebuild on demand. # Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission # prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at # all makes no decision (exit 0). @@ -14,20 +15,22 @@ # it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything. # # Deny-by-default: every token must consist only of a safe character set (alphanumerics, -# `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for +# `. _ / -` and glob chars `* ? [ ]`), the one exception being the leading `~/` or `$HOME/` that +# `expand_home_prefix` rewrites first. That set contains none of the characters bash uses for # quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), so those forms -# fail by construction rather than needing to be enumerated. `realpath -m` then resolves `..` +# fail by construction rather than needing to be enumerated. `canon_path` then resolves `..` # and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a # non-final path segment is refused because it can expand through a symlink realpath can't see. # -# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in -# lib-guarded-verb.sh. Globs auto-allow only under /tmp — elsewhere their expansion -# could reach `.git` or a dotfile the literal checks never see. Relative operands resolve +# Which targets those roots cover, and the tradeoff they rest on, is `path_class` in +# lib-guarded-verb.sh. Globs auto-allow only under /tmp and the MCP caches — elsewhere their +# expansion could reach `.git` or a dotfile the literal checks never see. Relative operands resolve # against the working directory the command runs from, which a `cd` in an earlier segment # moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a # relative operand can no longer be proved. # -# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. +# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev +# env and macOS; with neither backend available it proves nothing and every delete prompts. set -uo pipefail . "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh" @@ -51,13 +54,15 @@ has_substitution "$cmd" && defer "command substitution in the command line" # operands against $seg_cwd. Returns only once every operand is an auto-allowable target; # anything it cannot prove defers instead. check_rm_segment() { - local i=1 t canon candidates had_operand=0 end_opts=0 + local i=1 t p canon candidates had_operand=0 end_opts=0 while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do t="${SEG_TOKS[$i]}" i=$((i + 1)) + # Messages keep the token as written; everything downstream reasons about the expansion. + p=$(expand_home_prefix "$t") # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` # can't slip past): any character outside the safe set makes it unsafe to reason about. - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" + [ -n "$(printf '%s' "$p" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` # into an operand — never a real option, so defer. case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac @@ -73,25 +78,34 @@ check_rm_segment() { had_operand=1 # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. - case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac + case "$p" in */*) case "${p%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac # A relative operand has as many candidate paths as the command has candidate working # directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime # leaves the delete running in the directory it started in. - case "$t" in - /*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;; + case "$p" in + /*) candidates=$(canon_path "$p") ;; *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down" - candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + candidates=$(canon_path "$seg_cwd/$p") [ -n "$alt_cwd" ] && candidates="$candidates -$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)" +$(canon_path "$alt_cwd/$p")" ;; esac while IFS= read -r canon; do [ -n "$canon" ] || defer "cannot resolve \`$t\`" - # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its - # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the - # literal-path checks never see — so require literal operands in git repos. - case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac - path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # A glob may auto-allow only in a root where everything is deletable — /tmp and the MCP + # caches, both of which `rm -rf ` already clears wholesale, so matching inside one + # grants nothing more. In a checkout the expansion could reach `.git`, a dotfile like + # `.*`, or a nested checkout root that the literal-path checks never see, so require + # literal operands there. + case "$p" in + *[*?[]*) + case "$(path_class "$canon")" in + tmp | mcp-cache) ;; + *) defer "glob \`$t\` is outside /tmp and the MCP caches" ;; + esac + ;; + esac + path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and the MCP caches, and not inside a git checkout in \$HOME" done <<< "$candidates" done [ "$had_operand" = 1 ] || defer "no operand" @@ -136,5 +150,5 @@ for seg in "${SEGMENTS[@]}"; do done [ "$proved" = 1 ] || exit 0 -[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp, in an MCP cache, or inside a git checkout in $HOME' exit 0 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index 6ef76a5466..a1c7b79a9d 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,6 +10,45 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f +# Canonical absolute path: `..` and existing symlinks resolved, missing trailing components +# allowed. Resolving symlinks is the load-bearing half — a lexical normalizer would collapse +# `/tmp/link/..` without seeing where `link` points, and let an operand out of its root. +# GNU `realpath -m` is exactly this; BSD realpath on macOS has no `-m` and exits on it, which +# would leave every operand unresolvable and every delete prompting, so fall back to python3's +# os.path.realpath, which has the same semantics. Trying rather than probing keeps the cost off +# the Bash calls that never reach a path check — most of them. With neither available this +# prints nothing, and every caller treats that as "cannot prove". +canon_path() { + local out + out=$(realpath -m -- "$1" 2>/dev/null) && [ -n "$out" ] && { printf '%s' "$out"; return; } + python3 -c 'import os,sys;sys.stdout.write(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null +} + +# The roots every class is anchored to, in the form a canonicalized operand comes back in. On +# macOS /tmp is a symlink to /private/tmp, so a resolved scratch path never starts with `/tmp` +# and matching the literal would put every scratch path outside every class. Both exist, so +# `cd -P` resolves them without the process canon_path would spawn on every sourcing. +TMP_ROOT=$(cd -P -- /tmp 2>/dev/null && pwd) +[ -n "$TMP_ROOT" ] || TMP_ROOT=/tmp +HOME_ROOT="" +[ -n "${HOME:-}" ] && HOME_ROOT=$(cd -P -- "$HOME" 2>/dev/null && pwd) + +# Prints ($1) with a leading `~/`, `$HOME/` or `${HOME}/` — and those three words on +# their own — replaced by the home directory, so the ordinary spelling of a path outside every +# checkout can still be proved. Only that prefix and only those spellings: `~user/` names another +# account, and any other `$` is an expansion nothing here can evaluate, so both stay in the token +# and fail the caller's charset check. A quoted token keeps its quotes and fails there too. +expand_home_prefix() { + [ -n "$HOME_ROOT" ] || { printf '%s' "$1"; return; } + case "$1" in + '~' | '$HOME' | '${HOME}') printf '%s' "$HOME_ROOT" ;; + '~/'*) printf '%s/%s' "$HOME_ROOT" "${1#'~/'}" ;; + '$HOME/'*) printf '%s/%s' "$HOME_ROOT" "${1#'$HOME/'}" ;; + '${HOME}/'*) printf '%s/%s' "$HOME_ROOT" "${1#'${HOME}/'}" ;; + *) printf '%s' "$1" ;; + esac +} + # 0 iff ($1) starts with a command that only reads its input. An allowlist, because the # opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`, # `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only @@ -193,15 +232,16 @@ apply_cd() { local cwd="$1" t shift [ "$#" -eq 1 ] || return 1 - t="$1" + t=$(expand_home_prefix "$1") [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 # 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 + canon_path "$t" } -# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or +# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, +# `mcp-cache` for one in a browser-automation cache the MCP servers rebuild on demand, 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 @@ -224,19 +264,42 @@ apply_cd() { # `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends # would rename one out of those globs and hand back through `Read` exactly what they deny. path_class() { - local canon="$1" d root="" - case "$canon" in + local canon="$1" d root="" folded + # Matched against a lowercased copy: APFS is case-insensitive by default, so `.GIT` and `.git` + # are one directory, and a case-sensitive list would leave the history — and these guards' own + # settings — one keystroke from an auto-allowed delete. On a case-sensitive volume a genuinely + # distinct `.GIT/` over-matches, which costs a prompt and nothing else. `tr` and not `${x,,}`: + # macOS ships bash 3.2, which has no case-folding expansion. + folded=$(printf '%s' "$canon" | tr 'A-Z' 'a-z') + case "$folded" in *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; *"/.env" | *"/.env."*) return 1 ;; *"/secrets" | *"/secrets/"*) return 1 ;; *.pem | *.key | *"/credentials.json") return 1 ;; *"/.secret"* | *.secret | *.secrets) return 1 ;; esac - case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac - [ -n "${HOME:-}" ] || return 1 - case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac + case "$canon" in "$TMP_ROOT"/?*) printf 'tmp'; return 0 ;; esac + [ -n "$HOME_ROOT" ] || return 1 + # The Playwright MCP servers download browsers into `ms-playwright` and open a throwaway + # profile per session under `ms-playwright-mcp`; nothing prunes either, so they grow without + # bound (10G here) and clearing one costs a re-download and nothing else. They sit outside + # every checkout, where no other class reaches them. Matched including the root itself, + # unlike the repo class, because wiping the whole directory is the point. + # Each root is named exactly and then again with `/*`, rather than one trailing `*`: a case + # pattern's `*` spans the `-` as well, which would put a sibling somebody created themselves — + # `ms-playwright-mcp-backup` — in a class that auto-allows deleting it. + case "$canon" in + "$HOME_ROOT"/Library/Caches/ms-playwright | "$HOME_ROOT"/Library/Caches/ms-playwright/* \ + | "$HOME_ROOT"/Library/Caches/ms-playwright-mcp | "$HOME_ROOT"/Library/Caches/ms-playwright-mcp/* \ + | "$HOME_ROOT"/.cache/ms-playwright | "$HOME_ROOT"/.cache/ms-playwright/* \ + | "$HOME_ROOT"/.cache/ms-playwright-mcp | "$HOME_ROOT"/.cache/ms-playwright-mcp/*) + printf 'mcp-cache' + return 0 + ;; + esac + case "$canon" in "$HOME_ROOT"/?*) ;; *) return 1 ;; esac d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do + while [ "$d" != "/" ] && [ "$d" != "$HOME_ROOT" ]; do [ -e "$d/.git" ] && { root="$d"; break; } d=$(dirname "$d") done diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 01ed237baf..1263dbc669 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -58,6 +58,20 @@ run $G ask "rm -rf $CWD/.env.local" run $G $ROOT_SOLO "rm -rf $CWD" run $G ask "rm -rf $CWD/*" run $G ask "rm -rf /etc/passwd" +# The MCP caches are the one allowed root outside /tmp and the checkouts, and `~/` and `$HOME/` +# the one expansion the charset check tolerates — so the row that matters is the one proving the +# prefix does not carry anything else along with it. +run $G allow "rm -rf ~/Library/Caches/ms-playwright-mcp" +run $G allow "rm -rf ~/.cache/ms-playwright-mcp" # the Linux spelling of the same root +run $G allow 'rm -rf $HOME/Library/Caches/ms-playwright-mcp/mcp-chrome-*' +run $G ask "rm -rf ~/.cache/ms-playwright-mcp-backup" # a sibling, not the cache +run $G ask "rm -rf ~/not-a-git-tree" +# The exclusion list is the whole protection for these paths — the `repo:` class allows deletes +# everywhere else in a checkout — and macOS resolves `.GIT` to `.git`, so the fold is what keeps +# the list from failing open there. Pattern-matched, so the row holds on either platform. +run $G ask "rm -rf $CWD/.GIT" +run $G ask "rm $CWD/.CLAUDE/settings.json" +run $G ask "rm -rf $CWD/backend/.ENV" run $G ask 'rm -rf "$HOME/x"' run $G ask "rm -rf /tmp/../$OUT" run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour @@ -170,6 +184,13 @@ run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc" run $A none "cp $CWD/AGENTS.md /tmp/a" run $A none "tar -xzf /tmp/a.tar.gz -C $OUT" run $A none "cargo build" +run $A ask "chmod -R 777 $CWD/.GIT" +run $A allow "chmod -R 755 ~/Library/Caches/ms-playwright-mcp" +run $A ask "chmod -R 777 ~/Library/Caches/ms-playwright-mcp-backup" +# The home prefix reaches this guard through `operand_class`, not the rm guard's own resolver. +case "$CWD" in + "$HOME"/*) run $A allow "mv ~${CWD#"$HOME"}/frontend/a.ts ~${CWD#"$HOME"}/frontend/b.ts" ;; +esac run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')" diff --git a/AGENTS.md b/AGENTS.md index 1da4be6c98..47919cfeda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,14 +148,16 @@ $NAV --root backend callees "X" # what does X call? - **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each - operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git - checkout under `$HOME`, as long as one operation stays within a single root — a sibling - checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each - proved on its own operands, but keep writes to one per line, name the destination rather than - a directory to drop it in, and put anything else on its own line: a command the hook does not - prove drops the whole line back to the normal permission flow. A - quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like - `xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt. + operand, and auto-allows deletes, moves, copies and mode changes under `/tmp`, inside a git + checkout under `$HOME`, or in the Playwright MCP browser caches (`~/Library/Caches/ms-playwright` + and `ms-playwright-mcp`, `~/.cache/…` on Linux), as long as one operation stays within a single + root — a sibling checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain + deletes freely, each proved on its own operands, but keep writes to one per line, name the + destination rather than a directory to drop it in, and put anything else on its own line: a + command the hook does not prove drops the whole line back to the normal permission flow. A + leading `~/` or `$HOME/` is expanded and proved; a quoted operand, any other `$VAR`, a redirect, + a `$(…)`, a relative `cd`, or a wrapper like `xargs rm` cannot be, and that deferral is what + turns a cleanup into a prompt. - **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline `python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash