Skip to content

fix: prevent cross-home firstmate operations - #73

Open
bingb0t5 wants to merge 17 commits into
mainfrom
fm/fm-spawn-secondmate-fm-home-guard
Open

bingb0t5 wants to merge 17 commits into
mainfrom
fm/fm-spawn-secondmate-fm-home-guard

Conversation

@bingb0t5

@bingb0t5 bingb0t5 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Intent

CEO overview

  • What is changing: An agent home that is pointed at the wrong home now stops instead of quietly operating on it. Each home keeps its own private records - preferences, learnings, work queue, and running workers - and a mate aimed at another home could read and rewrite that home's records with no complaint. This adds a seatbelt against that mistake, refused before anything is written. It is misrouting prevention, not authentication, and the limits are written down rather than glossed over.
  • Why it matters: This actually happened twice. A mate started a worker inside the main home instead of its own, and a mate's memory sweep read and rewrote the main home's preference and learning records. Those records are the accumulated operating knowledge of the whole fleet, they are not in version control, and nothing stopped either event or reported it afterwards.
  • Customer or business impact: A mispointed mate now stops with a message naming the homes and identities involved, instead of quietly corrupting shared knowledge, which is what both incidents were. Work that was already correct is untouched: a mate still runs its own workers and sweeps its own memory, and the main home still reaches the mates it owns for the routine sweep, work handoff, and mate startup. The protection covers ordinary mate execution. It does not withstand a process that clears its own environment, and it does not extend to remotely placed homes whose identity cannot be confirmed on their own host.
  • Risk and rollout: Low and self-limiting. A refusal needs a signal that positively says the running process belongs elsewhere, and a home's identity counts only when the fleet registry confirms that exact path, so a stale marker left in a recycled working copy cannot cause a false refusal. Mates already running, and remotely placed mates, keep working on their own homes as they pick up this version rather than being locked out. Nothing is removed and no existing behaviour is relaxed.

What changed technically

The environment variable FM_HOME selects which home's data, state, config, and projects directory a command operates on, and every fm-* entrypoint resolves it identically. A secondmate process whose FM_HOME named a different home therefore operated on that home with full authority. The existing primary-only domain-mate check in bin/fm-spawn.sh could not stop the spawn incident, because it inspects $FM_HOME, which is the value that was already wrong, rather than the running process.

New library bin/fm-home-identity-lib.sh owns the refusal and how a home's identity is read for it; bin/fm-home-seed.sh still owns writing the marker. Guarded entrypoints are bin/fm-spawn.sh for every spawn kind including --secondmate, bin/fm-startup-memory-budget.sh and bin/fm-stow-cascade.sh for stow memory operations, and bin/fm-send.sh for steering. Each refuses with exit status 4 after FM_HOME is resolved and before anything is read for a write, spawned into, or steered, including before any library that would create the target's state directory.

Accepted scope, settled during review: this is an accidental-misrouting guard, not process provenance or impersonation protection, and the library's LIMIT section states that plainly. A process can unset or alter its inherited environment, leaving only code-root protection. Remotely placed homes run from their host's separate tracked code root, which carries no identity marker, so this guard neither establishes a remote session's own-home provenance nor protects a same-host sibling from it. Use of the primary home's own scripts by absolute path gets primary-only containment rather than sibling protection. The identity marker is a routing convention and never authorization on its own.

Two signals refuse, either alone. The code-root signal is the identity of the home whose bin directory is executing, resolved from the library's own physical location so FM_ROOT_OVERRIDE cannot relabel it; it covers a mate reaching the primary home and is the only signal covering a mate reaching a sibling. It counts only when corroborated: the code root must carry the .fm-secondmate-parent binding whose local parent registers that id at that exact path in data/secondmates.md. That requirement exists because the marker is gitignored and a pooled task worktree can be re-leased from a retired home with its marker still present; a retired marker is registered nowhere and establishes nothing. The launch-binding signal uses FM_PUBLIC_FOLLOWUP_PRIMARY_HOME, already stamped into every secondmate session, to contain accidental selection of the primary home through the primary's own scripts.

Directory overrides are covered too, since FM_STATE_OVERRIDE and its siblings reach the same directories FM_HOME selects. An override is refused only when it resolves inside another registry-corroborated home; an unrelated explicit directory keeps working, which preserves the alternate-directory capability the test suite exercises. Identity validation accepts exactly the character and shape contract the shared registry parser accepts, with no stricter local policy, and rejects an unsafe marker - a symlink, a directory, an empty file, a second line, or NUL bytes - rather than silently treating it as the unmarked primary.

