Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2fee5dc
fix(home): refuse cross-home spawns, steers, and memory sweeps
bingb0t5 Sep 15, 2026
e6415cf
no-mistakes(review): Captain: enforce cross-home authority boundaries
bingb0t5 Sep 15, 2026
b5b7733
no-mistakes(review): Captain: preserve home-boundary compatibility
bingb0t5 Sep 15, 2026
0f516a1
no-mistakes(review): Captain: authenticate remote control overrides
bingb0t5 Sep 15, 2026
e640f52
no-mistakes(review): Captain: document guard provenance limits
bingb0t5 Sep 15, 2026
7d6c663
no-mistakes(review): Captain: refuse cross-home spawns before state w…
bingb0t5 Sep 15, 2026
98e4e68
no-mistakes(review): Captain: align identity validation with registry…
bingb0t5 Sep 15, 2026
0153b71
no-mistakes(document): Clarify cross-home guard limitations
bingb0t5 Sep 15, 2026
fdda984
no-mistakes(ci): Captain, fixed the stale FM_PROJECTS_OVERRIDE positi…
bingb0t5 Sep 15, 2026
3155a9b
no-mistakes(review): Captain: preserve safe alternate override direct…
bingb0t5 Sep 15, 2026
726c732
no-mistakes(review): Captain: require registry-backed remote override…
bingb0t5 Sep 15, 2026
d6e03a9
no-mistakes(review): Captain: block launch-bound primary surface over…
bingb0t5 Sep 15, 2026
18a9ba1
no-mistakes(review): Captain: secure launch-bound override routing
bingb0t5 Sep 15, 2026
5c9d6c5
no-mistakes(review): Harden cross-home identity routing
bingb0t5 Sep 15, 2026
853dc28
no-mistakes(review): Document primary-only launch containment
bingb0t5 Sep 15, 2026
1c0a2cd
no-mistakes(document): Clarify option-B guard documentation
bingb0t5 Sep 15, 2026
addfe8a
no-mistakes(lint): Captain, fix lint declaration warnings
bingb0t5 Sep 15, 2026
e9410c7
no-mistakes(review): Captain: allow missing override leaves safely
bingb0t5 Sep 16, 2026
e4ed573
no-mistakes(review): Captain: reject protected-home override ancestors
bingb0t5 Sep 16, 2026
409e236
no-mistakes(document): Document protected-home override ancestry
bingb0t5 Sep 16, 2026
86de160
no-mistakes(lint): Fix ShellCheck local declaration warning
bingb0t5 Sep 16, 2026
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
1 change: 1 addition & 0 deletions .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ To move a live LOCAL secondmate onto a newly pinned harness, model, or effort wi
That plane refuses a remotely placed secondmate by name, because its agent runs on another host where none of the plane's postconditions can be read; [`docs/remote-secondmates.md`](../../../docs/remote-secondmates.md#normal-operation) owns the remote profile-change procedure.

