fix(worker): admit the compartment payload pair so a secondmate leg can dispatch - #284
Merged
Conversation
ruby-dlee
force-pushed
the
fix/compartment-payload-bounds
branch
from
August 21, 2026 03:16
781174e to
fd73530
Compare
…an dispatch bin/fm-spawn.sh stages two extra files for a KIND=secondmate spawn (the session runner and the spawn-intent pi extension) that PAYLOAD_FILE_BOUNDS never admitted, so staged_directory_manifest refused every compartment leg dispatch with "payload staging entry is not in the reviewed set: fm-secondmate-session.py". The reviewed-set check is correct; the allowlist was incomplete. Split the payload contract by lane instead of flattening one set: the ordinary crewmate lane keeps exactly repo.bundle and brief.md, and a new compartment set adds the two files with their own bounds. The lane is chosen from the worker's durable role under the controller lock, never from the payload's own contents. Both compartment files are required, not merely admitted, because the leg argv runs the runner and passes --pi-ext. Adds a structural guard so the producer and the validator cannot drift again: the test extracts every basename fm-spawn.sh writes into the payload directory and fails if the bounds do not admit it.
…il closed The structural guard shipped in the previous commit recognized only a literal basename written immediately after the literal payload directory path, so it was syntax-shaped: `.../cloud-payload/$NAME` and the ordinary `cp src .../cloud-payload/` idiom both staged an unadmitted file while the guard printed green. A guard defending against producer/validator drift that recognizes only today's spelling is the same class of weakness as the defect. The authoritative check is now effect-shaped and sits where a real fm-spawn.sh has just produced a real payload directory: it runs the production validator over the actual staged contents, so any staging spelling is caught. The static guard is kept as the cheap companion that names the offending line, but now fails closed: it classifies every payload-directory occurrence and refuses the ones it cannot read, rather than silently skipping them. Also pins the THIRD encoding of the same two names, the compartment monitor's leg argv, to the reviewed set in both directions.
ruby-dlee
force-pushed
the
fix/compartment-payload-bounds
branch
from
August 21, 2026 03:44
fd73530 to
7acdce4
Compare
The role-disagreement check added with the lane split was the one new behavior in this change that no test could distinguish from its own absence: deleting it outright left both suites fully green. That is the same failure mode this whole effort has been closing, so it gets the same treatment as the rest. Reaches the refusal through the real CLI by editing ONLY the queue item in controller.json, leaving the worker record and its cloud-attested VM tags intact, which is exactly the drift the check fails closed on. Covers a disagreeing role and an absent one, asserts neither wedges the slot with a durable claim, and carries a positive control so the refusals are attributable to the disagreement and nothing else.
The static guard's comment claimed a payload-directory reference "stages nothing". That is a property of today's callers, not of the form: tar -C, cd, and cp -R src/. all name the directory as a whole and do stage. Replaces the assumption with the scope the guard actually has, and names the real control (staged_directory_manifest at dispatch) so a later reader does not mistake a drift detector for a security boundary.
ruby-dlee
added a commit
that referenced
this pull request
Aug 21, 2026
… mirrors #284 added a second worker-versus-item divergence check, on role, inside command_execute's controller lock. This branch already had one, on task_home, in the removal receipt. Both read a drifted pair as untrustworthy and both fail closed; they differ only in what closed means for their lane, because execute must not run while a removal that does not happen leaves a credential on disk to be found. Neither can admit a state the other refuses: different fields, different commands, and neither widens what the other allows. Comment only. The receipt writes no controller state and takes no lock, and the one call sitting inside a lock hold is on the idempotent surrender path where write_surrender_output already writes a file at the same point.
ruby-dlee
added a commit
that referenced
this pull request
Aug 21, 2026
…d it (#283) Since the compartment-child task-home split, a child staged its plaintext provider credential under the COMPARTMENT's home, while withdraw and surrender re-derived the PRIMARY's state directory. They removed a path that never existed, reported success, and the credential stayed behind with nothing left that would ever take it. Removers no longer infer a home. Each is handed the state directory of the home whose task it is, and the per-task cloud file set is enumerated once. Two designs were rejected along the way and both rejections are load-bearing. An id-keyed record was destructive: task ids are home-scoped, so a stale record redirected an unrelated primary task's teardown into a compartment and would have removed a live child's state while leaving the leak intact. And carrying the value on a parsed line inside a mixed stdout stream was replaced with a dedicated `--task-home-out` file: one writer, one reader, no shared stream, a random exclusively-created name that cannot be pre-planted. Reviewed adversarially twice. The first pass found six issues; the second verified each fix by execution and confirmed the trust boundary was removed rather than relocated. Absorbed before merge: the post-removal audit no longer derives its subject from the resolution it audits, which is why the original leak was silent rather than loud; the teardown coverage is now a real run-time seam that executes the shipped loop against a recorder writing outside the home, so all four known guard escapes go red; the audit no longer hangs on a FIFO; and the enumeration checker no longer flags operator-advice strings or `rsync --delete`. Corrected rather than defended: a claim that both guard escapes were caught when one still passed, a claim that no reachable teardown path leaves the home alive when five do, and a doc line added by this change describing the very mechanism it removed. Rebased onto #284 and the admit-red mutations were re-driven on the rebased head, not merely the suite re-run. That discipline caught a real regression on the previous rebase.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
A live secondmate compartment (
azaccept) was admitted, assigned slot 2, and booted. Its first session leg then refused with exactly:The producer and the validator disagreed about what a compartment stages.
bin/fm-spawn.sh:4467-4468copies two extra files into the payload for aKIND=secondmatespawn:fm-secondmate-session.pyandfm-secondmate-spawn.pi-ext.ts.bin/fm-worker-lifecycle.pyPAYLOAD_FILE_BOUNDSadmitted onlyrepo.bundleandbrief.md, andstaged_directory_manifestraises for any staged entry with no bound.Every compartment leg dispatch has always refused. R2/R3 could never have worked. The reviewed-set check is correct; the allowlist was incomplete, so this PR does not weaken or remove that check.
The three design decisions
1. Bounds:
256 KiBand64 KiBfm-secondmate-session.pyfm-secondmate-spawn.pi-ext.tsThe allowlist bounds what can be staged onto a worker, so a generous bound silently weakens a security control. Both numbers are chosen so that headroom buys against ordinary source growth and nothing else:
256 KiB, the same ceilingbrief.mdalready carries. It is the one file here under active development, and a bound that a normal year of growth can trip is an outage waiting to happen.256 KiBis still small enough that nothing but source text fits.64 KiB, a quarter of the text ceiling. It declares one spawn intent and is not expected to grow like the runner, so it gets the tighter number rather than inheriting the runner's.Both are three orders of magnitude below
repo.bundle's 512 MiB, so the marginal widening of the total staging surface is about 0.06 percent. The suite pins both numbers and asserts each file actually fits under its own bound today, so a bound can never be quietly set below the file it exists to admit.2. Split by lane, do not flatten one set
PAYLOAD_FILE_BOUNDSstays exactly{repo.bundle, brief.md}. A newCOMPARTMENT_PAYLOAD_FILE_BOUNDSadds the two files, andpayload_contract(role)selects between them.Admitting compartment-only files into one universal set would widen what any worker may receive, including ordinary crewmates that have no business receiving a session runner. The split costs one function and one dict; it buys keeping the ordinary lane exactly as narrow as it is today. That is the cheaper side of the trade by a wide margin.
The lane is chosen from the worker's durable role, resolved under the controller lock with the queue item and worker record in hand, never from the payload's own contents. A payload must never select the rules it is judged by. This moved the manifest computation from before the inventory call to inside the lock, which is why
command_executeshows a hunk move;create_worker_recordcopies the item's role onto the worker, so the two are cross-checked and a disagreement fails closed.That role is cloud-attested, not merely local JSON.
expected_tagsbindsfirstmate-role: secondmate-compartmentinto the VM's own cloud tags when the worker's role issecondmate;resources_exactcompares those tags against what the cloud actually reports and returns a mismatch reason;classify_workeronly reachesreturn "assigned"after that comparison passes; andcommand_executerefuses anything not classifiedassignedbefore it ever reads the role. So selecting the lane fromworker["role"]is backed by the VM's attested tags, which is a materially stronger guarantee than "resolved under the controller lock" alone. (Chain verified by readingbin/fm-worker-lifecycle.pyend to end, not assumed.)3. Both files are REQUIRED, not merely admitted
The compartment monitor's leg-1 argv runs
fm-secondmate-session.pyand passes--pi-ext .../fm-secondmate-spawn.pi-ext.tsby literal path. A compartment whose staging silently lost either file would dispatch a leg that cannot possibly work, and the failure would surface on a booted VM instead of at the controller.staged_directory_manifestalready takesrequired=(), so making them required costs nothing and turns a silent broken leg into a loud local refusal.The deeper problem: three places, one contract
This is the same shape as the other drift defects. The anti-recurrence control is now effect-shaped, with a static companion.
These guards are DRIFT DETECTORS, not the security control. An earlier revision of this body claimed the effect-shaped check catches an unadmitted file "no matter how the staging was written". That is overstated and review proved it: a conditional non-literal site injected into the real
fm-spawn.shslips both controls. It is text-blind to the static guard, and invisible to the effect-shaped one because that scenario never makes the condition true. Five of eleven attempted bypasses defeat the static guard, all of them forms that refer to the directory as a whole (variable alias,
tar -C, subshellcd,cp -R src/., aPDIRvariable). My comment calling such a reference "the directory ITSELF: stages nothing" was an assumption rather than a fact, and is corrected in this PR.What they actually do: the effect-shaped check catches an unadmitted file no matter how the staging was written within the code path its scenario exercises, and the static guard fails closed on the sites it can read while making no claim about the ones it cannot.
The real control is elsewhere and is closed independently of both.
staged_directory_manifestrefuses any unadmitted entry at dispatch however it was spelled, andstaged_directory_archiveiterates only manifest entries and re-verifies sha256 and bytes before anything leaves the controller. Dotfiles are skipped by the validator but manifest-bound in the archive, so there is no hole there either. A future drifting staging site therefore reproduces the outage class this PR fixes, not unadmitted bytes reaching a guest.The effect-shaped check.
tests/fm-secondmate-cloud-monitor.test.shalready stages a real compartment payload through the realfm-spawn.sh. It now also runs the production validator over the actual staged directory, so it bounds what may be present at all rather than naming files it expects.The static guard is kept, but fails closed. The first version of this guard was syntax-shaped and it was wrong. It matched only a literal basename written immediately after the literal directory path, so two spellings staged an unadmitted file while it printed green:
The second is the ordinary "copy into the directory" idiom, no contrivance at all. A guard whose whole purpose is catching drift, that recognizes only today's syntax, is the same class of weakness as the four defects it exists to catch. It now classifies every occurrence of the payload directory and goes red on any it cannot read, so its silence is never ambiguous between "nothing unadmitted" and "I could not parse that".
The third encoding is closed too.
bin/fm-secondmate-cloud-monitor.sh:278,282names these same two files by absolute guest path in the leg argv. Producer, validator and consumer are three places holding one contract. The guard now pins the argv basenames to the reviewed set in both directions: the argv cannot name a file the bounds do not admit, and cannot name a filefm-spawn.shdoes not stage.I still did not move the names into a shared data file that
fm-spawn.shparses. Reasons unchanged and open to being overruled: the bounds are a security control currently reviewed as literals directly above their enforcement point, andfm-spawn.shstages with plaincpinside aumask 077subshell with no interpreter in the hot path. With an effect-shaped check over the real directory plus a fail-closed static guard plus the argv pinned, the drift class is covered without that coupling.Consequences of moving the manifest under the lock
Resolving the lane from the worker's durable role means the manifest is now computed inside the controller lock instead of before the inventory call. Two measured consequences, both judged non-blocking in review, disclosed here so a reader does not have to rediscover them:
payload staging entry exceeds its byte bound: repo.bundleand contacted no provider; head refuses withexecute requires one exact assigned task generationafter aninventoryround-trip. (Measured by the reviewer; it follows directly from the reordering.) A caller sees a different, still-correct refusal and one extra read-only provider call.repo.bundleat its full 512 MiB bound measures 0.275s on this host (independently measured here; the reviewer measured 0.22s). Callers wait on the lock rather than fail, and the refusal precedesmake_action,slot_leaseandclaim_pending, so no slot is ever wedged by it.Proof, executed
All runs used
env -iwithFM_*/AZURE*/ARM_*scrubbed (26 such vars were live in the ambient shell), no~/.fm-azure/fleet.env, and the fixture provider.az vm list -g rg-firstmate-pilot-eastus-001returns[], count0: nothing was created.1. Reproduced before fixing, driving the real
staged_directory_manifestover a fixture payload holding all four names at the live byte sizes:2. Passes after the fix, manifest carrying all four entries:
The suite additionally drives a real
executeon a genuinely assignedrole=secondmatecompartment and asserts the digest-bound request the provider receives carries all four entries with matching sha256/bytes, so the newcommand_executewiring is covered by an executed path and not only by the helper.3. ADMIT-red mutations.
Mutation A, remove the two new bounds entries. Red in three independent places, each the defect itself:
bin/fm-spawn.sh stages payload entries the reviewed set does not admit: ['fm-secondmate-session.py', 'fm-secondmate-spawn.pi-ext.ts']lifecycle.LifecycleError: payload staging entry is not in the reviewed set: fm-secondmate-session.pyAssertionError: ELASTIC WORKER REFUSED: payload staging entry is not in the reviewed set: fm-secondmate-spawn.pi-ext.ts, the live azaccept string.Guard bypasses, the two that were proven to slip past the first version. Both now go red in both guards, each naming the unadmitted file:
.../cloud-payload/$EVIL_NAMEcannot classify these bin/fm-spawn.sh sites ... line 4469: EVIL_NAME=evil.sh; cp ...fm-spawn.sh staged ['evil.sh'] into the compartment payload, which the reviewed set does not admitcp src .../cloud-payload/cannot classify these bin/fm-spawn.sh sites ... line 4469: cp "$SCRIPT_DIR/fm-brief.sh" "$STATE/$ID.cloud-payload/"fm-spawn.sh staged ['fm-brief.sh'] into the compartment payload, which the reviewed set does not admitMutation B, stage a literal unadmitted name (
fm-secondmate-tools.json): red naming it.The role-disagreement refusal, which nothing pinned until now. Review deleted the check outright and both suites stayed fully green (37/37 and 50/50), so the one new behavior in this change was indistinguishable from its own absence. It is now reached through the real CLI by editing only the queue item in
controller.json, leaving the worker record and its cloud-attested VM tags intact, which is exactly the drift it fails closed on. Two independent mutations prove it load-bearing:a worker/item role disagreement (author) was not refuseda worker/item role disagreement (None) was not refusedThe second is the subtle one: it still refuses a disagreeing role and is caught only by the absent-role case. The unit also asserts neither refusal leaves a durable claim on the slot, and carries a positive control so the refusals are attributable to the disagreement and nothing else.
Mutation C, rename the file in the monitor's leg argv only, leaving the producer alone:
the compartment leg argv names files the reviewed set does not admit, so the guest would be told to run something that never travels: ['fm-secondmate-spawn.ext.ts']4. Byte bounds still bind, at bound and one past it:
An unreviewed name (
id_rsa) is still refused in the compartment lane too: the set widened by exactly two names, it did not open.5. The ordinary crewmate lane is unchanged. Emitting the author-lane bounds, required set, and manifest over a byte-identical fixture on base and head and diffing them: identical, no output from
diff -u. Separately, the base and head assertion lists differ by exactly one added line, with all 36 pre-existing assertions passing on head, includingend_to_end_lifecycle, which runs the real crewmateexecutewith--payload-dirthrough the reordered code.6. Suites run via
python3 tests/run-one.py <test>, never barebash.tests/fm-worker-lifecycle.test.sh: green on base (36) and on head (37).tests/fm-secondmate-cloud-monitor.test.sh: green on head (50 assertions), carrying the new effect-shaped check.tests/fm-worker-supervisor.test.sh: green, including "the supervisor stages digest-bound payload, account, and repository exactly".tests/fm-lint.test.sh: green with the pinned ShellCheck 0.11.0 resolved.shellcheck --norc -xon the changed test file is clean on base and head.HOMEand scrubbing only the dangerousFM_*/AZURE*/ARM_*vars withenv -u, rather than blanketenv -i, both previously unrunnable suites pass. The earlierHOME: unbound variablefailures were purely an artifact of the blanket scrub, and reproduced identically on base.Rebased onto #280
Rebased onto
d8154a61after #280 merged. It applied with no conflicts: #280's account-pool work sits above my hunks inbin/fm-worker-lifecycle.py, and its test units are separate from mine. A clean rebase is not proof of semantic compatibility, so the assumption my change actually depends on was re-verified rather than assumed:create_worker_recordstill writes"role": item.get("role", "author")onto the worker record, which is what the lane selection reads under the lock. feat(worker): lease one Pi account per placement so concurrent crewmates never collide #280 changedaccount_bindingtoaccount_pool_homearound it, but did not touchrole.executeon a genuinely assignedrole=secondmatecompartment was re-run on top of feat(worker): lease one Pi account per placement so concurrent crewmates never collide #280 and re-proven load-bearing: with the two bounds entries removed and the unit block unregistered, the real CLI still refuses withELASTIC WORKER REFUSED: payload staging entry is not in the reviewed set: fm-secondmate-spawn.pi-ext.ts.fm-worker-placement,fm-spawn-cloud,fm-worker-supervisor,fm-secondmate-cloud-monitorandfm-worker-lifecycleall pass on the rebased head.Scope
Touches
bin/fm-worker-lifecycle.py,tests/fm-worker-lifecycle.test.sh, andtests/fm-secondmate-cloud-monitor.test.sh.The effect-shaped assertion lives in
tests/fm-secondmate-cloud-monitor.test.shbecause that is the only place in the repo where a realfm-spawn.shproduces a real payload directory, and the whole point of the finding is that the check must inspect the effect rather than the syntax. #280 previously held that file and has now merged, so there is no longer any ownership contention.The
accountstaging call passes onlytotal_boundand no per-namebounds, so it has no allowlist of this shape and cannot fail the same way. It is deliberately unchanged.