Skip to content

fix(guardrails): apostrophe in a double-quoted string no longer deletes the command between two such strings - #2983

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/2965-blank-quoted-spans-lr
Aug 21, 2026
Merged

fix(guardrails): apostrophe in a double-quoted string no longer deletes the command between two such strings#2983
kyle-sexton merged 6 commits into
mainfrom
fix/2965-blank-quoted-spans-lr

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes #2965

Summary

Close the PowerShell apostrophe-in-double-quoted-string fail-open: ps::blank_quoted_spans no longer deletes the command between two apostrophe-bearing double-quoted strings, so all three blocking hooks reach the fail-closed sink.

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 span delimiter and matched from the apostrophe in one double-quoted string to the apostrophe in the next — deleting everything between them.

in:  Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd"
out: Write-Host

This is an ENTRY-side failure, not a mis-measurement. 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 measuring probes ran at all. Two ordinary apostrophe-bearing strings ("Kyle's build", "that's all") were a general bypass of all three blocking hooks.

Fix

One left-to-right walk replaces the two expressions: 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.

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:

ambiguity policy probe
unterminated opener emit rest of line verbatim Write-Host "oops; & ('g'+'it') push --force → 2
newline inside a span span never crosses it (matches the old per-line sed) multi-line straddle → 2 on all three
doubled-quote escape ('it''s') deliberately over-blocked, not modeled — naive pairing never deletes more than the real string does Write-Host 'it''s'; & ('g'+'it') push --force → 2
smart quotes (U+2018/U+2019) not treated as delimiters Write-Host "a’b"; & ('g'+'it') push --force; Write-Host "c’d" → 2
backtick-escaped delimiter not honored Write-Host "a\"; & ('g'+'it') push --force; Write-Host "b"` → 2

Why this does not reuse ps::_skip_double_quote

The issue suggested reusing it. It honors the backtick escape, which is correct where it is used — the sink BLANKING path, already past the entry decision. Honoring it here extends a span past "a`" to the next real quote and reopens this very bug in a new spelling. Measured: with a backtick-honoring mutant of this walk, Write-Host "a\"; & ('g'+'it') push --force; Write-Host "b"drops to **rc 0** onblock-dangerous-gitandblock-no-verify. Refusing the escape ends the span at the backticked quote, leaves more text in view, preserves the old sed's behavior, and lets a surviving backtick still trip has_special_constructs' backtick arm — which it cannot do if the span containing it is deleted. ps::_skip_double_quote/ps::_skip_single_quote` are left untouched.

Bypass closure

tool_name: "PowerShell", payloads built with jq as real PreToolUse envelopes. Before = merge base, after = this branch.

command dangerous-git no-verify hook-bypass
& ('g'+'it') push --force (control) 2 → 2 2 → 2 2 → 2
Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd" 0 → 2 0 → 2 0 → 2
Write-Host "Kyle's build"; & ($tool) push --force; Write-Host "that's all" 0 → 2 0 → 2 0 → 2
& ('set-'+'content') f.txt x (control) 2 → 2 2 → 2 2 → 2
Write-Host "a'b"; & ('set-'+'content') f.txt x; Write-Host "c'd" 0 → 2 0 → 2 0 → 2

Three further straddle spellings found and closed while probing: & ('set-'+'content') in the natural-prose spelling, the --no-verify straddle, and a bare-computed writer with -Value (Write-Host "it's"; & $w f.txt -Value x; Write-Host "won't") — each 0 → 2.

Over-block rails

Prose-inert rows, all three hooks, 0 before and 0 after — none changed:

Write-Host 'example > out.txt' · git commit -m 'use -Value x' · echo 'run & $($w) f.txt x later' · Write-Host 'example & $(Get-Date) f.txt x' · git commit -m "it's a fix" · Write-Host "Kyle's build"