Do not reconstruct a secondmate's whole tree from the main home.
A secondmate home's own spawn, steer, and memory commands refuse when the cross-home guard detects that `FM_HOME` names another home, so a refusal is a mispointed home to fix, never a boundary to route around ([`docs/architecture.md`](../../../docs/architecture.md) "Cross-home authority boundary").
The main firstmate reconciles only direct reports.
Each secondmate is a firstmate in its own home, so it runs recovery on startup and reconciles its own crewmates.
A secondmate's recovery reconciles only work that is already its own and then idles.
Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/stow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Every `/stow` invocation performs this complete pass, even when the session cont
The helper's stable estimate is the documented conservative local approximation, not provider-exact accounting.
If it rejects the setting or a memory file, do not infer a default or silently continue.
Report that concrete exception and do not call the session reset-safe.
The same command refuses when the cross-home guard detects that `FM_HOME` names another home, so a refusal there means the pass is aimed at the wrong home and is never something to work around (`bin/fm-home-identity-lib.sh` owns that boundary).
A primary home curating a mate it owns through the cascade below is unaffected.
2. Read every current memory file completely: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`.
Treat an absent local file as absent, not as an invitation to manufacture content.
In a primary home, all three are curation inputs under their existing ownership rules.
Expand Down
450 changes: 450 additions & 0 deletions bin/fm-home-identity-lib.sh

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@
# steer onto the durable inbox plane even when its body starts with "/" or "$";
# without it, the existing shape-based routing is unchanged.
#
# An explicit FM_HOME is required, and a steer that the shared cross-home guard
# detects as aimed at another home's tasks exits 4 before any record is written
# (bin/fm-home-identity-lib.sh owns the boundary and its limitations).
#
# After a successful TYPED-plane submit fm-send pauses FM_SEND_SETTLE seconds
# (default 1, 0 disables) before returning: submit confirmation only proves the
# text was accepted, but the harness needs a beat to spin up the turn before its
Expand All @@ -183,6 +187,8 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"

# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-home-identity-lib.sh
. "$SCRIPT_DIR/fm-home-identity-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
Expand All @@ -197,6 +203,11 @@ if [ ! -d "$FM_HOME" ]; then
echo "error: FM_HOME '$FM_HOME' is not a directory; fm-send cannot resolve this home's state" >&2
exit 1
fi
# An explicit FM_HOME is required above precisely so a steer cannot resolve
# against the wrong home by accident - but "explicit" is not "this home's".
# Refuse the cross-home case before any steering record is written
# (see bin/fm-home-identity-lib.sh).
fm_refuse_cross_home "$FM_HOME" fm-send state
if [ ! -d "$STATE" ]; then
echo "error: state dir '$STATE' is missing; fm-send cannot resolve targets for FM_HOME '$FM_HOME'" >&2
exit 1
Expand Down
26 changes: 20 additions & 6 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@
# A reservation retry for the same id is allowed only while count stays at or
# below four; --secondmate and --relaunch bypass this backstop.
# A home marked .fm-secondmate-home refuses fresh --secondmate spawns; only the
# primary home may create a domain mate.
# primary home may create a domain mate. That check reads $FM_HOME, so it is
# layered above the cross-home refusal owned by bin/fm-home-identity-lib.sh,
# which asks instead what home this PROCESS belongs to and exits 4 before any
# spawn when its guarded signals establish that FM_HOME names a different one.
# With no harness arg, a crewmate/scout spawn resolves the CREW harness only when
# config/crew-dispatch.json is absent. When that file exists, crewmate/scout
# spawns require an explicit harness so firstmate cannot silently skip dispatch
Expand Down Expand Up @@ -252,6 +255,22 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
SUB_HOME_MARKER=".fm-secondmate-home"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-home-identity-lib.sh
. "$SCRIPT_DIR/fm-home-identity-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
# Fail closed before any fleet mutation when the shared guard detects this home's
# process spawning into ANOTHER home. The primary-only domain-mate check below
# asks what $FM_HOME is; this asks what the RUNNING PROCESS is, which is the half
# a mispointed FM_HOME defeats (see bin/fm-home-identity-lib.sh).
if fm_home_identity_remote_control_overrides "$FM_HOME"; then
fm_refuse_cross_home "$FM_HOME" fm-spawn projects
else
fm_refuse_cross_home "$FM_HOME" fm-spawn state data config projects
fi
# shellcheck source=bin/fm-ff-lib.sh
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
Expand All @@ -266,8 +285,6 @@ SUB_HOME_MARKER=".fm-secondmate-home"
. "$SCRIPT_DIR/fm-browser-lifecycle-lib.sh"
# shellcheck source=bin/fm-control-lib.sh
. "$SCRIPT_DIR/fm-control-lib.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-busy-lib.sh
. "$SCRIPT_DIR/fm-busy-lib.sh"
# shellcheck source=bin/fm-cursor-lib.sh
Expand Down Expand Up @@ -325,9 +342,6 @@ fm_spawn_attention_guard() {
return 1
fi
}
# 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
Expand Down
22 changes: 18 additions & 4 deletions bin/fm-startup-memory-budget.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# Bootstrap owns default materialization; this command never creates or repairs
# configuration, so an absent, malformed, symlinked, hardlinked, or otherwise
# unsafe value is a concrete error rather than an inferred default.
# A /stow pass begins and ends here, so a sweep the shared guard detects as
# aimed at another home is refused here with status 4; bin/fm-home-identity-lib.sh
# owns that contract and its limitations.
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -20,11 +23,25 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"

# shellcheck source=bin/fm-startup-memory-budget-lib.sh
. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh"
# shellcheck source=bin/fm-home-identity-lib.sh
. "$SCRIPT_DIR/fm-home-identity-lib.sh"

usage() {
sed -n '2,11{s/^# \{0,1\}//;p;}' "$0"
sed -n '2,15{s/^# \{0,1\}//;p;}' "$0"
}

# Help stays readable from anywhere; everything else is this home's own business.
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac

# This command is the mandatory first and last step of a /stow pass, so it is
# where the shared guard stops a memory sweep it detects as aimed at another home
# before it reads that home's captain and learning records to rewrite them. A
# PRIMARY home reaching its own mates is unaffected, which keeps
# bin/fm-stow-cascade.sh working (see bin/fm-home-identity-lib.sh).
fm_refuse_cross_home "$FM_HOME" fm-startup-memory-budget data config

print_error() {
printf 'startup-memory-budget: %s\n' "$1" >&2
}
Expand Down Expand Up @@ -84,9 +101,6 @@ case "${1:-}" in
[ "$#" -eq 1 ] || { usage >&2; exit 2; }
report
;;
-h|--help)
usage
;;
*)
usage >&2
exit 2
Expand Down
14 changes: 13 additions & 1 deletion bin/fm-stow-cascade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,17 @@
# A secondmate home never cascades: secondmates do not own secondmates, so this
# command reports the empty cascade there rather than reaching for a registry.
#
# The cascade reaches the homes its own registry names, so it must run from the
# home it is sweeping: status 4 refuses a cascade the shared guard detects as
# aimed at another home (bin/fm-home-identity-lib.sh).
#
# Exit status: 0 every home reported cleanly (or there were none); 3 at least
# one home reported an exception and every home was still reported; 1 the
# cascade input itself is unusable; 2 invalid use.
set -u

usage() {
sed -n '2,47{s/^# \{0,1\}//;p;}' "$0"
sed -n '2,51{s/^# \{0,1\}//;p;}' "$0"
}

case "${1:-}" in
Expand All @@ -69,11 +73,19 @@ SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}"

# shellcheck source=bin/fm-ff-lib.sh
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-home-identity-lib.sh
. "$SCRIPT_DIR/fm-home-identity-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-timeout-lib.sh
. "$SCRIPT_DIR/fm-timeout-lib.sh"

# The cascade reads one home's registry and then steers or curates the homes it
# names, so it must run from the home it is sweeping. Refuse before any of that
# when the shared guard detects that FM_HOME names another home
# (see bin/fm-home-identity-lib.sh).
fm_refuse_cross_home "$FM_HOME" fm-stow-cascade data state

BOUND=${FM_STOW_CASCADE_TIMEOUT:-60}
case "$BOUND" in
''|*[!0-9]*) printf 'error: FM_STOW_CASCADE_TIMEOUT must be a positive integer: %s\n' "$BOUND" >&2; exit 2 ;;
Expand Down
8 changes: 8 additions & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ family_for_basename() {
fm-remote-doctor.test.sh|fm-remote-job.test.sh|fm-remote-job-orphan-reap.test.sh|\
fm-remote-reply.test.sh|fm-remote-secondmate-lifecycle-e2e.test.sh|\
fm-remote-secondmate-trace-context.test.sh|\
fm-home-identity.test.sh|\
fm-secondmate-harness.test.sh|fm-secondmate-lifecycle-e2e.test.sh|\
fm-secondmate-liveness.test.sh|fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\
fm-startup-memory-budget.test.sh|fm-stow-cascade.test.sh|\
Expand Down Expand Up @@ -985,6 +986,13 @@ families_for_changed_path() {
bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh)
printf '%s\n' session-bootstrap
;;
bin/fm-home-identity-lib.sh)
# The cross-home refusal is sourced by fm-spawn, fm-send, and both stow
# memory surfaces, so a change to it reaches all three families.
printf '%s\n' secondmate
printf '%s\n' backend-dispatch
printf '%s\n' session-bootstrap
;;
bin/fm-sessionstart-run.sh|.claude/settings.json|.codex/hooks.json|\
.pi/extensions/fm-primary-turnend-guard.ts)
# The run tier's two harness-supplied facts (source vocabulary and
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,19 @@ Independently, `fm-spawn.sh`, `fm-send.sh`, `fm-control.sh`, and `fm-teardown.sh
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.