The refusal is one-way, which protects the primary home's records from ordinary mate and worker execution while keeping deliberate correct selections working. Neither signal fires when the executing home is the primary, so the memory cascade running each mate's own accounting, backlog handoff into a mate's queue, and standing a mate up all continue unchanged. A home operating on itself is always allowed. Refusal diagnostics name the resolved identities and canonical paths involved and carry no credential or message content.

Documentation is updated without duplicating the contract: the library header owns it, docs/architecture.md gains a cross-home boundary section beside the existing gate boundary, docs/configuration.md and the four entrypoint help surfaces carry pointers, and the stow and secondmate-provisioning skills carry their trigger lines. A pre-existing truncation in the startup-memory help output is fixed in the same pass.

Also fixed, found while validating: four test fixtures built their fake code roots by copying the runner's checkout without excluding the gitignored home identity files, so running the suite from a real secondmate home handed that home's identity to the fixture.

Validation

  • Checks passed: The full no-mistakes pipeline on the final head - review, test, document, lint, push, PR, and continuous integration. Hosted checks are green: lint, both portable parallel behaviour lanes, and final-head determination. A new regression suite, tests/fm-home-identity.test.sh, covers mismatched and valid routing across each guarded interface plus sibling routing, unsafe and uncorroborated markers, override handling, and the documented limits. Before the pipeline ran, both original incidents were reproduced end to end and then shown refused, with the primary home's records verified unchanged, and the new suite was run against a build with only the refusal calls removed to confirm no assertion passes vacuously.
  • Checks not run: The complete portable serial behaviour lane was not run to completion locally; continuous integration owns that broad regression, as this repository intends, and it is skipped in this pull request's check set along with the Herdr and stock-macOS lanes, which need hardware and credentials this run did not have.
  • Evidence and limitations: Scope narrowed during review from a general cross-home boundary to accidental-misrouting prevention, and the environment-unset, remote-session, and absolute-primary-bin limits are documented in the library rather than claimed closed. Review ran twelve fix rounds and continuous integration caught one real regression, which was repaired and revalidated. Three behaviour suites fail independently of this change and are reported rather than claimed green: two fail identically on the main baseline, and one allows a one second budget that a loaded host cannot meet. Several further suites failed only in the authoring worktree, which was re-leased from a retired secondmate home and still carries its identity files; they pass in a clean clone of the same commit, and that leftover state was deliberately preserved rather than deleted.

Module-boundary decision

Current module retained: the four entrypoints keep their existing boundaries and the refusal lives in one shared helper library beside the existing gate-refusal helper, following that established pattern, so the contract is stated once and each entrypoint carries only a call and a pointer.

Decision needed

No decision required.

What Changed

  • Added a shared, fail-closed home-identity guard that detects cross-home operations through corroborated code-root identity and secondmate launch bindings.
  • Applied the guard before spawning, steering, startup-memory accounting, and stow cascades, returning status 4 for mismatched or unsafe home selections.
  • Documented the cross-home authority boundary and added regression coverage, including fixture isolation for identity markers.

Risk Assessment

⚠️ Medium: Captain, the shared authority guard is coherent and the changed entrypoints, override handling, remote-control exception, and executable regression tests align with the stated primary-only containment contract, but the new guard is broad and security-adjacent.

Testing

Live CLI validation in fresh isolated homes exercised the guard and its side-effect boundaries, with the documented test-harness gate bypass used only to reach the guard; the focused behavioral suite corroborated the results. This is a CLI-only change, so rendered UI evidence does not apply.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Code-root cross-home steering refuses before inbox publication ✅ pass live Live cross-home CLI transcript: mate-bin fm-send exited 4 with code-root; no primary inbox was created.
Code-root cross-home spawning refuses before writes ✅ pass live Live cross-home CLI transcript: crewmate and --secondmate spawns exited 4; no metadata, task data, or absent state directory was created.
Cross-home memory operations refuse before primary-memory access ✅ pass live Live cross-home CLI transcript: memory accounting and stow cascade exited 4 before measurements or cascade traversal; primary memory bytes were unchanged.
Correct mate own-home operations remain functional ✅ pass live Live cross-home CLI transcript: own-home steering wrote its inbox and own-home accounting reported role=secondmate.
Primary-led mate cascade remains functional ✅ pass live Live cross-home CLI transcript: primary fm-stow-cascade completed registered mate accounting.
Absolute-primary-bin binding refuses primary selection ✅ pass live Live cross-home CLI transcript: absolute primary-bin command with launch binding exited 4 with launch-binding before memory output.
Launch-bound sibling override is refused before publication ✅ pass live Live cross-home CLI transcript: launch-bound sibling state override exited 4 with surface-override; no sibling inbox was created.
Accepted option-B and remote own-home compatibility remains runnable ✅ pass live Live cross-home CLI transcript: accepted option-B sibling selection and legacy remote own-home accounting both exited 0.
Evidence: Live cross-home CLI transcript
Target commit: 75d246778a6503ef361ef790fa5fd8acbc1ea4e2
Temp fixture: /tmp/fm-home-guard-live-final.NAmcdw
All lifecycle CLI calls set FM_GATE_REFUSE_BYPASS=1, the project-documented test-harness bypass for the outer no-mistakes gate guard; this reaches the cross-home guard under test in copied isolated product homes.


