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.3"
"version": "0.29.4"
}
56 changes: 56 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,62 @@
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.4]

### Fixed

- **PowerShell guards: an apostrophe inside a double-quoted string no longer
deletes the command between two such strings
([#2965](https://github.com/melodic-software/claude-code-plugins/issues/2965)).**
`Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd"` exited 0 from
`block-dangerous-git` and `block-no-verify`, while the bare
`& ('g'+'it') push --force` exited 2. The natural-prose spelling —
`Write-Host "Kyle's build"; & ($tool) push --force; Write-Host "that's all"` —
did the same, and `& ('set-'+'content') f.txt x` flanked the same way exited 0
from `block-hook-bypass`. Long-standing shipped behavior, not a 0.28.x/0.29.x
regression.

`ps::blank_quoted_spans` paired quote characters with two independent `sed`
expressions, neither aware of which quote style opened first. An apostrophe
inside a double-quoted string is a literal character to PowerShell, but the
single-quote expression treated it as a delimiter and matched from the
apostrophe in one string to the apostrophe in the next — deleting everything
between them. The whole command above reduced to the single token
`Write-Host`. This is an ENTRY-side failure, not a measurement error: with the
`(` deleted, `ps::has_special_constructs` saw no construct,
`ps::has_dynamic_invocation` saw no call and `ps::has_launcher` saw no
launcher, so `ps::classify_git_command` never entered the fail-closed sink and
none of the downstream probes ran at all.

The two expressions are replaced by one LEFT-TO-RIGHT walk in which whichever
quote character opens first owns everything up to its own next occurrence, so
an apostrophe inside a double-quoted span is ordinary text and a quote
character inside a single-quoted span is ordinary text. The reversed spelling
(`'a"b'; & ('g'+'it') push --force; 'c"d'`) closes by the same walk.

Ambiguity resolves toward NOT deleting, because this is an entry scan where
leaving text in view can only over-block while deleting it is the fail-open
above. An unterminated opener emits the rest of its line verbatim; a span never
crosses a newline; and smart quotes are not treated as delimiters.

Two escape spellings make pairing itself ambiguous, so the walk refuses the
question and emits the rest of the line verbatim rather than picking a closer.
PowerShell's doubled-quote escape (`'it''s'`, `"say ""hi"""`) is one: a doubled
candidate closer is treated as ambiguous, not naively paired. A backtick inside
a would-be double-quoted span is the other, and it has two failure modes —
honoring the escape (`ps::_skip_double_quote`) extends a span past `` "a`" ``
to the next real quote and reopens this same bypass; refusing it ends the span
at the backticked quote and leaves the string's real closer as a stray opener
that re-pairs far to the right. Review measured
``"a`""; & ('g'+'it') push --force; 'b"c'`` at 0 on all three hooks before the
walk started deleting nothing once a backtick is seen. A lone empty string
(`""`, `''`) is not doubled and still blanks normally.

No behavior change to the [#2848](https://github.com/melodic-software/claude-code-plugins/issues/2848)
must-allow shapes: all six stay 0 on all three blocking hooks, including when
contaminated with apostrophes (`& $py $script (Join-Path $dir "that's.jsonl")`).
Prose that merely mentions a write or a git command stays inert.

## [0.29.3]

### Fixed
Expand Down
70 changes: 70 additions & 0 deletions plugins/guardrails/hooks/block-dangerous-git.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1107,4 +1107,74 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \
"$(nul_stderr '' '')" "NUL byte"
run "empty command, no NUL (allowed)" "" 0

# --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter -----
# ps::blank_quoted_spans used to pair quotes with two independent `sed`
# expressions, neither aware of which style opened first. The single-quote
# expression matched from the apostrophe inside one double-quoted string to the
# apostrophe inside the next and DELETED everything between them:
#
# in: Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd"
# out: Write-Host
#
# With the `(` gone, has_special_constructs saw no construct and the fail-closed
# sink was never ENTERED — so every downstream measuring probe was moot. Each
# command below blocks on its own; adding two ordinary apostrophe-bearing strings
# is what made it vanish. The controls are load-bearing: without them an
# all-blocked column is equally consistent with a guard that refuses every
# command containing an apostrophe.
# shellcheck disable=SC2016
run_pwsh "PS: computed git push --force (control, blocked)" \
"& ('g'+'it') push --force" 2
# shellcheck disable=SC2016
run_pwsh "PS: same call straddled by apostrophe-bearing strings (blocked — #2965)" \
"Write-Host \"a'b\"; & ('g'+'it') push --force; Write-Host \"c'd\"" 2
# shellcheck disable=SC2016
run_pwsh "PS: natural-prose spelling, variable target (blocked — #2965)" \
"Write-Host \"Kyle's build\"; & (\$tool) push --force; Write-Host \"that's all\"" 2
# The REVERSED pairing — a double quote inside SINGLE-quoted strings — is the same
# defect with the roles swapped, and must stay closed by the same walk.
# shellcheck disable=SC2016
run_pwsh "PS: reversed straddle, quotes inside single-quoted strings (blocked — #2965)" \
"Write-Host 'a\"b'; & ('g'+'it') push --force; Write-Host 'c\"d'" 2
# AMBIGUITY RESOLVES TOWARD NOT DELETING. An UNTERMINATED opener must not swallow
# the rest of the line, a BACKTICK-escaped quote must not extend the span to the
# next real one (honoring the escape here is what would reopen this bug in a new
# spelling), and PowerShell's DOUBLED-quote escape is deliberately over-blocked.
# shellcheck disable=SC2016
run_pwsh "PS: unterminated opener does not swallow the command (blocked — #2965)" \
"Write-Host \"oops; & ('g'+'it') push --force" 2
# shellcheck disable=SC2016
run_pwsh "PS: backtick-escaped quote does not extend the span (blocked — #2965)" \
"Write-Host \"a\`\"; & ('g'+'it') push --force; Write-Host \"b\"" 2
# shellcheck disable=SC2016
run_pwsh "PS: doubled-quote escape over-blocks rather than deletes (blocked — #2965)" \
"Write-Host 'it''s'; & ('g'+'it') push --force" 2
# The escape cases have a SECOND failure mode that the first cut of this fix
# shipped and review caught: ending a span AT the backticked quote leaves the
# string's REAL closer behind as a stray opener, which then pairs with a quote
# far to the right and deletes the command anyway. Both escapes therefore delete
# NOTHING on their line. Pinned on the exact reviewed spelling.
# shellcheck disable=SC2016
run_pwsh "PS: escaped quote's real closer must not re-pair rightward (blocked — #2965)" \
"\"a\`\"\"; & ('g'+'it') push --force; 'b\"c'" 2
# A lone EMPTY string is not a doubled quote — its closer is followed by
# something other than the same quote — so it must still blank normally rather
# than fall into the delete-nothing branch and start over-blocking.
# shellcheck disable=SC2016
run_pwsh "PS: empty string still blanks normally (allowed — #2965)" \
"Write-Host \"\"; & \$py script.py" 0
# Over-block rails. Message text must stay inert, and the #2848 must-allow shapes
# must survive an apostrophe appearing beside them — more text is now VISIBLE to
# every probe, so this is exactly where a new over-block would surface.
run_pwsh "PS: apostrophe in a commit message stays inert (allowed — #2965)" \
"git commit -m \"it's a fix\"" 0
run_pwsh "PS: two apostrophe-bearing strings, no command between (allowed — #2965)" \
"Write-Host \"Kyle's build\"; Write-Host \"that's all\"" 0
# shellcheck disable=SC2016
run_pwsh "PS: #2848 bare-computed call target flanked by an apostrophe (allowed — #2965)" \
"Write-Host \"Kyle's build\"; & \$py \$script (Join-Path \$dir \"\$id.jsonl\")" 0
# shellcheck disable=SC2016
run_pwsh "PS: #2848 apostrophe inside the grouped operand itself (allowed — #2965)" \
"& \$py \$script (Join-Path \$dir \"that's.jsonl\")" 0

report
53 changes: 53 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1745,4 +1745,57 @@ nul_rc=0
bash "$HOOK" <<<"$(jq -n '{tool_name:"Bash",tool_input:{command:("git status" + ([0]|implode))}}')" >/dev/null 2>&1 || nul_rc=$?
assert_exit "NUL in command (blocked)" 2 "$nul_rc"

# --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter -----
# ps::blank_quoted_spans used to pair quotes with two independent `sed`
# expressions, neither aware of which style opened first. The single-quote
# expression matched from the apostrophe inside one double-quoted string to the
# apostrophe inside the next and DELETED everything between them, so a computed
# writer call flanked by two ordinary strings was ALLOWED even though the same
# call blocks on its own. The controls are load-bearing: without them an
# all-blocked column is equally consistent with a guard that refuses every
# command containing an apostrophe.
# shellcheck disable=SC2016
run_pwsh "PS: computed Set-Content call (control, blocked)" \
"& ('set-'+'content') f.txt x" 2
# shellcheck disable=SC2016
run_pwsh "PS: same call straddled by apostrophe-bearing strings (blocked — #2965)" \
"Write-Host \"a'b\"; & ('set-'+'content') f.txt x; Write-Host \"c'd\"" 2
# shellcheck disable=SC2016
run_pwsh "PS: natural-prose spelling of the straddle (blocked — #2965)" \
"Write-Host \"Kyle's build\"; & ('set-'+'content') f.txt x; Write-Host \"that's all\"" 2
# A bare-computed writer target with a -Value write signal is the other shape the
# deletion hid — it reaches write_bypass through a different gate than the
# subexpression target above, so it is pinned separately.
# shellcheck disable=SC2016
run_pwsh "PS: bare-computed writer with -Value, straddled (blocked — #2965)" \
"Write-Host \"it's\"; & \$w f.txt -Value x; Write-Host \"won't\"" 2
# The escape cases have a SECOND failure mode that the first cut of this fix
# shipped and review caught: ending a span AT the backticked quote leaves the
# string's REAL closer behind as a stray opener, which then pairs with a quote
# far to the right and deletes the writer call anyway. Both the backtick and the
# doubled-quote escape therefore delete NOTHING on their line. This spelling
# reaches write_bypass through `lcq_bt` — the backtick-intact copy built before
# backticks are stripped from `lcq` — so `ps::blank_quoted_spans` sees the
# backtick and the backtick-ambiguity branch emits the line verbatim. The
# doubled-quote arm is not what catches this pinned case.
# shellcheck disable=SC2016
run_pwsh "PS: escaped quote's real closer must not re-pair rightward (blocked — #2965)" \
"\"a\`\"\"; & ('set-'+'content') f.txt x; 'b\"c'" 2
# A lone EMPTY string is not a doubled quote, so it must still blank normally
# rather than fall into the delete-nothing branch and start over-blocking.
# shellcheck disable=SC2016
run_pwsh "PS: empty string still blanks normally (allowed — #2965)" \
"& \$py \$script \"\"" 0
# Over-block rails. Message text naming a write must stay inert — quote blanking
# is what makes it inert, and this change makes MORE text visible to every probe.
run_pwsh "PS: redirect inside message text stays inert (allowed — #2965)" \
"Write-Host 'example > out.txt'" 0
run_pwsh "PS: -Value inside a commit message stays inert (allowed — #2965)" \
"git commit -m 'use -Value x'" 0
run_pwsh "PS: two apostrophe-bearing strings, no command between (allowed — #2965)" \
"Write-Host \"Kyle's build\"; Write-Host \"that's all\"" 0
# shellcheck disable=SC2016
run_pwsh "PS: #2848 bare-computed call target flanked by an apostrophe (allowed — #2965)" \
"Write-Host \"Kyle's build\"; & \$py \$script (Join-Path \$dir \"\$id.jsonl\")" 0

report
29 changes: 29 additions & 0 deletions plugins/guardrails/hooks/block-no-verify.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -579,4 +579,33 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \
"$(nul_stderr '' '')" "NUL byte"
run "empty command, no NUL (allowed)" "" 0

# --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter -----
# ps::blank_quoted_spans used to pair quotes with two independent `sed`
# expressions, neither aware of which style opened first. The single-quote
# expression matched from the apostrophe inside one double-quoted string to the
# apostrophe inside the next and DELETED everything between them — including the
# command. With the `(` gone the sink was never entered, so this blocked on its
# own and was ALLOWED once flanked by two ordinary strings.
#
# The controls are load-bearing: without them an all-blocked column is equally
# consistent with a guard that refuses everything containing an apostrophe.
# shellcheck disable=SC2016
run_pwsh "PS: computed git commit --no-verify (control, blocked)" \
"& ('g'+'it') commit --no-verify -m x" 2
# shellcheck disable=SC2016
run_pwsh "PS: same call straddled by apostrophe-bearing strings (blocked — #2965)" \
"Write-Host \"a'b\"; & ('g'+'it') commit --no-verify -m x; Write-Host \"c'd\"" 2
# shellcheck disable=SC2016
run_pwsh "PS: natural-prose spelling of the straddle (blocked — #2965)" \
"Write-Host \"Kyle's build\"; & ('g'+'it') commit --no-verify -m x; Write-Host \"that's all\"" 2
# Over-block rails. An apostrophe inside message text must stay inert, and the
# #2848 must-allow shape must survive an apostrophe appearing next to it.
run_pwsh "PS: apostrophe in a commit message stays inert (allowed — #2965)" \
"git commit -m \"it's a fix\"" 0
run_pwsh "PS: two apostrophe-bearing strings, no command between (allowed — #2965)" \
"Write-Host \"Kyle's build\"; Write-Host \"that's all\"" 0
# shellcheck disable=SC2016
run_pwsh "PS: #2848 bare-computed call target flanked by an apostrophe (allowed — #2965)" \
"Write-Host \"Kyle's build\"; & \$py \$script (Join-Path \$dir \"\$id.jsonl\")" 0

report
Loading
Loading