From de54a539bab3711ffd60eed75116037fb435321b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:44:12 -0400 Subject: [PATCH 1/2] perf(autonomy): pick the managed-settings root by path, not uname 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 --- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 6 + plugins/autonomy/hooks/lane-stop-gate-lib.sh | 69 ++++++++++-- plugins/autonomy/hooks/lane-stop-gate.sh | 40 +++++-- plugins/autonomy/hooks/lane-stop-gate.test.sh | 104 ++++++++++++++++-- 5 files changed, 189 insertions(+), 32 deletions(-) diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 32d73d224..a07028de8 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.23.12", + "version": "0.23.13", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index abb39da8d..bde5bec98 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.23.13] + +### Changed + +- hooks: the Stop hook's payload-free pre-filter decides the managed-settings root by which fixed path exists rather than by asking `uname -s` which one to test, and the manifest read that names an unanchored install moves below the pre-filter. Outside a lane the hook now creates no process of its own (measured on Windows Git Bash: 6 creations to 4 for an anchored install, 8 to 4 for an unanchored one, against a 4-creation harness floor; the `$(uname -s)` alone cost 3 of them). Inside a lane the count is unchanged at 19 and the block decision is byte-identical. The trust boundary is unchanged: the candidate scan only routes, and every managed VALUE still comes from the `uname`-selected, absoluteness-asserted list, because the platform is now decided by which root-owned path exists, which a repository can no more forge than it could forge uname's answer. + ## [0.23.12] ### Changed diff --git a/plugins/autonomy/hooks/lane-stop-gate-lib.sh b/plugins/autonomy/hooks/lane-stop-gate-lib.sh index 0c39aa616..bd73a67ac 100644 --- a/plugins/autonomy/hooks/lane-stop-gate-lib.sh +++ b/plugins/autonomy/hooks/lane-stop-gate-lib.sh @@ -20,6 +20,23 @@ # resolved primary path is asserted absolute before use, so a future # platform-detection regression cannot yield a cwd-relative managed path a # repo could plant inside its own checkout. +# +# A caller that only needs to know whether managed settings could configure +# the gate AT ALL — the Stop hook's payload-free pre-filter, which runs on +# every interactive stop — asks gate_managed_candidates_load instead, which +# tests the fixed primary of EVERY platform with `[[ -f ]]` / `[[ -d ]]`. +# The platform is then decided by which fixed path exists, which is +# filesystem truth a repo can no more forge than it could forge uname's +# answer: the paths are the same root-owned literals, and a candidate +# belonging to another platform simply is not there. The one asymmetry is +# that the Windows spelling carries no leading `/`, so on a POSIX host it +# resolves against the hook's cwd — the watched checkout — where a repo +# CAN plant one. That plant routes the hook into evaluation and nothing +# more: the scan contributes no value and does not fill the file list the +# authoritative read walks, so a repo gains exactly the forcing power its +# own settings `env` block already has over the CLAUDE_PLUGIN_OPTION_* +# presence tests the pre-filter starts with. Every managed VALUE still +# comes from the uname-selected, absoluteness-asserted list below. # 2. the per-session arm record (gate only) — see lane-stop-gate.sh. # 3. the user settings.json, located ONLY from this script's own install path # via the documented `/plugins/cache///` @@ -194,11 +211,18 @@ gate_user_settings_file_to() { # # gate_managed_settings_files_load fills the GATE_MANAGED_FILES array in THIS # shell and marks it loaded; the print form below re-derives the list on every -# call. The `uname -s` it runs is the one process the gate's interactive -# default path pays, so a caller that needs the list twice in one run (the -# gate's payload-free pre-filter, then its option resolution) loads it once and -# gate_managed_options_to reuses the loaded list rather than asking the kernel -# a second time for an answer that cannot have changed. +# call. It is the AUTHORITATIVE list — every managed value the gate honors is +# read from it — so it keeps paying `uname -s`, and a caller that needs it twice +# in one run loads it once and gate_managed_options_to reuses the loaded list +# rather than asking the kernel a second time for an answer that cannot have +# changed. The gate's interactive default path never reaches it: that path asks +# gate_managed_candidates_load, which spawns nothing at all. +# +# One spelling per platform, shared by the selection below and by the +# platform-free candidate scan, so the two can never drift apart. +readonly GATE_MANAGED_PRIMARY_DARWIN="/Library/Application Support/ClaudeCode/managed-settings.json" +readonly GATE_MANAGED_PRIMARY_WINDOWS="C:/Program Files/ClaudeCode/managed-settings.json" +readonly GATE_MANAGED_PRIMARY_LINUX="/etc/claude-code/managed-settings.json" GATE_MANAGED_FILES=() GATE_MANAGED_FILES_LOADED=0 gate_managed_settings_files_load() { @@ -209,9 +233,9 @@ gate_managed_settings_files_load() { # gate_resolve_plugin_name): one process for uname, not two. { platform=$(uname -s); } 2>/dev/null || platform="" case "$platform" in - Darwin) primary="/Library/Application Support/ClaudeCode/managed-settings.json" ;; - MINGW* | MSYS* | CYGWIN*) primary="C:/Program Files/ClaudeCode/managed-settings.json" ;; - Linux) primary="/etc/claude-code/managed-settings.json" ;; + Darwin) primary="$GATE_MANAGED_PRIMARY_DARWIN" ;; + MINGW* | MSYS* | CYGWIN*) primary="$GATE_MANAGED_PRIMARY_WINDOWS" ;; + Linux) primary="$GATE_MANAGED_PRIMARY_LINUX" ;; *) return 0 ;; esac # Defense in depth: a managed path MUST be absolute (POSIX /… or a Windows @@ -230,6 +254,35 @@ gate_managed_settings_files_load() { fi return 0 } +# Every managed-settings file that EXISTS for ANY platform, in +# GATE_MANAGED_CANDIDATES: the three fixed primaries and each one's +# `managed-settings.d/*.json`, tested with `[[ -f ]]` / `[[ -d ]]` and a glob. +# Builtins only — no `uname`, no process of any kind — which is why the Stop +# hook's pre-filter, the path every interactive stop takes, can ask "could +# managed settings configure this gate" for free. Testing all three is +# equivalent to selecting one by platform because a candidate belonging to +# another platform does not exist. +# +# It deliberately leaves GATE_MANAGED_FILES and GATE_MANAGED_FILES_LOADED +# alone: this list ROUTES, it never contributes a value. See the header for why +# that separation is what keeps the highest-precedence scope decided by +# `uname -s` alone. +GATE_MANAGED_CANDIDATES=() +gate_managed_candidates_load() { + GATE_MANAGED_CANDIDATES=() + local primary dropin f + for primary in "$GATE_MANAGED_PRIMARY_DARWIN" "$GATE_MANAGED_PRIMARY_WINDOWS" \ + "$GATE_MANAGED_PRIMARY_LINUX"; do + [[ -f "$primary" ]] && GATE_MANAGED_CANDIDATES+=("$primary") + dropin="${primary%/*}/managed-settings.d" + [[ -d "$dropin" ]] || continue + for f in "$dropin"/*.json; do + [[ -f "$f" ]] && GATE_MANAGED_CANDIDATES+=("$f") + done + done + return 0 +} + gate_managed_settings_files() { local f gate_managed_settings_files_load diff --git a/plugins/autonomy/hooks/lane-stop-gate.sh b/plugins/autonomy/hooks/lane-stop-gate.sh index 4bcf4ed4c..5d9a65db8 100755 --- a/plugins/autonomy/hooks/lane-stop-gate.sh +++ b/plugins/autonomy/hooks/lane-stop-gate.sh @@ -91,7 +91,12 @@ source "$HOOK_DIR/lane-notify.sh" source "$HOOK_DIR/lane-stop-gate-lib.sh" _gate_root="" gate_plugin_root_to _gate_root "$HOOK_DIR" -gate_resolve_install "$_gate_root" || true +# Only the ANCHOR half of the install resolution runs here: it is pure parameter +# expansion, and the pre-filter below needs the GATE_CONFIG_ROOT it sets to +# locate the user settings.json. The other half — the manifest read that names +# an unanchored (--plugin-dir) install — costs a jq process and answers a +# question no one asks above the pre-filter, so it is deferred past it. +gate_resolve_anchor "$_gate_root" || true # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on an older host it is empty and hook::emit_telemetry skips fail-open. @@ -127,19 +132,23 @@ emit_tel() { # # Everything this reads is already in scope above: the two env presences, and # the two settings-file locators from lane-stop-gate-lib.sh — gate_user_settings_file_to, -# which derives from the GATE_CONFIG_ROOT that gate_resolve_install establishes -# at the top of this file, and gate_managed_settings_files_load, which depends -# on nothing but `uname -s` and fixed absolute paths. +# which derives from the GATE_CONFIG_ROOT that gate_resolve_anchor establishes +# at the top of this file, and gate_managed_candidates_load, which depends on +# nothing but `[[ -f ]]` over fixed paths. # Nothing here is payload-derived, so it MUST stay above the buffer — and # everything payload-derived (hook::require_jq, EVENT, SESSION_ID, and the # SubagentStop-versus-Stop discrimination) MUST stay below it (#2852). # -# This is the path every interactive stop takes, so it spawns nothing but the -# `uname -s` inside the managed-files load: the file scan is a builtin read -# where it used to be a `grep -q` process, and the locators write into -# variables where they used to be captured through a subshell. The loaded -# managed list is kept for the option resolution below, which would otherwise -# ask uname again. +# This is the path every interactive stop takes, and it now spawns NOTHING: the +# file scan is a builtin read where it used to be a `grep -q` process, the +# locators write into variables where they used to be captured through a +# subshell, and the managed-settings question is answered by testing the fixed +# primary of every platform rather than by asking `uname -s` which one to test +# (three process creations on this hook's Windows host: the `$( )` fork, then +# the fork and exec of uname). The candidate scan ROUTES only. The option +# resolution below still loads the authoritative, uname-selected list, which is +# where every managed VALUE comes from — see the lib header for why that +# separation is what preserves the trust boundary. gate_maybe_configured() { [[ -n "${CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ARM_ID:-}" ]] && return 0 [[ -n "${CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ENABLED:-}" ]] && return 0 @@ -147,8 +156,8 @@ gate_maybe_configured() { if gate_user_settings_file_to f && [[ -f "$f" ]]; then gate_file_mentions "$f" && return 0 fi - gate_managed_settings_files_load - for f in ${GATE_MANAGED_FILES[@]+"${GATE_MANAGED_FILES[@]}"}; do + gate_managed_candidates_load + for f in ${GATE_MANAGED_CANDIDATES[@]+"${GATE_MANAGED_CANDIDATES[@]}"}; do [[ -n "$f" ]] || continue gate_file_mentions "$f" && return 0 done @@ -178,6 +187,13 @@ gate_file_mentions() { } gate_maybe_configured || exit 0 +# Past the pre-filter: this session will be evaluated, so the deferred half of +# the install resolution runs now. It names an UNANCHORED (--plugin-dir) +# install from its manifest, which gate_settings_options_to needs to match a +# pluginConfigs entry; an anchored install already has its marketplace-qualified +# id and never reaches the jq. +[[ -n "$GATE_CONFIG_ROOT" ]] || gate_resolve_plugin_name "$_gate_root" || true + # Buffer stdin. Empty (rc 1) or timed-out (rc 2) → allow the stop (fail-open: a # gate that cannot read the payload must not trap the lane). hook::buffer_stdin_to INPUT || exit 0 diff --git a/plugins/autonomy/hooks/lane-stop-gate.test.sh b/plugins/autonomy/hooks/lane-stop-gate.test.sh index 68491cf8d..d1fb47c07 100755 --- a/plugins/autonomy/hooks/lane-stop-gate.test.sh +++ b/plugins/autonomy/hooks/lane-stop-gate.test.sh @@ -1123,6 +1123,86 @@ fi chmod 600 "$UNREAD_PLUGIN/.claude-plugin/plugin.json" "$UNREAD_OPTS" 2>/dev/null || true rm -rf "$UNREAD_PLUGIN" "$UNREAD_OPTS" +# ============================================================================ +# The pre-filter decides the managed-settings root by PATH, not by uname. +# ============================================================================ +# The platform-free candidate scan is what lets the interactive default path +# spawn nothing. It must stay a ROUTING list: filling the authoritative +# GATE_MANAGED_FILES from it would move the highest-precedence scope off +# `uname -s`, and on a POSIX host the Windows spelling is cwd-relative and so +# repo-plantable. Case 38 pins the authoritative selection; this pins the +# separation, and that the candidates are the fixed roots and nothing else. +if ( + # shellcheck source=lane-stop-gate-lib.sh + source "$STAGED_DIR/lane-stop-gate-lib.sh" + # shellcheck disable=SC2329 # would be invoked indirectly if the scan spawned + uname() { + printf 'STUB\n' + exit 1 + } + gate_managed_candidates_load || exit 1 + # The scan contributes nothing to the authoritative list. + ((GATE_MANAGED_FILES_LOADED == 0)) || exit 1 + ((${#GATE_MANAGED_FILES[@]} == 0)) || exit 1 + # Whatever exists on this host, every candidate is under one of the three + # fixed roots — nothing derived from the environment or the cwd. + for c in ${GATE_MANAGED_CANDIDATES[@]+"${GATE_MANAGED_CANDIDATES[@]}"}; do + case "$c" in + "${GATE_MANAGED_PRIMARY_DARWIN%/*}"/* | "${GATE_MANAGED_PRIMARY_WINDOWS%/*}"/* | "${GATE_MANAGED_PRIMARY_LINUX%/*}"/*) ;; + *) exit 1 ;; + esac + done + exit 0 +); then + ok "the managed-settings candidate scan routes only: it never fills the uname-selected authoritative list" +else + fail "the candidate scan leaked into GATE_MANAGED_FILES or emitted a path outside the fixed managed roots" +fi + +# --- The interactive default path launches NO external command -------------- +# The strace case below proves the count where ptrace is available; this proves +# the same property portably, and is the one that runs on the Windows host the +# gate is tuned for (where a `$(uname -s)` cost three process creations). Every +# external name the gate could reach for is replaced by a stub that records +# itself and exits 99, so a regression names the offender instead of drifting a +# number. +NOSPAWN="$(mktemp -d "$WORK/nospawn.XXXXXX")" +NOSPAWN_MARK="$WORK/nospawn-launched.txt" +# The stub body uses NO external command: `basename` is itself on this list, so +# a stub that called one would recurse instead of reporting. +for t in uname realpath readlink cygpath dirname basename jq grep sed tr cat awk date cksum find wc; do + # shellcheck disable=SC2016 # $0 is the STUB's own argument, expanded when it runs + printf '#!/bin/sh\nprintf "%%s\\n" "$0" >>"%s"\nexit 99\n' "$NOSPAWN_MARK" >"$NOSPAWN/$t" + chmod +x "$NOSPAWN/$t" +done +rm -f "$SETTINGS" "$NOSPAWN_MARK" +NOSPAWN_OUT="$(cd "$UNRELATED" && build_input Stop "no token" false | + env -u CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ENABLED \ + -u CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_SENTINEL \ + -u CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_MARKER \ + -u CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ARM_ID \ + -u CLAUDE_PLUGIN_DATA \ + CLAUDE_PLUGIN_OPTION_LANE_NOTIFY_ENABLED=false HOOK_TELEMETRY_SINK="" \ + PATH="$NOSPAWN" "$BASH" "$HOOK" 2>&1)" +NOSPAWN_RC=$? +if [[ $NOSPAWN_RC -eq 0 && -z "$NOSPAWN_OUT" ]]; then + ok "PATH-shim: the interactive default path still exits 0 silently" +else + fail "PATH-shim: default path (rc=$NOSPAWN_RC out=$NOSPAWN_OUT)" +fi +if [[ -s "$NOSPAWN_MARK" ]]; then + fail "PATH-shim: the default path launched $(tr '\n' ' ' <"$NOSPAWN_MARK") — it must launch nothing" +else + ok "PATH-shim: the default path launches no external command at all" +fi + +# --- An ENABLED session still resolves its config through the same path ----- +# The saving must come from deferring work the default path cannot use, never +# from skipping a lookup a lane needs: a trusted-enabled session still blocks. +write_settings true +OUT="$(run "$(build_input Stop "no token" false)")" +if is_block "$OUT"; then ok "an enabled lane still blocks after the pre-filter lost uname"; else fail "the pre-filter change lost the enabled lane's block: $OUT"; fi + # ============================================================================ # #3515 — the per-turn PROCESS-CREATION budget, proven by strace. # ============================================================================ @@ -1137,11 +1217,13 @@ rm -rf "$UNREAD_PLUGIN" "$UNREAD_OPTS" # (180-2,841 ms each), so the count that binds is this one. # # Two paths are traced from the staged install: -# default (no gate footprint anywhere): EXACTLY 1 creation and 1 launch, the -# `uname -s` the managed-settings platform selection rests on (its trust -# primitive; $OSTYPE is a variable a repo env block can set). Everything -# else this path paid was the hook's own: a subshell per path helper and a -# grep per settings file. +# default (no gate footprint anywhere): EXACTLY 0 creations and 0 launches. +# The last one was the `uname -s` the managed-settings platform selection +# rests on; the pre-filter now tests the fixed primary of every platform +# with `[[ -f ]]` instead of asking which one to test, and the authoritative +# uname-selected load has moved below the pre-filter with the rest of the +# evaluated path. Everything else this path pays is builtin: a variable +# write per path helper and a read loop per settings file. # enabled (user settings, first stop, no signal → block): a CEILING of 10 # creations and 5 launches. This hook's own share is 6 creations: the # payload jq pass (3: process substitution, printf writer, jq), @@ -1187,15 +1269,15 @@ if command -v strace >/dev/null 2>&1 && strace -qq -o /dev/null -e trace=execve if trace_hook "$(build_input Stop "no token" false)"; then ok "strace: the interactive default path was traced" if [[ -z "$TRACE_OUT" ]]; then ok "strace: the traced default path stayed silent (it is the real default path)"; else fail "strace: the traced default path emitted output: $TRACE_OUT"; fi - if [[ "$TRACE_CREATIONS" == "1" ]]; then - ok "strace: the default path creates exactly 1 process" + if [[ "$TRACE_CREATIONS" == "0" ]]; then + ok "strace: the default path creates no process at all" else - fail "strace: default path creates $TRACE_CREATIONS processes, budget is 1 (launches: $TRACE_PROGS)" + fail "strace: default path creates $TRACE_CREATIONS processes, budget is 0 (launches: $TRACE_PROGS)" fi - if [[ "$TRACE_PROGS" == "uname " ]]; then - ok "strace: the default path launches only uname" + if [[ -z "$TRACE_PROGS" ]]; then + ok "strace: the default path launches nothing" else - fail "strace: default path launched '$TRACE_PROGS', expected only uname" + fail "strace: default path launched '$TRACE_PROGS', expected nothing" fi else fail "strace: no usable trace captured for the default path" From e46d1674926bd12b88623fc71b1a6c26060dc8a5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:16:45 -0400 Subject: [PATCH 2/2] test(autonomy): excuse the stub name list from the shell-portability 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 --- plugins/autonomy/hooks/lane-stop-gate.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/autonomy/hooks/lane-stop-gate.test.sh b/plugins/autonomy/hooks/lane-stop-gate.test.sh index d1fb47c07..ebb7918b2 100755 --- a/plugins/autonomy/hooks/lane-stop-gate.test.sh +++ b/plugins/autonomy/hooks/lane-stop-gate.test.sh @@ -1170,7 +1170,7 @@ NOSPAWN="$(mktemp -d "$WORK/nospawn.XXXXXX")" NOSPAWN_MARK="$WORK/nospawn-launched.txt" # The stub body uses NO external command: `basename` is itself on this list, so # a stub that called one would recurse instead of reporting. -for t in uname realpath readlink cygpath dirname basename jq grep sed tr cat awk date cksum find wc; do +for t in uname realpath readlink cygpath dirname basename jq grep sed tr cat awk date cksum find wc; do # portability-ok: a list of stub NAMES, not a date -d invocation # shellcheck disable=SC2016 # $0 is the STUB's own argument, expanded when it runs printf '#!/bin/sh\nprintf "%%s\\n" "$0" >>"%s"\nexit 99\n' "$NOSPAWN_MARK" >"$NOSPAWN/$t" chmod +x "$NOSPAWN/$t"