=== Code-root misrouting: fm-send refuses before it creates an inbox ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_BACKEND=tmux FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-send.sh fm-primary-task --inbox-only attempt\ cross-home\ steer 
  error: fm-send refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a) but FM_HOME selects the 'primary' home (/tmp/fm-home-guard-live-final.NAmcdw/primary).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
[exit=4]
[PASS] exit 4
[PASS] diagnostic labels code-root signal
[PASS] no primary inbox was created

=== Code-root misrouting: fm-spawn crewmate refuses before metadata or task-data publication ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_BACKEND=tmux FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-spawn.sh worker /tmp/fm-home-guard-live-final.NAmcdw/project --mode no-mistakes --yolo off 
  error: fm-spawn refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a) but FM_HOME selects the 'primary' home (/tmp/fm-home-guard-live-final.NAmcdw/primary).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
[exit=4]
[PASS] exit 4
[PASS] no worker metadata was written
[PASS] no worker task data was written

=== Code-root misrouting: fm-spawn --secondmate refuses before it creates an absent selected state directory ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_BACKEND=tmux FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/absent-primary /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-spawn.sh newmate /tmp/fm-home-guard-live-final.NAmcdw/mate-b --secondmate 
  error: fm-spawn refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a) but FM_HOME selects the 'primary' home (/tmp/fm-home-guard-live-final.NAmcdw/absent-primary).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
[exit=4]
[PASS] exit 4
[PASS] selected state directory remains absent

=== Code-root misrouting: startup-memory accounting and stow cascade refuse before primary memory exposure ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-startup-memory-budget.sh report 
  error: fm-startup-memory-budget refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a) but FM_HOME selects the 'primary' home (/tmp/fm-home-guard-live-final.NAmcdw/primary).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
[exit=4]
[PASS] memory report exits 4
[PASS] memory report did not emit measured records
[PASS] primary memory bytes stay identical
$ env FM_GATE_REFUSE_BYPASS=1 FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-stow-cascade.sh 
  error: fm-stow-cascade refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a) but FM_HOME selects the 'primary' home (/tmp/fm-home-guard-live-final.NAmcdw/primary).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
[exit=4]
[PASS] cascade exits 4 before registry traversal
[PASS] cascade did not enumerate a mate

=== Correct own-home routing: mate writes its own inbox and accounts its own memory ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_BACKEND=tmux FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/mate-a /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-send.sh fm-mate-task --inbox-only own-home\ steer 
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ●  WATCHER DOWN - SUPERVISION IS OFF
  ●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
  ●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
  ●  This is a supervision warning only; the requested message WILL still be sent.
  ●  repair missing watcher supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds 180.
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  fm-send: doorbell did not reach fm-mate-task; the steer is durably recorded at /tmp/fm-home-guard-live-final.NAmcdw/mate-a/state/mate-task.inbox/001.msg and the watcher will re-ring
[exit=0]
[PASS] own-home send exits 0
[PASS] own-home inbox record exists
$ env FM_GATE_REFUSE_BYPASS=1 FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/mate-a /tmp/fm-home-guard-live-final.NAmcdw/mate-a/bin/fm-startup-memory-budget.sh report 
  estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate
  role=secondmate
  effective_budget_tokens=7500
  file=data/captain.md bytes=13 estimated_tokens=5 status=present
  file=data/captain-shared.md bytes=0 estimated_tokens=0 status=absent
  file=data/learnings.md bytes=15 estimated_tokens=5 status=present
  total_estimated_tokens=10
  budget_status=within-budget
[exit=0]
[PASS] own-home memory report exits 0
[PASS] own-home report identifies secondmate

=== Primary initiated operation: cascade accounts registered mate without cross-home refusal ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary FM_STOW_CASCADE_TIMEOUT=5 /tmp/fm-home-guard-live-final.NAmcdw/primary/bin/fm-stow-cascade.sh 
  role=primary
  
  secondmate=mate-a
  placement=local
  home=/tmp/fm-home-guard-live-final.NAmcdw/mate-a
  budget_report=ok
  estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate
  role=secondmate
  effective_budget_tokens=7500
  file=data/captain.md bytes=13 estimated_tokens=5 status=present
  file=data/captain-shared.md bytes=0 estimated_tokens=0 status=absent
  file=data/learnings.md bytes=15 estimated_tokens=5 status=present
  total_estimated_tokens=10
  budget_status=within-budget
  transport=direct
  reason=no recorded endpoint for this home
  
  secondmate=mate-b
  placement=local
  home=/tmp/fm-home-guard-live-final.NAmcdw/mate-b
  budget_report=ok
  estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate
  role=secondmate
  effective_budget_tokens=7500
  file=data/captain.md bytes=16 estimated_tokens=6 status=present
  file=data/captain-shared.md bytes=0 estimated_tokens=0 status=absent
  file=data/learnings.md bytes=18 estimated_tokens=6 status=present
  total_estimated_tokens=12
  budget_status=within-budget
  transport=direct
  reason=no recorded endpoint for this home
  
  secondmates=2
  exceptions=0
[exit=0]
[PASS] primary cascade exits 0
[PASS] cascade emits mate entry
[PASS] mate accounting completes

=== Absolute primary-bin session: launch binding refuses primary selection before memory read ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary /tmp/fm-home-guard-live-final.NAmcdw/primary/bin/fm-startup-memory-budget.sh report 
  error: fm-startup-memory-budget refuses a cross-home operation: the option-B launch binding names the primary home (/tmp/fm-home-guard-live-final.NAmcdw/primary) and FM_HOME selects that same primary home; it cannot identify the invoking home.
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: launch-binding]
[exit=4]
[PASS] exit 4
[PASS] diagnostic labels launch binding
[PASS] no memory measurement output

=== Absolute primary-bin session: state override into sibling refuses before sibling inbox publication ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_BACKEND=tmux FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/mate-a FM_STATE_OVERRIDE=/tmp/fm-home-guard-live-final.NAmcdw/mate-b/state /tmp/fm-home-guard-live-final.NAmcdw/primary/bin/fm-send.sh fm-sibling-task --inbox-only attempt\ sibling\ override\ steer 
  error: fm-send refuses a cross-home operation: FM_STATE_OVERRIDE selects /tmp/fm-home-guard-live-final.NAmcdw/mate-b/state inside the protected 'mate-b' home (/tmp/fm-home-guard-live-final.NAmcdw/mate-b) state surface instead of the selected 'mate-a' home (/tmp/fm-home-guard-live-final.NAmcdw/mate-a).
  error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: surface-override]
[exit=4]
[PASS] exit 4
[PASS] diagnostic labels surface override
[PASS] no sibling inbox was created

=== Option-B compatibility: launch-bound primary-bin sibling selection remains runnable ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/mate-b /tmp/fm-home-guard-live-final.NAmcdw/primary/bin/fm-startup-memory-budget.sh report 
  estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate
  role=secondmate
  effective_budget_tokens=7500
  file=data/captain.md bytes=16 estimated_tokens=6 status=present
  file=data/captain-shared.md bytes=0 estimated_tokens=0 status=absent
  file=data/learnings.md bytes=18 estimated_tokens=6 status=present
  total_estimated_tokens=12
  budget_status=within-budget
[exit=0]
[PASS] sibling accounting exits 0
[PASS] sibling report measures memory

=== Remote compatibility: remote mate runs its own accounting with legacy primary binding ===
$ env FM_GATE_REFUSE_BYPASS=1 FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=/tmp/fm-home-guard-live-final.NAmcdw/primary FM_HOME=/tmp/fm-home-guard-live-final.NAmcdw/remote-a /tmp/fm-home-guard-live-final.NAmcdw/remote-a/bin/fm-startup-memory-budget.sh report 
  estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate
  role=secondmate
  effective_budget_tokens=7500
  file=data/captain.md bytes=15 estimated_tokens=5 status=present
  file=data/captain-shared.md bytes=0 estimated_tokens=0 status=absent
  file=data/learnings.md bytes=0 estimated_tokens=0 status=absent
  total_estimated_tokens=5
  budget_status=within-budget
[exit=0]
[PASS] remote own-home report exits 0
[PASS] remote report identifies secondmate

RESULT: failures=0
Evidence: Targeted behavioral regression output
ok - stow memory accounting refuses another home and keeps valid routing
ok - an inherited identity bypass cannot disable the boundary
ok - surface overrides cannot escape the selected home
ok - remote control accepts only its authenticated endpoint layout
ok - fm-send refuses another home and keeps valid routing
ok - fm-spawn refuses another home and preserves the domain-mate boundary
ok - fm-stow-cascade refuses another home and keeps the primary sweep
ok - the secondmate session binding refuses its bound primary home
ok - an unreadable home identity refuses instead of collapsing to primary
ok - identity markers accept the registry id charset
ok - only a registry-corroborated marker establishes a home identity
ok - an own-home exception requires the canonical registered path
ok - a marker identity of primary remains distinct from marker absence
ok - startup-memory-budget help renders its full contract

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed (12) ✅
  • 🚨 bin/fm-send.sh:210 - Intent requires that “One agent home can no longer reach into another agent's home.” The new guard receives only $FM_HOME, but STATE may instead be FM_STATE_OVERRIDE: a mate can run its own fm-send with FM_HOME=/mate and FM_STATE_OVERRIDE=/primary/state; the guard permits its own home, then writes the primary task inbox via STATE. The same override pattern reaches cross-home state/data/config in the other guarded interfaces. Decide whether overrides must be constrained to the selected home; without that, the claimed boundary remains bypassable.
  • 🚨 bin/fm-home-identity-lib.sh:232 - Intent says the guard prevents one mate reaching another. In a secondmate session, invoking the primary bin by absolute path with FM_HOME=/sibling leaves the code-root signal as primary and the launch-binding check false because it rejects only when the target equals the primary binding. fm-send then operates on the sibling state. Covering this path needs a trusted binding for the invoking mate's own home or an explicitly authorized restriction.
  • 🚨 bin/fm-home-identity-lib.sh:221 - The stated criterion is that a home's identity is trusted only when the registry confirms it. The code-root check compares only IDs: a registered mate-a can select a distinct pooled/copy home containing an uncorroborated stale mate-a marker, pass the equality check, and operate on that other home's records. Require canonical home-path equality for an own-home exception, or corroborate the selected marker as well.
  • 🚨 bin/fm-home-identity-lib.sh:155 - Intent explicitly requires a marker with “more than one line” to be an error. This hunk strips all whitespace from lines 2 onward, so mate-a\n\n is accepted instead of refused. Detect any second line, including an empty one, before accepting the marker.

🔧 Fix applied.
3 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:279 - The new launch-binding check also runs after a local code root has already established that the caller selected its own home. An already-running local secondmate launched before this commit has only the pre-existing FM_PUBLIC_FOLLOWUP_PRIMARY_HOME, so its own fm-send/spawn/stow call now fails the missing FM_PUBLIC_FOLLOWUP_SECONDMATE_HOME corroboration with exit 4. Fresh remote secondmates fail too: their seeded parent record is route=remote, which this local-only corroboration rejects. This contradicts the required behavior that mates keep running their own workers and memory sweeps. Limit this extra binding requirement to the primary-code-root gap, and handle valid remote routes without refusing own-home operations.
  • 🚨 bin/fm-home-identity-lib.sh:312 - FM_HOME_IDENTITY_BYPASS=1 disables every signal before the guarded entrypoints operate. For example, a secondmate can run its own bin/fm-send.sh with that variable and FM_HOME plus FM_STATE_OVERRIDE pointing at the primary, then write the primary task inbox. The added regression test explicitly confirms this bypass. This contradicts the required fail-closed cross-home boundary; remove this operational environment escape hatch or authorize a test-only mechanism that cannot be inherited by agent commands.
  • 🚨 bin/fm-home-identity-lib.sh:155 - The marker reader captures its first line through shell command substitution, which strips NUL bytes. A marker whose bytes are mate\0-a\n becomes mate-a and passes the character check, rather than being rejected as an id outside the registry character set. Reject NUL-bearing marker files before extracting the id, as the existing parent-binding parser does.