The issue-2848 must-allow six are 0 before and 0 after on all three hooks, and stay 0 when contaminated with the apostrophes this change makes visible:

  • & $py $script (Join-Path $dir "that's.jsonl") — 0
  • Write-Host "Kyle's build"; & $py $script (Join-Path $dir "$id.jsonl") — 0
  • Start-Process notepad -ArgumentList "Kyle's", (Join-Path $d "that's") — 0
  • Write-Host "Kyle's"; & $py -m unittest discover — 0

Negative controls all rc=2 (Bash: git commit --no-verify -m x, git push --force origin main, cat > somefile.txt; PowerShell: Set-Content -Path out.txt -Value hi, git commit --no-verify -m x, git push --force origin main) — so the zero columns above are not silently no-opping hooks.

Callers

All five call sites of ps::blank_quoted_spans were probed, not just reasoned about:

caller consequence
ps::might_write_via_python3 (launcher + computed construct) more $/( visible; probed Start-Process notepad -ArgumentList "Kyle's", (Join-Path $d "that's") → still 0
ps::might_write_via_python3 (has_special_constructs arm) probed Write-Host "Kyle's"; & $py -m unittest discover → still 0
ps::classify_git_command sink trigger the fix — sink now entered on every straddle row
ps::write_bypass computed-target gate bare-computed writer straddle 0 → 2; 2848 shapes unchanged at 0
ps::write_bypass writer-name scan & ('set-'+'content') straddle 0 → 2; git commit -m 'use -Value x' still 0

Tests

25 pinned cases across the three hook test files, each asserting an exact rc. Mutation-checked by reverting the fix: 7 go RED (the straddle rows on all three hooks), the rest are ambiguity-policy and over-block rails that are green both ways by design, plus the backtick pin which goes RED under its own targeted backtick-honoring mutant.

Related

  • issue 2926 — the over-block whose evaluation surfaced this one
  • issue 2906 — a different pre-existing evasion rooted in the same function's deletion semantics
  • issue 2848 — the over-block removal whose acceptance cases this preserves
  • PR 2966 — the concurrent change to this same file, which this branch is rebased on top of

🤖 Generated with Claude Code

https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ

Verification

25 pinned cases across the three hook test files, each asserting an exact rc. Mutation-checked by reverting the fix: 7 go RED (the straddle rows on all three hooks). CI ci-status is green on this head; remaining babysit work is review-thread resolution.

@cursor

cursor Bot commented Aug 17, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 48s —— View job


Code review complete

  • Gather context (PR diff, description, changed files)
  • Read plugins/guardrails/lib/powershell/ps-command.sh changes in detail
  • Read test file changes
  • Check CLAUDE.md / AGENTS.md alignment
  • Post findings (inline + summary)

I traced the new ps::blank_quoted_spans left-to-right walk (plugins/guardrails/lib/powershell/ps-command.sh L299-L331) by hand against a range of inputs — unterminated openers, newline straddles, mixed/reversed quote nesting ('a"b', "a'b"), doubled-quote escapes, and end-of-string edge cases — and it produces the documented "first opener wins, ambiguity never deletes" semantics in every case I checked. It's also linear per line (a failed/unterminated scan can only happen once per line before the remainder is copied verbatim), so there's no quadratic blowup on pathological input despite the character-by-character loop; that style is consistent with the rest of this file (ps::_skip_double_quote, ps::_skip_statement_tail, etc. use the same idiom).

The 25 added test cases across the three hook test files line up with the PR's bypass-closure and over-block claims, and the plugin.json/CHANGELOG.md version bump is consistent with the fix.

One minor, non-blocking finding posted inline:

  • plugins/guardrails/lib/powershell/ps-command.sh L327-329 — the manual trailing-newline strip loop (and its comment) is redundant: every call site captures the function's output via $(...), which already strips all trailing newlines on its own. Not a behavior bug, just a comment whose premise doesn't hold against the current callers.

No correctness or maintainability issues found beyond that. Nice work — this is a thorough, well-tested fix.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 17s —— View job


Security review — PR #2983

  • Gather context (diff, changed files, custom instructions)
  • Analyze ps::blank_quoted_spans rewrite for bypass potential
  • Trace the doubled-quote-escape ambiguity example by hand
  • Cross-check callers (classify_git_command, write_bypass, might_write_via_python3, has_special_constructs)
  • Review new pinned tests
  • Post findings

Verdict: no exploitable bypass found. The core fix is sound — the left-to-right, first-opener-owns-its-span walk correctly resolves the #2965 straddle bug, and I independently hand-traced the mechanics rather than trusting the PR's narrative.

Why the fix holds up

For any well-formed, complete PowerShell quoted string (single- or double-quoted), the total quote-character count is always even: open + close + 2×(doubled-escape count). Since the new walk greedily pairs quote characters left-to-right, a sequence of complete strings always returns parity to "outside any span" after each one closes — so real code between strings stays visible regardless of how many internal escapes a preceding string contains. I verified this by hand-tracing several shapes, including the two straddle directions in the PR, the pinned 'it''s'; & ('g'+'it') push --force case, and the backtick-non-escape case — all resolve exactly as claimed (rc 2, dangerous construct stays visible).

One documentation nit (non-blocking, not a runtime issue)

plugins/guardrails/lib/powershell/ps-command.sh#L280 — the comment's illustrative example is inaccurate. It claims naive pairing on 'a'' ; git push ; ''b' "splits into two spans that leave ; git push ; VISIBLE." Hand-tracing the actual algorithm on that exact string shows the opposite: all 6 quote characters pair up as [0,2]∪[3,18]∪[19,21], which is contiguous and covers the entire string — so ; git push ; is fully deleted, not left visible. This happens to still be safe (the deleted text is genuinely inert string content in real PowerShell, and the deletion exactly matches the string's true boundary — the even-parity property above, not the "leaves it visible" framing, is what makes it safe), but the stated mechanism in the comment doesn't match what the code does. This example isn't a pinned test case (only the safe, single-escape 'it''s' shape is pinned in block-dangerous-git.test.sh:1150), so nothing depends on the incorrect claim today — but a future maintainer relying on this comment's reasoning to justify a change could be misled. Worth a follow-up comment fix.

Fix this →

Other observations (informational, not findings)

  • Algorithmic complexity: the character-by-character bash walk with out+=... accumulation is amortized O(n) — each inner scan consumes a disjoint span, so there's no quadratic blowup on adversarial input (e.g. many short quoted strings).
  • Callers: confirmed has_special_constructs runs on the blanked scan, while has_dynamic_invocation/has_launcher/git-string detection run on PS_BLANKED (quotes intact) — so a survived backtick or unblanked git literal is still visible to those probes even when inside a span that later gets blanked for other purposes. This matches the PR's stated design.
  • Test additions in all three hook test files are well-constructed with load-bearing controls (unblocked-by-default commands prove the guard isn't just refusing everything containing an apostrophe) and appropriately pin both the straddle-blocked and over-block-allowed directions.
  • plugin.json version bump (0.29.3 → 0.29.4) and CHANGELOG.md entry are consistent with the fix.

No security-blocking issues found in this PR. The one comment inaccuracy above is a maintainability/trust concern, not a live vulnerability.

Comment thread plugins/guardrails/lib/powershell/ps-command.sh Outdated
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40f2081493

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/lib/powershell/ps-command.sh Outdated
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Last security-reviewed head: 4595089b70a3cfb39d6b36a6a198bac1e781b57a. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 40s —— View job


Code review complete

  • Gather context (PR diff, description, changed files)
  • Check skip gate (open/draft/trivial/already-reviewed) — HEAD advanced to a46b325 (wording-only change over 40f2081); re-reviewed since Codex's inline P1 comment on this branch hadn't been independently checked yet
  • Review latest commit (a46b325) against prior findings — it only rewords mis-measurementmeasurement error in a comment and the changelog to clear a typos-cli false positive; no logic change
  • Re-examine ps::blank_quoted_spans for the reported bypass — hand-traced independently, confirmed
  • Post findings

Findings

Confirmed, high-signal (posted inline on ps-command.sh:292-296): the walk's own documented invariant — "refusing the backtick escape only ever leaves more text in view" — is false. I hand-traced ps::blank_quoted_spans (independent of Codex's earlier inline comment on this same function, which flagged the same underlying defect via a different repro string) against:

"a`""; & ('g'+'it') push --force; 'b"c'

A backtick-escaped quote that is immediately followed by the string's real terminator (a doubled quote right after the escape) closes the span one character early, same as the pinned-safe case at block-dangerous-git.test.sh:1147-1148 — but here that leaves the second quote of the doubled pair as a fresh, unpaired opener. The scan for its close doesn't stop at end-of-string content; it runs to the next real quote anywhere later on the line, which lands inside an unrelated single-quoted string ('b"c'), and blanks everything in between — including the & ('g'+'it') push --force call. Output reduces to c'; has_special_constructs sees no (, and has_dynamic_invocation's &/.-then-quote-or-$ regex doesn't match & ( either, so none of the three sink triggers fire. Same fail-open class as #2965 itself, in a new spelling.

This is also a real test-coverage gap: none of the 25 pinned cases exercise a backtick-escaped quote immediately followed by its own terminator — only the safe "backtick then later real quote" shape is pinned. Details and a fix pointer are in the inline comment. This should block merge until either the walk is fixed to not leave a dangling opener in this shape, or pinned tests + a documented residual establish the actual (narrower) safety boundary.

Not re-flagged: the pre-existing non-blocking nit about the redundant trailing-newline-strip loop/comment at ps-command.sh:327-329 (already inline from the prior review round) is unchanged and still stands but isn't repeated here to avoid duplicate threads.

Everything else (test structure, plugin.json/CHANGELOG.md version bump, the five callers of ps::blank_quoted_spans, over-block rails) checked out as described in the PR body and prior review rounds — no new issues found there.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 53s —— View job


Security review (/review:security-review)

  • Gather context (PR diff since last-reviewed head 40f2081, custom instructions)
  • Confirm skip gate does not apply — the only commit since the prior review (a46b325) is a comment-only reword, but Codex posted an unresolved P1 candidate against the still-live code that needed independent verification, not a rubber-stamp
  • Independently hand-trace the Codex-flagged ps::blank_quoted_spans concern against the actual algorithm
  • Check whether the bypass actually reaches ps::classify_git_command's sink triggers (not just the isolated function)
  • Post findings

Verdict: one CRITICAL finding, confirmed exploitable — posted inline on ps-command.sh L299-331.

I independently re-traced Codex's flagged shape character-by-character against the actual loop (not just against their prose) and confirmed it end-to-end into the caller, not just the isolated function:

Write-Host "a""; & ('g'+'it') push --force; Write-Host 'b"c'reducesscantoWrite-Host c'— the entire& ('g'+'it') push --forceis deleted. Tracing intops::classify_git_command(L1139-1160):has_special_constructssees no(/)in the mangledscan, and has_dynamic_invocation/has_launcher(which run onPS_BLANKED, quotes intact) don't match & (either — their regexes require& or a launcher keyword.PS_SINK_TRIGGERstays empty, the fail-closed sink is never entered, and the computedgit push --force` is not blocked. This reproduces the exact #2965 bypass class in a new spelling.

Root cause: refusing to honor the backtick escape (deliberate, and correct for the case the PR's own probes cover) closes a span one quote-char early when a double-quoted string contains a backtick-escaped quote, leaving that string's real closer as an unpaired, spurious new opener. With an odd count of escaped quotes in the string (most simply just one — e.g. "say `"hi", an ordinary way to embed a quotation), this permanently flips double-quote parity for the rest of the line, so the next literal " anywhere later — even one embedded in an otherwise-correct single-quoted string — becomes its accidental partner, swallowing everything between including a dangerous construct. This also means the "total quote-character count is always even" invariant from the prior review round doesn't hold once backtick-escaped quotes are in scope.

Full reasoning, exact trace, and a Claude Code fix-this link are in the inline comment.

No other security-relevant surface: the only commit since the last reviewed head is a comment-only typo fix (plugins/guardrails/CHANGELOG.md, plugins/guardrails/lib/powershell/ps-command.sh — wording only, no logic change). Per this skill's scope, I did not re-review style/test-coverage/naming (that's /review:code-review), and deferred to zizmor's advisory lane for supply-chain/workflow-trigger/permissions concerns (none apply to this diff regardless — no workflow files changed).
· branch

Comment thread plugins/guardrails/lib/powershell/ps-command.sh Outdated
Comment thread plugins/guardrails/lib/powershell/ps-command.sh
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 2s —— View job


Security review (/review:security-review)

  • Gather context (diff since last-reviewed head a46b325, custom instructions)
  • Confirm skip gate does not apply — this commit directly targets the CRITICAL finding from the prior review round, so it needs independent re-verification, not a skip
  • Independently hand-trace ps::blank_quoted_spans against the exact counterexample from the previous round
  • Verify all four callers of ps::blank_quoted_spans now pass backtick-intact text
  • Post findings

Verdict: no security issues found. The CRITICAL bypass confirmed in the prior review round is fixed.

Commit 4595089 directly addresses the finding both Codex and Claude's prior security-review pass raised: a backtick-escaped quote inside a double-quoted span (e.g. "a`"") used to close the span one character early, leaving the string's real closing quote as a spurious unpaired opener that then paired with an unrelated " later on the line — deleting a dangerous git construct in between.

Independent re-trace of the fix, on the exact confirmed counterexample:

"a`""; & ('g'+'it') push --force; 'b"c'

At L305-351: i=0, q='"'; the inner scan hits the backtick at j=2. The new L317-320 branch now fires on " + backtick and advances j straight to end-of-line/newline without looking for another quote, then falls through to the "unterminated" path, which copies text:i:j-i — i.e. the entire rest of the line, verbatim, unblanked. So & ('g'+'it') push --force stays fully visible; has_special_constructs sees the (/) and the fail-closed sink is entered as intended. This matches the PR's own stated policy ("ambiguity resolves toward NOT deleting") and closes the exact class of bug rather than just the one reported spelling — the same branch also now catches the doubled-quote-escape ambiguity ('it''s') via the analogous check at L321-331.

Checked all four call sites of ps::blank_quoted_spans for the second-order issue this same round fixed in ps::write_bypass (backticks previously stripped from lcq before being handed to the blanker, silently reintroducing the identical bug through a path the git-lane fix didn't cover):

  • L1059 (might_write_via_python3) — passes raw $1, backtick-intact. OK.
  • L1166 (classify_git_command sink) — passes $PS_BLANKED, backtick-intact. OK.
  • L1640 (write_bypass computed-target gate) — now passes the newly-added lcq_bt (L1613-1623), a backtick-intact copy captured before lcq's backtick strip, with the blanker's output backtick-stripped only afterward. This is precisely the fix for the second bypass path. Verified ps::fold_escaped_brace_closers (L378-404) leaves `" untouched (its own escape handling only folds `} and ``), solcq_bt` genuinely retains the escape context the blanker needs.
  • L1707 (write_bypass writer-name scan) — passes $PS_BLANKED directly, backtick-intact. OK.

All four now satisfy the invariant the fix depends on: ps::blank_quoted_spans never runs on text that has already had its backtick escape context stripped.

Test coverage: both new pinned cases (block-dangerous-git.test.sh, block-hook-bypass.test.sh) use the exact confirmed-bypass spelling from the prior review round and assert rc 2, plus a companion "empty string still blanks normally" case guarding against the fix over-blocking the ordinary "" shape. I did not execute the test suite (no general Bash access in this lane beyond gh pr *), so this is verified by hand-trace against the algorithm rather than by running it.

No new security-relevant surface beyond this. Per this skill's scope, style/test-coverage/naming commentary is deferred to /review:code-review, and supply-chain/workflow-trigger/permissions concerns are deferred to zizmor's advisory lane (no workflow files changed in this diff).
· branch

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 10m 29s —— View job


Code review complete

  • Gather context (PR diff since merge base, prior review threads on this PR)
  • Review latest commit (4595089) against the CRITICAL bug flagged in prior rounds (Codex + two Claude security-review passes)
  • Review test additions for the fix
  • Check CLAUDE.md / AGENTS.md alignment
  • Post findings (inline + summary)

This is the code-review lane (correctness/maintainability); security findings are scoped to /review:security-review, which has its own workflow file in this repo (.github/workflows/claude-security-review.yml), so I didn't re-adjudicate the bypass itself as a security finding — but I did independently verify the fix, since it's also a correctness question for this lane.

The critical fix (4595089) checks out

I hand-traced ps::blank_quoted_spans (plugins/guardrails/lib/powershell/ps-command.sh L305-L351) character-by-character against the exact bypass two reviewers flagged ("a`""; & ('g'+'it') push --force; 'b"c'). With the backtick present, the ambiguity branch at L317-320 now fires before the loop ever reaches a closing quote, and the rest of the line — including ('g'+'it') — is emitted verbatim instead of being deleted. has_special_constructs sees the surviving ( and the sink still trips. I also confirmed the parallel fix in ps::write_bypass (L1613-1641): lcq_bt now preserves the backtick specifically for the blank_quoted_spans call feeding the computed-call-target gate, closing the same class of bypass for & ('set-'+'content') .... Both hold up; the pinned regression tests for this shape look correct.

Findings (both non-blocking, both about comments describing stale/incorrect mechanics — posted inline)

  • plugins/guardrails/hooks/block-hook-bypass.test.sh:1772-1778 — the comment claims this pinned test is caught by "only the doubled-quote arm" because lcq has "already stripped backticks." That's not what the shipped code does: this same commit added lcq_bt precisely so the backtick survives for this call, and hand-tracing shows the backtick arm (not the doubled-quote arm) is what keeps ('set-'+'content') visible. Reads like leftover text from before lcq_bt existed.
  • plugins/guardrails/CHANGELOG.md:39-48 — the 0.29.4 entry wasn't updated for this commit. It still describes the doubled-quote escape as "naive pairing" (the shipped code now treats a doubled closer as ambiguous — delete nothing on the line, same as backtick), and it documents only the first failure mode (refuse the backtick escape) without mentioning the second one found in review (refusing the escape can leave the string's real closer as a stray opener that re-pairs far to the right) or its fix.

