From 5ecbd15fcabd0ef805e5b9a68f237187f2a54f7e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 18 Aug 2026 10:41:09 +0200 Subject: [PATCH] refactor(agents): state the glob and cd rationale once Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 54 ++++++++++++--------------- .claude/hooks/guard-rm-outside-tmp.sh | 15 +------- .claude/hooks/lib-guarded-verb.sh | 17 ++++++++- 3 files changed, 41 insertions(+), 45 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 5705e3846b..87ce6541aa 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -7,11 +7,12 @@ # 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. +# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. 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, AND exactly one of them writes (see the +# gate at the foot of this file — an earlier write can change what a later operand means). 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, @@ -72,25 +73,25 @@ defer() { exit 0 } -# A command substitution is concatenated into the word it sits in, and splitting the command on -# its opener cuts that word in half: `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/` -# and the traversal validated as an unrelated segment. Nothing here can evaluate the -# substitution, so a command carrying one is never proved — heredoc bodies excepted, since -# those are data the split already dropped. -case "$(strip_heredoc_bodies "$cmd")" in - *'$('* | *'`'*) defer "command substitution in the command line" ;; -esac +has_substitution "$cmd" && defer "command substitution in the command line" -# 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, +# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash +# expands it only after the hook has decided, so realpath sees the unexpanded pattern — +# `/tmp/link*` canonicalizes to itself and passes, then expands onto a symlink whose target is +# outside, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target. +# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following +# it.) The charset holds none of the characters bash uses for quoting, expansion or separation. +literal_path() { + case "$1" in *[*?[]*) return 1 ;; esac + [ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ] +} + +# Prints the root class of a 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 - # 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 + literal_path "$t" || return 1 case "$t" in /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; *) # A `cd` may fail at runtime and leave the command where it started, so a relative @@ -116,13 +117,7 @@ operand_class() { # 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 - # sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then - # expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line - # symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs - # 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 + literal_path "$t" || return 1 case "$t" in /*) ;; *) return 1 ;; esac canon=$(realpath -m -- "$t" 2>/dev/null) [ -n "$canon" ] || return 1 @@ -294,10 +289,7 @@ for seg in "${SEGMENTS[@]}"; do ;; cd) # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative - # operand points — to one of two places, since the `cd` may fail and `;` runs what follows - # regardless. Both are carried, and an operand must land in the same root from either. - # Past the first, the branching outruns two candidates, so a second `cd` gives up on - # relative operands entirely. + # operand points, to one of the two candidates `apply_cd` describes. if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then alt_cwd="$seg_cwd" seg_cwd="$new_cwd" diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index feb6762e5d..4d253ffc62 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -45,14 +45,7 @@ defer() { exit 0 } -# A command substitution is concatenated into the word it sits in, and splitting the command on -# its opener cuts that word in half: `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/` -# and the traversal validated as an unrelated segment. Nothing here can evaluate the -# substitution, so a command carrying one is never proved — heredoc bodies excepted, since -# those are data the split already dropped. -case "$(strip_heredoc_bodies "$cmd")" in - *'$('* | *'`'*) defer "command substitution in the command line" ;; -esac +has_substitution "$cmd" && defer "command substitution in the command line" # 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; @@ -122,11 +115,7 @@ for seg in "${SEGMENTS[@]}"; do ;; cd) # A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative - # operand points — to one of two places, since the `cd` may fail and `;` runs what follows - # regardless. Both are carried, and an operand must be auto-allowable from either, which - # also means a `cd` that word splitting invented out of quoted text can only add a - # constraint and never drop one. Past the first, the branching outruns two candidates, so - # a second `cd` gives up on relative operands entirely. + # operand points, to one of the two candidates `apply_cd` describes. if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then alt_cwd="$seg_cwd" seg_cwd="$new_cwd" diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index 10e0072dea..6ef76a5466 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -151,6 +151,18 @@ split_segments() { while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')" } +# 0 iff ($1) carries a command substitution outside a heredoc body. A substitution is +# concatenated into the word it sits in, and splitting on its opener cuts that word in half: +# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an +# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a +# command. Heredoc bodies are excepted — those are data the split has already dropped. +has_substitution() { + case "$(strip_heredoc_bodies "$1")" in + *'$('* | *'`'*) return 0 ;; + esac + return 1 +} + # 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, @@ -173,7 +185,10 @@ segment_tokens() { # Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and # `;` runs the next command anyway, leaving it in the directory it started in. So a caller may # never treat this as the working directory outright — it is one of two candidates, and a -# relative operand has to be provable against the one the command started in as well. +# relative operand has to be provable against the one the command started in as well. That also +# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate, +# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that +# sees a second gives up on relative operands entirely. apply_cd() { local cwd="$1" t shift