🔧 Fix applied.
3 issues (2 errors, 1 warning) still open:

  • 🚨 bin/fm-home-identity-lib.sh:319 - The new surface-override check blocks the existing remote secondmate launch path. fm-remote-secondmate-control.sh invokes fm-spawn.sh with FM_HOME=$FM_ROOT but deliberately stores its endpoint state/data under the selected remote mate ($TARGET_HOME/state/parent-route and $TARGET_HOME/data/.parent-route); this check rejects those paths as cross-home before --secondmate is handled. Preserve this established, captain-required primary-to-mate startup path with a narrowly authenticated internal exception rather than treating its control records as arbitrary overrides.
  • 🚨 bin/fm-home-identity-lib.sh:281 - The launch-binding signal is optional and entirely inherited environment state. A secondmate using the primary bin by absolute path can run env -u FM_PUBLIC_FOLLOWUP_PRIMARY_HOME -u FM_PUBLIC_FOLLOWUP_SECONDMATE_HOME FM_HOME=<primary> <primary>/bin/fm-send.sh ...; code-root then identifies the primary and this branch is skipped, allowing a write to the primary task inbox. This contradicts the required criterion that the main home be read-only to every mate. A real fix needs provenance that the invoking agent cannot unset or forge, which expands beyond the accepted environment-binding mechanism and needs a captain decision.
  • ⚠️ docs/architecture.md:220 - The new architecture section says this helper's header owns a “test-harness bypass,” but the current fix round removed that bypass from both the helper and tests/lib.sh. Remove the stale reference so operators are not directed to a nonexistent boundary exception.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:316 - The required rollout compatibility is still broken for an already-running remote secondmate. The pre-change launch environment stamped only FM_PUBLIC_FOLLOWUP_PRIMARY_HOME; after this update, its commands run from the remote tracked code root (which has no marker), select its own remote home, and reach line 316 with the new own-home binding absent. That path exits 4 before fm-send, spawn, or stow can operate. This contradicts the required intent that a mate still run its own workers and memory sweep as it picks up this version. Preserving both legacy remote own-home operation and sibling refusal needs an approved provenance/compatibility rule.
  • 🚨 bin/fm-home-identity-lib.sh:308 - The launch binding can still be stripped by the invoking mate. A secondmate can run env -u FM_PUBLIC_FOLLOWUP_PRIMARY_HOME -u FM_PUBLIC_FOLLOWUP_SECONDMATE_HOME FM_HOME=<primary> <primary>/bin/fm-send.sh ...; the primary code root has no secondmate identity, line 308 skips the only remaining signal, and fm-send then writes the primary state inbox. The same path permits the protected memory reads through the guarded primary scripts. This contradicts the intent that the main home is read-only to every mate and worker. A durable fix requires trusted caller provenance beyond inherited environment variables, so the mechanism needs authorization.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-spawn.sh:325 - The required refusal is not before every write: the new guard at line 325 runs after fm-wake-lib.sh was sourced at line 258, and that library immediately executes mkdir -p "$STATE". A registered mate running its own bin/fm-spawn.sh with FM_HOME=<primary whose state/ is absent> therefore creates <primary>/state before exiting 4. This contradicts the intent criterion that each entrypoint refuse “before anything is written”; move the refusal ahead of the wake-library source.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:298 - Required criterion says the code-root signal is the sibling-mate protection, but a remote mate has no corroborated code-root identity: fm_home_identity_corroborated_id rejects its route=remote parent, and this launch-binding branch rejects only the remote code root. An unmodified remote mate can set FM_HOME to a same-host sibling's home and run its own bin/fm-send.sh, which then writes the sibling inbox. This is distinct from the documented env-unset limitation. Preventing it needs retained authenticated own-home provenance for remote agent sessions, beyond the accepted containment mechanism.
  • 🚨 bin/fm-home-identity-lib.sh:118 - The intent requires rejecting ids outside the registry's own [A-Za-z0-9._-] character set. This added validator also rejects any leading dot and ids longer than 128 characters, although the shared registry parser accepts both. A registry-confirmed home with such an id is therefore refused even for its own operations. Align this predicate with the registry contract, or explicitly authorize the narrower identity-id policy.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • 🚨 bin/fm-home-identity-lib.sh:233 - The new surface-override branch refuses any override not exactly equal to $FM_HOME/<surface>, including a primary using a separate non-home projects directory. Before this change, tests/fm-spawn-batch.test.sh exercised FM_HOME=<temp home> FM_PROJECTS_OVERRIDE=<separate temp projects> and verified normal project resolution; this branch changes the fixture to <home>/projects to avoid the refusal. That removes the documented alternate-directory capability for tests and specialized harnesses, contradicting the intent's “Work that was already correct is untouched” and “Nothing is removed” criteria. Decide whether overrides are now intentionally canonical-home-only, or preserve safe alternate directories while refusing overrides that resolve to another protected home.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-home-identity-lib.sh:208 - Intent requires that a home's identity is trusted only when its fleet registry confirms it and that a stale marker cannot cause a false refusal. This new helper accepts any readable .fm-secondmate-home plus route=remote parent binding as a protected home without registry corroboration. A primary using a valid separate FM_DATA_OVERRIDE=/tmp/recycled/data, where /tmp/recycled merely retains a retired remote marker and parent record, is refused by fm-startup-memory-budget.sh even though the override is unrelated to an active home. Decide whether a remote marker alone is now sufficient authority for override blocking, or retain the stated registry-only rule; the narrower intent-compliant remedy is to avoid treating uncorroborated remote markers as protected override targets.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:232 - A normal local mate session can still access the primary through an override when it invokes the primary bin by absolute path. fm-spawn stamps FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=<primary> while retaining FM_HOME=<mate>; with FM_STATE_OVERRIDE=<primary>/state, this code checks the mate's data registry at line 232, does not recognize the primary surface, and returns success. fm-send then writes the primary task inbox. The analogous data/config overrides let startup-memory accounting read the primary's preferences and learnings. Extend protected-surface recognition to the corroborated launch-bound primary when code-root identity is unavailable.
  • 🚨 bin/fm-home-identity-lib.sh:278 - Required intent says: “Diagnostics name both home paths, both identities, and which signal fired.” For a surface-override refusal, this new reason names only the override and selected paths/surface, not either home identity. For example, mate-a selecting sibling/state produces no mate-a or sibling id. The generic second line also lacks them. The changed hunk at this line therefore contradicts that required diagnostic contract; approve adding identity resolution to this diagnostic.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:235 - A launch-bound local mate can still steer or spawn into a sibling through an override when it invokes the primary bin by absolute path. With FM_HOME=mate-a, FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=primary, and FM_STATE_OVERRIDE=sibling/state, code-root identity is primary, the binding only checks primary/state, and the probe consults mate-a/data/secondmates.md rather than the bound primary registry. fm-send then writes sibling's inbox without refusing. Use the bound primary as the registry/expected parent when code-root provenance is unavailable but the launch binding is present.
  • 🚨 bin/fm-home-identity-lib.sh:286 - The required diagnostic criterion says refusals name both home paths and identities. A surface-override refusal only reports the selected path and override path; it omits both identities and does not render the protected home's root path. For example, mate-a selecting sibling/state produces neither mate-a nor mate-b. Add resolved selected/protected identities and both canonical home paths to this diagnostic.