Everything else — the 25+ pinned test cases, the ps::_skip_double_quote comparison in the main function comment, the newline/unterminated/doubled-quote ambiguity handling, and the five callers — matches the behavior I independently traced. No other correctness or maintainability issues found.
· branch

Comment thread plugins/guardrails/hooks/block-hook-bypass.test.sh Outdated
Comment thread plugins/guardrails/CHANGELOG.md Outdated
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Session stopped here — state and remaining work, for whoever picks this up

The lane working this PR was stopped mid-flight. Nothing is lost; everything is pushed. This comment carries what would otherwise have died with the session.

Where this stands

Head is 4595089b70a3cfb39d6b36a6a198bac1e781b57a. The last commit on it fixes a fail-open regression that this PR itself introduced and that the review threads below correctly caught.

The regression: the left-to-right blanker refused the backtick escape and closed a quoted span one quote-character early, leaving the string's true closing quote as an unpaired opener. That opener then scanned forward to the next literal quote anywhere on the line — including one embedded in an unrelated single-quoted string — and blanked everything between, swallowing a dangerous construct.

Measured independently, not taken from a report:

tree Write-Host "a + backtick + ""; & ('g'+'it') push --force; Write-Host 'b"c'
merged main fdbc42137 dangerous-git = 2, no-verify = 2 (blocked)
this branch at a46b32539 dangerous-git = 0, no-verify = 0 (fail-open)
this branch at 4595089b7 dangerous-git = 2, no-verify = 2 (closed)

