Skip to content

perf(claude-ops): gate session-event-log rows in-shell when disabled - #4189

Merged
kyle-sexton merged 6 commits into
mainfrom
claude-ops-session-event-log-spawns-whil
Sep 16, 2026
Merged

kyle-sexton merged 6 commits into
mainfrom
claude-ops-session-event-log-spawns-whil

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

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

🤖 Generated with Claude Code

kyle-sexton and others added 2 commits September 15, 2026 18:41
The 30 generated producer rows become shell form and read the kill switch
themselves before exec'ing the script:

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

Job-object process census on Windows Git Bash, n=5, PreToolUse payload:

  old / switch off   3 creations, 107 ms median wall
  new / switch off   1 creation,   41 ms
  old / switch on    3 creations, 182 ms
  new / switch on    3 creations, 117 ms

Per Bash tool call that is 9 to 12 creations charged to this hook down to
3 to 4 while the log is off, and no change while it is on: the row execs
the script, which still logs and still reads its own switch for a direct
invocation. Residual: a shell-form row costs the one shell Claude Code
runs the command in, because hooks.json cannot read a plugin option.

scripts/gen-hook-event-registry.sh owns the template; the rows were
regenerated offline from the committed registry and --check re-derives
them clean.

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

The shell-form row census is serial per-event, so it does not retire the
hook-budget parallel-wall comparison the enabled rows still owe on Windows
Git Bash, which is why the default stays off. Restate that alongside the
new numbers.

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

chatgpt-codex-connector Bot commented Sep 15, 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-15T23:04:18.998467Z 98f8ee2 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 15, 2026

Copy link
Copy Markdown
Contributor

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


Claude finished the code review

  • Gather context (diff, changed files, CLAUDE.md conventions)
  • Run /review:code-review per instructions
  • Post inline findings
  • Post summary comment

Summary

Reviewed the diff (gh pr diff 4189, since the sandboxed checkout is shallow and has no merge-base with origin/main) against the CI code-review lane's criteria: correctness and maintainability, scoped out of security.

One high-signal finding, posted inline on scripts/gen-hook-event-registry.sh#L106:

The new gate ([ "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" = true ] || exit 0; exec "...") is POSIX/bash-only syntax, and none of the 30 generated rows in hooks.json (e.g. line 19) declare "shell": "bash". I fetched the current Claude Code hooks reference to check the claim in the script's own comment (lines 100–101) that shell-form "runs under sh -c on macOS and Linux (Git Bash on Windows)" — that's incomplete: the doc states the Windows default is Git Bash or PowerShell when Git Bash isn't installed. [ ], unbraced $VAR, and exec aren't valid PowerShell, so on a Windows host without Git Bash every one of these 30 rows would error on every fire, breaking the session-event-log feature outright rather than just missing the perf win — worse than the pre-PR bare-path row, which carried no shell-specific syntax and worked under any shell. Two sibling plugins already in this repo (markdown-format, disk-hygiene) hit exactly this and pin "shell": "bash" on every row, matching docs/plugin-philosophy.md's documented Windows-safe pattern. Details and a fix link are in the inline comment.

Verified, not just author-claimed:

  • All 30 hooks.json rows carry the new gate; no leftover bare-path rows for session-event-log.sh remain.
  • scripts/gen-hook-event-registry.test.sh's PRODUCER fixture was updated to match the generator's new template, keeping the test consistent with the generator.
  • The script's own kill-switch semantics (line 41–43 of session-event-log.sh) and the new hooks.json gate agree on treating unset/non-"true" as off.

Could not verify: the sandbox denied approval to execute scripts/gen-hook-event-registry.sh --check or the test suites, so I could not independently re-run the drift check or the shellcheck/test suites the PR body reports as passing — those remain author-claimed and unverified by me.

Everything else in the diff (CHANGELOG.md, README.md, plugin.json version bump, session-event-log.sh comment updates) is prose/doc accuracy and reads consistent with the code change.
· Branch

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 58s —— View job


Security review

  • Gather context (PR diff, changed files)
  • Run /review:security-review criteria over the diff
  • Report findings

Scope reviewed: plugins/claude-ops/hooks/hooks.json, hooks/session-event-log.sh, scripts/gen-hook-event-registry.sh, scripts/gen-hook-event-registry.test.sh, README.md, CHANGELOG.md, plugin.json (diff against origin/main).

Findings: none.

The change replaces a bare script-path command with an inline POSIX shell gate:

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

Checked specifically for injection/quoting hazards since this is new shell text embedded in 30 generated hooks.json rows:

  • "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" is double-quoted inside the [ ] test, so word-splitting/glob expansion on its value can't turn it into extra test operands or break out of the comparison — it can only ever be compared as a single string against the literal true.
  • The variable is a plugin option the operator sets in local config, not data that flows from tool output, prompts, or any external input, so there's no realistic attacker-controlled path into this string in the first place.
  • The string is generated once in scripts/gen-hook-event-registry.sh and passed into jq --arg prod "$PRODUCER" ... (scripts/gen-hook-event-registry.sh#L190) — a safe --arg bind, not eval or string concatenation, so the JSON encoding of the command is correct regardless of content.
  • Fail-safe default: an empty/unset option value makes the [ ] test false, so the row exits before exec, i.e. it fails closed (logging stays off) rather than open.
  • The only logic edits to session-event-log.sh in this PR are comment text (hooks/session-event-log.sh#L5-L15); the script's own kill-switch check is unchanged.

README/CHANGELOG/plugin.json changes are documentation and version-bump text only, no security surface.

No CRITICAL, IMPORTANT, or SUGGESTION findings to report.

@github-actions

Copy link
Copy Markdown
Contributor

Last security-reviewed head: 98f8ee2607b03284c5ad6673ca1298d996992615. 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 scripts/gen-hook-event-registry.sh
@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 3 commits September 15, 2026 22:36
The 30 generated producer rows and the SessionEnd retention row are shell
form with no `shell` field, so they inherit the documented default: bash,
"or to powershell on Windows when Git Bash isn't installed"
(https://code.claude.com/docs/en/hooks.md, the `shell` field, verified
2026-09-15). Under PowerShell the row's `[ ... ]`, `$VAR` and `exec` are
all errors, so on such a host every row would error on every fire instead
of gating. regen_rows now writes `"shell": "bash"` on every row it
generates, matching the markdown-format and disk-hygiene hook configs.

The gate syntax stays POSIX-compatible regardless; the row command itself
is unchanged. Job-object process census on Windows Git Bash, n=5, the
PreToolUse row invoked as Git Bash -c: switched off, 1 process creation
(median 47 ms), unchanged; switched on, 3 creations (median 186 ms).

hooks.json is the regenerated output: 31 added lines, every one of them
the pinned field, and --check re-derives the rows clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
origin/main landed its own claude-ops 0.56.14, a documentation-only entry
about the plugins skill's mass install. This branch's two session-event-log
bullets move to a fresh 0.56.15 heading above it, and plugin.json follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
…ump claude-ops to 0.56.16

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton merged commit 4b47d26 into main Sep 16, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude-ops-session-event-log-spawns-whil branch September 16, 2026 12:51
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
… its fail-close to executable git (#4188)

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 = #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

- #4185 (item 1, base of this stack; its shellcheck fix df80eb8 is
merged in here so the lint lane matches).
- #4189 (item 3). Program item 20260915-153000; the item-4 hooks
(20260913-034032/034033/034034/034037) follow 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
)

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