diff --git a/global-settings/VERSION b/global-settings/VERSION index 8e8299dc..79a61441 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.4.2 +2.4.4 diff --git a/global-settings/block-write-commands.sh b/global-settings/block-write-commands.sh index a0e3c31b..168d6cdc 100644 --- a/global-settings/block-write-commands.sh +++ b/global-settings/block-write-commands.sh @@ -90,7 +90,36 @@ fi # have NO canonical path — they are never allowed. The only permitted operation is a full # overwrite sourced from the canonical repo (enforced by the write guard below). This is a # HARD BLOCK regardless of source, because these operations cannot carry canonical content. -if echo "$cmd" | grep -qE "\b(sed|perl|awk|gawk|ruby)\b[^|]*[[:space:]]-i\b[^|]*${_prot}" \ +# +# Exactly ONE thing differs from the long-standing form: on the first arm, the gap +# between the tool name and its -i flag is [^|;&]* instead of [^|]*. That closes a +# false positive where the verb and the flag came from DIFFERENT commands in one +# chain — `awk '{print}' f; grep -c -i x ~/.claude/hooks/y.sh` was hard-denied as an +# "in-place edit" using awk from the first command and -i from the second. A `;` or +# `&` can never legitimately sit between a command name and its own flag, so +# narrowing there costs no coverage. +# +# Everything else is left exactly as it was, and both temptations to "tidy up" are +# deliberately resisted: +# +# 1. Do NOT anchor the first two arms with `(^|[;&|]\s*)` to match the rm arm. +# A genuine verb is often not at a segment start: ` sed -i … ~/.claude/x`, +# `(sed -i … ~/.claude/x)`, `{ sed -i … ~/.claude/x; }`, `env sed -i …` and +# `if true; then sed -i …; fi` all stop matching, because `(`, `{` and a bare +# leading space are not separators. +# +# 2. Do NOT narrow the gap that precedes ${_prot} on any arm. This guard is plain +# text matching with no shell awareness, so it cannot tell a command separator +# from the same character inside a quoted argument. `sed -i "s/a/b/;s/c/d/" +# ~/.claude/settings.json` is an ordinary two-substitution sed script; with a +# narrowed gap it stops matching and — for perl/awk/truncate/unlink, which have +# no generic fallback rule — becomes a silent ALLOW of a real in-place edit. +# +# The residual false positives (`rm /tmp/junk; cat ~/.claude/settings-version`, and +# `sed -i … /tmp/x; grep … ~/.claude/x`) are the price of that. They fail CLOSED — a +# refused read, never an allowed write — and cannot be fixed at the regex level +# without opening the fail-open hole above. Fixing them needs real shell parsing. +if echo "$cmd" | grep -qE "\b(sed|perl|awk|gawk|ruby)\b[^|;&]*[[:space:]]-i\b[^|]*${_prot}" \ || echo "$cmd" | grep -qE "\b(truncate|shred|unlink)\b[^|]*${_prot}" \ || echo "$cmd" | grep -qE "(^|[;&|]\s*)rm\b[^|]*${_prot}"; then hard_deny "BLOCKED: in-place edits, truncation, or deletion of ~/.claude/ config files are not permitted. The only allowed operation is a full overwrite with canonical content from the configured source." diff --git a/global-settings/tests/test-block-write-commands.sh b/global-settings/tests/test-block-write-commands.sh index afa452cd..6c308e71 100644 --- a/global-settings/tests/test-block-write-commands.sh +++ b/global-settings/tests/test-block-write-commands.sh @@ -165,6 +165,68 @@ done add_allow "npm ci (lockfile-pinned)" "npm ci" add_allow "npm ci --ignore-scripts" "npm ci --ignore-scripts" +# Destructive/in-place guard — the verb and its -i flag must come from the SAME +# command. The gap between them used to be [^|]*, which stops at a pipe but spans +# `;` and `&&`, so the guard could pair a verb from one command with a `-i` from +# another and hard-deny a read-only inspection as an "in-place edit". The command +# below only READS the protected path; `awk` and the `-i` belong to different +# commands and neither touches it. +FP_PROT_FILES=( "settings.json" "hooks/check-settings-version.sh" "settings-version" ) +for f in "${FP_PROT_FILES[@]}"; do + add_allow "verb/-i from different commands → $f" \ + "awk '{print}' /tmp/x; grep -c -i needle \"\$HOME/.claude/${f}\"" + # Controls: the narrowing must not let a genuine destructive op through, + # including one after a command-chain prefix (section 4 below never exercises + # the chained form for in-place mutators). + add_deny "control: chained sed -i → $f" \ + "echo foo; sed -i 's/a/b/' \"\$HOME/.claude/${f}\"" + add_deny "control: chained truncate → $f" \ + "echo foo && truncate -s 0 \"\$HOME/.claude/${f}\"" +done + +# Separator characters inside a QUOTED ARGUMENT. This guard is plain text matching +# with no shell awareness, so it cannot tell a real command separator from the same +# character inside an argument. Narrowing the gap that precedes the protected path +# would make every command below stop matching — and for perl/awk/truncate/unlink, +# which have no generic fallback rule, that is a silent ALLOW of a real in-place +# edit. `sed -i "s/a/b/;s/c/d/" ` is an ordinary two-substitution script, not +# a contrived evasion. These must stay denied. +for f in "${FP_PROT_FILES[@]}"; do + for wrap in \ + "sed -i \"s/a/b/;s/c/d/\" PATH" \ + "perl -i -pe 's/a/b/;s/c/d/' PATH" \ + "gawk -i inplace '{a=1;print}' PATH" \ + "sed -i 's/a/b/' \"x&&y\" PATH" \ + "truncate -s 0 \"a;b\" PATH" \ + "rm \"a;b\" PATH"; do + add_deny "separator inside a quoted arg: ${wrap%% PATH*}… → $f" \ + "${wrap//PATH/\"\$HOME/.claude/${f}\"}" + done +done + +# Non-segment-start destructive verbs. These are the shapes that a +# `(^|[;&|]\s*)` anchor on the sed/perl/awk/gawk/ruby and truncate/shred/unlink +# arms would silently stop matching, because `(`, `{`, a leading space and a +# wrapper word are not command separators. The guard deliberately keeps a bare +# \bverb\b match on those two arms for exactly this reason — every command below +# genuinely mutates the protected path and must stay denied. +for f in "${FP_PROT_FILES[@]}"; do + for wrap in \ + " sed -i 's/a/b/' PATH" \ + " sed -i 's/a/b/' PATH" \ + "(sed -i 's/a/b/' PATH)" \ + "{ sed -i 's/a/b/' PATH; }" \ + "env sed -i 's/a/b/' PATH" \ + "if true; then sed -i 's/a/b/' PATH; fi" \ + " truncate -s 0 PATH" \ + "(truncate -s 0 PATH)" \ + "{ unlink PATH; }" \ + "env shred PATH"; do + add_deny "non-segment-start: ${wrap%% PATH*}… → $f" \ + "${wrap//PATH/\"\$HOME/.claude/${f}\"}" + done +done + # ── DENY fixtures ───────────────────────────────────────────────────────────── # 1) Redirects: `>` and `>>` against every path variant. for op in '>' '>>'; do