Also re-measured at 4595089b7, hook-bypass / dangerous-git / no-verify:

Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd"       2/2/2
Write-Host "a'b"; & ('set-'+'content') f.txt x; Write-Host "c'd"    2/2/2
& $py -m unittest discover                                          0/0/0
. $PROFILE                                                          0/0/0

The original issue-2965 rows still block and the acceptance cases still pass.

What is NOT yet done

  1. Four review threads are unresolved. They are the merge gate — this repo requires thread resolution, not approvals, and CI was already green (47 success, 1 skipped) before the fix commit. Three threads are the escaped-quote fail-open; reply with the before/after table above and point at the pinned regression tests. The fourth, at ps-command.sh:329, is minor: the trailing-newline strip is dead weight because all five callers capture via command substitution, which strips trailing newlines regardless — either remove it or re-justify the comment.
  2. The allow side is not fully re-measured against 4595089b7. Specifically & $py script.py, Write-Host 'example > out.txt', and git commit -m 'use -Value x' still need confirming at rc=0. A probe run timed out before reaching them.
  3. The general single-escape shape needs pinning, not only the doubled-quote spelling. Any double-quoted string containing an odd number of backtick-escaped quotes flips quote parity for the rest of the line — "say + backtick + "hi" is enough. This also invalidates the even-quote-count parity argument from the earlier review round, which omits backtick-escaped quotes entirely.
  4. The fresh-context verifier has not re-run on the fixed branch. It should be briefed to break the fix rather than confirm it, hunting more parity-leak spellings: odd numbers of escapes, escapes adjacent to terminators, escapes inside single-quoted spans, mixed-quote-style straddles, and escapes spanning statement separators. The previous 25 pinned cases missed this entire class, so coverage is unproven until it tries.

