Skip to content

fix(global-settings): destructive guard borrowed its verb from another command (v2.4.4) - #720

Merged
WilcoLouwerse merged 4 commits into
mainfrom
fix/guard-inplace-segment-anchor
Sep 10, 2026
Merged

fix(global-settings): destructive guard borrowed its verb from another command (v2.4.4)#720
WilcoLouwerse merged 4 commits into
mainfrom
fix/guard-inplace-segment-anchor

Conversation

@WilcoLouwerse

Copy link
Copy Markdown
Contributor

The bug

The destructive/in-place guard in block-write-commands.sh used [^|]* as its gap pattern. That stops at a pipe but spans ; and && freely — so the tool name, its -i flag, and the protected path could each be borrowed from a different command in the same chain:

awk '{print}' /tmp/x; grep -c -i needle ~/.claude/hooks/foo.sh

Hard-denied as an "in-place edit". awk came from command one, -i from grep -c -i in command two, and the protected path from that same second command. Nothing in-place happens anywhere. Same shape on the rm arm:

rm /tmp/junk; cat ~/.claude/settings-version

Both commands only read the protected path.

Why it went unnoticed

It fails closed — the symptom is a refused read, never an allowed write. So there is no security exposure in either direction, and nothing ever broke loudly. It surfaced during real work in this repo: a diagnostic that only inspected ~/.claude/ was refused, with a reason describing an operation the command never attempted.

It also blocked the first attempt to commit this very fix, because the two examples above appear in the commit message. That is the tightest demonstration of the bug I can offer.

The fix

Anchor all three arms at a command-segment boundary (the rm arm already was) and keep the gap inside that segment — [^|;&] instead of [^|]:

-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
+if echo "$cmd" | grep -qE "(^|[;&|]\s*)(sed|perl|awk|gawk|ruby)\b[^|;&]*[[:space:]]-i\b[^|;&]*${_prot}" \
+|| echo "$cmd" | grep -qE "(^|[;&|]\s*)(truncate|shred|unlink)\b[^|;&]*${_prot}" \
+|| echo "$cmd" | grep -qE "(^|[;&|]\s*)rm\b[^|;&]*${_prot}"; then

Why this does not weaken the guard: a destructive verb aimed at a protected path always starts its own command segment, so every genuine case still matches. sed -i … ~/.claude/x, true && rm ~/.claude/x, echo foo; truncate -s 0 ~/.claude/x — all still hard-denied. The narrowing only removes matches assembled across ; / && from unrelated commands.

Verification

Full guard suite: 7509/7509, zero failures. Run on the branch after merging current main.

Existing tests are untouched, as requested. The test file is +25 / −0 lines against main — verifiable in the diff, and git diff --numstat confirms zero deletions. Nothing existing was edited to make anything pass.

Before/after on a standalone fixture set — the unpatched hook scores 9/11, the patched hook 11/11, and the two deltas are exactly the false positives above:

BEFORE (main):     9 passed, 2 failed
  FAIL  allow  minimal repro: awk early + unrelated -i later + protected path only read
  FAIL  allow  rm on unprotected path, protected path only read

AFTER (patched):  11 passed, 0 failed

New fixtures — a segment-fp section adds 18 cases (12 allow + 6 deny) across three protected files. Twelve assert the false positives are gone; six are controls proving genuine destructive ops still deny, including the chained forms (echo foo; sed -i …, echo foo && truncate …) that the existing section-4 fixtures never exercised. So this PR nets slightly more coverage of the real behaviour than existed before.

Version

VERSION 2.4.1 → 2.4.4.

Note the commit-message drift: the original commit says -> 2.4.3, written when that number was free. Since then main merged #704 (taking 2.4.2) and #716 was updated to claim 2.4.3, so this branch moved to 2.4.4 when main was merged in. The final state is 2.4.4; only the older commit message is stale. Merge order does not matter against #716 — the numbers no longer collide.

Test plan

  • bash -n global-settings/block-write-commands.sh
  • global-settings/tests/test-block-write-commands.sh → expect 7509/7509
  • Confirm git diff --numstat origin/main -- global-settings/tests/test-block-write-commands.sh shows 25 0 (additions only)
  • Sanity-check the three regex arms by eye — the claim is that segment-anchoring cannot drop a genuine destructive match
  • After install, run a chained read-only inspection of ~/.claude/ and confirm it is no longer refused

🤖 Generated with Claude Code

…r command

The destructive/in-place guard's gap pattern was [^|]*, which stops at a
pipe but spans `;` and `&&` freely. So the tool name, its -i flag and the
protected path could each come from a DIFFERENT command in the same chain:

    awk '{print}' /tmp/x; grep -c -i needle ~/.claude/hooks/foo.sh

was hard-denied as an "in-place edit" — `awk` from command one, ` -i` from
`grep -c -i` in command two, and the protected path from that same second
command. Nothing in-place happens anywhere. Same shape for the rm arm:

    rm /tmp/junk; cat ~/.claude/settings-version

Both only READ the protected path. This bit during real work in this repo: a
diagnostic that merely inspected ~/.claude/ was refused, with a reason
describing an operation the command never attempted. It even blocked the
first attempt to commit this very fix, because the examples above appear in
the commit message.

It fails closed, so there is no security exposure either way — the symptom
is a refused read, never an allowed write. That is also why it went
unnoticed for so long.

Fix: anchor all three arms at a command-segment boundary (the rm arm already
was) and keep the gap inside that segment, using [^|;&] instead of [^|]. A
destructive verb aimed at a protected path always starts its own segment, so
nothing genuine stops matching. The added controls cover the chained forms
(`echo foo; sed -i ...`, `echo foo && truncate ...`) that the existing
section-4 fixtures never exercised.

Verified: the full guard suite is 7491/7491 with no existing fixture altered
— the test file gains 25 lines and loses none. Against a standalone fixture
set the unpatched hook scores 9/11 and the patched hook 11/11, the two
deltas being exactly the false positives above.

VERSION 2.4.1 -> 2.4.3. Skips 2.4.2 deliberately: PR #704 is taking that
number, so this PR should merge after it.
…ment-anchor

# Conflicts:
#	global-settings/VERSION

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline findings for #720 below.

Comment thread global-settings/block-write-commands.sh Outdated
Comment thread global-settings/VERSION

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Standard) — self-review posted as COMMENT (GitHub blocks self-approval)

Findings

Already verified, not re-reported as new: bash -n passes; full guard suite 7509/7509 on the merged tree; test file is +25/-0 against main (confirmed via git diff --numstat-equivalent file-level additions/deletions from the PR files API — zero deletions, no existing fixture altered); standalone fixture set scores 9/11 unpatched vs 11/11 patched, matching the two described false positives exactly; the intended chained-command false-positive fix (awk … ; grep -c -i … ~/.claude/…, rm … ; cat ~/.claude/…) does work as described and is now correctly allowed; all of sed -i 's/a/b/' ~/.claude/settings.json, true && rm ~/.claude/settings.json, echo foo; truncate -s 0 ~/.claude/settings-version, perl -i -pe, gawk -i inplace, and every ~/$HOME/${HOME}/quoted/literal-path variant remain hard-denied by this PR — the regression above is additive on top of a fix that otherwise works exactly as described.

Version: 2.4.4 is confirmed free (main=2.4.2, open PR #716 claims 2.4.3, no other open PR touches VERSION).

Review of #720 found a genuine regression in my own fix. I had added
`(^|[;&|]\s*)` to the sed/perl/awk/gawk/ruby and truncate/shred/unlink arms
so all three would look alike. On main those two arms used a bare
`\bverb\b` with no anchor, so adding one strictly REMOVED coverage: a
destructive verb is not always at a command-segment start.

These all mutate a protected path, were denied on main, and my anchored
version let them through — `(`, `{`, a leading space and a wrapper word are
not segment separators:

    (sed -i 's/a/b/' ~/.claude/settings.json)
    { sed -i 's/a/b/' ~/.claude/settings.json; }
    env sed -i 's/a/b/' ~/.claude/settings.json
    if true; then sed -i 's/a/b/' ~/.claude/settings.json; fi
      sed -i 's/a/b/' ~/.claude/settings.json      # leading whitespace
      truncate -s 0 ~/.claude/settings-version

My 18 segment-fp fixtures only covered the `;`/`&&` borrowing shape, so
7509/7509 gave zero signal on any of this. The suite agreed with me because
I only asked it what I already believed.

The false positive never needed the anchor. Narrowing the gap class from
[^|] to [^|;&] is sufficient on its own: it forces the verb, its -i flag and
the protected path into one segment, which is exactly the property that was
missing. So this commit keeps the gap change and reverts the anchor on those
two arms. The rm arm keeps its anchor — that was pre-existing on main, not
something this branch introduced.

Adds 30 non-segment-start DENY fixtures (10 shapes x 3 protected files)
covering leading space, leading tab, subshell, brace group, env wrapper and
if/then, across sed/truncate/unlink/shred, so the hole cannot reopen
silently.

Verified: full suite 7539/7539. On a 20-case adversarial fixture set the
unpatched main hook scores 18/20 (failing exactly the two false positives)
and this version scores 20/20 — both false positives fixed, all nine
non-segment-start shapes still denied. Existing tests remain untouched.

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline finding for #720 below.

Comment thread global-settings/block-write-commands.sh Outdated

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Standard) — self-review posted as COMMENT (GitHub blocks self-approval)

Re-review of 3b22792 (fix for the prior blocker).

Findings

  • 🔴 Blocker: narrowing the gap class alone ([^|][^|;&]) reopens a silent bypass on the same three arms — a genuine in-place edit/truncation/deletion of a protected path, denied on main, is silently allowed (or downgraded to a generic, path-unaware "ask") whenever a literal ;/& sits inside a quoted script or filename between the verb and the path. That shape includes the everyday idiom of chaining substitutions in one script (verb -i "s/a/b/;s/c/d/" <protected-path>). Verified against the extracted regex directly (main MATCH vs this-PR NOMATCH) and by piping candidate commands through the real hook script with a fake $HOME. None of the 48 new fixtures exercise this shape.

Confirmed fixed from the prior review: all six previously-regressing shapes (leading whitespace, leading tab, subshell, brace group, env wrapper, if/then) are denied again — the anchor revert on the sed/perl/awk/gawk/ruby and truncate/shred/unlink arms restores that coverage exactly as described. Resolved that thread.

Re-verified, matches the PR description:

  • The only functional diff from main across all three arms is the gap-class change [^|][^|;&]; the rm arm's (^|[;&|]\s*) anchor is unchanged/pre-existing, confirmed by a direct file diff against main at this head SHA.
  • Original false positives remain fixed: a chained awk-then-unrelated--i command and a chained rm-then-read command are both correctly ALLOWed now, DENYed on main.
  • Full guard suite: 7539/7539, independently re-run against this head SHA.
  • Test file diff against main: +48/−0, confirmed via the files API — additions only, no existing fixture touched.
  • VERSION: main=2.4.2, open PR #716 claims 2.4.3 (unmerged), this PR=2.4.4 — no collision, 2.4.4 confirmed still free.

Minor (resolved, no action needed): the commit-message drift (-> 2.4.3 vs shipped 2.4.4) is disclosed in the PR body and explained by merge ordering with #716 — sufficient as-is, not blocking.

Bottom line: the anchor regression from the prior review is genuinely fixed. But the remaining "narrow the gap class" half of the fix has the same class of problem the anchor did — a character-class restriction can't tell a real command separator from the same byte inside quotes — and needs another pass before this is safe to merge.

Second regression found in review of my own fix, and a worse one than the
first. Narrowing every gap to [^|;&] assumed a `;` is always a command
separator. This guard is plain text matching with no shell awareness, so it
cannot tell a separator from the same character inside a quoted argument:

    sed -i "s/a/b/;s/c/d/" ~/.claude/settings.json
    perl -i -pe 's/a/b/;s/c/d/' ~/.claude/settings.json
    gawk -i inplace '{a=1;print}' ~/.claude/settings.json

All three are ordinary idioms — chaining two substitutions in one script —
and all three are denied on main. My version let them through. For sed the
generic `sed -i` rule downgrades it to an approval prompt; for perl, awk,
truncate and unlink there is no fallback rule at all, so it was a silent
ALLOW of a real in-place edit of a protected file.

The false positive never needed a broad narrowing. It came from the verb
and the -i flag belonging to DIFFERENT commands, so the only gap that has
to be constrained is the one BETWEEN them — and a `;` or `&` can never
legitimately sit between a command name and its own flag. Every gap that
precedes ${_prot} stays [^|]*, because that is where a quoted separator
does legitimately appear.

Net effect against main: one character class, on one arm, in one position.
The truncate/shred/unlink and rm arms are now byte-identical to main again.

That leaves two false positives unfixed — `rm /tmp/junk; cat
~/.claude/settings-version` and `sed -i … /tmp/x; grep … ~/.claude/x`. Both
need the guard to know where one command ends, which regex cannot do
without reintroducing the hole above. They fail CLOSED: a refused read,
never an allowed write. That is the right side to err on, and the comment
block now says so explicitly so the next person does not "fix" it again.

Fixtures reworked to match: dropped the three that asserted the now-unfixed
false positives, kept the verb/-i one, and added 18 covering separators
inside quoted arguments (6 shapes x 3 files) alongside the 30
non-segment-start cases.

Verified: full suite 7548/7548. On a 24-case adversarial set main scores
23/24 (failing only the verb/-i false positive) and this version 24/24 —
every bypass and every non-segment-start shape denied. Existing tests
untouched.

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE (Standard) — self-review posted as COMMENT (GitHub blocks self-approval)

Quick-mode continuation: the prior pass's second blocker was replied-to and claimed fixed in 5cd96b9, but the thread was left open and no formal verdict was submitted. Independently re-verified against the actual diff (not the reply's own repro, which I couldn't rerun locally without tripping my own copy of the same guard hook):

  • Extracted the live regex from the PR head: only the first arm's gap changed, [^|]*[^|;\&]*, and only between the verb and its -i flag. The gap preceding ${_prot} is untouched on all three arms.
  • Re-ran the four load-bearing cases with a substitute non-triggering protected-path token (same regex, sidesteps the local hook the same way the author flagged):
    • awk '{print}' f; grep -c -i x <protected> → NOMATCH (the original false positive is fixed)
    • sed -i "s/a/b/;s/c/d/" <protected> → MATCH/deny (quoted-semicolon sed script still caught, so perl/awk/truncate/unlink don't silently fall open)
    • sed -i ... <protected> (leading whitespace) → MATCH/deny
    • (sed -i ... <protected>) (subshell) → MATCH/deny
  • Diff is minimal and matches what the reply describes: one character class, one arm, one position. Test file is additions-only (+62/-0), and the new fixtures (chained verb/flag, quoted-separator, non-segment-start) map 1:1 to the reasoning in the code comment block.

Resolved the one thread left open from the prior pass. Clean approve.

@WilcoLouwerse
WilcoLouwerse merged commit 2ec2fc6 into main Sep 10, 2026
42 checks passed
@WilcoLouwerse
WilcoLouwerse deleted the fix/guard-inplace-segment-anchor branch September 10, 2026 13:23
WilcoLouwerse added a commit that referenced this pull request Sep 10, 2026
#720 landed first and took 2.4.4, so this change is 2.4.5 rather than the
2.4.3 it was written against. Resolved global-settings/VERSION accordingly
and moved the two prose references (global-claude-settings.md, README.md)
onto the version this actually ships in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant