Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/autonomy/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions plugins/autonomy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 61 additions & 8 deletions plugins/autonomy/hooks/lane-stop-gate-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<config>/plugins/cache/<marketplace>/<name>/<ver>`
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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
Expand Down
40 changes: 28 additions & 12 deletions plugins/autonomy/hooks/lane-stop-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -127,28 +132,32 @@ 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
local f
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
Expand Down Expand Up @@ -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
Expand Down
104 changes: 93 additions & 11 deletions plugins/autonomy/hooks/lane-stop-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ============================================================================
Expand All @@ -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),
Expand Down Expand Up @@ -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"
Expand Down
Loading