From 5639bf470b968bf88958ff7f79dc6bee6dd85f53 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 17 Aug 2026 18:26:03 +0200 Subject: [PATCH] fix(agents): prove scratch file ops per command segment Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 248 +++++++++++++++++--------- .claude/hooks/guard-rm-outside-tmp.sh | 178 +++++++++--------- .claude/hooks/lib-guarded-verb.sh | 166 +++++++++++++---- .claude/hooks/test-hooks.sh | 38 +++- AGENTS.md | 9 +- 5 files changed, 426 insertions(+), 213 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 665c23cf0a..00d474940a 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,17 +1,28 @@ #!/usr/bin/env bash -# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line -# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand -# resolves under /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). +# 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. +# 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). +# +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `mkdir -p /tmp/a && mv /tmp/b /tmp/a` is two operations, each proved on its own +# operands. A decision covers the whole command line, so `allow` is emitted only when every +# segment is one of these verbs proved here or a `cd` that resolved. A line that mixes a +# proven op with some other command makes no decision instead and leaves that line to the +# normal permission flow, rather than waving an unexamined command through with it. # # This is a hook rather than an allow rule because permission rules match a command prefix, so # they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, # and requiring every operand is the point. # -# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a -# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy -# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows. +# 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. # # 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 @@ -19,12 +30,19 @@ # any glob character, so all of those forms fail by construction. `realpath -m` 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 +# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names, +# not a file named P, and resolving it as a path would put an option in a root and allow it. +# The other five take relative operands, resolved against the working directory that `cd` +# tracking maintains, since for those a bare word really is a path (a GNU option starts with +# `-`, and the option allowlist below rejects the ones that would change symlink handling). +# # `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE # (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it. # Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's # refusal to extract `..` and absolute member paths — defer rather than needing enumeration. -# Extraction additionally requires an explicit destination under /tmp, or a cwd already under -# /tmp, since otherwise members land in the project checkout. +# Extraction additionally requires an explicit destination under /tmp, or a working directory +# already under /tmp, since otherwise members land in the project checkout. # # Residual risk accepted: an archive whose members include a symlink pointing out of /tmp # followed by a write through it can still escape, because tar applies member symlinks as it @@ -52,12 +70,27 @@ defer() { exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; 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. +operand_class() { + local t="$1" canon + # Globs never auto-allow: bash expands them only after this hook has decided, so realpath + # sees the unexpanded pattern and `/tmp/link*` passes before expanding onto a symlink whose + # target is outside. chmod and cp follow command-line symlinks, so that is a write to it. + case "$t" in *[*?[]*) return 1 ;; esac + [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 + case "$t" in + /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + *) [ -n "$seg_cwd" ] || return 1 + canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) ;; + esac + [ -n "$canon" ] || return 1 + path_class "$canon" +} -read -r -a toks <<< "$cmd" - -# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. +# 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 # Globs never auto-allow. Bash expands them only after this hook has decided, so realpath @@ -67,10 +100,6 @@ under_tmp() { # because `rm` unlinks the symlink itself rather than following it. case "$t" in *[*?[]*) return 1 ;; esac [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 - # Absolute only. Resolving a relative operand against the cwd makes any bare word look like - # a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option: - # `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a - # dereferencing recursive copy, not a file named -RL. case "$t" in /*) ;; *) return 1 ;; esac canon=$(realpath -m -- "$t" 2>/dev/null) [ -n "$canon" ] || return 1 @@ -79,29 +108,16 @@ under_tmp() { return 1 } -# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer. -# 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 -# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate -# such a symlink as a symlink instead, so no outside content is materialized. -case "${toks[0]:-}" in - mkdir) takes_mode=0; ok_opts='pv' ;; - cp) takes_mode=0; ok_opts='rRvfnpa' ;; - mv) takes_mode=0; ok_opts='vfn' ;; - touch) takes_mode=0; ok_opts='acmv' ;; - chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path - tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; - unzip) ok_flags='oqnljvd'; val_flags='d' ;; - *) defer "not the leading command word" ;; -esac - -# ---------------------------------------------------------------- tar / unzip -if [ -n "${ok_flags:-}" ]; then - saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 - i=1 - while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" +# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS. +check_archive_segment() { + local verb="$1" ok_flags val_flags t flags val + local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1 + case "$verb" in + tar) ok_flags='xctzjJavfC'; val_flags='fC' ;; + unzip) ok_flags='oqnljvd'; val_flags='d' ;; + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" i=$((i + 1)) if [ "$end_opts" = 0 ]; then [ "$t" = "--" ] && { end_opts=1; continue; } @@ -112,13 +128,13 @@ if [ -n "${ok_flags:-}" ]; then # leave a residue here and defer rather than being enumerated as denials. [ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`" case "$flags" in *x*) extracting=1 ;; esac - case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac + case "$verb$flags" in unzip*[lv]*) listing=1 ;; esac # A flag consuming the next token must be alone in its bundle's final position # (`-xzf a.tar`), else the token it eats is ambiguous. case "${flags%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac case "${flags: -1}" in [$val_flags]) - val="${toks[$i]:-}" + val="${SEG_TOKS[$i]:-}" i=$((i + 1)) [ -n "$val" ] || defer "option \`$t\` has no value" under_tmp "$val" || defer "\`$val\` is outside /tmp" @@ -136,54 +152,122 @@ if [ -n "${ok_flags:-}" ]; then # first is the archive. Requiring every one under /tmp is conservative for member names, # which are not filesystem paths — those defer rather than being wrongly allowed. under_tmp "$t" || defer "\`$t\` is outside /tmp" - [ "${toks[0]}" = "unzip" ] && saw_archive=1 + [ "$verb" = "unzip" ] && saw_archive=1 done # tar without -f reads a tape/stdin; unzip needs an archive [ "$saw_archive" = 1 ] || defer "no archive operand" # Writes land relative to the working directory unless a destination was given. `unzip -l` # and `-v` only list, so they need no destination. - if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then - [ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || defer "extraction target is outside /tmp" + if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then + # An extraction with no destination lands in the working directory. Word splitting cannot + # tell a `cd` inside a quoted string from one the shell runs, and believing a false one + # would put an archive's members in the checkout, so once any `cd` is in the line only an + # explicit destination will do. + [ "$saw_dest" = 1 ] \ + || { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \ + || defer "extraction target is outside /tmp" fi - decide allow "archive paths and extraction target are under /tmp" -fi +} -# ------------------------------------------- mkdir / cp / mv / touch / chmod -path_operand=0 -seen_mode=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) +# 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 + # 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 + # dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate + # such a symlink as a symlink instead, so no outside content is materialized. + case "$verb" in + mkdir) takes_mode=0; ok_opts='pv' ;; + cp) takes_mode=0; ok_opts='rRvfnpa' ;; + mv) takes_mode=0; ok_opts='vfn' ;; + touch) takes_mode=0; ok_opts='acmv' ;; + chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path + esac + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Checked at any position, not just before the first operand: GNU utils permute, so - # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. - case "$t" in - -?*) - # Allowlist: long options and the dereferencing flags leave a residue and defer. - [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" - continue - ;; - esac - fi + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Checked at any position, not just before the first operand: GNU utils permute, so + # `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion. + case "$t" in + -?*) + # Allowlist: long options and the dereferencing flags leave a residue and defer. + [ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`" + continue + ;; + esac + fi - # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. - if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then - case "$t" in - [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; - *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; - esac - seen_mode=1 - continue - fi + # chmod: consume the mode operand without a path check. Octal, or symbolic clauses. + if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then + case "$t" in + [0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;; + *) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;; + esac + seen_mode=1 + continue + fi - under_tmp "$t" || defer "\`$t\` is outside /tmp" - path_operand=1 + 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" + seen_class="$cls" + path_operand=1 + done + + [ "$path_operand" = 1 ] || defer "no path operand" +} + +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +saw_cd=0 # a `cd` moved the working directory somewhere +proved=0 # at least one op came out inside a single root +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + mkdir | cp | mv | touch | chmod) + check_fileops_segment "${SEG_TOKS[0]}" + proved=1 + continue + ;; + tar | unzip) + check_archive_segment "${SEG_TOKS[0]}" + proved=1 + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where a relative + # operand points. Tracking it is only sound while every step stays known and inside the + # roots, so an unresolvable destination, or one outside them, makes the working directory + # unknown — and a `cd` is only followed from a known one, so a later `cd` cannot walk it + # back into a root it has already left. + saw_cd=1 + if [ -n "$seg_cwd" ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}") \ + && path_class "$new_cwd" >/dev/null; then + seg_cwd="$new_cwd" + else + seg_cwd="" + fi + continue + ;; + esac + # Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind + # a wrapper, an env prefix or a path — this hook cannot say what it writes to. + for verb in mv chmod; do + segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`" + done + only_ours=0 done -[ "$path_operand" = 1 ] || defer "no path operand" -decide allow "every path operand is under /tmp" +[ "$proved" = 1 ] || exit 0 +[ "$only_ours" = 1 ] && decide allow "every path operand is under /tmp" +exit 0 diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 5aff497d89..703dedc885 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash -# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every -# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME -# (a version-controlled project dir). 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). +# 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). +# 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). # -# The git-tree allowance trades on "this is a project under version control" being lower-stakes -# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git, -# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history -# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# The command is read one segment at a time, so chaining and line breaks carry no weight of +# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands. +# A decision covers the whole command line, so `allow` is emitted only when every segment is +# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with +# some other command makes no decision instead and leaves that line to the normal permission +# flow: the delete is not what needed a prompt, and waving the rest of the line through with +# 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 @@ -17,12 +20,12 @@ # 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. # -# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's -# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in -# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`, -# `.claude` or `.env` path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion +# 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 -# against the command's cwd (from the hook input). +# 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. set -uo pipefail @@ -35,87 +38,90 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) # Every bail-out below goes through `defer`, so the forms this guard refuses to reason about — -# compound, quoted, wrapped — still reach the user as a prompt whenever an `rm` runs among them. +# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them. runs_verb rm "$cmd" && guarded=1 || guarded=0 defer() { [ "$guarded" = 1 ] && decide ask "$1" exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac - -read -r -a toks <<< "$cmd" -# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer. -[ "${toks[0]:-}" = "rm" ] || defer "rm is not the leading command word" - -# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly -# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at -# ~ can't make all of $HOME deletable, and top-level ~ files stay protected. -allowed_target() { - local canon="$1" d root="" - case "$canon" in /tmp/?*) return 0 ;; esac - [ -n "${HOME:-}" ] || return 1 - case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac - # Never auto-allow: history, and the two kinds of path the "it's under version control" - # premise doesn't hold for — the agent's own guards and settings (deleting them is what - # removes the prompt on everything else), and gitignored `.env` files. - case "$canon" in - *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; - *"/.env" | *"/.env."*) return 1 ;; - esac - d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do - [ -e "$d/.git" ] && { root="$d"; break; } - d=$(dirname "$d") +# 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. +check_rm_segment() { + local i=1 t canon had_operand=0 end_opts=0 + while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do + t="${SEG_TOKS[$i]}" + i=$((i + 1)) + # 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\`" + # 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 + if [ "$end_opts" = 0 ]; then + [ "$t" = "--" ] && { end_opts=1; continue; } + # Skip real options only before the first operand. A bare `-` is a filename, and under + # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` + # is a filename too — validate it rather than skipping it. + if [ "$had_operand" = 0 ]; then + case "$t" in -?*) continue ;; esac + fi + fi + 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 "$t" in + /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory left by a \`cd\` this guard cannot resolve" + canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) ;; + esac + [ -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" done - [ -n "$root" ] || return 1 # not inside a git working tree under $HOME - if [ "$canon" = "$root" ]; then - # Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is - # a file/pointer so the history lives in the main repo and survives. A primary checkout's - # `.git` is a directory holding the history, so deleting it is unrecoverable — defer. - [ -f "$root/.git" ] && return 0 - return 1 - fi - return 0 + [ "$had_operand" = 1 ] || defer "no operand" } -had_operand=0 -end_opts=0 -i=1 -while [ "$i" -lt "${#toks[@]}" ]; do - t="${toks[$i]}" - i=$((i + 1)) - # 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\`" - # 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 - if [ "$end_opts" = 0 ]; then - [ "$t" = "--" ] && { end_opts=1; continue; } - # Skip real options only before the first operand. A bare `-` is a filename, and under - # POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name` - # is a filename too — validate it rather than skipping it. - if [ "$had_operand" = 0 ]; then - case "$t" in -?*) continue ;; esac - fi - fi - 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 "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; - *) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;; +split_segments "$cmd" +seg_cwd="${cwd:-$PWD}" +proved=0 # at least one `rm` segment came out auto-allowable +only_ours=1 # ... and nothing else shares the command line + +for seg in "${SEGMENTS[@]}"; do + segment_tokens "$seg" + case "${SEG_TOKS[0]:-}" in + "") continue ;; + rm) + check_rm_segment + proved=1 + continue + ;; + cd) + # A `cd` writes nothing, so it never blocks an allow; it only moves where the operands + # of a later segment point. Tracking it is only sound while every step stays known and + # inside the allowed roots, so an unresolvable destination, or one this guard would not + # delete in, makes the working directory unknown — and a `cd` is only followed from a + # known one, so a later `cd` cannot walk it back into a root it has already left. + if [ -n "$seg_cwd" ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}") \ + && path_class "$new_cwd" >/dev/null; then + seg_cwd="$new_cwd" + else + seg_cwd="" + fi + continue + ;; esac - [ -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 - allowed_target "$canon" || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper, + # an env prefix or a path — this guard cannot say what it deletes. + segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`" + only_ours=0 done -[ "$had_operand" = 1 ] || defer "no operand" -decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +[ "$proved" = 1 ] || exit 0 +[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp 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 c4d5f5ac9a..dc83a73220 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,17 +10,6 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f -# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash -# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: -# the command splits on `; & |` and newlines, and a leading env assignment or process wrapper -# (`timeout 5 rm`, `xargs rm`) is skipped before the command word is read. -# -# The split set also carries the characters that open a nested command — `$(`, backticks and -# `( )` — because a rule matches the verb inside one (`echo $(rm -rf ~)` prompts), and a -# separator that only ends statements would read that as an `echo`. Braces are handled as -# words rather than separators, since splitting on them cuts `xargs -I {} … rm` in half and -# strands the `rm` in a segment that no longer knows a wrapper preceded it. - # 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 @@ -115,35 +104,134 @@ strip_heredoc_bodies() { done } +# 0 iff ($1) runs as a command word in ($2), which must already be one +# segment (no separator left in it). Wrapper, env-prefix and `/bin/` forms all count. +segment_runs_verb() { + local verb="$1" w wrapped=0 + for w in $2; do + # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and + # `r\m` run rm and have to compare equal to it. + w="${w//[\"\'\\]/}" + case "$w" in + "$verb" | */"$verb") return 0 ;; + *=*) ;; # leading env assignment + -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect + [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose + '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command + timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) + wrapped=1 ;; + # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), + # so past a wrapper the scan runs to the end of the segment instead of stopping at the + # first ordinary word. Before one, that word is the command and the verb cannot follow + # it. Nothing bounds the scan: a wrapper takes unboundedly many operands + # (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted + # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the + # price, and it only over-prompts. + *) [ "$wrapped" = 1 ] || break ;; + esac + done + return 1 +} + +# Splits ($1) into its command segments, into the global array SEGMENTS. Every guard +# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable +# blob, and a newline is a separator like any other. +# +# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested +# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an +# `echo`. Braces are handled as words rather than separators, since splitting on them cuts +# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper +# preceded it. +# +# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself, +# which silently leaves the command unsplit and every separator unseen. +split_segments() { + local seg + SEGMENTS=() + while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')" +} + +# Reads ($1) into the global array SEG_TOKS, dropping the shell keywords that can +# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word +# splitting only: quotes are left in the token and fail the guards' charset check downstream, +# which is what keeps `rm -rf "$HOME/x"` unprovable. +segment_tokens() { + SEG_TOKS=() + read -r -a SEG_TOKS <<< "$1" + while [ "${#SEG_TOKS[@]}" -gt 0 ]; do + case "${SEG_TOKS[0]}" in + '!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;; + *) break ;; + esac + done +} + +# 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. The caller then +# treats the working directory as unknown, so that a later relative operand is never resolved +# against a directory the command has already left. +apply_cd() { + local cwd="$1" t + shift + [ "$#" -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 +} + +# 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. +# +# 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, +# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history +# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff. +# +# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and +# top-level ~ files stay out of one. Never in a class at all: git history, the agent's own +# guards and settings (removing those is what removes the prompt on everything else), and +# gitignored `.env` files — the version-control premise holds for none of the three. A working +# tree's own root folder counts only when it is a linked worktree, whose `.git` is a pointer +# file so the history lives in the main repo and survives; a primary checkout's `.git` is a +# directory holding the history itself, so losing it is unrecoverable. +path_class() { + local canon="$1" d root="" + case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac + [ -n "${HOME:-}" ] || return 1 + case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac + case "$canon" in + *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; + *"/.env" | *"/.env."*) return 1 ;; + esac + d="$canon" + while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do + [ -e "$d/.git" ] && { root="$d"; break; } + d=$(dirname "$d") + done + [ -n "$root" ] || return 1 # not inside a git working tree under $HOME + if [ "$canon" = "$root" ]; then + [ -f "$root/.git" ] || return 1 + fi + printf 'repo' +} + +# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash +# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: +# a guard consults this before it starts proving segments, and every bail-out it then takes +# is a prompt for exactly the commands a rule would have caught. runs_verb() { - local verb="$1" seg w wrapped - while IFS= read -r seg; do - wrapped=0 - for w in $seg; do - # The shell strips quotes and backslashes before it looks up the command, so `'rm'` and - # `r\m` run rm and have to compare equal to it. - w="${w//[\"\'\\]/}" - case "$w" in - "$verb" | */"$verb") return 0 ;; - *=*) ;; # leading env assignment - -* | *'>'* | *'<'*) ;; # a flag, or a leading redirect - [0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose - '!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command - timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env) - wrapped=1 ;; - # A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`), - # so past a wrapper the scan runs to the end of the segment instead of stopping at the - # first ordinary word. Before one, that word is the command and the verb cannot follow - # it. Nothing bounds the scan: a wrapper takes unboundedly many operands - # (`env -u A -u B …`), and any cutoff — a word count, or stopping at the first quoted - # word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the - # price, and it only over-prompts. - *) [ "$wrapped" = 1 ] || break ;; - esac - done - # `tr` and not `${2//[...]}`: a `}` inside the bracket expression closes the expansion - # itself, which silently leaves the command unsplit and every separator unseen. - done <<< "$(strip_heredoc_bodies "$2" | tr ';&|()`' '\n')" + local verb="$1" seg + split_segments "$2" + for seg in "${SEGMENTS[@]}"; do + segment_runs_verb "$verb" "$seg" && return 0 + done return 1 } diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 65631b6a48..b225827f2c 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -4,6 +4,10 @@ # What this pins is the `ask` column: a matcher change that turns one into a no-decision drops # that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted # rows are the ones that catch it. +# +# The `allow` column carries its own weight, because a decision covers the whole command line: +# `allow` may only appear where every segment was proved here, and a line that also runs +# something unexamined has to come out `none` so the normal permission flow still sees it. set -uo pipefail H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" CWD="$(git -C "$H" rev-parse --show-toplevel)" @@ -46,7 +50,7 @@ run $G ask "rm -rf $CWD/*" run $G ask "rm -rf /etc/passwd" run $G ask 'rm -rf "$HOME/x"' run $G ask "rm -rf /tmp/../$OUT" -run $G ask "ls /tmp && rm -rf /tmp/x" +run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour run $G ask 'echo $(rm -rf /etc)' run $G ask 'echo `rm -rf /etc`' run $G ask "{ rm -rf /etc; }" @@ -107,6 +111,20 @@ run $G none 'echo $(ls /tmp)' run $G none 'grep -rn "rm" backend/' run $G none "cargo build --release" +# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its +# own operands, and a `cd` moves where a relative one points. +run $G allow "rm -f /tmp/a; rm -rf /tmp/b" +run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")" +run $G allow "cd /tmp/scratch && rm -rf sub" +run $G none "mkdir -p /tmp/x && rm -rf /tmp/x" +run $G ask "$(printf 'ls /tmp\nrm -rf /etc')" +# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the +# working directory unknown, and an unknown one proves nothing. +run $G ask "cd /etc && rm -rf foo" +run $G ask 'cd "$D" && rm -rf foo' +run $G ask "cd $CWD && rm -rf .git" +run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back + echo echo "== allow-fileops-in-tmp.sh ==" A=allow-fileops-in-tmp.sh @@ -117,7 +135,7 @@ run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out" run $A ask "mv /tmp/a $OUT" run $A ask "mv $CWD/AGENTS.md /tmp/a" run $A ask "chmod -R 777 $CWD" -run $A ask "ls && mv /tmp/a /tmp/b" +run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour run $A ask 'echo $(mv /tmp/a /etc)' run $A ask "timeout --signal KILL 5 mv /tmp/a /etc" run $A ask "time -f FORMAT chmod 777 $OUT" @@ -129,5 +147,21 @@ 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 allow "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" +run $A allow "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')" +run $A ask "ls && mv /tmp/a /etc" +run $A ask "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")" +run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out" +# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the +# in-repo delete already was — but one operation may not straddle it and /tmp. +run $A allow "chmod +x scripts/worktree-env" +run $A allow "mv backend/.sqlx backend/.sqlx.bad" +run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts" +run $A ask "mv /tmp/a $CWD/frontend/a.ts" +run $A ask "chmod -R 777 $CWD/.git" +run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak" +run $A ask "mv $CWD/AGENTS.md $OUT" +run $A ask "cd /etc && mv a b" + echo [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index aab920746b..4096e2fc9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,10 +146,11 @@ $NAV --root backend callees "X" # what does X call? - **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - **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 `rm`/`mv`/`cp` as one plain unchained command: a PreToolUse hook - auto-allows those when every operand is under `/tmp` or inside this checkout, but it defers - on `&&`, `;`, redirects, quotes and `$VAR` — that deferral, not the delete itself, is what - turns a routine cleanup into a permission prompt. + committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each + operand, and auto-allows deletes under `/tmp` or inside this checkout, and `mv`/`cp`/`chmod` + under `/tmp`. Chaining and line breaks are fine, since every command on the line is proved on + its own operands, but a quoted or `$VAR` operand, a redirect, or a wrapper like `xargs rm` + cannot be proved, and that deferral is what turns a routine cleanup into a permission prompt. - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked