diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index a07028de8..e2eec4bd3 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.13", + "version": "0.23.14", "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 36a24da23..985ac1a90 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.14] + +### 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.13] ### 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..ebb7918b2 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 # 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" +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"