🔧 Fix applied.
4 errors still open:

  • 🚨 bin/fm-home-identity-lib.sh:177 - primary is valid under the registry ID regex but is also used as the unmarked-home sentinel. A locally registered mate whose marker contains primary is never corroborated at this check; after its inherited launch binding is unset, it can select the real primary home and steer/spawn there. Track marker presence separately from its ID, or reserve primary in the registry contract.
  • 🚨 bin/fm-home-identity-lib.sh:217 - The remote-route check does not return when the parsed parent route is local. In the if caller, a local parent binding paired with a matching remote registry row passes the later checks, activates the remote-control exception, and skips state/data/config surface protection. Make this predicate fail immediately unless route=remote.
  • 🚨 bin/fm-startup-memory-budget.sh:30 - Intent requires that the startup-memory help-range truncation be fixed. The changed range ends at line 14, but the header continues through line 15, so --help still omits “owns that contract and its limitations.” Confirm the intended help boundary before changing it.
  • 🚨 bin/fm-home-identity-lib.sh:383 - Intent requires diagnostics to name both home paths and identities. The launch-binding refusal reports only the bound primary path and no invoking-home identity - precisely the absolute-primary-bin route this signal covers. Meeting that requirement needs retained invoking-home provenance, or an explicit narrowing of the diagnostic criterion.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-home-identity-lib.sh:390 - Captain, the absolute-primary-bin route still permits a mate to operate on a sibling by directly selecting it: a spawned mate has FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=<primary>, invokes <primary>/bin/fm-send.sh (or spawn/memory/stow) with FM_HOME=<sibling>, and uses no surface override. At line 390 the launch-binding signal refuses only when the selected home equals the bound primary; the primary code-root is unmarked, so line 377 establishes no mate origin and the sibling operation proceeds. This contradicts the required criterion that “One agent home can no longer reach into another agent's home.” The current binding cannot distinguish the invoking mate from a sibling, so a durable origin binding or an explicit narrowing to primary-only containment is needed; authorize which contract to preserve.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
