perf(autonomy): pick the managed-settings root by path, not uname - #4191
Conversation
The Stop hook's payload-free pre-filter runs on every interactive stop. It asked `uname -s` which platform's managed-settings path to test, and on the Windows Git Bash host this gate is tuned for that command substitution costs three process creations. It now tests the fixed primary of EVERY platform with `[[ -f ]]`, which is equivalent: a candidate belonging to another platform does not exist. The manifest read that names an unanchored (--plugin-dir) install moves below the pre-filter with the rest of the evaluated path. Outside a lane the hook creates no process of its own: 6 creations to 4 for an anchored install and 8 to 4 for an unanchored one, against a 4-creation harness floor measured from a no-op script under the same invocation. Inside a lane the count is unchanged at 19 and the block payload is byte-identical. The trust boundary is unchanged. The candidate scan only ROUTES: it does not fill GATE_MANAGED_FILES, so every managed VALUE still comes from the uname-selected, absoluteness-asserted list. What decides the platform on the pre-filter is now which root-owned path exists, which a repository can no more forge than it could forge uname's answer; the one asymmetry, a cwd-relative Windows spelling on a POSIX host, can only force an evaluation a repository's own settings `env` block can already force through the CLAUDE_PLUGIN_OPTION_* presence tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 2m 17s —— View job Security review — PR #4191
Scope reviewed: What changed, from a trust standpoint:
Findings: none. The one place this diff touches the actual trust boundary is well contained:
Nothing else in the diff (version bump, CHANGELOG, test file) has security-relevant surface. No CRITICAL / IMPORTANT / SUGGESTION findings to report. |
|
Last security-reviewed head: |
|
Claude finished @kyle-sexton's task in 3m 38s —— View job Review: PR #4191 — perf(autonomy): pick the managed-settings root by path, not uname
FindingsNone. This is a scoped, well-contained perf change and it holds up under review:
Verification noteThe sandbox this review ran in declined to execute |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…lint The lint read the word date in the no-spawn stub list as a date -d invocation; the same-line portability-ok annotation names it as a list of names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ump autonomy to 0.23.14 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…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>
#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>
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
Stophook runs on every interactive stop. Its payload-free pre-filter, the path every stop outside a lane takes, askeduname -swhich 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
[[ -f ]], which is equivalent: a candidate belonging to another platform does not exist. The scan only routes (gate_managed_candidates_loadfills its own array and leavesGATE_MANAGED_FILESalone), so every managed value still comes from theuname-selected, absoluteness-asserted list ingate_managed_settings_files_loadonce 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 settingsenvblock can already force through the twoCLAUDE_PLUGIN_OPTION_*presence tests; documented in the lib header and the pre-filter comment.gate_resolve_installis split:gate_resolve_anchor(pure parameter expansion) stays above the pre-filter because it sets theGATE_CONFIG_ROOTthe 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 andCLAUDE_PLUGIN_DATAat temp dirs). The floor on this invocation shape is 4, measured from a#!/usr/bin/env bash+exit 0script under the identical call.The hook now equals the floor: it spawns nothing of its own outside a lane. Spawn sites were attributed with xtrace and a
$BASHPIDprompt before the change (lane-stop-gate-lib.shline 210uname -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 launchesuname, the patched one launches nothing. Inside a lane the{"decision":"block",…}payload is byte-identical before and after.Suites:
lane-stop-gate.test.sh99/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.sh10/0 both sides. Four new cases: the candidate scan never fillsGATE_MANAGED_FILESand 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/mainpasses.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 orProgramDatais worth checking separately, since it decides whether this gate has ever read Windows managed settings.Related
🤖 Generated with Claude Code