Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion global-settings/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.4.2
2.4.4
Comment thread
WilcoLouwerse marked this conversation as resolved.
31 changes: 30 additions & 1 deletion global-settings/block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
62 changes: 62 additions & 0 deletions global-settings/tests/test-block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/" <path>` 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
Expand Down
Loading