## Cross-home authority boundary

`FM_HOME` selects which home's `data/`, `state/`, `config/`, and `projects/` a command operates on, so a secondmate process whose `FM_HOME` named a different home could operate on that home with full authority.
That has actually happened twice: a secondmate spawned a worker into the primary home, and a secondmate's memory sweep read and rewrote the primary home's captain and learning records.
`fm-spawn.sh`'s primary-only domain-mate check could not stop the first, because it inspects `$FM_HOME` - the value that was already wrong - rather than the running process.
`fm-spawn.sh`, `fm-send.sh`, `fm-startup-memory-budget.sh`, and `fm-stow-cascade.sh` therefore source `bin/fm-home-identity-lib.sh` and exit with status 4 before any spawn, steer, or memory accounting when its guarded signals establish that the selected home belongs to another process.
The refusal is one-way: a primary home keeps reaching the secondmate homes it owns, which is what the `/stow` cascade, backlog handoff, and `--secondmate` spawns depend on, while ordinary secondmate execution is refused before entering the primary home's records.
The helper's header owns the identity marker's safety rules, what corroborates a home's identity so a marker left behind in a re-leased worktree establishes nothing, the two independent signals and what each one covers, and the protected-home ancestor check for directory overrides.
This is an option-B accidental-misrouting guard rather than process provenance: a mate can unset or alter the inherited launch binding, so the code-root signal is then the only protection.
When an agent invokes the primary home's bin directory by absolute path, its launch binding provides primary-only containment: it rejects that primary home but cannot classify a non-primary selection as its own or a sibling's.
Remote secondmate sessions likewise have no authoritative own-home provenance: their launch binding identifies only the primary home, so this boundary does not prevent a remote session from selecting a same-host sibling home.
An identity marker alone is non-authoritative and establishes no invoking identity unless the local parent registry corroborates that exact code-root path.

