Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
23 changes: 20 additions & 3 deletions global-settings/block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,26 @@ 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}" \
|| echo "$cmd" | grep -qE "\b(truncate|shred|unlink)\b[^|]*${_prot}" \
|| echo "$cmd" | grep -qE "(^|[;&|]\s*)rm\b[^|]*${_prot}"; then
#
# The gap classes are [^|;&] rather than [^|] so the tool name, its -i flag and the
# protected path must all sit in the SAME command segment. With the old [^|]* — which
# stops at a pipe but spans `;` and `&&` — they could each be borrowed from a
# DIFFERENT command in one chain, e.g. `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. Fails closed, so the symptom was a refused
# read-only inspection with a misleading reason.
#
# Deliberately NOT anchored on the first two arms. Adding `(^|[;&|]\s*)` there looks
# tidier and matches the rm arm, but it strictly removes coverage: a genuine verb is
# not always at a segment start. ` sed -i … ~/.claude/x` (leading whitespace),
# `(sed -i … ~/.claude/x)`, `{ sed -i … ~/.claude/x; }`, `env sed -i … ~/.claude/x`
# and `if true; then sed -i … ~/.claude/x; fi` all stop matching, because `(`, `{`
# and a bare `^`-plus-space are not segment separators. Keep the bare \bverb\b match:
# narrowing the gap alone fixes the false positive without opening those holes.
# The rm arm keeps its anchor — that is pre-existing behaviour, not added here.
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}" \
Comment thread
WilcoLouwerse marked this conversation as resolved.
Outdated
|| 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."
fi

Expand Down
48 changes: 48 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,54 @@ done
add_allow "npm ci (lockfile-pinned)" "npm ci"
add_allow "npm ci --ignore-scripts" "npm ci --ignore-scripts"

# Destructive/in-place guard — segment-boundary regressions.
# The guard's gap pattern used to be [^|]*, which stops at a pipe but happily spans
# `;` and `&&`. So the tool name, its -i flag and the protected path could each be
# borrowed from a DIFFERENT command in the same chain, and a read-only inspection got
# hard-denied as an "in-place edit". Every command below only ever READS the protected
# path; the destructive verb operates on an unprotected one.
FP_PROT_FILES=( "settings.json" "hooks/check-settings-version.sh" "settings-version" )
for f in "${FP_PROT_FILES[@]}"; do
add_allow "segment-fp: awk earlier, unrelated -i later → $f" \
"awk '{print}' /tmp/x; grep -c -i needle \"\$HOME/.claude/${f}\""
add_allow "segment-fp: sed -i on an unprotected path → $f" \
"sed -i 's/a/b/' /tmp/x; grep -c needle \"\$HOME/.claude/${f}\""
add_allow "segment-fp: rm earlier on an unprotected path → $f" \
"rm /tmp/junk; cat \"\$HOME/.claude/${f}\""
add_allow "segment-fp: unlink earlier on an unprotected path → $f" \
"unlink /tmp/junk && wc -l \"\$HOME/.claude/${f}\""
# Controls: narrowing the gap must not let a genuine destructive op through,
# including one that sits after a command-chain prefix (section 4 below never
# exercises the chained form for in-place mutators).
add_deny "segment-fp control: chained sed -i → $f" \
"echo foo; sed -i 's/a/b/' \"\$HOME/.claude/${f}\""
add_deny "segment-fp control: chained truncate → $f" \
"echo foo && truncate -s 0 \"\$HOME/.claude/${f}\""
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