Skip to content

perf(guardrails): make the PowerShell guard path fork-free and narrow its fail-close to executable git - #4188

Merged
kyle-sexton merged 20 commits into
mainfrom
guardrails-powershell-guard-path-3x-slow
Sep 16, 2026
Merged

kyle-sexton merged 20 commits into
mainfrom
guardrails-powershell-guard-path-3x-slow

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

No related issue: handoff-inbox items 20260914-183000 and its correction 20260915-144500, under program item 20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

Stacked on #4185 (base branch guardrails-run-guards-in-process-no-subs): this branch needs that PR's in-process dispatcher and lib. GitHub retargets it to main when #4185 merges; #4185's commits are merged in here.

Summary

A PowerShell tool call paid 80 process creations and about 2.6 s in the guardrails PreToolUse chain against 3 and 0.27 s for a Bash call, all of it inside lib/powershell/ps-command.sh: $(ps::…) captures (some inside per-character loops) and printf | sed pipelines, which on Windows Git Bash each cost one or more process creations. Separately, the fail-closed "cannot be parsed with confidence and could reach git" sink fired on read-only pipelines that only name git as data, such as Get-Process | Where-Object { $_.Name -eq 'git' }, because the git probe is quote-intact by design.

Fix

Two changes, each with its own differential contract.

  • perf(guardrails): make ps-command.sh fork-free (no behavior change). Every $(ps::…) capture becomes an out-parameter _to helper assigning with printf -v; every printf | sed pipeline and < <(printf …) reader becomes a pure-bash substitution or split (ps::_gsub_to, ps::_split_lines_to). Two host behaviors the captured forms carried are reproduced deliberately, because the command arrives with Windows line endings and goes on to a Bash tokenizer: $( ) here drops a trailing CRLF whole, and this host's sed reads in text mode so a CRLF line ending loses its CR.

  • The comparison-operand narrowing (behavior change, deliberate), in three commits whose last supersedes the first two. A quoted git literal is blanked before the probe only when BOTH hold. First, it is the right-hand operand of a comparison operator (-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge, optional c/i prefix, reached through (, @( and ,-separated list elements; left-hand operands and & 'git' -eq $x are calls, not comparisons). Second, the whole command is provably a read-only cmdlet pipeline, judged as an allowlist (ps::_is_readonly_cmdlet_pipeline): with every quoted string replaced by an opaque placeholder, the command is refused outright on a surviving backtick, <#, --%, ::, [, & or a . before (, then walked token by token so that every token at a command position (start of input, or after | ; { } ( = newline) is an allowlisted interrogator: any Get-* verb, the read-only aliases (gps, ps, gcim, gwmi, gci, ls, dir, gi, gc, cat, type, gsv, gcm, gmo, gv, gl, pwd), Where-Object/?, Select-Object, ForEach-Object/%, Sort-Object, Measure-Object, Group-Object, Format-*, Out-*, Write-Output/Write-Host/echo, Select-String/sls, the path helpers, Compare-Object/diff, ConvertTo-*/ConvertFrom-*, and the if/else/elseif/in/return keywords. Variable chains, -parameters, placeholders, numbers and arguments are inert; any other command word refuses, and a command past the scan's length ceiling refuses rather than being scanned.

    The first two narrowing commits gated the exemption on a blocklist of known executors. A fresh-context security review reproduced 13 BLOCKED-to-ALLOWED flips through executors no list enumerates: [Diagnostics.Process]::Start, $ExecutionContext.InvokeCommand.InvokeScript, [scriptblock]::Create().Invoke(), a run-time Set-Alias, iwmi/Invoke-CimMethod Win32_Process Create, nsv/New-Service, schtasks, New-ScheduledTaskAction, wmic, and every launcher on PATH (npx, dotnet, cscript, explorer, ssh). A blocklist of executors is structurally under-inclusive, so the third commit inverts it: an unrecognized command word now costs an over-block, never a bypass. All 13 shapes plus Invoke-Item/ii, Start-Job, New-Object are counterexample tests, and seven predicate pins assert the gate directly. The correction item's own proposed rule (exempt any quoted literal not preceded by &, . or iex) was not implemented either: it would have allowed Start-Process 'git' reset --hard and cmd /c 'git push --force'.

    A second fresh-context review of the allowlist commit found that an expandable string is itself a command position: PowerShell evaluates $( … ) inside a double-quoted string at construction time, and the blanked-text walk cannot see inside a string. Write-Output ("x" -eq "$(cmd /c git push --force)") and 14 sibling shapes flipped BLOCKED to ALLOWED. The fourth commit adds two disqualifiers: the exemption refuses on any expandable "…" span (detected by the quote walk's own opener flag, since the opaque pass does not distinguish 'git' from "git"), and the sink refuses when an expandable @"…"@ here-string was blanked at intake, because that blanking erases the git token before the probe runs. Single-quoted (verbatim) operands stay exempt. All 15 reviewed shapes plus the three pre-existing holes they exposed (-eq "$(git push --force)", a schtasks subexpression, and the here-string operand) are blocked test cases.

Guardrails is bumped to 0.35.0 (a behavior change). No lib or dispatcher file changes here.

Verification

Census (exact job-object count of the harness's own bash -c invocation, n=5, isolated p50), re-run on the final commit:

Payload Creations before Creations after p50 before p50 after
PowerShell exit 0 80 3 2422 ms 300 ms
PowerShell git status | Where-Object { $_ -match 'x' } (reaches the sink, rc 2) 44 3 1433 ms 299 ms
PowerShell Get-Process | Where-Object { $_.Name -eq 'git' } (newly allowed) 44 3 333 ms
Bash true 3 3 288 ms 274 ms

3 is the harness floor (bash -c, env, bash). The PowerShell lane is now at 1.1x Bash; the program asked for at most 2x and at most 6 creations.

Differentials (two-root, rc + stdout + stderr byte-for-byte):

  • Fork-removal commit (baseline = perf(guardrails): run the guard chain in-process and answer payload fields without jq #4185's tree): perf corpus 17 commands x {Bash, PowerShell} 34 cases 0 mismatches; 653 harvested commands PowerShell lane 0 mismatches; 653 Bash lane 0 mismatches. Two probes the chain differentials cannot see: every converted helper over 697 inputs, 15334 rows 0 differences; each pure-bash substitution against the sed it replaced, 5054 cases 0 differences. Those probes caught two bugs the corpus could not (an ERE mistranslation and a CR left on PS_SAFE_COMMAND), fixed before commit.
  • Narrowing, final tree (baseline = the pre-narrowing tree, which has no exemption at all): 34 cases 0 mismatches; 653 PowerShell lane 0 decision changes, with 0 predicted beforehand (no harvested command has a git literal in operand position, and none carries a double-quoted string together with one); one run of that lane reported 6 mismatches on rows whose baseline arm returned an abnormal rc 256 with empty output under host contention, and re-running those 15 rows in isolation gave 0 mismatches; 653 Bash lane 0 mismatches. The designed corpus of 47 shapes (the exempt forms, the original counterexamples and the 13 reviewed executor shapes) flips exactly the 5 intended commands (2 to 0, sink message gone), allows 6 on both roots and leaves 36 blocked byte-for-byte; extended with the 18 reviewed expandable-string shapes, 65 commands, it flips 6: those same 5, plus the @"…"@ here-string operand ALLOWED to BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte.

Suites: block-dangerous-git 491 to 579 pass, 0 fail (the correction's 8 rows, the counterexamples, the executor shapes, the expandable-string shapes, predicate pins); block-no-verify 256/0; block-noncanonical-commit 227/0; block-hook-bypass 645 pass with the same 2 pre-existing symlink failures; run-guards 216 pass with the same 11 pre-existing failures on this host. check-changelog-parity.sh --check-bump origin/main passes; shellcheck -x clean.

Residuals: the CRLF fidelity is pinned to Windows Git Bash behavior; on Linux the pre-change code would have chomped only the LF and sed would have read bytes, so the new code makes the Windows behavior uniform. The allowlist over-blocks by design: a read-only pipeline that uses a cmdlet outside the list, exceeds the length ceiling, or carries any double-quoted string next to a compared git literal keeps today's fail-closed sink, and a read-only git command carrying both a balanced @"…"@ here-string and another sink trigger now blocks where it did not before. Pre-existing and out of scope: a bare Write-Output @" / $(git push --force) / "@ is allowed on both roots because the here-string body is blanked at intake and no sink trigger fires at all; closing it means treating every expandable here-string as a sink trigger, filed as a follow-up. ForEach-Object -MemberName Kill / -ArgumentList method dispatch is pinned at its current (allowed) behavior as a recorded residual.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 4 commits September 15, 2026 13:51
…ields without jq

run-guards.sh sources its guards into its own shell instead of one
command-substitution subshell per guard: `exit` is a dispatcher function
that records the guard's status and runs the next guard from inside the
call, stdout documents are collected through hook::emit_document, and a
guard that dies of a hard error hands its status to the abort boundary's
new chain slot (_GAB_CONTINUE), which runs the guards still owed in one
subshell. Per-invocation analysis state (the alias memo) is reset before
each guard, so a later guard's alias walk is not answered by an earlier
guard's memo.

lib/hook-utils.sh answers a well-formed payload's plain-string fields
with its builtin JSON parser (hook::_fast_fields) and runs jq only for a
shape it cannot prove; hook::jq_fields_uncached names the same body for
the dispatcher's cache, hook::emit_document is the one stdout path, and
hook::extract_bash_subject_to is the in-shell telemetry subject. The 17
carrying plugins take the synced copy with a patch bump.

Measured on Windows 11 + Git Bash with an exact job-object census of the
harness's own invocation: the Bash-lane chain went from 23 process
creations to 3 and 880 ms to 285 ms isolated p50; the PowerShell lane
from 100 to 80 creations and 3.3 s to 2.7 s. Decisions are byte-identical
(rc, stdout, stderr) against 0.33.11 over the perf baseline's 17-command
corpus in both tool modes, over 653 commands harvested from the guard
suites on the Bash lane and 200 on the PowerShell lane, and over the
Write, Edit and drive-root-tmp lanes.

Handoff item: 20260913-034031 (program 20260915-153000).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The PowerShell lane's PreToolUse chain spent 77 of its 80 process creations
inside this one library, across 35 sites: 24 command-substitution captures of
ps:: helpers (1 creation each), 8 `printf | sed` pipelines (4 each) and 3
`< <(printf …)` line readers (1 each). Every capture is now an out-parameter
helper assigning with `printf -v` (the `_to` convention lib/hook-utils.sh
uses), and every pipeline and reader is a pure-bash substitution or split.

Measured on Windows Git Bash, 5 runs each (creations / isolated p50):

  PowerShell `exit 0`                       80 / 2422 ms  ->  3 / 300 ms
  PowerShell script block + git token       44 / 1433 ms  ->  3 / 299 ms
  Bash `true` (never loads this library)     3 /  288 ms  ->  3 / 274 ms

3 is the harness floor: the `bash -c`, the `env` of the shebang, and bash.

No decision changes. Byte-identical rc, stdout and stderr against the
pre-change tree:

  17-command perf corpus, both tool modes   cases=34  mismatches=0
  653 harvested commands, PowerShell lane   cases=653 mismatches=0
  653 harvested commands, Bash lane         cases=653 mismatches=0

Plus two narrower differentials over the same corpus: every converted helper
answer, sink-blanking result, classify verdict and write_bypass verdict for 697
inputs (15334 rows, 0 differences), and each converted substitution against the
`sed` it replaced (5054 cases, 0 differences). Two behaviors that only the
narrow differentials could see are carried deliberately: `$(…)` on this host
eats a trailing CRLF whole, and its `sed` reads in text mode, so a CRLF line
ending loses its CR. Both are reproduced rather than dropped.

block-dangerous-git, block-no-verify, block-hook-bypass,
block-noncanonical-commit and run-guards report the same results as the
pre-change tree on this host, failures included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ps::might_invoke_git blanks a quoted string literal to the inert bareword
`_q_` before its literal-git probe when BOTH hold, and keeps the quote-intact
probe otherwise:

  (a) the literal's nearest preceding non-whitespace token is a PowerShell
      comparison operator (-eq -ne -in -notin -contains -notcontains -like
      -notlike -match -notmatch -lt -le -gt -ge, optional c/i case prefix),
      reached through an optional `(` / `@(` and through `,`-separated earlier
      elements of the same list. RIGHT-hand operands only: `& 'git' -eq $x` is
      a call, not a comparison, so a left-hand literal is not decidable here.
  (b) the whole command carries no invocation shape that could execute a
      computed value — no call or dot-source of a non-bare-word target
      (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`), no
      iex / Invoke-Expression / Invoke-Command / icm, and no launcher or
      nested shell as a bare command word (Start-Process, saps, start, pwsh,
      powershell, cmd, bash, sh, wsl, node, python).

Under (b) the only thing the command can do with the string is compare it, so
the exemption cannot reach execution.

Newly allowed (blocked before): read-only pipelines that merely NAME git —
`Get-Process | Where-Object { $_.Name -eq 'git' }`, `-ceq 'git'`,
`-in @('git.exe','bash.exe')`, `-notin @('git','node')`, `-like 'git*'`.

Still blocked, each a test case: `& 'git' commit --no-verify`, `git 'commit'`,
`Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`,
`cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`,
`'git' | % { & $_ push -f }`, `'git' -in $names`, an operand list long enough
to exhaust the bounded walk, and every `-eq 'git' … | % { <exec> $_.Name }`
form (&, ., iex, Invoke-Command, cmd /c, bash -c, a quoted `& 'bash' -c`,
a path-shaped `& .\$_.Name`).

Bounded by a two-root differential against the pre-narrowing tree: the
653-command harvested corpus is byte-identical on both the PowerShell and the
Bash lane, the perf baseline's 17-command corpus is identical across both tool
modes, and a designed-case corpus flips exactly 5 commands, all
BLOCKED->ALLOWED with the sink message gone, leaving its other 18 blocked.
The chain still creates 3 processes on a PowerShell call that reaches the sink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erShell fail-close

ps::_can_execute_computed_value's bare-command-word list covered the shells and
interpreters but not the cmdlets that run a program without a call operator, an
evaluator or a shell word. A comparison-operand exemption could therefore stand
beside `Invoke-Item $_.Name`, which turns the compared string back into a
command word.

Added as bare words, same boundary class as the existing launchers:
invoke-item, ii, start-job, sajb, register-scheduledtask, new-service,
invoke-wmimethod, invoke-cimmethod, new-object.

This widens the DISQUALIFIER, never the exemption: every addition can only
restore the quote-intact probe. New counterexample tests keep
`… -eq 'git' … | % { Invoke-Item $_.Name }`, its `ii` spelling, a `Start-Job`
form, and `New-Object System.Diagnostics.Process` beside a comparison blocked.

block-dangerous-git 536 PASS / 0 FAIL, block-no-verify 256 / 0,
block-hook-bypass 645 / 2 (pre-existing symlink pair). The designed-case
differential still flips exactly the same 5 commands (cases=29 mismatches=5)
and the perf baseline's 17-command corpus stays identical across both tool
modes (cases=34 mismatches=0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kyle-sexton and others added 3 commits September 15, 2026 19:19
…y cmdlet pipeline

The comparison-operand exemption was gated by a list of known executors. A
security review reproduced BLOCKED->ALLOWED flips through executors no list
enumerates: [Diagnostics.Process]::Start, $ExecutionContext.InvokeCommand.
InvokeScript, [scriptblock]::Create(...).Invoke(), a run-time Set-Alias, WMI/CIM
Win32_Process Create, New-Service, schtasks, New-ScheduledTaskAction, wmic, and
every launcher on PATH (npx, dotnet, cscript, explorer, ssh).

ps::_can_execute_computed_value is replaced by ps::_is_readonly_cmdlet_pipeline,
the inverse: the exemption applies only when the command is PROVABLY a read-only
cmdlet pipeline. With every quoted string replaced by an opaque placeholder, the
command is refused outright on a surviving backtick, `<#`, `--%`, `::`, `[`, `&`
or a `.` before `(`; the remainder is walked token by token, and every token at
a command position (start of input, or after | ; { } ( = newline) must be an
allowlisted interrogator — any Get-* verb, the read-only aliases, Where-Object,
Select-Object, ForEach-Object, Sort/Measure/Group-Object, Format-*, Out-*,
Write-Output/Write-Host, Select-String, the path helpers, Compare-Object, the
ConvertTo/ConvertFrom pair, and the if/else/elseif/in/return keywords. Variable
chains, -parameters, string placeholders, numbers and ARGUMENTS are inert; any
other command word refuses. An over-long command is refused rather than scanned.

An unrecognized command word now costs an over-block, never a bypass. The
exemption itself is unchanged: right-hand comparison operand, bounded list walk.

All fourteen reviewed executor shapes are counterexample tests, plus the
Invoke-Item / ii / Start-Job / New-Object set, and seven predicate pins assert
the gate directly.

block-dangerous-git 552 PASS / 0 FAIL, block-no-verify 256 / 0,
block-hook-bypass 645 / 2 (pre-existing symlink pair). Two-root differentials
against the pre-narrowing tree: 653-command harvested corpus, PowerShell lane
cases=653 mismatches=0; perf baseline's 17-command corpus cases=34 mismatches=0
across both tool modes; designed cases plus the executor shapes cases=47
mismatches=5 — the same five BLOCKED->ALLOWED flips, with all 41 blocked rows
identical on both roots. The chain still creates 3 processes on a PowerShell
call that reaches the sink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hook::extract_bash_subject_to assigns SUBJECT through a nameref, which
shellcheck 0.11 cannot follow; CI's lint lane failed SC2154 on the two
guards that read it in emit_tel. Declaring the variable empty first is
the idiom block-hook-bypass already uses. block-no-verify 256/0 and
flag-commit-pr-skill-bypass 35/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI's machine-specific-paths hygiene check refuses a Windows user path
in a fixture. The value is opaque to the parser under test; hook-utils
504/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kyle-sexton and others added 2 commits September 15, 2026 21:11
…ble string

The comparison-operand exemption is gated on a provably read-only cmdlet
pipeline, and that gate walks the command with every quoted string replaced by
an opaque placeholder. A DOUBLE-quoted string collapses to `$q` (or `-_q_`),
which the walk treats as inert, but PowerShell evaluates an expandable string
where it is written: `"$( … )"` runs its subexpression to BUILD the value, so
the operand is itself a command position and the placeholder is exactly what
hides it. A security review turned that one hole into BLOCKED -> ALLOWED flips
through `cmd /c`, `bash -c`, `powershell -c`, `Start-Process`, `& 'git'`,
`Start-Job`, `Invoke-Item`, `New-Object`, `node -e` and `schtasks`, under `-eq`,
`-like` and `-in`, inside a second `Where-Object`, behind `Get-Content`, and
interpolated beside `${env:ComSpec}`. A bare `"$(git push --force)"` and the
`schtasks` shape were open before the gate change as well.

ps::_is_readonly_cmdlet_pipeline now refuses on the presence of ANY expandable
string, whatever is inside it. The witness is the quote character that OPENED
the span, published by the single walk (PS_QUOTED_SPAN_SAW_EXPANDABLE) and read
by its immediate caller. Neither shortcut is exact: the opaque placeholder kind
shares `_q_` between `'git'` and `"git"`, and a raw `"` scan misreads the `"`
inside `'he said "hi"'`. Verbatim `'…'` operands stay exempt, so the five
designed shapes (`-eq 'git'`, `-ceq 'git'`, `-in @('git.exe','bash.exe')`,
`-notin @('git','node')`, `-like 'git*'`) are unchanged.

The other expandable form needed a second disqualifier, because no change to
that gate can reach it: a `@"` … `"@` here-string is blanked at INTAKE, so the
git token leaves with the body and ps::might_invoke_git is never consulted.
ps::blank_herestrings records that an expandable body was blanked
(PS_HERESTRING_EXPANDABLE) and ps::classify_git_command refuses at the sink,
where a NO from the git probe is a statement about text the command does not
have rather than a proof of git-freedom. A verbatim `@'` … `'@` body, and an
unbalanced opener, which leaves the raw command in view for the probe, are both
untouched.

Corrected while in there: the exemption's comment listed `-match "^git\.exe$"`
among the false positives it retires. That command is allowed on both roots
because `^` is not a command-position predecessor, so no git token is visible
and the exemption never runs on it.

The eighteen reviewed shapes are counterexample tests, plus predicate pins for
the two cases the inexact detectors get wrong, a sink-trigger pin for the
here-string, a verbatim here-string allow, and a recorded-residual pin that
`ForEach-Object -MemberName Kill` / `-ArgumentList` method dispatch stays where
it is.

block-dangerous-git 579 PASS / 0 FAIL, block-no-verify 256 / 0, block-hook-bypass
645 / 2 (the pre-existing symlink pair). shellcheck clean. Two-root differentials
against the pre-exemption tree:

  cases=653 mismatches=6 root_strings_normalized=0 exit_codes={2: 385, 0: 253, 256: 15} seconds_per_arm={'baseline': 2914, 'candidate': 385}
  cases=15 mismatches=0 root_strings_normalized=0 exit_codes={0: 8, 2: 7} seconds_per_arm={'baseline': 34, 'candidate': 6}
  cases=34 mismatches=0 root_strings_normalized=0 exit_codes={0: 15, 2: 19} seconds_per_arm={'baseline': 144, 'candidate': 38}
  cases=65 mismatches=6 root_strings_normalized=0 exit_codes={0: 7, 2: 58} seconds_per_arm={'baseline': 347, 'candidate': 31}

The 653-command harvested corpus reported 6 mismatches, every one of them on a
row whose BASELINE arm returned the abnormal rc 256 with empty output, 15 such
rows in all; the second line is those 15 re-run, identical on both roots, so the
harvested corpus carries 0 decision changes. Predicted 0, because no command in
it holds a double-quoted string together with a compared git literal. The perf
baseline's 17-command corpus is identical across both tool modes. The designed
corpus plus the executor shapes and the 18 new ones, 65 commands, flips 6: the 5
verbatim exempt shapes BLOCKED -> ALLOWED, and the `@"…"@` operand ALLOWED ->
BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. Its
47-command subset is 5 flips, 6 allowed on both roots and 36 blocked
byte-for-byte. The chain still creates 3 processes on a PowerShell call that
reaches the sink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 16, 2026 01:23
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T01:30:00.375061Z 502416c Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 8m 22s —— View job


Code review complete

  • Gather context (PR body, changed files, diff base)
  • Invoke /review:code-review and follow its instructions
  • Post inline findings
  • Post final summary

Ran the review:code-review lane's criteria against git diff origin/guardrails-run-guards-in-process-no-subs...HEAD (6 files, +767/-124). This repo carries .github/workflows/claude-security-review.yml, so per REVIEW.md this lane omits security-classed findings (the comparison-operand exemption's threat model) and sticks to correctness/maintainability — that PowerShell-detection-bypass surface is the security lane's job.

Summary

The fork-free rewrite of lib/powershell/ps-command.sh is a careful, mechanical port: every $(ps::…) capture becomes a printf -v out-parameter helper, every printf | sed becomes a pure-bash ps::_gsub_to/ps::_split_lines_to. I hand-traced the CRLF-handling edge cases the port explicitly calls out (trailing CRLF eaten whole by $(), per-line CR-loss mimicking this host's sed text-mode reads, doubled/tripled trailing newlines) against the old sed+command-substitution behavior and they check out. The comparison-operand narrowing and its ps::_is_readonly_cmdlet_pipeline allowlist are extensively commented and counter-example-tested; I did not find a bypass shape beyond what the PR's own review rounds already found and fixed (and finding new bypasses in this detector is the security lane's remit, not this one's).

One finding (posted inline on ps-command.sh:434): four of the new _to out-parameter helpers (ps::_walk_quoted_spans_to, ps::fold_escaped_brace_closers_to, ps::call_site_operand_region_to, ps::blank_bracket_interiors_to) don't prefix their own internal locals (out, text, i, n, ch, depth, etc.), unlike every sibling helper in this same diff (ps::_gsub_to, ps::_split_lines_to, ps::_chomp_to, the ps::_skip_*_to walkers). The file's own header comment states this prefixing is what makes the _to convention safe: without it, a future caller that happens to pass a colliding name (out is the dominant accumulator name used throughout this very file) as the destination variable would have printf -v silently assign the callee's local instead of the caller's, via bash's dynamic scoping — a silent wrong answer, not an error. No current call site collides (checked all of them), so this isn't live today, but it's a real gap against an invariant the PR itself documents and relies on elsewhere. Suggested fix: prefix each function's internal locals the same way the other helpers do.

Everything else — the version bump, CHANGELOG entry, and the three test-file diffs (comment-text updates for renamed functions, plus the new comparison-operand/expandable-string test rows and predicate pins) — matches the code changes and reads correctly; no other findings.

I did not independently run the test suites or shellcheck (no permission to invoke arbitrary shell tools in this session) — the PR body's verification numbers are author-claimed and unverified by me.
· Branch

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 19s —— View job


Security review (/review:security-review)

  • Gather context (PR body, changed files, diff)
  • Read plugins/guardrails/lib/powershell/ps-command.sh in full (2306 lines) and the new test cases in block-dangerous-git.test.sh
  • Trace the new comparison-operand exemption end to end for a bypass
  • Post findings

No security issues found.

Scope reviewed: the two behavior-changing pieces in this PR — the fork-free rewrite of ps-command.sh (declared no-behavior-change) and the new comparison-operand narrowing that exempts a quoted 'git' literal from the fail-closed PowerShell sink (ps::might_invoke_git, L1276-L1326) via ps::_is_readonly_cmdlet_pipeline (L652-L697) and ps::_is_comparison_operand_context (L533-L555).

This is the security-relevant surface of the PR: a bug here would be a fail-open in a guard whose entire job is fail-closed behavior. I traced it manually rather than trusting the PR's own claims:

  • The exemption requires BOTH the literal to be a right-hand comparison operand AND the whole command to pass ps::_is_readonly_cmdlet_pipeline, which is a deny-by-default allowlist (unrecognized command word → refuse), not a blocklist of known executors — the structural fix the PR describes for the 13+15 bypasses found in its own review rounds.
  • Confirmed the disqualifiers are load-bearing: any surviving backtick, <#, --%, ::, [, &, or a . immediately before ( anywhere in the command refuses the whole pipeline (checked on the quote-opaqued text, so these only count outside quoted spans, correctly making them structural rather than data-driven).
  • Confirmed an expandable double-quoted string anywhere disqualifies the pipeline (PS_QUOTED_SPAN_SAW_EXPANDABLE), which is what closes the -eq "$(cmd /c git push --force)" class the PR says its 4th commit fixed — verified this check runs and returns before any other classification, so it can't be short-circuited.
  • Walked several non-obvious executor shapes by hand against the allowlist scan (member calls via .Invoke(/.Kill(, [Type]::Method, Set-Alias-minted commands, launching through a nested { }/( ) at a command position, chaining a real git reset --hard elsewhere in the same statement list) — each is caught, because the allowlist scan treats every |;{}()=-delimited command position uniformly across the entire command, not just around the matched literal. A real git mutation anywhere in the command makes git itself a disqualifying, non-allowlisted command-position token, independent of the comparison-operand carve-out.
  • Cross-checked that ps::_is_readonly_cmdlet_pipeline / ps::_is_comparison_operand_context are only ever reached from ps::might_invoke_git (grepped the plugin), so the exemption can't leak into the unrelated write-bypass or hook-bypass guards.
  • Spot-checked the new adversarial test cases in block-dangerous-git.test.sh (the [Diagnostics.Process]::Start, InvokeScript, Set-Alias, scriptblock]::Create, schtasks, wmic, Invoke-Item, Start-Job, New-Object, and expandable-here-string/subexpression shapes) against the allowlist logic by hand — they match the code's actual behavior, not just its comments.

One pre-existing, already-disclosed residual: ForEach-Object -MemberName Kill -ArgumentList … dispatches a method call per pipeline object without going through the .Method( text pattern the allowlist scan disqualifies on, so it's admitted by this exemption. The PR body already names this explicitly as an accepted residual, it doesn't touch git specifically (it's a process-kill dispatch, not a git-reachability hole), and it doesn't provide a new path to defeat this guard's stated purpose (blocking dangerous git invocations) — so I'm not raising it as a new finding, just confirming I found the same edge the author already flagged rather than a new one.

The fork-free primitives (ps::_gsub_to, ps::_split_lines_to, ps::_chomp_to, the quoted-span walk) are declared behavior-preserving perf-only changes; I didn't find a divergence from the sed/$() semantics they replace that would matter for the classification logic, and the PR's differential-testing methodology (15k+ row probes against the sed originals) is the right tool for that class of bug rather than manual re-derivation.

No CRITICAL, IMPORTANT, or SUGGESTION findings.
· Branch

kyle-sexton and others added 2 commits September 15, 2026 21:26
…ty lint

The shell-portability lint reads the backslash-w in the previous spelling as a GNU-only regex class. The value is opaque to the parser under test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
plugins/guardrails/*.md is declared purged in scripts/em-dash-purged-paths.txt, so the lint lane treats one as a regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

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

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

Copy link
Copy Markdown
Contributor

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

kyle-sexton and others added 6 commits September 15, 2026 21:44
A designed test case used a Windows repo path as its fixture, and the read-only cmdlet case pattern was read as a sort -V invocation. Neither changes a decision; block-dangerous-git suite unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ne case

CI's machine-specific-paths check refuses a Windows repo path in a fixture; the value is opaque to the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts key bound

hook::_fast_fields indexes the payload's key strings with an associative
array, which Bash added in 4.0, and it ran on every hook::jq_fields call.
On the 3.2 shell macOS ships, and which these hooks document support for,
`local -A` fails per call. hook::_fast_fields_supported is the predicate,
split out the way hook::read_supports_nchars is so a test can force the
below-floor branch on a modern host, and hook::jq_fields_uncached asks it
before entering the fast path. Below the floor jq answers, unchanged.

The index loop also skipped any string body longer than a fixed 60 bytes
before decoding it, while nothing capped the key names a caller may ask
for. Two wrong answers came out of that: a requested key longer than 60
characters was proven ABSENT while present, and a key of 11 or more
characters spelled with \u escapes (hook_event_name is 15, 90 escaped)
was missed the same way. The bound is now six times the longest requested
key name, the width of `\uXXXX` per identifier character, which is the
bound the header comment always described.

The suite gains four cases: the below-floor branch forced with the fast
path replaced by a tripwire, a present 70-character key, an absent one,
and hook_event_name spelled entirely in \u escapes. Each compares the
fast path against jq rather than against an expectation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The chain-slot paragraph asks a function plugged into _GAB_CONTINUE to be
builtins only, never exit, and never touch the trap, and the slot's own
comment says it must not return. run-guards.sh's consumer does none of
that: run_guards::guard_died forks a subshell for the guards still owed,
spawns jq to merge their documents, and ends at `builtin exit`.

Say so. run-guards.sh is the one documented exception, and it is one
because it is the dispatcher finishing the run the process owes rather
than a hook doing exit-time work. The discipline is unchanged for
everyone else, and the slot comment now matches it: a chained function
that returns hands control back and the handler settles the status.

Comment only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lliding plugins

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four `_to` helpers added on this branch kept unprefixed locals, breaking the
reliability contract the file's header states: `printf -v` walks bash's dynamic
scope outward, so a caller passing one of those names as the out-parameter would
have its own variable left untouched while the callee's local took the value. No
current call site collides; `out` is the file's dominant accumulator name, so a
future one would.

ps::_walk_quoted_spans_to, ps::fold_escaped_brace_closers_to,
ps::call_site_operand_region_to and ps::blank_bracket_interiors_to now use
`__wq_`, `__fb_`, `__cs_` and `__bb_` prefixes, matching ps::_gsub_to and the
ps::_skip_*_to walkers. The globals they set are unchanged.

The guard suite gains a shadowing self-check: each of the four is called with
`out` as the destination from a scope holding its own `out` local, and the result
has to match a reference call whose destination collides with nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-no-subs' into guardrails-powershell-guard-path-3x-slow
Base automatically changed from guardrails-run-guards-in-process-no-subs to main September 16, 2026 12:29
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
No related issue: handoff-inbox item 20260913-034034 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

## Summary

disk-hygiene's `Stop` row started a whole Python interpreter on every
interactive stop to read the transcript and discover that this session
never launched `destructive_guard.py`, which is true of most sessions
because the engine-gate rows are `if`-gated on the engine's file name.
`Stop` rows accept neither `matcher` nor `if`, so the gate has to live
in the bash launcher, before the interpreter is resolved.

## Fix

`hooks/run-python-hook.sh` takes three optional leading flags, consumed
there and never forwarded to Python:

- `--marker-root <dir>`: the root the marker tree lives under, spelled
identically on the writer and the reader rows
(`"${CLAUDE_PLUGIN_DATA}"`, the same literal the rows already pass as
`--authorized-data-root`). An explicit root rather than the environment
variable, because a writer and a reader that disagree about the root
skip silently, which is the missed detection this monitor exists to
prevent.
- `--launch-marker <subdir>` (the engine-gate rows): write
`<root>/<subdir>/<session>.launched` before exec'ing the target, so a
guard that launches and dies still leaves the marker that keeps the
monitor running.
- `--skip-unless-marker <subdir>` (the `Stop` row): exit 0 without
exec'ing anything when that file is absent.

Candidates mirror the monitor's own `_marker_path_candidates` (the data
root, then a `${TMPDIR:-/tmp}` fallback beside the `.warned` marker).
The session id is recovered by a bash regex anchored to the payload's
opening key, with an unanchored fallback so a reordered payload degrades
to running Python rather than to keying on nothing; a payload it cannot
key on records nothing and skips nothing. The payload is buffered with
the `read` builtin and replayed to Python on a here-string.

disk-hygiene is bumped 0.23.11 to 0.23.12 with the numbers and the
marker semantics in the CHANGELOG.

## Verification

Job-object census (n=5, Git Bash `-c "<row string>"`,
`CLAUDE_PLUGIN_DATA` at a temp dir, harness floor `bash -c ':'` = 1):

| Row | Creations before | Creations after | Wall p50 before | Wall p50
after |
|---|---|---|---|---|
| Stop, no guard launched this session | 5 | 3 | 271 ms | 120 ms |
| Stop, marker present | 5 | 5 | 271 ms | 276 ms |
| engine-gate, later launches | 5 | 5 | 268 ms | 277 ms |
| engine-gate, first launch in a data root (`mkdir`) | 5 | 7 (n=1) | |
348 ms |

The `mkdir` is paid once per data root, never per session or per launch.

Byte identity: the fixture target writes `sys.stdin.buffer.read()`;
stdin after equals the payload plus the one trailing newline `<<<`
appends, which both consumers (`json.load(sys.stdin)` in the guard,
`sys.stdin.read()` + `json.loads` in the monitor) ignore. End to end on
a transcript carrying a real `hook_non_blocking_error`: stdout 534 bytes
on both roots, `cmp` identical; stderr identical;
`guard-decisions/decisions.jsonl` identical with timestamp and session
normalized. Skipped case: rc 0, 0 bytes out, 0 files written.

Suites: `run-python-hook.test.sh` 50 pass / 0 fail (32 pre-existing + 18
new; the pre-existing no-python case now also stubs `py`, because this
host's Windows py launcher resolved a real 3.13 and aborted the suite
before this change); `test_guard_launch_monitor.py` 28/28 both sides.
shellcheck and `check-shell-portability.sh --paths` clean;
`check-changelog-parity.sh --check-bump origin/main`,
check-hook-exec-form, check-hook-userconfig-argv,
check-hook-wiring-liveness, validate-plugin-contracts.mjs and
validate-plugins.sh green. Pre-existing and reproduced on the unmodified
tree: `test_hook_telemetry` 2 sink-timeout failures, `test_hygiene` 10
git-fixture errors.

Residuals, for review:

- The marker is one empty file per session that launched a guard, never
removed, and nothing sweeps the data root (`lib/guard_decision_log.py`
rotates only its own log). Stated in the wrapper and the CHANGELOG; a
retention sweep is a separate decision.
- A skipped turn emits no telemetry envelope where it used to emit an
`ok` one.
- The hooks reference shows `session_id` as the opening key but
documents no ordering guarantee; anchored-first plus fallback covers
both, not proven against a live wire payload.

## Related

- Program item 20260915-153000; siblings #4185 (item 1), #4188 (item 2),
#4189 (item 3), and the context-guard, hook-failure-audit and autonomy
hooks as their own PRs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
…4189)

No related issue: handoff-inbox item 20260913-034035 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

## Summary

`hooks/session-event-log.sh` is registered on 30 events, including every
PreToolUse, PostToolUse, PostToolBatch, UserPromptSubmit and Stop. With
the `session_event_log_enabled` option off (the default) each row still
spawned a three-process bash chain (the harness's `bash -c`, the `env`
of the shebang, bash) only to exit on the script's first check: 9 to 12
creations per Bash tool call, 0.3 to 0.5 s each on the critical path
under load.

## Fix

The 30 generated rows now carry an inline POSIX shell gate instead of a
bare script path:

```
[ "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" = true ] || exit 0; exec "${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh
```

The harness's own shell evaluates the option and exits; only an enabled
logger execs the script. The row template lives in
`scripts/gen-hook-event-registry.sh` (the rows are generated from the
committed hook-event registry), so the template and its test changed and
the rows were regenerated; `--check` is clean over 33 events and the
registry is byte-identical. The script's own line-41 gate stays for
direct invocation. claude-ops is bumped to 0.56.14; a second commit
corrects the script's and README's now-false "pays the kill-switch read"
clause.

Why not an `if` field: the current hooks reference says `if` holds one
permission rule and is evaluated only on tool events, so it cannot read
a plugin option and cannot gate Stop or UserPromptSubmit rows at all.

## Verification

Exact job-object census of the generated command string as Git Bash runs
it, PreToolUse payload, n=5:

| Row command | Switch | Creations | p50 |
|---|---|---|---|
| old | off | 3 | 107 ms |
| new | off | 1 | 41 ms |
| old | on | 3 | 182 ms |
| new | on | 3 | 117 ms |

Per Bash tool call attributable to this hook while off: 9 to 12 → 3 to 4
(one per firing row). With the switch on the new command wrote the same
`sessions/<id>.jsonl` rows as the old one (10 lines, `status: ok`).

Suites: gen-hook-event-registry and session-event-log report the same
named pre-existing failures as before the edit on this host (a jq CRLF
case and a symlink-root case); check-killswitch-hoist,
check-hook-exec-form, check-hooks-description,
check-hook-userconfig-argv, check-hook-wiring-liveness,
validate-plugin-contracts, shellcheck, and changelog-parity `--check`,
`--check-bump origin/main` and `--check-preserved` all pass. The wider
affected-suite set is left to CI.

Residual: a registered shell-form row always costs the harness's one
shell process, so the item's target of 0 is reachable only by not
registering the per-tool rows while the option is off. hooks.json cannot
vary by option. Dropping the `PreToolUse *` and `PostToolUse *` rows in
favor of PostToolBatch, as the item also suggests, is a product call
left open.

## Related

- Program item 20260915-153000; siblings #4185 (guardrails in-process
chain) and #4188 (PowerShell guard path).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton merged commit 2bc7dda into main Sep 16, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the guardrails-powershell-guard-path-3x-slow branch September 16, 2026 12:51
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
)

No related issue: handoff-inbox item 20260913-034037 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

## Summary

autonomy's `Stop` hook runs on every interactive stop. Its payload-free
pre-filter, the path every stop outside a lane takes, asked `uname -s`
which platform's managed-settings path to test; on the Windows Git Bash
host this gate is tuned for, that one command substitution costs three
process creations (the `$( )` fork, then the fork and exec of uname). An
unanchored (`--plugin-dir`) install paid a jq read of the plugin
manifest on the same path, answering a question nothing above the
pre-filter asks.

## Fix

- The pre-filter tests the fixed primary managed-settings path of every
platform with `[[ -f ]]`, which is equivalent: a candidate belonging to
another platform does not exist. The scan only routes
(`gate_managed_candidates_load` fills its own array and leaves
`GATE_MANAGED_FILES` alone), so every managed value still comes from the
`uname`-selected, absoluteness-asserted list in
`gate_managed_settings_files_load` once a session is actually evaluated.
The one asymmetry, the cwd-relative Windows spelling on a POSIX host,
can only force an evaluation that a repository's own settings `env`
block can already force through the two `CLAUDE_PLUGIN_OPTION_*`
presence tests; documented in the lib header and the pre-filter comment.
- `gate_resolve_install` is split: `gate_resolve_anchor` (pure parameter
expansion) stays above the pre-filter because it sets the
`GATE_CONFIG_ROOT` the user-settings locator needs;
`gate_resolve_plugin_name` (the jq) moves below it.

autonomy is bumped 0.23.12 to 0.23.13 with the numbers and the
threat-model note in the CHANGELOG.

## Verification

Job-object census (n=5, identical every run; Stop row as `bash.exe -c
'${CLAUDE_PLUGIN_ROOT}/hooks/lane-stop-gate.sh'`, HOME and
`CLAUDE_PLUGIN_DATA` at temp dirs). The floor on this invocation shape
is 4, measured from a `#!/usr/bin/env bash` + `exit 0` script under the
identical call.

| Arm | Creations before | Creations after |
|---|---|---|
| Outside a lane, anchored install | 6 | 4 |
| Outside a lane, unanchored install | 8 | 4 |
| Inside a lane (settings.json enables the gate) | 19 | 19 |

The hook now equals the floor: it spawns nothing of its own outside a
lane. Spawn sites were attributed with xtrace and a `$BASHPID` prompt
before the change (`lane-stop-gate-lib.sh` line 210 `uname -s`, and line
95 jq for the unanchored case) and show a single PID after. A PATH-shim
case proves the discrimination out of band: the previous hook launches
`uname`, the patched one launches nothing. Inside a lane the
`{"decision":"block",…}` payload is byte-identical before and after.

Suites: `lane-stop-gate.test.sh` 99/1 to 103/1 (the one failure,
`LANE-STOP\r-OK authorized: LAST must preserve CR`, is pre-existing on
the unmodified tree and unrelated); `lane-notify.test.sh` 10/0 both
sides. Four new cases: the candidate scan never fills
`GATE_MANAGED_FILES` and emits only fixed-root paths; the PATH shim; an
enabled lane still blocks; the strace budget case is updated to 0
launches (it SKIPs on Windows and is verified on CI's Linux lane only).
shellcheck clean on all four hook scripts; `check-changelog-parity.sh
--check-bump origin/main` passes.

Residual, out of scope here: the lib spells the Windows managed path
`C:/Program Files/ClaudeCode/managed-settings.json`; the pre-filter now
shares that one constant with the authoritative branch so they cannot
drift. Whether the current Claude Code settings reference names that
directory or `ProgramData` is worth checking separately, since it
decides whether this gate has ever read Windows managed settings.

## Related

- Program item 20260915-153000; siblings #4185 (item 1), #4188 (item 2),
#4189 (item 3), and the context-guard, hook-failure-audit and
disk-hygiene hooks as their own PRs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
…anged (#4193)

No related issue: handoff-inbox item 20260913-034032 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

Stacked on #4185 (base branch
`guardrails-run-guards-in-process-no-subs`): the envelope parse rides on
that PR's `hook::jq_fields` builtin parser in the synced lib. GitHub
retargets it to main when #4185 merges.

## Summary

context-guard's `zone-crossing-inject.sh` fires on every `PostToolBatch`
and `UserPromptSubmit`. Each fire spawned a jq for the envelope and then
the zone resolver (its own bash plus a jq) even when nothing the
resolver reads had changed since the last fire, which is the common
case: the statusline snapshot is rewritten only when the statusline
renders.

## Fix

- An unchanged-input skip. A `$STATE_DIR/$SESSION.seen` mark records the
inputs behind the last completed resolve in two ways: its mtime, stamped
with a redirection and compared with `-nt`, and one flags line (`z=<0|1>
c=<0|1>`) recording whether `zones.json` and the compaction marker
existed when that resolve ran, read back with builtin `read`. The fire
exits before starting a process only when the per-session snapshot,
`zones.json` and the compaction marker are all no newer than the mark
AND both existence flags still match the current `-e` results; a mark
with no readable flags line never takes the skip. The mark moves only
after the resolve persisted its markers, so a resolver failure, an
`unknown` reading and a failed marker write are each retried next fire.
A missing snapshot is never skippable. Skipping can only choose silence:
no arrangement of timestamps or existence changes can manufacture an
injection the full path would not have made.
- The envelope parse is size-branched. Under 64 KiB it goes through
`hook::jq_fields`' builtin parser (zero spawns); above it keeps the
single here-string jq (2 creations), because the helper's oversize
fallback reads through a process substitution and measured 4. Plain
routing through the helper would have made the large-payload path 9 to
11 creations; the branch is what keeps every cell at or below before.
The `65536` literal mirrors the helper's private proof ceiling and is
documented at the site.
- `STATE_DIR` resolution moves ahead of the resolver, so a session with
no state root exits one process earlier.

context-guard is bumped 0.7.64 to 0.7.65 with the numbers in the
CHANGELOG, and the README gains a "Skipping the resolve when nothing
moved" subsection carrying the table.

## Verification

Job-object census (n=5, identical across reps; subject floor 3 = `bash
-c`, `env`, bash):

| Fire | Payload | Creations before | Creations after |
|---|---|---|---|
| first (resolves) | small | 11 | 9 |
| repeat, nothing moved | small | 9 | 3 |
| snapshot rewritten | small | 9 | 7 |
| first | 150 KB | 11 | 11 |
| repeat, nothing moved | 150 KB | 9 | 5 |
| snapshot rewritten | 150 KB | 9 | 9 |

No cell is worse than before. Small repeat fire wall p50 1448 ms to 237
ms (re-run 365 ms; the host is bimodal). On Windows a resolve costs 4
creations rather than 2 because `bash "$RESOLVER"` hits the
`bin\bash.exe` wrapper, which re-spawns `usr\bin\bash`.

Crossing messages are byte-identical, asserted in the suite against a
control session driven through the same zone sequence with no skipped
fire. Suite: `zone-crossing-inject.test.sh` 78/1 to 97/2, where both
failures are `strace: no usable trace` on this host (Git Bash's cygwin
strace rejects `-e trace=`; the second is the new strace block, not a
regression); `zone-gate` 26/0, `post-compact-mark` 18/0. shellcheck
clean; `check-changelog-parity.sh --check-bump origin/main` and
`--check` green.

Residuals: the strace pins (steady fire 0 creations / 1 execve;
resolving fire 2 / 3) are set by reasoning and verified only on CI's
Linux lane. The one miss window is the resolve itself: a snapshot
written between the resolver's read and the stamp is marked seen and its
crossing is reported one fire late, never lost, since the statusline
rewrites the snapshot on its next render; on a filesystem or bash build
that compares mtimes at whole-second granularity the window is up to one
second. The README still carries the older "0.7.49 brought it to 3"
paragraph; the new subsection supersedes it.

## Related

- #4185 (base of this stack). Program item 20260915-153000; siblings
#4188, #4189, #4190, #4191, #4192.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
#4192)

No related issue: handoff-inbox item 20260913-034033 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

Stacked on #4185 (base branch
`guardrails-run-guards-in-process-no-subs`): this hook rides on that
PR's `hook::buffer_stdin_to` field read in the synced lib. GitHub
retargets it to main when #4185 merges.

## Summary

claude-ops' `hook-failure-audit.sh` `Stop` hook set the per-turn wall:
it re-scanned the whole session transcript on every stop (a `wc`, a
`grep`, a jq over every candidate) to discover that no new
`hook_non_blocking_error` record had appeared, which is the common case.

## Fix

- A per-session cursor,
`${CLAUDE_PLUGIN_DATA}/hook-failure-audit/<session>.cursor`, holds the
count of complete lines already audited and the `transcript_path` it was
taken against, beside the existing warning marker and under the same
7-day prune.
- A warm `Stop` reads with `mapfile -s` from one line before the cursor
(that line is an anchor: the same read that fetches new lines proves the
file still has that many; an empty result means shrinkage), prefilters
candidates in bash with the same fixed string the grep used, and runs jq
only for a candidate line. `mapfile` runs without `-t` so joined
candidates are byte-identical to `$(grep …)`; a final line with no
newline is scanned but not counted, so it is re-read next turn.
- The cursor resets to 0 (a full rescan) on: no data home, malformed
cursor, different `transcript_path`, fewer lines than the cursor, pruned
cursor, or bash without `mapfile` (the old grep path). It advances only
at the four disposal points (no candidate, empty summary, nothing new,
after the system message); a jq failure leaves it put. Rescanning cannot
re-warn, because the marker still decides that.
- The cold scan keeps its tail cap; one `wc -lc` now answers both the
cap decision and the cursor's starting line count.
- Payload fields ride on `hook::buffer_stdin_to INPUT '.transcript_path'
'.session_id'`, which fuses the library's validation probe into the
builtin field read; `hook::require_jq` moves after it.

claude-ops is bumped 0.56.14 to 0.56.15 with the numbers in the
CHANGELOG. Sibling #4189 also claims 0.56.14 on its branch; whichever
merges second needs its version and CHANGELOG re-based (parity is
checked against origin/main).

## Verification

Job-object census (n=5, Windows Git Bash; floor 3 = `bash -c`, `env`,
`bash`):

| Arm | Creations before | Creations after |
|---|---|---|
| second Stop, 20 benign lines appended (the common turn) | 10 | 3 (=
floor) |
| first Stop, data dir exists | 10 | 5 |
| first Stop, fresh data dir | 10 | 7 |
| second Stop, a failure record appended | 30 | 21 |

Wall clock after 0.23 to 0.36 s where before ran 0.36 to 1.38 s across
two runs; the host drifts about 4x within an hour, so the creation count
is the record.

Byte identity, old versus new hook on fresh data dirs, identical stdout:
a single record; three mixed classes; multiple registrations plus both
false-positive shapes; an over-cap transcript; a last line without a
trailing newline; and a two-turn incremental sequence where turn 2
appends a failure past the cursor. The suite asserts the same in-tree:
incremental turn-2 output equals a full rescan against identical marker
state.

Suites: `hook-failure-audit.test.sh` 84/4 to 94/4 (10 new assertions;
the 4 failures are pre-existing on this host, a Windows CRLF artefact in
`jq … @tsv` on `HAS_COMPLETED`). Discrimination checked: the jq
PATH-shim test fails against the old hook, and the path-reset test fails
against a copy with the path check removed. Degraded paths: a
no-`mapfile` copy warns then dedups, an unwritable marker home still
warns, an empty transcript is silent. shellcheck, shfmt, markdownlint
and `check-changelog-parity.sh --check-bump origin/main` clean.

Unproven: the strace budget block runs only on CI's Linux lane; the warm
ceiling (0 creations, 0 execs) follows from the Windows census at floor,
the cold ceilings (6 creations, 2 execs) are conservative estimates and
are what to adjust if CI reports otherwise. The bash 3.2 fallback was
exercised by forcing `HAVE_MAPFILE=0`, not on a real bash 3.2. An
over-cap cold scan sets the cursor from the whole file's line count
while reading only the last 2 MB; pre-window lines were never read
before either, and the cursor makes that permanent for the session.

## Related

- #4185 (base of this stack), #4189 (item 3, the other claude-ops bump).
Program item 20260915-153000; siblings #4188, #4190, #4191 and the
context-guard hook as its own PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <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