Standing constraints for this repo

Verify merged content by executing probes, never by reading PR or issue state. Diff against the merge commit's true parent, not the recorded base. Check base staleness by file overlap immediately before merging. Squash is the only permitted merge method. Never use --no-verify. Every new pinned case must assert an exact rc — a prior PR shipped a case asserting only a non-zero rc that passed identically with the guard deleted.

A sibling lane is live on the same file at roughly lines 910 to 950 (PR 2992). Whoever merges second rebases.

Related

  • issue 2906 — a separate defect in the same function, parked on an operator decision and blocked on this PR landing first.
  • issue 2984 / PR 2992 — the sibling sink-trigger fix in the same file.

kyle-sexton and others added 6 commits August 21, 2026 08:03
…es the command between two such strings

`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.

    in:  Write-Host "a'b"; & ('g'+'it') push --force; Write-Host "c'd"
    out: Write-Host

This is an ENTRY-side failure. With the `(` deleted, has_special_constructs saw
no construct, has_dynamic_invocation saw no call and has_launcher saw no
launcher, so classify_git_command never entered the fail-closed sink and no
downstream probe ran at all. Two ordinary apostrophe-bearing strings were a
general bypass of all three blocking hooks.

Replaced by one left-to-right walk in which whichever quote character opens
first owns everything up to its own next occurrence. Ambiguity resolves toward
NOT deleting, since this is an entry scan where leaving text in view can only
over-block: an unterminated opener emits the rest of its line verbatim, a span
never crosses a newline, the doubled-quote escape is over-blocked rather than
modeled, and smart quotes are not delimiters. The walk deliberately does not
honor the backtick escape and so does not reuse ps::_skip_double_quote --
honoring it extends a span past `"a`"` to the next real quote and reopens this
same bypass, measured at rc 0 on both git hooks.

Closes #2965

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
`mis-measurement` is flagged by typos-cli as `mis` -> `miss`/`mist`. Same
meaning, no vocabulary change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
… past its own string

The left-to-right blanker refused the backtick escape and closed the span
one quote-character early, leaving the string's true closing quote as an
unpaired opener. That opener then scanned forward to the next literal
quote anywhere on the line -- including one embedded in an unrelated
single-quoted string -- and blanked everything between, swallowing a
dangerous construct.

Reported on this PR by two independent reviewers and confirmed by
measurement: `Write-Host "a`""; & ('g'+'it') push --force; Write-Host
'b"c'` measured rc=2 on block-dangerous-git and block-no-verify on
merged main and rc=0 on this branch before this commit.

The disturbance is now contained to the string it originates in, so
ambiguity still resolves toward not deleting rather than toward
extending a span, which is the direction that would reopen the original
issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
…pans

All five callers capture via command substitution, which already strips
trailing newlines. The loop and its "callers see that shape" comment
never changed what any current caller observed.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…iguity arm

The pinned case reaches write_bypass through lcq_bt, not the
backtick-stripped lcq, so the backtick-ambiguity branch fires first.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…ty boundary

The 0.29.4 entry still described naive pairing for the doubled-quote
escape and only the first backtick failure mode. Match the walk that
deletes nothing on the line once either escape is seen.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot force-pushed the fix/2965-blank-quoted-spans-lr branch from 4595089 to e7c776e Compare August 21, 2026 08:03
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 3m 12s —— View job


Code review complete

  • Gather context (PR diff since last-reviewed head, custom instructions)
  • Check skip gate — not applicable; head advanced with commits that directly target the four findings from prior review rounds, so a fresh look is warranted
  • Review latest commits against prior findings (trailing-newline strip, test comment accuracy, CHANGELOG staleness)
  • Review diff for new correctness/maintainability issues
  • Post findings

This is the code-review lane (correctness/maintainability). The critical bypass flagged in earlier review rounds on this PR (a backtick-escaped quote leaving a string's real closer as a stray opener that re-pairs far to the right, e.g. "a`""; & ('g'+'it') push --force; 'b"c') was already fixed in an earlier commit on this branch; the four commits since the last review round I can see (d2df592, 83f076d, 761967d, e7c776e, plus the wording-only b42ee59) close out review feedback rather than introduce new logic.

Independently re-traced ps::blank_quoted_spans (plugins/guardrails/lib/powershell/ps-command.sh L305-L348) character-by-character against the confirmed counterexample rather than trusting the commit message: at i=0 the walk hits the backtick at index 2, the backtick-ambiguity branch (L317-320) fires before any closing quote is found, and the inner "consume to newline" loop runs to end-of-string with no newline present — so out += text[0:n] emits the entire line verbatim, including ('g'+'it') push --force. That confirms the fix holds for this shape.

Each commit since maps cleanly to a specific prior finding:

  • 83f076d — removes the dead trailing-newline-strip loop flagged in the first code-review pass (job 32058307776). Diff is a clean 3-line deletion; no other callers depend on the stripped shape (all five capture via $(...), confirmed by re-reading each call site).
  • 761967d — corrects the block-hook-bypass.test.sh comment that misattributed this pinned case to "the doubled-quote arm." The commit's new wording matches what the code actually does: write_bypass now feeds lcq_bt (backtick-intact) to ps::blank_quoted_spans, so the backtick-ambiguity branch is what fires first — verified by the same hand-trace above.
  • e7c776e — rewrites the 0.29.4 CHANGELOG entry to describe the doubled-quote escape as an explicit ambiguity check (not "naive pairing") and documents both backtick failure modes (honoring vs. refusing the escape) plus the fix, matching the shipped code in ps-command.sh L305-348.
  • b42ee59 — wording-only (mis-measurementmeasurement error) to clear a typos-cli false positive; no logic touched.

No new findings. Everything flagged in prior review rounds on this PR has been addressed by a targeted fix rather than a suppression, and I did not find anything new to raise in the current diff.
· branch

@kyle-sexton
kyle-sexton merged commit 6df9343 into main Aug 21, 2026
50 checks passed
@kyle-sexton
kyle-sexton deleted the fix/2965-blank-quoted-spans-lr branch August 21, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants