From 65568825840f020730b0ff2c32b7b24c6e16ceb8 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:01:11 -0700 Subject: [PATCH 1/6] fix: prevent no-mistakes gate agents from driving the fleet (#518) * feat: contain no-mistakes gate agents from driving the fleet Add bin/fm-gate-refuse-lib.sh, sourced at the top of fm-spawn/fm-send/ fm-teardown before any fleet mutation. It fails closed when NO_MISTAKES_GATE is set, and via an unspoofable git-common-dir backstop when invoked from a no-mistakes gate worktree (.no-mistakes/repos/*.git) even with the marker unset. A normal firstmate session has neither signal and is unaffected. Set disable_project_settings: true in the tracked .no-mistakes.yaml so the installed pipeline neutralizes gate agents' project instructions for this repo (trusted-only, honored from the default branch). firstmate's own suite runs from a gate worktree during validation, so the shared test helpers set FM_GATE_REFUSE_BYPASS=1 to exempt it; the dedicated tests/fm-gate-refuse.test.sh strips it to verify real refusal. * no-mistakes(review): Captain, refuse empty no-mistakes gate markers * no-mistakes(document): Document no-mistakes gate authority boundary --- .no-mistakes.yaml | 9 + bin/fm-gate-refuse-lib.sh | 84 ++++++++ bin/fm-send.sh | 6 + bin/fm-spawn.sh | 5 + bin/fm-teardown.sh | 5 + docs/architecture.md | 8 + docs/scripts.md | 2 + tests/fm-gate-refuse.test.sh | 398 +++++++++++++++++++++++++++++++++++ tests/fm-gotmp.test.sh | 10 + tests/herdr-test-safety.sh | 6 + tests/lib.sh | 9 + 11 files changed, 542 insertions(+) create mode 100644 bin/fm-gate-refuse-lib.sh create mode 100755 tests/fm-gate-refuse.test.sh diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index f2bb03231..8fc97893a 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -1,5 +1,14 @@ # Per-repo no-mistakes overrides. +# firstmate is an agent-orchestration repo: its AGENTS.md installs a fleet-captain +# identity. Disable project-level agent settings/instructions for gate agents so a +# no-mistakes review/fix/document/test/lint/pr/rebase/ci agent never adopts that +# identity or drives the fleet. Trusted-only: a pushed branch cannot turn this off, +# so it is honored only from the default-branch copy of this file. Layered above +# the NO_MISTAKES_GATE lifecycle refusal (bin/fm-gate-refuse-lib.sh) and the +# HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." +disable_project_settings: true + # Pin lint and test to the SAME deterministic commands CI runs, instead of # leaving them to no-mistakes' default handling. Without a configured # commands.lint, the gate's lint step never ran the deterministic diff --git a/bin/fm-gate-refuse-lib.sh b/bin/fm-gate-refuse-lib.sh new file mode 100644 index 000000000..bec9b30b9 --- /dev/null +++ b/bin/fm-gate-refuse-lib.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# fm-gate-refuse-lib.sh - fail-closed refusal that keeps a no-mistakes GATE agent +# out of firstmate's fleet lifecycle. +# +# The hazard (data/nm-gate-ambient-authority-containment-c3/report.md): a +# no-mistakes gate agent runs inside a firstmate checkout with a free shell, so +# it auto-loads firstmate's AGENTS.md, adopts the captain identity, and - seeing +# crew "in flight" - reaches for fm-spawn.sh/fm-send.sh/fm-teardown.sh to +# "delegate" and "reconcile" the shared worktree. It has real capability because +# those entrypoints self-locate their home and never knew a gate context existed. +# +# no-mistakes owns the authority-removal half (it neutralizes the project +# instructions and stamps NO_MISTAKES_GATE into the gate agent's environment). +# THIS is the firstmate capability-removal half: an enforceable script refusal, +# not a prose rule the neutralized agent would never read. It is sourced at the +# top of the three fleet-lifecycle entrypoints and called before any fleet +# mutation, so a gate agent that still reaches for the fleet is stopped cold. +# +# Two independent signals, either of which refuses (fail closed): +# +# 1. NO_MISTAKES_GATE set - the durable env marker no-mistakes stamps into every +# gate agent. This is the primary signal and covers a relocated NM_HOME. +# 2. The current worktree's git-common-dir resolves under a no-mistakes gate +# repo (.../.no-mistakes/repos/*.git) - the UNSPOOFABLE backstop. It derives +# from the checkout's real filesystem location, which the agent cannot +# relocate without breaking the gate's own git operations, so it still +# refuses even if the agent tampered NO_MISTAKES_GATE away. Its limit: the +# literal-path match only fires for the default NM_HOME (~/.no-mistakes); a +# relocated NM_HOME is covered by signal 1. +# +# A NORMAL firstmate session - a real primary checkout, a real treehouse/Orca +# crew worktree - has NEITHER signal and is COMPLETELY unaffected: the function +# returns 0 and the lifecycle proceeds exactly as before. +# +# This mirrors the unspoofable-marker precedent in bin/fm-marker-lib.sh: a signal +# the agent cannot forge, keyed on at a chokepoint, keeping the pattern familiar +# to firstmate maintainers. It layers ABOVE no-mistakes' separately-shipping +# HEAD-continuity guard, which remains the adversarial/residual backstop. +# +# TEST-HARNESS ESCAPE HATCH (FM_GATE_REFUSE_BYPASS=1): firstmate's own test suite +# must exercise the REAL fm-spawn/fm-send/fm-teardown, but the no-mistakes gate +# runs that suite FROM a gate worktree (cwd git-common-dir under +# .no-mistakes/repos/*.git, and possibly NO_MISTAKES_GATE set) - the exact +# environment this guard refuses. So both signals would fire during firstmate's +# own validation and break unrelated tests. FM_GATE_REFUSE_BYPASS=1 makes the +# guard a no-op; firstmate's shared test helpers (tests/lib.sh and the backend +# safety helpers) export it, so every test that drives these scripts against its +# temp-sandbox fleet is exempt. This does NOT weaken the boundary against the +# real hazard: the threat is a CONFUSED-not-adversarial gate agent that runs +# bin/fm-spawn.sh directly after adopting firstmate's identity - it never sources +# firstmate's test helpers, so it never carries the bypass; and the adversarial +# case (an agent that would deliberately set it) is covered by no-mistakes' +# neutral-execution-context and the HEAD-continuity guard. The dedicated +# tests/fm-gate-refuse.test.sh strips the bypass so it still verifies real refusal. +# +# Sourced by bin/fm-spawn.sh, bin/fm-send.sh, bin/fm-teardown.sh, and the tests. +# No side effects on source. set -u / set -e safe. The refusal is a hard exit, +# not a return, because there is no safe way to continue a fleet mutation from a +# gate context. + +# The exit code every refusal uses, distinct enough to recognize in a caller or +# test as "the gate refusal fired" rather than an ordinary usage error. +FM_GATE_REFUSE_EXIT=3 + +# fm_refuse_if_gate_agent: exit FM_GATE_REFUSE_EXIT with a clear stderr message if +# this process looks like a no-mistakes gate agent. Call before any fleet +# mutation. No-ops (returns 0) for a normal firstmate session, or when firstmate's +# own test harness sets FM_GATE_REFUSE_BYPASS=1 (see the header). +fm_refuse_if_gate_agent() { + if [ "${FM_GATE_REFUSE_BYPASS:-}" = 1 ]; then + return 0 + fi + if [ "${NO_MISTAKES_GATE+x}" = x ]; then + echo "error: no-mistakes gate agent must not drive the fleet (NO_MISTAKES_GATE set)" >&2 + exit "$FM_GATE_REFUSE_EXIT" + fi + local common + common=$(cd "$(git rev-parse --git-common-dir 2>/dev/null || echo /nonexistent)" 2>/dev/null && pwd -P || true) + case "$common" in + */.no-mistakes/repos/*.git) + echo "error: refusing fleet lifecycle from inside a no-mistakes gate worktree ($common)" >&2 + exit "$FM_GATE_REFUSE_EXIT" ;; + esac +} diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 1a15d4b0c..223e2f2bf 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -38,6 +38,12 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# Fail closed before any fleet mutation: a no-mistakes gate agent must never steer +# a crewmate (see bin/fm-gate-refuse-lib.sh). +fm_refuse_if_gate_agent + if [ -z "${FM_HOME+x}" ] || [ -z "${FM_HOME:-}" ]; then echo "error: FM_HOME is not set; fm-send refuses to resolve targets without an explicit firstmate home" >&2 exit 1 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9a8a159a8..0c9e40809 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -104,6 +104,11 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-config-inherit-lib.sh" # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn +# a direct report (see bin/fm-gate-refuse-lib.sh). +fm_refuse_if_gate_agent # Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is # set by the batch loop below), so the guard runs once for the batch, not once per pair. [ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e14f42456..83b66cc2a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -88,6 +88,11 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# Fail closed before any fleet mutation: a no-mistakes gate agent must never tear +# down a worktree (see bin/fm-gate-refuse-lib.sh). +fm_refuse_if_gate_agent FM_LOCK_LOG_PREFIX=teardown "$FM_ROOT/bin/fm-guard.sh" || true ID=$1 diff --git a/docs/architecture.md b/docs/architecture.md index 2c60e2fe4..ab23db09c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,14 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle. If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/`, then stop with a blocked status if it landed in the primary checkout. +## No-mistakes gate authority boundary + +Firstmate's own no-mistakes gate runs agents inside a checkout that also contains the fleet-captain identity in `AGENTS.md`, so gate execution needs an authority boundary separate from ordinary crewmate worktree isolation. +The tracked `.no-mistakes.yaml` sets `disable_project_settings: true`; no-mistakes honors that setting only from the trusted default-branch copy, so a pushed branch cannot enable its own project instructions during validation. +Independently, `fm-spawn.sh`, `fm-send.sh`, and `fm-teardown.sh` source `bin/fm-gate-refuse-lib.sh` and exit with status 3 before fleet mutation when the gate environment marker is present or the current checkout matches the default no-mistakes gate-repository topology. +A normal primary checkout or crewmate worktree has neither signal and remains unaffected. +The helper's header owns the exact signal detection, relocated-home limitation, test-harness bypass, and relationship to no-mistakes' HEAD-continuity guard. + ## Two task shapes Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data//report.md` and never push. diff --git a/docs/scripts.md b/docs/scripts.md index f72f29385..6ad6a4e54 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -3,6 +3,7 @@ The first mate drives these; interactive entrypoints work by hand too, while `*-lib.sh` files are sourced helpers. Each row is one purpose clause only: the script's own header comment is the authoritative description of its behavior, flags, and contracts, so read the header before first use. If you have changed away from the firstmate home in an interactive shell, invoke these scripts by absolute path through the repo's `bin/` directory; the scripts self-locate internally after they start. +The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm-teardown.sh` is summarized in [architecture.md](architecture.md#no-mistakes-gate-authority-boundary); `fm-gate-refuse-lib.sh`'s header owns its exact contract. | Script | Purpose | | ------------------------ | ------------------------------------------------------------------------------------ | @@ -39,6 +40,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base | | `fm-marker-lib.sh` | Shared from-firstmate request marker and detector | +| `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | | `fm-watch-arm.sh` | Verified home-scoped watcher arm wrapper with honest status reporting | | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | | `fm-watch.sh` | Singleton-safe always-on watcher: absorb benign wakes, queue and exit on actionable ones | diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh new file mode 100755 index 000000000..5f13039c1 --- /dev/null +++ b/tests/fm-gate-refuse.test.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +# Behavior tests for the no-mistakes GATE-agent fleet-lifecycle refusal. +# +# A confused no-mistakes gate agent runs inside a firstmate checkout, adopts the +# captain identity from AGENTS.md, and reaches for fm-spawn/fm-send/fm-teardown. +# bin/fm-gate-refuse-lib.sh is the firstmate capability-removal half: sourced at +# the top of those three entrypoints and called before any fleet mutation, it +# fails closed on either of two independent signals: +# 1. NO_MISTAKES_GATE set in the environment (the marker no-mistakes stamps); +# 2. the current worktree's git-common-dir resolves under a no-mistakes gate +# repo (.../.no-mistakes/repos/*.git) - the unspoofable backstop, which +# still refuses even if the marker was tampered/unset. +# A normal firstmate session (real primary, real crew worktree) has NEITHER +# signal and is completely unaffected. +# +# Each entrypoint is exercised in three scenarios, isolating exactly ONE signal: +# - env-marker refuse : neutral cwd + NO_MISTAKES_GATE set -> exit 3, no mutation +# - path-backstop refuse: gate-worktree cwd + marker UNSET -> exit 3, no mutation +# - no-regression : neutral cwd + marker UNSET -> succeeds, no gate error +# The marker is UNSET explicitly in the no-regression/backstop runs (env -u) and +# those runs stand in a controlled NON-gate repo, so the suite is hermetic even +# when it is itself executed inside the real no-mistakes gate (whose process has +# NO_MISTAKES_GATE=1 and a gate-worktree cwd). +# +# Finally, assert firstmate's TRACKED .no-mistakes.yaml parses and sets +# disable_project_settings: true (the trusted-only opt-out that neutralizes gate +# agents' project instructions on the no-mistakes side). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +GATE_LIB="$ROOT/bin/fm-gate-refuse-lib.sh" +SPAWN="$ROOT/bin/fm-spawn.sh" +SEND="$ROOT/bin/fm-send.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" + +TMP=$(fm_test_tmproot fm-gate-refuse) +fm_git_identity fmtest fmtest@example.invalid + +# The env marker's exact stderr fragment (the primary signal). +ENV_MSG='NO_MISTAKES_GATE set' +# The git-common-dir backstop's exact stderr fragment (the unspoofable signal). +PATH_MSG='no-mistakes gate worktree' + +# --- shared fixtures -------------------------------------------------------- + +# make_gate_worktree -> echoes a worktree whose git-common-dir is +# /.no-mistakes/repos/.git, reproducing no-mistakes' gate topology +# (/repos/.git + /worktrees//). +make_gate_worktree() { + local root=$1 id=016d88035d58 run=01KXC3SD5NZYMERGDS68Z1C8ER seed + mkdir -p "$root/.no-mistakes/repos" + git init -q --bare "$root/origin.git" + seed=$(mktemp -d "$TMP/gate-seed.XXXXXX") + git init -q -b main "$seed" + git -C "$seed" commit -q --allow-empty -m init + git -C "$seed" push -q "$root/origin.git" HEAD:refs/heads/main + rm -rf "$seed" + git clone -q --bare "$root/origin.git" "$root/.no-mistakes/repos/$id.git" + git -C "$root/.no-mistakes/repos/$id.git" worktree add --detach \ + "$root/.no-mistakes/worktrees/$id/$run" main >/dev/null 2>&1 + printf '%s\n' "$root/.no-mistakes/worktrees/$id/$run" +} + +# make_normal_repo -> echoes a plain (non-gate) git repo to stand in for a +# normal primary/crew checkout: its git-common-dir is /.git, never a gate. +make_normal_repo() { + local dir=$1 + git init -q -b main "$dir" + git -C "$dir" commit -q --allow-empty -m init + printf '%s\n' "$dir" +} + +GATE_WT=$(make_gate_worktree "$TMP/gate") +NORMAL_CWD=$(make_normal_repo "$TMP/normal-cwd") + +# --- the shared helper, tested directly ------------------------------------- + +# run_guard_lib [set|empty] : from , source the lib and call the guard under +# set -eu in a subshell (proving set -eu safety). NO_MISTAKES_GATE is unset first; +# a literal "set" second argument re-exports it, so callers pick the signal under +# test. Echoes combined output; the guard's exit is the caller's $?. +run_guard_lib() { + local cwd=$1 marker=${2:-unset} + ( + cd "$cwd" || exit 111 + unset NO_MISTAKES_GATE FM_GATE_REFUSE_BYPASS + case "$marker" in + set) export NO_MISTAKES_GATE=1 ;; + empty) export NO_MISTAKES_GATE= ;; + esac + set -eu + # shellcheck source=bin/fm-gate-refuse-lib.sh + . "$GATE_LIB" + fm_refuse_if_gate_agent + ) 2>&1 +} + +test_helper_env_marker_refuses() { + local out rc + out=$(run_guard_lib "$NORMAL_CWD" set); rc=$? + expect_code 3 "$rc" "helper: env marker must exit 3" + assert_contains "$out" "$ENV_MSG" "helper: env-marker refusal message" + pass "fm-gate-refuse-lib: refuses when NO_MISTAKES_GATE is set" +} + +test_helper_empty_env_marker_refuses() { + local out rc + out=$(run_guard_lib "$NORMAL_CWD" empty); rc=$? + expect_code 3 "$rc" "helper: empty env marker must exit 3" + assert_contains "$out" "$ENV_MSG" "helper: empty env-marker refusal message" + pass "fm-gate-refuse-lib: refuses when NO_MISTAKES_GATE is set empty" +} + +test_helper_path_backstop_refuses() { + local out rc + # Marker UNSET: only the git-common-dir backstop can fire here. + out=$(run_guard_lib "$GATE_WT"); rc=$? + expect_code 3 "$rc" "helper: gate worktree must exit 3 even with the marker unset" + assert_contains "$out" "$PATH_MSG" "helper: path-backstop refusal message" + assert_not_contains "$out" "$ENV_MSG" "helper: backstop must not be attributed to the env marker" + pass "fm-gate-refuse-lib: refuses from a gate worktree via git-common-dir (marker unset)" +} + +test_helper_normal_is_noop() { + local out rc + out=$(run_guard_lib "$NORMAL_CWD"); rc=$? + expect_code 0 "$rc" "helper: a normal session (neither signal) must not refuse" + [ -z "$out" ] || fail "helper: normal session printed output: $out" + pass "fm-gate-refuse-lib: no-op for a normal session (neither signal, set -eu clean)" +} + +# --- fm-spawn --------------------------------------------------------------- + +# A fake tmux/treehouse so fm-spawn resolves the crew worktree from a controlled +# pane path and completes without a live terminal (mirrors tests/fm-tangle-guard). +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|send-keys|set-window-option) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +# run_spawn [ASSIGN...] -> combined output +run_spawn() { + local cwd=$1 home=$2 id=$3 proj=$4 pane=$5 fakebin=$6; shift 6 + mkdir -p "$home/data/$id" + printf 'brief\n' > "$home/data/$id/brief.md" + ( cd "$cwd" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ + "FM_ROOT_OVERRIDE=" "FM_HOME=$home" \ + "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_PROJECTS_OVERRIDE=$home/projects" "FM_CONFIG_OVERRIDE=$home/config" \ + "FM_SPAWN_NO_GUARD=1" "FM_FAKE_PANE_PATH=$pane" "TMUX=fake,1,0" \ + "PATH=$fakebin:$PATH" "$@" \ + "$SPAWN" "$id" "$proj" codex ) 2>&1 +} + +test_spawn_refuses_and_admits() { + local home proj fakebin wt out rc + home="$TMP/spawn-home"; mkdir -p "$home/data" + proj=$(make_normal_repo "$TMP/spawn-proj") + fakebin=$(make_spawn_fakebin "$TMP/spawn-fake") + wt="$TMP/spawn-wt" + git -C "$proj" worktree add -q --detach "$wt" >/dev/null 2>&1 + + # env-marker refuse: neutral cwd, marker set. + out=$(run_spawn "$NORMAL_CWD" "$home" spawn-envmark "$proj" "$wt" "$fakebin" NO_MISTAKES_GATE=1); rc=$? + expect_code 3 "$rc" "spawn: NO_MISTAKES_GATE must refuse" + assert_contains "$out" "$ENV_MSG" "spawn: env-marker refusal message" + assert_absent "$home/state/spawn-envmark.meta" "spawn: refused env-marker launch must not record meta" + + # path-backstop refuse: gate-worktree cwd, marker UNSET. + out=$(run_spawn "$GATE_WT" "$home" spawn-backstop "$proj" "$wt" "$fakebin"); rc=$? + expect_code 3 "$rc" "spawn: gate-worktree cwd must refuse with the marker unset" + assert_contains "$out" "$PATH_MSG" "spawn: path-backstop refusal message" + assert_absent "$home/state/spawn-backstop.meta" "spawn: refused backstop launch must not record meta" + + # no-regression: neutral cwd, marker UNSET, genuine isolated worktree. + out=$(run_spawn "$NORMAL_CWD" "$home" spawn-ok "$proj" "$wt" "$fakebin"); rc=$? + expect_code 0 "$rc" "spawn: a normal session must still spawn" + assert_contains "$out" "spawned spawn-ok" "spawn: normal launch should report success" + assert_not_contains "$out" "$ENV_MSG" "spawn: normal launch must not print the gate refusal" + assert_not_contains "$out" "$PATH_MSG" "spawn: normal launch must not print the backstop refusal" + assert_present "$home/state/spawn-ok.meta" "spawn: normal launch should record meta" + pass "fm-spawn: refuses on marker and gate-worktree backstop; a normal crew spawn is unaffected" +} + +# --- fm-send ---------------------------------------------------------------- + +# A fake tmux that logs send-keys to FM_TMUX_LOG and reports live endpoints +# (mirrors tests/fm-send-strict), so a successful send is observable and a +# refused one leaves an empty log (proving no message was typed). +make_send_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + send-keys) + shift; literal=0; target= + while [ $# -gt 0 ]; do + case "$1" in + -t) target=$2; shift 2 ;; + -l) literal=1; shift ;; + *) break ;; + esac + done + printf 'send-keys target=%s literal=%s arg=%s\n' "$target" "$literal" "${1:-}" >> "$FM_TMUX_LOG" + exit 0 ;; + display-message) printf '%%1\n'; exit 0 ;; + capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/sleep" + chmod +x "$fakebin/sleep" + printf '%s\n' "$fakebin" +} + +# run_send [ASSIGN...] -> combined output +run_send() { + local cwd=$1 home=$2 fakebin=$3 log=$4 target=$5 text=$6; shift 6 + ( cd "$cwd" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ + "PATH=$fakebin:$PATH" "FM_HOME=$home" "FM_ROOT_OVERRIDE=$home" \ + "FM_TMUX_LOG=$log" "FM_SEND_SETTLE=0" "$@" \ + "$SEND" "$target" "$text" ) 2>&1 +} + +test_send_refuses_and_admits() { + local home fakebin log out rc + home="$TMP/send-home"; mkdir -p "$home/state" + fakebin=$(make_send_fakebin "$TMP/send-fake") + log="$TMP/send-tmux.log" + fm_write_meta "$home/state/lane-ok.meta" "window=sess:fm-lane-ok" "kind=ship" "harness=codex" + + # env-marker refuse. + : > "$log" + out=$(run_send "$NORMAL_CWD" "$home" "$fakebin" "$log" fm-lane-ok "hello captain" NO_MISTAKES_GATE=1); rc=$? + expect_code 3 "$rc" "send: NO_MISTAKES_GATE must refuse" + assert_contains "$out" "$ENV_MSG" "send: env-marker refusal message" + [ ! -s "$log" ] || fail "send: refused env-marker send still typed to the endpoint"$'\n'"$(cat "$log")" + + # path-backstop refuse (marker UNSET). + : > "$log" + out=$(run_send "$GATE_WT" "$home" "$fakebin" "$log" fm-lane-ok "hello captain"); rc=$? + expect_code 3 "$rc" "send: gate-worktree cwd must refuse with the marker unset" + assert_contains "$out" "$PATH_MSG" "send: path-backstop refusal message" + [ ! -s "$log" ] || fail "send: refused backstop send still typed to the endpoint"$'\n'"$(cat "$log")" + + # no-regression. + : > "$log" + out=$(run_send "$NORMAL_CWD" "$home" "$fakebin" "$log" fm-lane-ok "hello captain"); rc=$? + expect_code 0 "$rc" "send: a normal session must still send" + assert_not_contains "$out" "$ENV_MSG" "send: normal send must not print the gate refusal" + assert_not_contains "$out" "$PATH_MSG" "send: normal send must not print the backstop refusal" + assert_contains "$(cat "$log")" "target=sess:fm-lane-ok literal=1 arg=hello captain" "send: normal send should type the text" + pass "fm-send: refuses on marker and gate-worktree backstop; a normal steer is unaffected" +} + +# --- fm-teardown ------------------------------------------------------------ + +# make_teardown_case -> echoes a case dir holding a LANDED no-mistakes ship +# task (HEAD reachable from origin), so a normal teardown genuinely succeeds and a +# refused one leaves the task untouched (mirrors tests/fm-teardown make_case). +make_teardown_case() { + local name=$1 case_dir fakebin t + case_dir="$TMP/$name"; fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/state" "$case_dir/config" "$fakebin" + for t in treehouse tmux; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/$t" + chmod +x "$fakebin/$t" + done + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []"; exit 0 ;; + "pr view") echo "error: pull request not found" >&2; exit 1 ;; +esac +exit 0 +SH + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr view") echo "error: pull request not found" >&2; exit 1 ;; +esac +exit 0 +SH + chmod +x "$fakebin/gh-axi" "$fakebin/gh" + git init -q --bare "$case_dir/origin.git" + git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main + git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null + git -C "$case_dir/_seed" commit -q --allow-empty -m "origin baseline" + git -C "$case_dir/_seed" push -q origin main + rm -rf "$case_dir/_seed" + git clone -q "$case_dir/origin.git" "$case_dir/project" + git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true + git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main + git -C "$case_dir/wt" commit -q --allow-empty -m "shippable work" + git -C "$case_dir/wt" push -q origin fm/task-x1 + git -C "$case_dir/project" fetch -q origin + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" "worktree=$case_dir/wt" "project=$case_dir/project" \ + "kind=ship" "mode=no-mistakes" + touch "$case_dir/state/.last-watcher-beat" + printf '%s\n' "$case_dir" +} + +# run_teardown [ASSIGN...] -> combined output +run_teardown() { + local cwd=$1 case_dir=$2; shift 2 + ( cd "$cwd" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ + "FM_ROOT_OVERRIDE=$ROOT" "FM_STATE_OVERRIDE=$case_dir/state" \ + "FM_CONFIG_OVERRIDE=$case_dir/config" "PATH=$case_dir/fakebin:$PATH" "$@" \ + "$TEARDOWN" task-x1 ) 2>&1 +} + +test_teardown_refuses_and_admits() { + local case_dir out rc + + # env-marker refuse: a genuinely-landed task is still refused; nothing is torn down. + case_dir=$(make_teardown_case teardown-envmark) + out=$(run_teardown "$NORMAL_CWD" "$case_dir" NO_MISTAKES_GATE=1); rc=$? + expect_code 3 "$rc" "teardown: NO_MISTAKES_GATE must refuse" + assert_contains "$out" "$ENV_MSG" "teardown: env-marker refusal message" + assert_present "$case_dir/state/task-x1.meta" "teardown: refused env-marker teardown must leave the task" + + # path-backstop refuse (marker UNSET). + case_dir=$(make_teardown_case teardown-backstop) + out=$(run_teardown "$GATE_WT" "$case_dir"); rc=$? + expect_code 3 "$rc" "teardown: gate-worktree cwd must refuse with the marker unset" + assert_contains "$out" "$PATH_MSG" "teardown: path-backstop refusal message" + assert_present "$case_dir/state/task-x1.meta" "teardown: refused backstop teardown must leave the task" + + # no-regression: a normal session tears down the landed task. + case_dir=$(make_teardown_case teardown-ok) + out=$(run_teardown "$NORMAL_CWD" "$case_dir"); rc=$? + expect_code 0 "$rc" "teardown: a normal session must still tear down landed work" + assert_not_contains "$out" "$ENV_MSG" "teardown: normal teardown must not print the gate refusal" + assert_not_contains "$out" "$PATH_MSG" "teardown: normal teardown must not print the backstop refusal" + assert_not_contains "$out" "REFUSED" "teardown: normal teardown of landed work must not refuse" + pass "fm-teardown: refuses on marker and gate-worktree backstop; a normal teardown is unaffected" +} + +# --- tracked .no-mistakes.yaml ---------------------------------------------- + +test_no_mistakes_yaml_disables_project_settings() { + local file="$ROOT/.no-mistakes.yaml" val tab + assert_present "$file" "tracked .no-mistakes.yaml is missing" + git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ + || fail ".no-mistakes.yaml is not tracked by git" + + # Parse with a real YAML loader and assert the field is boolean true, so a + # malformed file or a stringy "true" fails where a naive grep would pass. + if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then + val=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1])).get("disable_project_settings"))' "$file") \ + || fail ".no-mistakes.yaml did not parse as YAML (python3)" + [ "$val" = "True" ] || fail "disable_project_settings is not boolean true (python3 read: $val)" + elif command -v ruby >/dev/null 2>&1; then + ruby -ryaml -e 'exit((YAML.safe_load(File.read(ARGV[0]))["disable_project_settings"] == true) ? 0 : 1)' "$file" \ + || fail ".no-mistakes.yaml did not parse or disable_project_settings != true (ruby)" + else + # No YAML loader: fall back to a strict structural check - no tab indentation + # (YAML forbids it) and the top-level key mapped to the bare boolean true. + tab=$(printf '\t') + case "$(cat "$file")" in + *"$tab"*) fail ".no-mistakes.yaml uses a tab (invalid YAML indentation)" ;; + esac + grep -qxE 'disable_project_settings:[[:space:]]+true' "$file" \ + || fail "top-level 'disable_project_settings: true' not found in .no-mistakes.yaml" + fi + pass ".no-mistakes.yaml parses and sets disable_project_settings: true (trusted-only gate opt-out)" +} + +test_helper_env_marker_refuses +test_helper_empty_env_marker_refuses +test_helper_path_backstop_refuses +test_helper_normal_is_noop +test_spawn_refuses_and_admits +test_send_refuses_and_admits +test_teardown_refuses_and_admits +test_no_mistakes_yaml_disables_project_settings diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 6241aabad..7407a458f 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -11,6 +11,12 @@ # the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). set -u +# This suite does not source tests/lib.sh, so exempt its teardown subprocess from +# the gate-lifecycle refusal (bin/fm-gate-refuse-lib.sh) the way lib.sh does for +# the rest of the suite: the no-mistakes gate runs this suite from a gate worktree, +# which the guard would otherwise refuse. +export FM_GATE_REFUSE_BYPASS=1 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" @@ -54,6 +60,8 @@ make_fake_root() { ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" # fm-lock-lib.sh: teardown sources it for the shared lock-staleness proof. ln -s "$ROOT/bin/fm-lock-lib.sh" "$fake/bin/fm-lock-lib.sh" + # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. + ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -149,6 +157,8 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" ln -s "$ROOT/bin/fm-lock-lib.sh" "$fake/bin/fm-lock-lib.sh" + # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. + ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/herdr-test-safety.sh b/tests/herdr-test-safety.sh index bd10e0de8..eabc5f730 100644 --- a/tests/herdr-test-safety.sh +++ b/tests/herdr-test-safety.sh @@ -4,6 +4,12 @@ # fleet-state tripwire contract is bin/fm-herdr-lab.sh. set -u +# Herdr backend tests drive the real fm-spawn/fm-teardown but do not source +# tests/lib.sh, so exempt them from the gate-lifecycle refusal here too (see +# tests/lib.sh and bin/fm-gate-refuse-lib.sh for why firstmate's own suite, +# which the no-mistakes gate runs from a gate worktree, must be exempt). +export FM_GATE_REFUSE_BYPASS=1 + HERDR_TEST_SAFETY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=bin/fm-herdr-lab.sh . "$HERDR_TEST_SAFETY_DIR/bin/fm-herdr-lab.sh" diff --git a/tests/lib.sh b/tests/lib.sh index 6e425367b..add7f2904 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -25,6 +25,15 @@ if [ -n "${FM_TEST_LIB_SOURCED:-}" ]; then fi FM_TEST_LIB_SOURCED=1 +# Exempt firstmate's own test suite from the gate-lifecycle refusal +# (bin/fm-gate-refuse-lib.sh). The no-mistakes gate runs this suite FROM a gate +# worktree - the exact environment that guard refuses - so without this every +# test that drives the real fm-spawn/fm-send/fm-teardown would be refused during +# firstmate's own validation. A confused gate agent never sources this helper, so +# the boundary against the real hazard is unaffected. tests/fm-gate-refuse.test.sh +# strips this to verify real refusal. +export FM_GATE_REFUSE_BYPASS=1 + # Resolve the repo root from this library's own location. Consumed by sourcing # test files, not by this library, so it reads as "unused" here. # shellcheck disable=SC2034 From 2364817c2cb196e9b2c6b892a499f4aa42cc181f Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:19:34 -0700 Subject: [PATCH 2/6] fix: guard secondmate primary sessions from blind turn ends (#505) * fix: guard secondmate own-home turn ends Remove the .fm-secondmate-home early-exit in fm-turnend-guard.sh so the 'no turn ends blind' backstop fires in a secondmate's own primary session, matching the cd-guard's scope: the own home is guarded, child crew/scout worktrees stay exempt via the retained git-dir/git-common-dir test. This was pure scoping from the guard's primary-only origin and guarded against no secondmate-specific hazard. Add secondmate regression tests (blind-turn block, idle-by-default, stop_hook_active loop guard, deferred-death recovery loop, child-worktree exemption) and record the autonomous background-notify re-invoke measurement (Claude Code 2.1.207, 11s) in docs/turnend-guard.md. * no-mistakes(document): Correct secondmate guard documentation, captain * fix: force-include marked secondmate homes in turn-end guard The prior remove-only form (just deleting the .fm-secondmate-home check) left the DEFAULT secondmate topology unguarded: a treehouse-leased home is a linked git worktree (git-dir != git-common-dir), which the retained git-dir exemption still skipped, so its own primary session could still end a turn blind. Invert the marker: a genuinely-marked home is force-included as a guarded primary (treehouse-leased linked OR git-cloned plain), and the git-dir exemption applies only to UNMARKED child worktrees. Marker validation (regular non-symlink file, non-empty id-token content) blocks a stray or empty marker from spoofing inclusion. Add real linked-worktree regression tests: a treehouse-leased LINKED secondmate home is guarded, a stray/empty marker stays exempt, and the unmarked child worktree stays exempt - the topology the plain git-init fixtures masked. Predicates, in-flight gate, and loop guard untouched. * fix: force ASCII collation in secondmate marker validation Add a function-scoped local LC_ALL=C in fm_root_is_secondmate_home so the [A-Za-z0-9._-] id allowlist matches under C collation, not the ambient locale - a locale-crafted non-ASCII marker id can no longer slip through the range match and spoof force-inclusion of a linked child worktree. Add a regression test proving a non-ASCII marker id is rejected and the linked worktree stays exempt. * no-mistakes(test): fix backend baseline gate-refusal dependency * no-mistakes(document): Correct secondmate turn-end guard documentation --- AGENTS.md | 2 +- bin/fm-cd-pretool-check.sh | 19 ++- bin/fm-turnend-guard.sh | 74 +++++++--- docs/architecture.md | 2 +- docs/cd-guard.md | 14 +- docs/supervision-protocols/opencode.md | 2 +- docs/turnend-guard.md | 42 +++++- tests/fm-backend.test.sh | 2 +- tests/fm-turnend-guard.test.sh | 189 ++++++++++++++++++++++++- 9 files changed, 296 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70181739a..705c30768 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -613,7 +613,7 @@ If a crewmate sent to work firstmate-on-itself branches or commits in the primar Only a named non-default branch checked out in the primary alarms: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes) and the default branch never do. The same assertion runs at session start as the bootstrap `TANGLE:` line (handled via `bootstrap-diagnostics`), and two upstream guards prevent the tangle: `fm-spawn`'s isolated-worktree assertion and the ship brief's opening isolation check (section 11). -On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, fires only in the actual primary checkout, and stays silent when supervision is healthy. +On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, guards both the main primary and a secondmate's own primary session, and stays silent when supervision is healthy. `docs/turnend-guard.md` owns the per-harness hook mechanisms, empirical validation, scoping details, and documented fail-open tradeoffs. Watcher liveness is harness-aware. Do not assume one primary harness can use another harness's foreground or background shape. diff --git a/bin/fm-cd-pretool-check.sh b/bin/fm-cd-pretool-check.sh index 2867c7c9e..a57ba9d2a 100755 --- a/bin/fm-cd-pretool-check.sh +++ b/bin/fm-cd-pretool-check.sh @@ -123,16 +123,15 @@ esac SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)} || exit 0 -# Scope to the ACTUAL primary firstmate checkout. This reuses the turn-end -# guard's primary detection (docs/turnend-guard.md): a plain, non-worktree -# checkout has git-dir equal to git-common-dir, while a crewmate/scout task -# worktree - the shape bin/fm-spawn.sh always hands out - is a linked git -# worktree where the two differ. Unlike the turn-end guard, the cd-guard does -# NOT exclude secondmate homes (the .fm-secondmate-home marker): a secondmate's -# OWN primary session is a primary and must be guarded too; only its child -# crew/scout worktrees are exempt, and they are exempt here by the same -# linked-worktree test. Any failure to confirm the primary is inert (exit 0), -# never a block, so a broken environment never denies a shell command. +# Scope to a plain, non-worktree firstmate checkout, where git-dir equals +# git-common-dir. A crewmate/scout task worktree - the shape bin/fm-spawn.sh +# always hands out - is a linked git worktree where the two differ. This guard +# does not inspect .fm-secondmate-home, so it applies in a git-cloned secondmate +# home but remains inert when the secondmate home is itself a treehouse-leased +# linked worktree. docs/cd-guard.md owns this scope; docs/turnend-guard.md owns +# the turn-end guard's separate marker-aware scope. Any failure to confirm the +# checkout is inert (exit 0), never a block, so a broken environment never +# denies a shell command. [ -f "$FM_ROOT/AGENTS.md" ] || exit 0 [ -d "$FM_ROOT/bin" ] || exit 0 command -v git >/dev/null 2>&1 || exit 0 diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 3fcadf6bd..eef947f85 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# Primary turn-end guard for the firstmate PRIMARY session only. +# Turn-end guard for any firstmate PRIMARY session: the main home OR a +# secondmate's own home. A secondmate runs its own primary firstmate session and +# is guarded exactly like the main primary; only child crew/scout worktrees are +# exempt (see the scoping block below and docs/turnend-guard.md). # # fm-guard.sh (bin/fm-guard.sh) is pull-based: it only warns when some other # supervision script happens to run. A primary session that ends a turn without @@ -14,11 +17,14 @@ # and fail-open tradeoffs. # # Ships with TRACKED harness hook files at the repo root, so this file is -# checked out into every worktree of this repo: the primary checkout, any -# crewmate/scout task worktree spawned to work on firstmate itself (the -# recursive "firstmate improving itself" case), and every secondmate home -# (treehouse-leased or git-cloned). It must therefore scope itself to the -# PRIMARY at runtime and stay a silent, fast no-op everywhere else. +# checked out into every worktree of this repo: the primary checkout, every +# secondmate home (treehouse-leased or git-cloned), and any crewmate/scout task +# worktree spawned to work on firstmate itself (the recursive "firstmate +# improving itself" case). A secondmate home runs its OWN primary firstmate +# session, so it must be guarded like the main primary; only child crew/scout +# worktrees are exempt. It must therefore scope itself at runtime to a real +# primary checkout - the main home or a genuinely marked secondmate home - and +# stay a silent, fast no-op inside child task worktrees. # # Loop-guard: never block twice in the same turn. Claude Code and codex Stop # payloads carry stop_hook_active=true when the CURRENT stop attempt was itself @@ -55,19 +61,49 @@ command -v jq >/dev/null 2>&1 || exit 0 STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0 [ "$STOP_HOOK_ACTIVE" = "true" ] && exit 0 -# --- scope precisely to the PRIMARY checkout -------------------------------- -# Excludes secondmate homes (the .fm-secondmate-home marker is written at seed -# time regardless of whether the home was treehouse-leased or git-cloned; see -# bin/fm-home-seed.sh) and ordinary crewmate/scout task worktrees of -# firstmate-on-itself (bin/fm-spawn.sh only ever hands those out as genuine -# linked `git worktree`s - it aborts the spawn otherwise - so a plain, -# non-worktree checkout is never one of those). A linked worktree's git-dir -# lives under the main repo's .git/worktrees/ and differs from the common -# (shared) git-dir; only the main, non-worktree checkout has the two equal. -[ -f "$FM_ROOT/.fm-secondmate-home" ] && exit 0 -GIT_DIR=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || exit 0 -GIT_COMMON_DIR=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || exit 0 -[ "$GIT_DIR" = "$GIT_COMMON_DIR" ] || exit 0 +# Return 0 when $1 (a firstmate root) carries a GENUINE secondmate-home marker. +# bin/fm-home-seed.sh writes .fm-secondmate-home at a seeded secondmate home's +# root (gitignored, so it never propagates into a child worktree); its content is +# the secondmate id. Validate the marker's form so a stray/empty/symlink file +# cannot spoof inclusion and an unmarked child is never guarded by accident: it +# must be a regular (non-symlink) file whose first line, with all whitespace +# removed, is a non-empty id token (letters, digits, dot, underscore, dash only). +# The allowlist is matched under forced C (ASCII) collation - `local LC_ALL=C`, +# restored on return - so a locale-crafted non-ASCII id cannot slip through the +# range match and spoof force-inclusion. This is a deliberately lightweight +# guard-local presence check, distinct from fm-ff-lib.sh's validate_secondmate_home +# (which matches an EXPECTED id and does path-safety); the guard does not source +# that heavier library. +fm_root_is_secondmate_home() { + local marker="$1/.fm-secondmate-home" id LC_ALL=C + [ -L "$marker" ] && return 1 + [ -f "$marker" ] || return 1 + IFS= read -r id < "$marker" 2>/dev/null || return 1 + id=${id//[[:space:]]/} + [ -n "$id" ] || return 1 + case "$id" in + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# --- scope precisely to a PRIMARY checkout ---------------------------------- +# A genuinely-marked secondmate home runs its OWN primary firstmate session, so +# force-INCLUDE it as a guarded primary whether treehouse leased it as a linked +# worktree (git-dir != git-common-dir) or it is a git-cloned plain checkout. This +# mirrors the cd-guard's intent that a secondmate's own session is a guarded +# primary. Only an UNMARKED checkout (or one with an invalid marker) falls +# through to the linked-worktree exemption: firstmate hands out crewmate/scout +# task worktrees as genuine linked `git worktree`s (bin/fm-spawn.sh aborts +# otherwise), whose git-dir lives under the parent repo's .git/worktrees/ +# and differs from the common (shared) git-dir, while a main, non-worktree +# checkout has the two equal. Child worktrees never carry the gitignored marker, +# so this exempts them while guarding every real secondmate home. +if ! fm_root_is_secondmate_home "$FM_ROOT"; then + GIT_DIR=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || exit 0 + GIT_COMMON_DIR=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || exit 0 + [ "$GIT_DIR" = "$GIT_COMMON_DIR" ] || exit 0 +fi [ -f "$FM_ROOT/AGENTS.md" ] || exit 0 [ -d "$FM_ROOT/bin" ] || exit 0 [ -d "$STATE" ] || exit 0 diff --git a/docs/architecture.md b/docs/architecture.md index ab23db09c..cf746f58b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with prominent bordered banners for the tangle and no-watcher cases so they cannot be skimmed past. On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. -The guard is scoped out of secondmate homes and crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). +The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 7485b3c71..e324bfeb4 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -19,19 +19,19 @@ This guard is not a general sandbox. It classifies shell command positions only; it never evaluates, expands, sources, or runs any byte of the submitted command. Its threat model is agent mistakes, the same as the watcher-arm seatbelt: an accidental bare `cd projects/foo`, not a deliberately obfuscated bypass. -## Scope: the real primary checkout only +## Scope: plain firstmate checkouts only -The guard fires only in the actual primary firstmate checkout. +The guard fires only in a plain firstmate checkout where git-dir equals git-common-dir. It is a silent no-op (exit 0, no output) everywhere else, so it never interferes with a crewmate or scout that legitimately works inside its own project or firstmate task worktree. -`bin/fm-cd-pretool-check.sh` reuses the turn-end guard's primary detection (`docs/turnend-guard.md`). +`bin/fm-cd-pretool-check.sh` owns its checkout detection; the turn-end guard's marker-aware scope is a separate contract (`docs/turnend-guard.md`). A plain, non-worktree checkout has `git rev-parse --git-dir` equal to `git rev-parse --git-common-dir`. A crewmate or scout task worktree - the shape `bin/fm-spawn.sh` always hands out - is a linked git worktree where the two differ, so the guard is inert there. The checkout must also carry `AGENTS.md` and `bin/`, and any failure to confirm the primary is treated as inert, never as a block. -The one deliberate difference from the turn-end guard: the cd-guard does **not** exclude secondmate homes. -A secondmate is a firstmate in its own home, so its own primary session is a primary and its firstmate-owned commands must stay in its home too; the guard applies there. -Only a secondmate's child crew and scout worktrees are exempt, and they are exempt automatically by the same linked-worktree test. +The cd-guard does not inspect `.fm-secondmate-home`. +It therefore applies in a git-cloned secondmate home where git-dir equals git-common-dir, but remains inert in a treehouse-leased secondmate home that is itself a linked worktree. +Secondmate child crew and scout worktrees are likewise inert under the linked-worktree test. ## Block vs allow @@ -125,7 +125,7 @@ Every shell variable reference in the Grok hook command carries an inline defaul `tests/fm-cd-pretool-check.test.sh` owns the acceptance matrix. Every block and allow case runs through Codex-shaped stdin, Claude-shaped stdin, Grok-shaped stdin, OpenCode-shaped CLI, and Pi-shaped CLI entry forms. -The suite also proves the end-to-end cwd-leak regression (a firstmate-owned backlog write leaking into a project clone, then denied at the exact command), the primary-checkout scoping (fires in a secondmate home, inert in a crewmate/scout linked worktree, inert outside a firstmate checkout, inert outside a git repo), the fail-open transport behavior, the prefilter fast path, the policy CLI output contract, and the per-harness wiring. +The suite also proves the end-to-end cwd-leak regression (a firstmate-owned backlog write leaking into a project clone, then denied at the exact command), the checkout scoping (fires in a git-cloned secondmate fixture, inert in a crewmate/scout linked worktree, inert outside a firstmate checkout, inert outside a git repo), the fail-open transport behavior, the prefilter fast path, the policy CLI output contract, and the per-harness wiring. Run: diff --git a/docs/supervision-protocols/opencode.md b/docs/supervision-protocols/opencode.md index 885702944..56cafc4bb 100644 --- a/docs/supervision-protocols/opencode.md +++ b/docs/supervision-protocols/opencode.md @@ -11,4 +11,4 @@ When this session owns supervision and away mode is not active: 7. Do not rely on this plugin in headless `opencode run`; firstmate primary supervision targets persistent OpenCode TUI sessions. OpenCode's persistent TUI plugin runtime is the wake mechanism. -The plugin scopes itself to the primary firstmate checkout and stays silent in crewmate worktrees and secondmate homes. +The plugin applies in the main primary checkout and a secondmate's own home, and stays silent only in child crewmate and scout worktrees. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index afc94123a..344417161 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -3,7 +3,8 @@ This is the authoritative contract for the "no turn ends blind" primary guard referenced from AGENTS.md section 8. The shared predicate lives in `bin/fm-turnend-guard.sh`. Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate. -Two related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`) and the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`), which reuses this guard's linked-worktree exemption but deliberately remains active in secondmate homes. +Two related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`) and the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`). +Each seatbelt's own document defines its scope; they do not share the turn-end guard's marker-aware primary detection. ## Gap Closed @@ -16,9 +17,11 @@ When tasks are in flight and there is no live identity-matched watcher with a fr ## Shared Predicate -The guard first scopes itself to the real primary checkout. -It is inert in secondmate homes because `.fm-secondmate-home` exists there. -It is inert in crewmate and scout worktrees because firstmate provisions them as linked git worktrees, where `git rev-parse --git-dir` differs from `git rev-parse --git-common-dir`. +The guard first scopes itself to a real primary checkout. +A secondmate home runs its own primary firstmate session, so a genuine `.fm-secondmate-home` marker force-includes it whether treehouse leased it as a linked worktree or it is a git-cloned plain checkout. +The marker must be a regular non-symlink file whose first line, after all whitespace is removed, contains a non-empty identifier made only of letters, digits, dots, underscores, and dashes. +An unmarked checkout, or one with an invalid marker, falls through to the git-dir check. +That check keeps crewmate and scout worktrees inert because firstmate provisions them as linked git worktrees, where `git rev-parse --git-dir` differs from `git rev-parse --git-common-dir`. It also requires `AGENTS.md`, `bin/`, and the effective state directory to exist. For an in-scope primary checkout, it counts in-flight work from `state/*.meta`. @@ -112,8 +115,37 @@ If Grok declines to load project hooks, this primary guard fails open and `fm-gu The hook command was fixed to reference `${GROK_WORKSPACE_ROOT:-}` directly everywhere instead of assigning it to `$root` first, and re-validated against grok 0.2.93 to fire and complete cleanly. See `docs/arm-pretool-check.md`'s "Harness wiring" section for the same Grok expansion requirement; that document's Grok hook shares the same fix. +### 2026-07-12: secondmate-home enablement and the autonomous background-notify wake + +The guard originally early-exited in every secondmate home on the `.fm-secondmate-home` marker. +That was a scoping choice inherited from the guard's primary-only origin, not a defense against any secondmate-specific hazard. +A genuinely marked secondmate home is now force-included as a guarded primary regardless of whether it is a treehouse-leased linked worktree or a git-cloned plain checkout. +Only unmarked child worktrees fall through to the linked-worktree exemption, and marker validation prevents an empty, malformed, or symlink marker from spoofing inclusion. + +"No turn ends blind" for a secondmate is delivered by the same two mechanisms the main primary relies on. +Mechanism B, the turn-end backstop, is this guard; its secondmate-home behavior is covered by hermetic tests in `tests/fm-turnend-guard.test.sh` (`test_hook_blocks_in_secondmate_own_home`, `test_hook_blocks_in_treehouse_leased_secondmate_home`, `test_hook_silent_in_idle_secondmate_home`, `test_hook_secondmate_loop_guard_allows_retry`, `test_hook_secondmate_reinvoke_recovery_loop`, `test_hook_silent_in_secondmate_child_worktree`, and `test_hook_exempts_linked_worktree_with_stray_marker`). +Mechanism A, the autonomous wake, is a harness property: when a background watcher task exits, the harness re-invokes the model, which drains the wake, advances children, and re-arms a fresh watcher. +Mechanism A cannot be a hermetic CI assertion because it requires a live model session, so it is recorded here as a dated first-hand measurement while `test_hook_secondmate_reinvoke_recovery_loop` covers the guard's deterministic half of the same recovery loop. + +Autonomous-re-invoke measurement, run first-hand on Claude Code 2.1.207 (Darwin 25.5.0) on 2026-07-12. +Procedure: launch a detached `run_in_background` Bash task that models a one-shot watcher - it records a launch epoch, runs `sleep 25`, then records a completion epoch just before exit, writing only to the session scratchpad - then end the turn with no further tool calls and no pending question, a genuinely idle session with no human input. +Observed marker timestamps: + +``` +launch_epoch = 1783890980 (14:16:20) turn ends, session goes idle +complete_epoch = 1783891005 (14:16:45) background task exits, 25s idle +reinvoke_epoch = 1783891016 (14:16:56) MODEL RE-INVOKED +-------------------------------------------------------------- +wake latency (task complete -> model re-invoked): 11s, with ZERO human input +``` + +The re-invocation arrived as a `` whose accompanying system notice stated verbatim "No human input has been received since the last genuine user message in this conversation". +So the model was re-invoked solely by the background task's completion while idle, which is Mechanism A - the same background-notify wake the Claude supervision protocol relies on for the main primary. +This matches the harness tool contract that a `run_in_background` task "keeps running across turns and re-invokes you when it exits", and reproduces the 11s latency the task audit measured independently on the same harness version. +No Herdr command was issued and no fleet state was touched; the experiment wrote only to the session scratchpad, which was discarded. + ## Tests -`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. +`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 5a9fafcde..cd815f547 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-guard.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # -> echoes root dir (root/bin/