A mate selecting the primary or sibling home is refused before memory reads, steering, either spawn kind, or cascade work ✅ pass live Live isolated CLI transcript
A mate cannot escape its selected home through FM_STATE_OVERRIDE ✅ pass live Live isolated CLI transcript
A secondmate bound to the primary is refused even when invoking the primary code path ✅ pass live Live isolated CLI transcript
Unsafe multiline identity markers refuse locally while an unregistered recycled marker does not create a false origin ✅ pass live Live isolated CLI transcript
Valid own-home accounting and steering still work, and the primary can cascade accounting to its registered mate ✅ pass live Live isolated CLI transcript
Startup-memory-budget help renders its complete boundary sentence ✅ pass live Live isolated CLI transcript
  • bash tests/fm-home-identity.test.sh
  • Isolated live CLI drive of fm-startup-memory-budget.sh, fm-send.sh, fm-spawn.sh (crewmate and --secondmate), and fm-stow-cascade.sh from primary, mate, sibling, and recycled-marker homes
  • Isolated live fm-startup-memory-budget.sh --help check

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Code-root cross-home steering refuses before inbox publication ✅ pass live Live cross-home CLI transcript: mate-bin fm-send exited 4 with code-root; no primary inbox was created.
Code-root cross-home spawning refuses before writes ✅ pass live Live cross-home CLI transcript: crewmate and --secondmate spawns exited 4; no metadata, task data, or absent state directory was created.
Cross-home memory operations refuse before primary-memory access ✅ pass live Live cross-home CLI transcript: memory accounting and stow cascade exited 4 before measurements or cascade traversal; primary memory bytes were unchanged.
Correct mate own-home operations remain functional ✅ pass live Live cross-home CLI transcript: own-home steering wrote its inbox and own-home accounting reported role=secondmate.
Primary-led mate cascade remains functional ✅ pass live Live cross-home CLI transcript: primary fm-stow-cascade completed registered mate accounting.
Absolute-primary-bin binding refuses primary selection ✅ pass live Live cross-home CLI transcript: absolute primary-bin command with launch binding exited 4 with launch-binding before memory output.
Launch-bound sibling override is refused before publication ✅ pass live Live cross-home CLI transcript: launch-bound sibling state override exited 4 with surface-override; no sibling inbox was created.
Accepted option-B and remote own-home compatibility remains runnable ✅ pass live Live cross-home CLI transcript: accepted option-B sibling selection and legacy remote own-home accounting both exited 0.
  • bin/fm-session-start.sh
  • Fresh isolated copied-home live CLI fixture: fm-send.sh, fm-spawn.sh, fm-spawn.sh --secondmate, fm-startup-memory-budget.sh, and fm-stow-cascade.sh
  • FM_GATE_REFUSE_BYPASS=1 bash tests/fm-home-identity.test.sh
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

⚠️ **Lint** - 0 issues

✅ No issues found.

  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

bingb0t5 and others added 17 commits September 15, 2026 09:23
FM_HOME selects which home's data/, state/, config/, and projects/ a
command operates on, so a secondmate process whose FM_HOME named another
home operated on that home with full authority. That 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'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.

bin/fm-home-identity-lib.sh is the new owner of home identity and of the
refusal. fm-spawn, fm-send, fm-startup-memory-budget, and fm-stow-cascade
source it and exit 4 before any spawn, steer, or memory accounting when
the selected home is not this process's own. It fails closed on either of
two independent signals: the executing code root's own corroborated
secondmate identity, which also covers a sibling mate; and the
FM_PUBLIC_FOLLOWUP_PRIMARY_HOME session binding, which covers a mate that
invokes the primary home's own bin/ by absolute path. An identity marker
counts only when the home's durable parent binding and that parent's
registry place the same id at the same path, so a marker left behind in a
re-leased pool worktree establishes nothing.

The refusal is one-way: a primary home keeps reaching the mates it owns,
which the stow cascade, backlog handoff, and --secondmate spawns depend
on, while the primary home's own data stays read-only from every mate and
worker.

Also fix a test-hermeticity bug found on the way: the remote-secondmate
and cursor-primary fixtures built their fake code roots by tarring the
runner's checkout without excluding the gitignored home identity files, so
running the suite from any real secondmate home handed that home's
identity to the fixture and tripped the pre-existing primary-only guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRrBY9ykN3yGy1N2QbYzHh
…ve fixture in tests/fm-spawn-batch.test.sh to use the selected home’s canonical projects directory. Focused batch and home-identity tests pass, along with bash syntax and diff checks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant