Skip to content

perf(autonomy): pick the managed-settings root by path, not uname - #4191

Merged
kyle-sexton merged 3 commits into
mainfrom
autonomy-lane-stop-gate-fast-exit-outsid
Sep 16, 2026
Merged

kyle-sexton merged 3 commits into
mainfrom
autonomy-lane-stop-gate-fast-exit-outsid

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

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

🤖 Generated with Claude Code

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>
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 15, 2026 23:50
@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:52:21.474013Z de54a53 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 2m 17s —— View job


Security review — PR #4191

  • Gather context (PR diff, hook scripts, lib)
  • Run /review:security-review criteria against the changed files
  • Post findings

Scope reviewed: plugins/autonomy/hooks/lane-stop-gate.sh, plugins/autonomy/hooks/lane-stop-gate-lib.sh, plugins/autonomy/hooks/lane-stop-gate.test.sh, plus the version/CHANGELOG bump. This is the only PR content with security-relevant surface (the Stop hook's trusted-config resolution).

What changed, from a trust standpoint:

  • The payload-free pre-filter (gate_maybe_configured) now asks a new gate_managed_candidates_load (lane-stop-gate-lib.sh#L270-L284) instead of the authoritative, uname-selected gate_managed_settings_files_load.
  • gate_resolve_install is split into gate_resolve_anchor (kept above the pre-filter) and gate_resolve_plugin_name (deferred to after gate_maybe_configured || exit 0, lane-stop-gate.sh#L190-L195).

Findings: none. The one place this diff touches the actual trust boundary is well contained:

  • gate_managed_candidates_load fills a separate array (GATE_MANAGED_CANDIDATES) that only routes into full evaluation; it never populates GATE_MANAGED_FILES. Every managed value the gate honors still comes from gate_managed_settings_files_load, which is still uname-selected and still asserts absoluteness before use (lane-stop-gate-lib.sh#L228-L256). A repo-planted file can at most flip the pre-filter's routing decision, not supply a config value.
  • The one confirmed asymmetry — the Windows primary's C:/... spelling has no leading /, so on a POSIX host [[ -f ]] resolves it against the hook's cwd (the watched checkout), which a repo can plant — is explicitly documented in both the lib header and the pre-filter comment, and is exercised by new tests (lane-stop-gate.test.sh candidate-routes-only case, the PATH-shim no-spawn case, and the "enabled lane still blocks" case). Tracing the actual impact: forcing that route only buys extra work (stdin buffer + jq parse), because gate_option_to/gate_managed_options_to never consult GATE_MANAGED_CANDIDATESENABLED still resolves from the trusted, uname-selected list, so it stays unset and the hook exits 0 silently. That's the same forcing power an untrusted CLAUDE_PLUGIN_OPTION_* env presence already has over this same pre-filter, as the code says. No path lets a repo forge an enablement, sentinel, marker, or arm-record value.
  • Deferring gate_resolve_plugin_name (the manifest jq read) past the pre-filter doesn't change what's trusted, only when it's paid — it still reads from the hook's own install-derived path ($HOOK_DIR/..), never anything repo/env-controlled, and runs before the code that needs GATE_PLUGIN_NAME.

Nothing else in the diff (version bump, CHANGELOG, test file) has security-relevant surface. No CRITICAL / IMPORTANT / SUGGESTION findings to report.
· View PR checks

@github-actions

Copy link
Copy Markdown
Contributor

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

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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

  • Gather context (PR body, changed files, repo instructions)
  • Run /review:code-review criteria against the diff
  • Check for inline-comment-worthy findings
  • Final summary

Findings

None. This is a scoped, well-contained perf change and it holds up under review:

  • gate_resolve_install split (lane-stop-gate-lib.sh#L72-L135, used at lane-stop-gate.sh#L99 and #L195): gate_resolve_anchor (pure parameter expansion) still runs unconditionally before the pre-filter, so GATE_CONFIG_ROOT is available for gate_user_settings_file_to there. gate_resolve_plugin_name (the jq manifest read, needed only for an unanchored --plugin-dir install) is deferred to just after gate_maybe_configured passes, and always finishes before the first gate_option_to call that needs GATE_PLUGIN_NAME (~270 lines later). No ordering bug.
  • New gate_managed_candidates_load (lane-stop-gate-lib.sh#L270-L284) deliberately never touches GATE_MANAGED_FILES/GATE_MANAGED_FILES_LOADED, so the authoritative, uname-selected list used once a session is actually evaluated (gate_managed_options_to) is untouched — the trust boundary the header describes (values only ever come from the uname-gated list) still holds.
  • The one asymmetry the PR body itself flags — the Windows managed-path literal being tested unconditionally on a POSIX host, where it resolves relative to cwd — is explained in both the lib header and the pre-filter comment, is scoped to routing-only (can force evaluation, never a managed value), and is exercised by the new PATH-shim/candidate-scan tests. Not flagging it again; it's already disclosed and accepted in the PR body's "Residual, out of scope" note.
  • Version bump (0.23.12 → 0.23.13) and CHANGELOG entry are consistent with the change.

Verification note

The sandbox this review ran in declined to execute bash/shellcheck (approval required, none available), so I could not independently run the test suite or shellcheck myself. The PR body's test counts (lane-stop-gate.test.sh 99/1→103/1, lane-notify.test.sh 10/0), the strace budget numbers, and the shellcheck-clean claim are author-claimed and unverified by me — I only confirmed the logic reads correctly by tracing the code paths manually, and read the new test cases (candidate-scan routing, PATH-shim no-spawn, enabled-lane-still-blocks) to confirm they assert what the PR body says they assert.
· branch autonomy-lane-stop-gate-fast-exit-outsid

@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 2 commits September 15, 2026 20:16
…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>
@kyle-sexton
kyle-sexton merged commit d22ed91 into main Sep 16, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the autonomy-lane-stop-gate-fast-exit-outsid branch September 16, 2026 12:51
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