## Two task shapes

Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks leave standalone investigation reports at `data/<id>/report.md` and never push.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc
`FM_ROOT_OVERRIDE` overrides the firstmate repo root used by scripts, including the primary checkout watched by the worktree-tangle guard.
When `FM_HOME` is unset, it also behaves as the old whole-root override.
`bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home.
The guarded spawn, steer, startup-memory, and stow-cascade commands also refuse with status 4 before accessing their protected operational surfaces when the cross-home boundary detects that a secondmate process selected another home.
The [Cross-home authority boundary](architecture.md#cross-home-authority-boundary) owns the guard signals, deliberate primary-to-secondmate direction, and option-B provenance limitation.
`FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup.
Before `fm-brief.sh`, `fm-spawn.sh`, or `fm-afk-launch.sh` persists a path or passes it to another process, it resolves each applicable relative `FM_HOME`, `FM_STATE_OVERRIDE`, or `FM_DATA_OVERRIDE` directory against the caller's working directory, preserves absolute spellings unchanged, and rejects an unresolvable relative directory with the offending variable named.
Bootstrap applies the same relative `FM_HOME` resolution only when embedding that home in the generated Relay poll shim; other transient consumers retain their existing shell-relative behavior.
Expand Down
3 changes: 2 additions & 1 deletion tests/fm-cursor-primary-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ trap cleanup_all EXIT
# A plain (non-worktree) checkout of the CURRENT working tree, so the guard
# tests the code under review rather than whatever is committed.
mkdir -p "$HOME_DIR"
(cd "$ROOT" && tar --exclude=.git --exclude=state --exclude=projects --exclude=node_modules -cf - .) \
(cd "$ROOT" && tar --exclude=.git --exclude=state --exclude=projects --exclude=node_modules \
--exclude=.fm-secondmate-home --exclude=.fm-secondmate-parent -cf - .) \
| (cd "$HOME_DIR" && tar -xf -) \
|| harness_fail "could not stage the working tree into the throwaway home"
git init -q "$HOME_DIR"
Expand Down
Loading
Loading