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 plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,5 @@
"min": 1
}
},
"version": "0.29.2"
"version": "0.29.3"
}
80 changes: 80 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,86 @@
All notable changes to the `guardrails` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.29.3]

### Fixed

- **PowerShell guards: an fd-dup merge no longer hides a computed writer call's
operands ([#2927](https://github.com/melodic-software/claude-code-plugins/issues/2927)).**
`& $w 2>&1 f.txt x` exited 0 from `block-hook-bypass` while the identical
`& $w f.txt x` exited 2 — a working `Set-Content <path> <value>`, verified as a
real write under `pwsh`, waved through. `& $w 2>&1 @p`, `& $env:w 2>&1 f.txt x`,
`'x' | & $w 2>&1 f.txt`, and the same shape on a non-leftmost call site were all
allowed too. Pre-existing rather than a 0.28.x regression: every shape measured
0 on the pre-0.28.33 base as well.

`ps::call_site_operand_region` ends a call's operand region at a statement or
pipeline separator (`;` `|` `&`) at bracket depth ZERO, and the `&` inside
`2>&1` sits at depth zero. So the region of `& $w 2>&1 f.txt x` was truncated to
`" 2>"`, both measuring probes went silent, and the command fell through
ALLOWED. `ps::write_bypass` already knew fd-dup merges are plumbing rather than
writes and stripped them — but only into a separate `gate` variable that fed the
`>` redirect probe alone, while the measuring probes were handed the unstripped
text. That divergence between what the gate stripped and what the probes
measured WAS the defect; the strip now runs once, before every probe in the
branch, so the remaining `&` characters really are separators.

Deliberately ACCEPTED behavior change: with the merge stripped,
`& $py a.py 2>&1 b.txt` reads as two positionals with a visible literal and
moves from 0 to 2. The class is narrow — it needs a positional on BOTH sides of
the merge — and it is consistent with `& $py script.py arg`, which already
blocked. `& $tool 2>&1` (plumbing, no operands after the merge) and
`git status 2>&1 > out.txt` (a tool producer) both stay allowed, which is what
the strip existed to protect.

- **PowerShell guards: call-site boundaries now follow PowerShell's tokenizer,
not bash's character classes
([#2928](https://github.com/melodic-software/claude-code-plugins/issues/2928)).**
Two spellings PowerShell separates and bash does not made a whole call site
invisible to every measuring probe, each proven as a real write under `pwsh`:
`$a=& $w f.txt x` (assignment with no space before the call operator) and
`& $w<U+00A0>f.txt x` (a no-break space between the target and its operands).
Both exited 0 while their ordinary-whitespace twins exited 2. Pre-existing, not
a 0.28.x regression.

`=` is now in the separator class of every call-target predicate — gate entry
(`ps::call_target_is_bare_computed`), the subexpression and interpolating-string
halves, the `re_var` of both measuring probes, and the quoted-writer regex in
`ps::write_bypass`. Entry and measurement had to move together: widening entry
alone is precisely the "gate admits, probes cannot see" mechanism behind
[#2922](https://github.com/melodic-software/claude-code-plugins/issues/2922) and
[#2924](https://github.com/melodic-software/claude-code-plugins/issues/2924), so
widening one without the others would have manufactured a third instance.
`ps::might_invoke_git`'s launcher class gained `=` for the same reason
(`$p=Start-Process ('g'+'it') reset` was evading the fail-closed sink that the
spaced form already reached).

The Unicode gap is closed by NORMALIZING at intake rather than by widening the
character classes. Under a single-byte locale a multi-byte sequence inside a
bracket expression decomposes into independent byte members, and `\xa0` is the
second byte of `à`, so a class-widening fix would have split ordinary accented
paths into extra operands — an over-block of exactly the class
[#2848](https://github.com/melodic-software/claude-code-plugins/issues/2848)
exists to keep closed. `ps::blank_herestrings` now maps every code point
PowerShell's tokenizer treats as token-separating whitespace to an ASCII space,
spelled as raw UTF-8 byte sequences so the substitution is identical under the C
and UTF-8 locales. The set was derived by parsing each candidate with
`[System.Management.Automation.Language.Parser]::ParseInput` and keeping only
those that genuinely split a call into three command elements; U+200B and
U+FEFF are excluded because they measured as zero-width and never separate.
`,& $w f.txt x`, raised as a possible third member, was ruled out the same way —
it does not parse.

Because the normalization sits at intake, it also closes the same evasion on the
git lanes: `git commit --no-verify<U+00A0>-m x` and
`git<U+00A0>commit --no-verify -m x` now exit 2 from `block-no-verify`, and
`git push --force<U+00A0>origin main` and `git<U+00A0>reset --hard` exit 2 from
`block-dangerous-git`. All four exited 0 before.

The six `#2848` must-allow cases stay at 0 on all three blocking hooks, and
`& $py café.py`, `& $py -m café`, and `& $w café.txt` are pinned at 0 as the
guard against the Latin-1-supplement over-block.

## [0.29.2]

### Fixed
Expand Down
37 changes: 37 additions & 0 deletions plugins/guardrails/hooks/block-dangerous-git.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,43 @@ run_pwsh "PS: grouping + computed launcher (still blocked)" \
run_pwsh "PS: grouping + interpolating-string call target (still blocked)" \
"foreach (\$x in @('a')) { & \"\$tool\" reset --hard }" 2

# --- PowerShell token separators bash does not honor (#2928) ----------------
# PowerShell's tokenizer treats U+00A0 as token-separating whitespace; bash's
# `[[:space:]]` does not, so a destructive form spelled with one fell outside
# every boundary this guard measures. Normalized to an ASCII space at intake.
# Built from a BYTE ESCAPE, never pasted literally — a formatter that normalized
# a raw U+00A0 to a plain space would leave a case that passes while pinning
# nothing.
PS_NBSP=$'\xc2\xa0' # U+00A0 NO-BREAK SPACE
run_pwsh "PS: U+00A0 between --force and its remote (blocked — #2928)" \
"git push --force${PS_NBSP}origin main" 2
run_pwsh "PS: U+00A0 between git and its subcommand (blocked — #2928)" \
"git${PS_NBSP}reset --hard" 2
# The normalization changes where token boundaries fall, not what counts as
# destructive: read-only git stays allowed.
run_pwsh "PS: U+00A0 inside a read-only git command (allowed — #2928)" \
"git log --oneline${PS_NBSP}-n 5" 0

# --- Unspaced assignment before a computed launcher (#2928) -----------------
# `ps::might_invoke_git`'s launcher class lacked `=`, so dropping the spaces
# around an assignment hid a launcher whose program is assembled at run time and
# could be git. The spaced form already failed closed; these bring the two level.
# The allow rows are the other direction of the same edit: widening a class that
# gates a FAIL-CLOSED sink is the over-block direction, so an ordinary
# assignment-of-a-launcher with a bare-variable program must stay allowed.
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment before a computed launcher (fail-closed block — #2928)" \
"\$p=Start-Process ('g'+'it') reset" 2
# shellcheck disable=SC2016
run_pwsh "PS: spaced assignment before a computed launcher (fail-closed block — pre-existing)" \
"\$p = Start-Process ('g'+'it') reset" 2
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment before a bare-variable launcher (allowed — #2928 guard)" \
"\$out=pwsh \$script" 0
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment before Start-Process of a variable (allowed — #2928 guard)" \
"\$p=Start-Process \$app" 0

# --- #2662: fail-closed headlines must not assert a git command is present -----
# The sink is possibly-git (iex / computed call / computed launcher can fire with
# no git token). Assert the softened headline on both the no-git-token path and a
Expand Down
114 changes: 114 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,120 @@ run_pwsh "PS: & { Write-Output secret } > file (blocked)" \
run_pwsh "PS: & { git diff } > file (tool producer, allowed)" \
"& { git diff } > out.txt" 0

# --- fd-dup merge must not hide a computed writer's operands (#2927) ---------
# The `&` inside `2>&1` sits at bracket depth ZERO, and ps::call_site_operand_region
# ends a call's operand region at a depth-zero `;` `|` `&`. So the region of
# `& $w 2>&1 f.txt x` was truncated to `" 2>"`, both measuring probes went silent,
# and a working `Set-Content <path> <value>` — verified as a real write under
# pwsh — fell through ALLOWED. The strip that already existed for the redirect
# probe now runs before EVERY probe in the branch, so the remaining `&`
# characters really are separators. Each row is a shape the truncation hid.
# shellcheck disable=SC2016
run_pwsh "PS: fd-dup before positional Path+Value (blocked — #2927)" \
"& \$w 2>&1 f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: fd-dup before a splat (blocked — #2927)" \
"& \$w 2>&1 @p" 2
# shellcheck disable=SC2016
run_pwsh "PS: fd-dup with an env: target (blocked — #2927)" \
"& \$env:w 2>&1 f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: fd-dup on a piped computed call (blocked — #2927)" \
"'x' | & \$w 2>&1 f.txt" 2
# The walk measures EVERY call site, so the fd-dup on a NON-leftmost one is
# reached too — an earlier bare `& $w` must not consume the scan.
# shellcheck disable=SC2016
run_pwsh "PS: fd-dup on a non-leftmost call site (blocked — #2927)" \
"& \$w; & \$w2 2>&1 f.txt x" 2
# The deliberately ACCEPTED behavior change: once the merge is stripped, one
# literal positional before it and one after it read as the Path+Value pair. That
# is consistent with `& $py script.py arg`, which already blocked, so the class is
# narrow — it needs BOTH sides of the merge to carry a positional.
# shellcheck disable=SC2016
run_pwsh "PS: positional on each side of an fd-dup (blocked — accepted #2927 change)" \
"& \$py a.py 2>&1 b.txt" 2
# The protective half of the strip, which the fix must not undo: a merge with no
# operands after it is plumbing, and a tool's own capture-and-redirect is a tool
# producer, not a content author.
# shellcheck disable=SC2016
run_pwsh "PS: computed call with a bare fd-dup (still allowed — #2927)" \
"& \$tool 2>&1" 0
# shellcheck disable=SC2016
run_pwsh "PS: computed call, flag-first, trailing fd-dup (still allowed — #2927)" \
"& \$py -m pip install x 2>&1" 0

# --- call-site boundaries PowerShell's tokenizer honors (#2928) -------------
# The separator classes were derived from bash character classes rather than from
# PowerShell's tokenizer, so any spelling PowerShell separates and bash does not
# made the call site vanish from every measuring probe — a fail-OPEN. Two members
# were proven with real writes under pwsh; both are pinned here.
#
# The Unicode separators are built from BYTE ESCAPES, never pasted as literal
# characters. A raw U+00A0 in this file is one formatter or `.gitattributes` rule
# away from becoming a plain space, at which point the row degrades to
# `& $w f.txt x` — which blocks anyway, so the case would pass while pinning
# nothing.
# U+00A0 NO-BREAK SPACE and U+2003 EM SPACE.
PS_NBSP=$'\xc2\xa0'
PS_EMSP=$'\xe2\x80\x83'
# Gap 1: `=` was absent from the separator class, so an assignment with no space
# before the call operator never entered the gate — while the spaced form did.
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment before a computed writer call (blocked — #2928)" \
"\$a=& \$w f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment, spaced contrast (blocked — pre-existing)" \
"\$a = & \$w f.txt x" 2
# The `=` widening has to reach the QUOTED-writer and SUBEXPRESSION-target
# predicates too, not just the bare-variable one: entry and measurement moving
# apart is what produced #2922 and #2924.
run_pwsh "PS: unspaced assignment before a quoted writer name (blocked — #2928)" \
"\$a=& 'Set-Content' f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment before a subexpression target (blocked — #2928)" \
"\$a=& (\$w) f.txt x" 2
# Gap 2: PowerShell's tokenizer treats U+00A0 as token-separating whitespace and
# bash's `[[:space:]]` does not, so the whole call site disappeared. Normalized at
# intake instead of widened into the classes — under a single-byte locale the two
# bytes of U+00A0 inside a bracket expression become independent members, and
# `\xa0` is the second byte of `à`, which would over-block ordinary accented
# paths.
# shellcheck disable=SC2016
run_pwsh "PS: U+00A0 between a computed target and its positionals (blocked — #2928)" \
"& \$w${PS_NBSP}f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: U+00A0 before a splat (blocked — #2928)" \
"& \$w${PS_NBSP}@p" 2
# U+2003 is the same defect, not a separate one: the whole measured set of
# PowerShell-separating Unicode spaces is normalized, so a second member proves
# the class moved rather than one character.
# shellcheck disable=SC2016
run_pwsh "PS: U+2003 between a computed target and its positionals (blocked — #2928)" \
"& \$w${PS_EMSP}f.txt x" 2
# Both gaps at once.
# shellcheck disable=SC2016
run_pwsh "PS: unspaced assignment plus U+00A0 (blocked — #2928)" \
"\$a=& \$w${PS_NBSP}f.txt x" 2
# THE OVER-BLOCK GUARD ON THE NORMALIZATION. If the U+00A0 bytes had gone into a
# character class, `\xa0` would match on its own under a single-byte locale and
# split an accented literal into two operands — reopening #2848 one shape
# narrower. These stay allowed.
# shellcheck disable=SC2016
run_pwsh "PS: accented single positional after a computed target (allowed — #2928 guard)" \
"& \$py café.py" 0
# shellcheck disable=SC2016
run_pwsh "PS: accented flag operand after a computed target (allowed — #2928 guard)" \
"& \$py -m café" 0
# shellcheck disable=SC2016
run_pwsh "PS: accented single positional, writer-shaped target (allowed — #2928 guard)" \
"& \$w café.txt" 0
# U+200B and U+FEFF are deliberately NOT normalized. They measured as ZERO-width
# under `[System.Management.Automation.Language.Parser]::ParseInput` — the token
# does not split, so the call target never resolves and there is no write to hide.
# shellcheck disable=SC2016
run_pwsh "PS: U+200B does not separate tokens (allowed — #2928 scope)" \
"& \$w"$'\xe2\x80\x8b'"f.txt x" 0

# Interpreter-producer writes under the PowerShell tool: PowerShell is not
# faithfully bash-tokenizable, so this lane follows the SINK DOCTRINE — block on
# the mangle-resistant co-occurrence of a raw write indicator (_py_write) AND a
Expand Down
26 changes: 26 additions & 0 deletions plugins/guardrails/hooks/block-no-verify.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,32 @@ run_pwsh "PS: dot-source, single-quoted literal script path (allowed)" \
run_pwsh "PS: call-op, interpolated variable target (fail-closed block)" \
'& "$tool" commit --no-verify' 2

# --- PowerShell token separators bash does not honor (#2928) ----------------
# PowerShell's tokenizer treats U+00A0 as token-separating whitespace; bash's
# `[[:space:]]` does not. Every boundary in the PowerShell classifier is now fed
# text where those code points have been normalized to an ASCII space at intake,
# so a `--no-verify` hidden behind one is seen. Built from a BYTE ESCAPE, never
# pasted literally — a formatter that normalized a raw U+00A0 to a plain space
# would leave a case that passes while pinning nothing.
PS_NBSP=$'\xc2\xa0' # U+00A0 NO-BREAK SPACE
run_pwsh "PS: U+00A0 before the -m flag of a --no-verify commit (blocked — #2928)" \
"git commit --no-verify${PS_NBSP}-m x" 2
run_pwsh "PS: U+00A0 between git and its subcommand (blocked — #2928)" \
"git${PS_NBSP}commit --no-verify -m x" 2
# The normalization must not turn read-only git into a block: it changes where
# token boundaries fall, not what counts as a bypass.
run_pwsh "PS: U+00A0 inside a read-only git command (allowed — #2928)" \
"git log --oneline${PS_NBSP}-n 5" 0

# --- Unspaced assignment before a computed launcher (#2928) -----------------
# `ps::might_invoke_git`'s launcher class lacked `=`, so dropping the spaces
# around an assignment hid a launcher whose program is assembled at run time and
# could be git. The spaced form already failed closed; these bring the two level.
run_pwsh "PS: unspaced assignment before a computed launcher (fail-closed block — #2928)" \
"\$p=Start-Process ('g'+'it') reset" 2
run_pwsh "PS: spaced assignment before a computed launcher (fail-closed block — pre-existing)" \
"\$p = Start-Process ('g'+'it') reset" 2

# --- Sink remediation TEXT --------------------------------------------------
# run_pwsh discards stderr, so nothing asserted what the trigger lines actually
# SAY — and a remediation line that describes a shape it never sees is as much a
Expand Down
Loading
Loading