Repository navigation
T41 — Bound a root-in-VM spoofer to the resident union - #1792
norrietaylor merged 6 commits into
Conversation
A root process in the VM that spoofs another box's address is decided by the row that holds the spoofed source — that box's own declaration, never the union — so its reach stays inside the union of resident boxes' declared egress plus the node-plane baseline set (NET-085). The pure case records every spoofed attempt and prints the verdicts in the gate's drop-line shape, grouped by source, so the bound can be read off; where the shipped announced interim differs from the bound's shape, both arms are pinned, with the flips that turn the interim's admits into drops named (T66 #1711, baseline phase #1786).
Add the VM-boundary integration test for the escape bound (NET-085), deferred to the vm lane with the rest of the libkrun e2e suite: a spoofer speaking the shuttle's own protocol at the egress gate socket, wearing made-up in-plan leases (admitted end-to-end by the shipped interim, whose lines name each source) and an address outside the plan's lease block (refused outright under either phase), with the positive controls proving each resident box's own declared traffic arrives. Path (a) - the guest's own interface - is asserted as its precondition: the box holds no CAP_NET_RAW/CAP_NET_ADMIN, so the raw route around the relay's lease check does not exist; the relay-side bound itself is minimald's relay_rejects_non_lease_source.
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
Reviewed: 1 finding, none blocking at Address the comments on the diff. Clobber: none: |
There was a problem hiding this comment.
Review at cebac24 against the T41 plan block and NET-085 (spec 18 line 590). The PR adds no production change; its thesis, that the gate's per-row decision already bounds a spoofer to the spoofed box's own reach, holds, and the unit case drives the real gate_verdict under both unregistered-source arms and both baseline phases with genuine per-pair pins. The two Announced-phase gaps (in-plan unrowed sources, the node row's allow-all) are pre-existing and honestly stated; the flips are tracked in #1790 and #1786. Size: plan est 60 code and 400 tests, PR is 1757 test lines.
Three asks inline, none merge-blocking, but the first should land before the deferred VM lane ever runs: NET-085's own verify line currently asserts that an in-plan spoof succeeds, so it turns red at the flip it names. Design question (per-row decision vs a materialized union) is with ARCH-EXPERT.
|
/revise Round 1, bounded to the three threads on review 5380601783. Nothing else is in scope for this round.
Re-run the plan's unit test line at the new head. |
Address the review findings on the root-in-VM spoofer's two lanes: - BoxSession::open mints a unique session name per invocation, the way minimald_session_integration.rs does: minimald dedups sessions by name and rejects a duplicate CreateSession, and a record persists once created even when a later step of the open fails, so the open_box retry loop could never recover a transient failure past CreateSession under the fixed name it used to collide on. - Guest::boot waits 150 s for the vm-up line, the justfile's exported MINVMD_READY_TIMEOUT_SECS, instead of 15 s: the line comes only after the daemon's own READY wait completes, and a cold boot runs 40-70 s before pid-1 starts (the AGENTS.md boot footgun). - the unit case's verdict_of lookup keys the port too, so a pinned verdict is matched by the whole attempt record, never a prefix of it.
|
Round 1 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A root process inside the VM that spoofs another box's address reaches only destinations in the union of resident boxes' declared egress plus the baseline set — and the harness that shows it can now actually run: the VM-boundary test's open retry survives a transient failure past CreateSession, its boot wait covers a cold 40–70 s boot, and the unit case pins every verdict by the full attempt record (phase, source, protocol, destination, port). Notes from the agent: Revise round 1, all three review findings judged valid and fixed in one commit (99de220), both files inside the task's layers. (1) BoxSession::open now mints a unique session name per invocation (nanos suffix, the minimald_session_integration.rs convention), so open_box's six-attempt retry can no longer collide on AlreadyExists after any prior attempt persisted a record. (2) BOOT_TIMEOUT is 150 s — the justfile's exported MINVMD_READY_TIMEOUT_SECS — so the vm-up wait no longer kills boot --foreground before its own READY wait (60 s default) can finish on a cold boot. (3) verdict_of keys the port too; the locked box's UDP-53/TCP-53 pins are now matched by the whole record. Deferred (lane: vm): cargo nextest run -p minvmd vm_escape_bounded_to_resident_union — the test exists from round 0 and was not run; this box lacks the lane, and the two harness fixes above are exercised only on it. Out of scope but worth a next task: the sibling minimald_session_integration.rs carries the same 15 s BOOT_TIMEOUT shape. cargo clippy -p minvmd --all-targets --locked -- -D warnings clean (1m32s); cargo fmt applied, no drift outside the two layers. just clippy-strict not run (it scopes to --workspace on Linux and would pay for every crate); the changed lines carry no strict-lint constructs — the strict-sensitive .expect and assert lines remained context in the diff. |
|
/revise Round 2 at 99de220 (round 1 closed the runtime review's three findings; my three threads from review 5380601783 are still open), from the ARCH-EXPERT design ruling (PASS-with-conditions; the per-row decision is design §4.3 rule 2's stated reading). Bounded to the items below; nothing else is in scope for this round.
Re-run the plan's unit test line at the new head. |
Extend the in-force universal the resident-union test makes cheap: no attempt decided against the in-force baseline set carries an interim arm's verdict, and every admit any phase gave a frame is the row that holds the source's own doing — AdmittedByRow only when table.by_source (src) is the row whose rules admit the destination, never another box's row. The per-pair pins name the destinations; the universal binds the shape over the whole record, so a future attempt is covered without being pinned. Beside the node-row attempt rows, record that the node row stands in for the host_ip cohort's identity today (NET-078) and what the table gains once the cohort address exists (design §4.1): the cohort's any-admits rule across live members and a revoked member's destinations admitting no longer. Sharpen the thesis to the gate's own bound: a spoofer is bounded to the spoofed box's own reach; the resident union is what an attacker assembles by choosing sources (design §4.3 rule 2, §8). And give the out-of-plan source a literal of its own — it shares nothing with the outside destination but a TEST-NET spelling.
- e2e_enabled skips when MINVMD_GVPROXY_BIN is absent, like the MINVMD_E2E != 1 arm: the harness lanes that run the ignored tests (test-kvm, the macOS harness step) set MINVMD_E2E=1 but fetch gvproxy only for the session-e2e step after them, so the assert on the variable reddened the lane; the bound is proved where the switch is provided and the lane stays green where it is not. - the in-plan spoof arm is phase-tolerant: on a refusal it requires the silence and the unknown-source line naming the source and records that as the in-force verdict, so T66's flip (#1711) strengthens the test instead of breaking it; the Ok arm keeps today's interim assertions. - the SessionPolicy literal is built as a struct literal, which the macOS unit lane's clippy -D warnings asks of field-reassignment after Default::default().
|
Round 2 of 10 on a /revise note, pushed as
Verify Review threads the coder judged:
A root process inside the VM that spoofs another box's address is decided by the row that holds the spoofed source — bounded to that box's own reach, and so inside the union of resident boxes' declared egress plus the baseline set — read off the unit record's printed drop lines grouped by source, asserted now as a universal over the whole record (no in-force attempt carries an interim arm's verdict; every admit is the source's own row's decision, never another box's), and driven at the VM boundary by a test that skips green where no switch is provided and flips into its in-force arm instead of breaking when the shipped interim goes. Notes from the agent: All six directive items addressed: (1) the universal extended — beside the in-force half, every AdmittedByRow is checked against table.by_source(src) with the attempt's frame re-decided by that record's rules alone; (2) the node-row attempt comment records the host_ip cohort stand-in and what the cohort address adds (design §4.1); (3) the thesis now states the gate bounds a spoofer to the spoofed box's own reach and the union is what an attacker assembles by choosing sources (design §4.3 rule 2, §8), the plan's proof sentence untouched; (4) SessionPolicy built as a struct literal; (5) all three of round 1's threads addressed this round (phase-tolerant in-plan arm at the integration test, the in-force universal, the out-of_plan source given its own literal out_of_plan_src = 198.51.100.7, TEST-NET-2); (6) e2e_enabled skips with a printed reason when MINVMD_GVPROXY_BIN is absent, like the MINVMD_E2E != 1 arm. Also ran |
|
/revise Round 3. One item from the runtime review at 99de220 that round 2 could not see (it posted after the round started); nothing else is in scope.
Re-run the plan's unit test line at the new head. |
The lease-reading block grepped each box's /proc/net/fib_trie and excluded only the four infrastructure addresses, but fib_trie lists the switch subnet's 100.64.0.0/16 route prefix too, which sorts before any box lease — so the parsed "lease" could be the network prefix, and both boxes sharing one fabric would trip the assert_ne on the same address. The read never fed the bound: the spoof loop wears hardcoded in-plan sources, and the cross-box pairs the real leases would name are not sent (their arrival is unobservable today). Drop the read and keep the in-plan sources, with a comment saying why: the arm under test is an in-plan address no row holds, which a row-held lease cannot name.
|
Round 3 of 10 on a /revise note, pushed as
Verify Review threads the coder judged:
At this branch the spoofer's VM-boundary test asserts only what it can observe — its spoof sources are the hardcoded in-plan unrowed addresses, with a comment recording why they are written there rather than read off the boxes — and the unit case still proves a root-in-VM spoof wearing a resident box's address reaches only the source's own row's destinations (the union) or the baseline set's endpoints, with the universal pinning no in-force attempt onto the interim arms. Notes from the agent: Round 3 directive item: fixed by dropping the dead read (the reviewer's second option) rather than driving the cross-box spoof from real leases — the test explicitly declines unobservable frames (shipped interim admits cross-box pairs with nothing listening, no arrival and no gate line to attribute), and the arm under test is an in-plan address no row holds, which a row-held lease cannot name. lease_from_fib_trie and the fib_trie probe block are gone; the in-plan sources (100.64.0.99/.88) stay hardcoded with the reason beside them. The fix also removes the latent false failure: the fib_trie parse read the 100.64.0.0/16 route prefix as both boxes' 'lease', tripping assert_ne on one fabric. Deferred test line still owed to its lane: cargo nextest run -p minvmd vm_escape_bounded_to_resident_union (lane: vm) — not runnable on this box; the nextest run's package build compiled and linked it at the new head. Re-ran the plan's unit test line at the new head per the directive. clippy was not re-run: the diff is deletion-only plus comments, and the nextest build already type-checked all -p minvmd test targets. Both threads fixed in round 2 were verified present at this head before judging. |
|
Round 4 of 10 on the runtime's review findings: the coder changed nothing. This round's only delta was the single review thread; judged invalid (see threads), so the branch is unchanged at cf629de and no commit was added — nothing was touched, inside or outside the layers. Deferred line, run by neither this box nor the runner, per the survey: 'cargo nextest run -p minvmd vm_escape_bounded_to_resident_union' (lane: vm). What the next reader of the PR should know about that evidence: on the lanes that run the ignored surface (test-vm/test-ignored, the KVM and macOS harness steps: --run-ignored all/ignored-only with MINVMD_E2E=1 and the kernel/rootfs/initramfs images) the test's runtime gate skips it wherever the switch is absent — MINVMD_GVPROXY_BIN — the posture 63f39fd chose deliberately and the file header documents; and neither just test-vm nor any automated lane exports MINVMD_GVPROXY_BIN for the harnesses today, so a person proves the bound with that variable exported before just test-vm (the header's '(just test-vm, the nightly)' is the…[truncated] Review threads the coder judged:
|
There was a problem hiding this comment.
Round 4 landed with no change at cf629de; all three asked rounds verified (universal and by_source admit-by-own-row, cohort stand-in, fib_trie lease parse), ARCH conditions met, lanes green, no open threads. Approving.
The merge of main brings #1792's vm_escape test, which registers the node namespace with the signature main had; this branch's takes the node's proxy and answerer ports.
The merge of #1792 added the field to SessionConfig and every other initializer in the tree carries it; this one did not, so the macOS unit lane failed to compile the minvmd test target (E0063).
* feat(minvmd): control socket registers a box and hands its addresses The host-side box table (NET-138) had no way to be filled from a creator: the activating client had no door to minvmd, so an own-address box's row never landed in the registry the egress gate decides by, and its addresses were left to the daemon to draw itself. Add the VM host daemon's box-registration control socket, beside the ssh socket in the provider-instance dir, speaking one JSON line each way over the wire types in minimald-rpc (RegisterBoxRequest / RegisterBoxReply). A registration allocates the box's switch address from the plan's lease run and its loopback address from the slice the host switch publishes at — shared cursors across registry clones, so no address is ever handed twice — fills the row from the client's expanded spec, and answers with the addresses; one info line per registration names the box and both of them. run.rs binds the socket at boot and degrades to a warn, not a failed boot, when it cannot. * feat(minimal): activate registers an own-address box with the VM host An own-address activation on a minvmd-backed host had no way to put its box into the host-side table the egress gate decides by: it created the session without telling the VM host daemon anything, so the box drew its own switch address and its declared rules reached no host-side gate. Before the create, activate registers the box over the VM host daemon's control socket — the box's name, its expanded ingress ports, its egress policy — and carries the addresses the daemon hands back in the create request, so the in-VM daemon attaches with the handed switch address. A host-ip box shares the node's own row, a none box has no switch address, and a native daemon has no box table: all three register nothing. A registration that cannot be made degrades to a warning, never a failed activation, the same way a supervisor predating the socket behaves. Carries the wire groundwork with it: sessions::BoxAddresses (the pair handed together) rides the SessionConfig and the session Record, and the struct literals across the workspace gain the new field. * feat(minimald): attach an own-address box at its handed address An own-address session now attaches at the switch and loopback addresses the VM host daemon allocated for its box registration and handed back in the create request, instead of drawing one from the switch allocator itself. The handed address still lands in the lease table, so the never-reuse rule and every downstream consumer of that table see it the same way as a self-allocated one. - Record carries the handed pair from the create request into the session launcher, and the task path passes none deliberately: a task sandbox is not the box the registration named. - SwitchClient::attach_handed validates the handed address against the plan's PTask run and records the lease without a draw; a handed address outside the plan is NetError::HandedAddressOutsidePlan. - The one debug line per attach names the handed address and whether it was honored from the host. The switch allocator's next draw skips past a handed lease, so an interim self-allocating session cannot take the box's address. * style: rustfmt the box-registration layers cargo fmt across the three box-registration commits; no code-meaning change. * style: satisfy the strict clippy gate on the registration layers - read_request_line carries the slice invariant as an expect with a reason, matching the header-parts precedent. - the test register helper maps a serialization failure into its io::Result instead of expecting. - complete_own_ip_attach switches its too_many_arguments allow to an expect with a reason. - the socketpair's second descriptor gets its own SAFETY comment. - the fake VM host's reply writes propagate a client that is already gone instead of discarding the must-use result. * feat(minimald): the coder's uncommitted work as it stood when its 7200s ran out the coder's uncommitted work as it stood when its 7200s ran out The coder was stopped at the end of its time with these changes not committed. The runtime committed its working tree as it stood so the work is kept: this commit is a snapshot, not a finished step, and may not build or pass its tests. * fix(minimald,minvmd): split the PTask run between the two allocators The plan's PTask run is now two disjoint sub-runs: the daemon self-allocates task sandboxes and unregistered boxes from the lower half (net::self_allocation_run), and the VM host daemon hands registered boxes only from the upper half (box_registry::hand_out_run, the mirrored midpoint rule). Each side's tests pin the default plan's split literally, so the two statements cannot drift silently. IpAllocator::hand now refuses any recorded lease whatever drew it (a box attaches once per daemon life; a restart starts from an empty allocator, so the re-hand allowance and its handed list are gone) and refuses an address inside the reserve, where a host that disagrees about the split would put a box beside the daemon's own draws. Exhaustion stays an explicit error with no wrap or reuse on either side; the interim is stated in the IpAllocator and task_network docs — task sandboxes self-allocate from the reserve until the task registering every live box host-side retires daemon-side allocation — and the control socket's module doc states its v1 posture: the same-uid 0600 socket is the trust, requests are served serially with the 30 s read timeout. * fix(minimal): warn that an unregistered box runs unattributed A box whose VM host registration cannot be made runs with no host-side row: its frames are unattributed under the egress gate's announced interim, and the gate's per-box default fails closed for it when it binds (#1790). The warnings on the registration failure path — an unresolvable provider dir, a refusal, the deadline — now name the missing registration and that posture instead of the old egress-not-enforced phrasing. * style(minimald,minvmd): take the reserve length with div_ceil clippy's manual_div_ceil flags (last - first + 1) / 2 in both mirrored split functions; (last - first).div_ceil(2) is the same number, since a PTask run's address span is always even. Doc: state that the hand-out run keeps one address more than the reserve. * fix(minimald): release a handed lease when its attach ends Re-attaching a handed (own-ip VM) box after its in-process host was rebuilt met HandedAddressCollision: hand() records the handed address into the allocator's table, detach() never removed a lease, so the lease outlived the teardown and the re-attach looked like a live attach. The session launcher threads the persisted box addresses into the respawn, so the box stayed unreachable until daemon restart. Key the collision to a live attach instead: detach now releases the handed lease it is given (a drawn lease is never released), and the OwnIp guard and abandon path pass their lease through. The same box re-attaching re-hands its address; a genuinely colliding attach is still refused. Addresses the review finding on IpAllo 10BC8 cator::hand. * style(minimald): use expect for the reserved mutex in abandon clippy-strict flags unwrap_used on changed lines; the lock is the one the abandon rewrite touched, so give it the same poisoned-mutex expect the rest of the strict-gated lines carry. * fix(minvmd): pass box_addresses in the vm-escape session config The merge of #1792 added the field to SessionConfig and every other initializer in the tree carries it; this one did not, so the macOS unit lane failed to compile the minvmd test target (E0063). * fix(minimald): release every lease with its attach Per the ARCH-EXPERT ruling the lease's life is the attachment's for every attachment, handed or drawn alike: a task sandbox's self-drawn address ends with the sandbox the way a registered box's handed one ends with the box's attach. SwitchClient::detach withdraws the lease on every detach and IpAllocator::release_handed is renamed release; the handed set is unneeded and goes. The never-reuse rule (R1.6) is carried by the draw cursor, not by a lease the static-lease table keeps for a tap that is gone: the cursor never regresses, so a withdrawn drawn address is never re-drawn, and it is never handed either (the reserve hand refuses). * feat(minvmd,minimald-rpc): add the withdraw verb to box control The destroyed or failed activation's client withdraws the row its registration bought: a tagged request envelope (register | withdraw), a withdrawal answered with the pair echoed back, and the pair-proven removal in the registry — lookup, proof, and removal under one write of the row lock, no row at the address answered as the success it is. The withdrawn addresses are not returned to the cursors; what their frames do next stays the gate phase's to say, both arms pinned at relay level through the phase parameter in host_row_withdrawn_on_destroy. The unregistered-source phase flip is not this change: it lands with the last row source (T66's follow-up), and the docs on register_client_box and the control module say so. * feat(minimal): withdraw the box's row on destroy and failure The session's creator now speaks the withdraw verb everywhere the registration bought a row it no longer holds: at destroy (single and --all, whose per-session record fetch carries the pair), at every activation failure after registering — create transport and daemon refusals, the autogen retry's abandoned row, the version-skew bail, compose and verdict failures, the finalize teardown, the Ctrl-C guard — and the registration itself moved to just before the create, the last fallible client-side step, so the checks between config and create can no longer strand a row. Withdrawals are best-effort and quiet when nothing is owed; the Ctrl-C guard fetches the record before aborting, which may take the pair with the session. cmd/mod.rs and task.rs are outside this task's layers; the guard's withdrawal required the former (the signature is unchanged, so task sessions need no edit) — noted in result.json. * style(minimal,minvmd): cargo fmt the withdraw changes * fix(minvmd): silence the strict gate's poison-guard expect * docs(minimald-rpc,minvmd): sharpen the verb-tag corner and the request wording --------- Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>
…ts ports (gominimal#1788) * feat(sessions): pure publish-verb decision for the host-side gate The frame half of NET-081's gate decides what a box may send out; this adds the other half, the decision over what its daemon may ask the switch to publish. applied() is pure over an owned request summary (verb, switch address, up to four records as ports or name indices), the host-side table NET-138 fills, and the unregistered-source phase: a publish is applied only where a row holds the address and admits every record; a retraction is decided table-wide, by what the rows hold rather than where the guest sits; an address no row holds is the phase's to decide, mirroring the frame verdict's lease-block interim. The Kani harness pins the decision as an iff over a fully symbolic request and row, in the frame verdict harness's shape. * feat(minvmd): the coder's uncommitted work as it stood when its 7200s ran out the coder's uncommitted work as it stood when its 7200s ran out The coder was stopped at the end of its time with these changes not committed. The runtime committed its working tree as it stood so the work is kept: this commit is a snapshot, not a finished step, and may not build or pass its tests. * fix(minvmd): drain box-table withdrawals from the registry itself The drainer took the report channel's receiver from a clone, whose withdrawal_reports_rx is always None by the Clone contract — so no drainer thread ever spawned and no row was ever withdrawn; a box's row outlived its shuttle connection past the box-end bound (NET-133). Take the receiver from the registry that created the channel. * fix(minvmd): quiet node-port helpers on libkrun-less hosts The node-port assignment helpers compile everywhere but are only called from run_foreground, which is libkrun-gated — so every libkrun-less build of the lib warned them dead. Allow them under not(minvmd_libkrun), the pattern DetachPoll and vm.rs's helpers already carry; the tests still cover them on every target. * docs(sessions): document the publish decision's refusal cases clippy asks a # Errors section of a fn returning Result; applied's refusals (UnknownAddress, Undeclared, Unheld) get theirs. * feat(minimald): bind the ports the boot line hands the guest daemon minvmd assigns the node's hostname-proxy (TCP) and zone-answerer (UDP) ports before the VM boots and hands them on the kernel command line; pid-1 reads the tokens off the environment the kernel passes through and binds them as handed: pinned, never re-picked, never relocated. The host's box table already names exactly these ports, so a daemon-chosen replacement would publish a listener the host gate refuses to carry. A boot without tokens — an older minvmd, a native run — keeps the pre-handoff default-then-select behaviour. * test(sessions): split the publish decision table into section helpers The exhaustive decision test was 177 lines, over clippy's too-many-lines ceiling, and summed its width range the pedantic lints reject. The sections become named helpers the survey-named test still drives in the same order; no case changes. * style(minimald): rustfmt the handed-port test * fix(minvmd): race the control relay's legs and pin its tests to the gate Three control-relay tests failed against the gate they shipped with: - the pipelined-request and connect-path-body tests read the relayed bytes through gate_over_control, which already consumes and asserts the whole admitted request — the body re-read found the head's bytes under it, and the pipelined test asserted the smuggled request was forwarded. Both now write by hand and read exactly the bytes the gate owes the switch. - a switch hanging up on an idle control connection ended only the response leg; the relay_control implementation never raced its legs, so the per-connection close a keep-alive control channel makes went unseen until the guest spoke again or the drain bound passed. Race the response leg against the request-leg probe like the frame relay does, and say which leg ended. * test(minvmd): hold the node-port probe on the wildcard it probes The test held its busy port on 127.0.0.1 while assign_node_port probes 0.0.0.0, a collision Linux raises and other hosts do not, so the busy case's fall-through was not asserted everywhere. Holding each probe on the wildcard address the assigner itself addresses makes the collision unconditional, and the freed case the same inverse of the busy one. * fix(sessions): bound the harness's interim arm to an address no row holds The publish-verb proof restated the announced interim as any in-plan address, so a publish at a held address whose row refuses a record read as applied while the decision refuses it under either phase. The oracle now grants the interim only where no row holds the address, and a retraction's interim only where no row holds its records. The Kani lane expects the ninth sessions harness. * fix(minvmd): hand the node row its ports in the merged-in spoofer test The merge of main brings gominimal#1792's vm_escape test, which registers the node namespace with the signature main had; this branch's takes the node's proxy and answerer ports. * fix(sessions): key the switch retract by the row at its own address A retraction was decided table-wide: it applied whenever any published row held the record it names, so a retraction could withdraw a record a publication at another address had made. Read the row at the address the summary carries — the publication's own, as the gate attributes it — and apply only when that row holds every record; otherwise Refusal::Unheld, which now carries the address for the refusal line to name. The announced interim stays for an in-plan address no row holds, as for a publish. The Kani harness restates the admit per address for every verb, and the unit test pins a retraction justified only by another namespace's row as refused. * fix(minvmd): attribute each switch retract to its publication's address The unexpose body names only the loopback listener it retracts, and the pure decision now applies a retract by the row at its own address — so the gate supplies that address itself, from a publish ledger kept beside its limiter: every applied publish files its listener and the address it was applied at, and an applied retraction takes its listener out. A retraction for a listener no applied publish names is keyed at the unspecified address, which no row holds, and refused under either phase; the same request was applied table-wide before, so a fabricated retract of a port some row declared travelled to the switch. The interim's own teardowns key at the lease their publish went out at, under the announced phase that applied them. The interim's admit line is marked interim and reads verb-neutral, and the phase's docs now say the two bounds its admits carry: read once per build, never changed while a VM runs; lease-run addresses only, never the node's own address or the plan's reserved pair. The withdrawal test is renamed for the connection it proves — the box's shuttle relay, which ends with the box's traffic, not with the box: creator-driven withdrawal at destroy lands with T66 (gominimal#1711) — and the registry drainer's doc says the same. * fix(minimald): fail the boot on a handed node port that cannot serve A boot token present but unusable — unparseable, out of range — was warned about and counted as absent, sending the daemon into its own default-then-select and publishing listeners the host's box table does not name. The read is now a Result: only an absent token, or the host's own 0 as 'pick one yourself', leaves the pre-handoff fallback; anything else is a surfaced boot error naming the token and the value it carried. The pid-1 branch reads the tokens once the log sink is live and probes the handed pair in the microVM's bind base before the daemon starts, so a port something in the guest already holds ends the boot with the error surfaced, never after READY when the host already believes the VM healthy and never rescued by a re-pick. * fix(minvmd): resolve the node ports once for the handoff and the row Directive finding 4 (T63 round 5): run.rs resolved the pair itself while vm.rs re-read MINVMD_NODE_*_PORT off its own env and silently fell back to absent on garbage, so a half-set or mistyped override could boot the guest onto ports different from the ones the node row registered. Now run.rs resolves each port once — the operator's override in the supervisor's own env (the same names the child transport decodes), refused fail-closed when it is unparseable or 0, else the default-first probe — and the one resolved pair is written twice from the same value: onto the VMM child's env (the explicit .env shadows any inherited override) and into the node row's own publishes. vm.rs's decode becomes strict transport decoding: both-or-neither, a half-pair or an undecodable value fails the boot naming the variable and the value. Its read stays in vm.rs because the decode's only production caller is apply(); moving it to cmd/vmm_child.rs is out of this task's layers. node_port_assigned_on_host_and_handed_to_daemon (both the run.rs and the vm.rs test of that name) now pins the resolution through the override and asserts handed == registered, plus the decode's strict cases. * fix(minimald): set the dropped box_addresses in two test constructors T14's (gominimal#1765) merged session tests construct `SessionConfig` and `SandboxLauncher` without the `box_addresses` field their declarations require, so minimald's test targets fail on E0063 and every lane that builds them — tests, minimald-root-integration, clippy — goes red with them. Both mean a session the activating client did not register, which self-allocates as it always has, so the value is `None`. * fix(minvmd): keep the node's row off the relay's withdrawal report A frame relay's end files a withdrawal report naming every admitted frame's source, and the node plane's own frames were among them: their source is the daemon's own address, admitted by the node's row, so one relay end — a shuttle error, an oversized frame claim — withdrew the row the run path registered once at boot. Nothing re-registers it, and the announced interim cannot reach it (the plan keeps the node's address outside the lease run), so the in-VM daemon stayed frameless and unpublishable for the rest of the VM's life over a close its control path rode out. The gate now attributes the node's own address to nothing: the row that decides its frames is the host's registration for the VM's lifetime, not a box's row that goes with its shuttle connection (NET-133). Pinned by node_row_survives_the_relay_that_carried_its_frames, which watches a box's row go with its relay while the node's row stands and its frames still pass on the next connection. The withdrawal test takes its surveyed name, host_table_row_withdrawn_within_60s_of_box_end, so the plan's line runs it: it sat under ..._of_relay_end, which a nextest filter for the surveyed name matches nothing of and exits 4 on. * ci(kani): count the publish-verb harness in the lane's gate The lane asserts the harness count, not just the exit status, so a `#[cfg(kani)]` module that stops compiling cannot go green having proved nothing. The publish-verb decision's harness (kani_switch_request_applied_iff_table_admits) raises the sessions suite to ten; the gate still said nine, so the lane failed its own count with every harness verified — reproduced locally on 0.68.0: "Complete - 10 successfully verified harnesses, 0 failures" against an expectation of nine. * fix(minvmd): publish by the listener and defer the name-declaring drop The row's publish decision read both ends of an exposed mapping, but the registration wire carries only the external ports, so every mapping whose ends differ — most of them, an own-address box's whole ingress — was refused and the attach died with it. The record is now the host-side listener alone; the inside end is the target's own, decided by its in-guest ingress rules. A row that declared DNS hosts now defers the undeclared-destination drop to the in-guest decision: name-based admission is resolution-time pins the host's static rules cannot see, so making the drop here vetoed every pinned frame the box's own declaration granted. Denied ranges, protocols, sources and families stay host-made. The zone-adds the daemon publishes its box's name with — the bare form and the deprecated host-qualified one beside it, host ids unbounded — map onto the row's own name now instead of dying as undeclared; a foreign name is still refused, and the address keying keeps the mapping the owner's. * style: cargo fmt the publish-verb edits * fix(minvmd): refuse frames from a box to the switch's gateway address The switch's own address is a control surface, not a destination a box's egress rules decide (design $4.1, $7.1). The gate's frame decision now refuses every frame to the plan's gateway on a port that is neither the resolver's nor the source namespace's declared exposure, before the row's decision and the interim, under every phase - so no allow-all row, no announced concession, and no per-box default can admit a frame at the switch's own address. The exceptions fall through to the decision behind the refusal, which still decides them, so the resolver carve-out and a declared exposure admit what they admitted before. The refusal's warn line is rate-limited like a drop's and names the box's address and the port. Pins at the pure decision and at a live gate: two allow-all boxes (one with no egress section, one with a declared one) and an unregistered in-plan source are refused at the gateway while the resolver's port still answers and the declared exposure passes. * test(minvmd): pin the node row's exclusion in the withdrawal report The round-6 finding's fix - the relay never attributes the node plane's own address, so its row is never filed for withdrawal - is watched at the report itself: one relay carries a node-sourced frame and a lease-run frame, ends, and the report the host files names the lease-run address only, with the node's row still standing. The report is read off the channel the drainer acts on, not inferred from the rows left after it. * style: cargo fmt the gateway-refusal edits * fix(minvmd): admit only the resolver port at the switch's own address The control-surface refusal at the gateway excepted a port the source row admits as well as the resolver's. Those admitted ports are the box's ingress - what others reach on the box's own address through the switch's forwarders - and no box-to-gateway flow at them exists in the design, while gvproxy's API listens on the gateway address, so a box declaring ingress port 80 could reach the switch API. The exception is gone: at the gateway the gate admits TCP or UDP to port 53 only (a query falls back to TCP on truncation, so the carve-out is by address and port, not protocol), and every other port and every other protocol, ICMP included, is refused before any row or phase is consulted. The relay test drops the admitted-port cases, adds a TCP 53 frame from each registered box that reaches the switch, and an ICMP frame from a registered box that is refused; the pure decision pins a frame to the gateway at the row's admitted ingress port as a control-surface refusal. * fix(minvmd): cut a rendered target name on a char boundary render_record truncated a dictionary name to MAX_NAMED_TARGET with String::truncate, which panics when byte 64 falls inside a multi-byte UTF-8 character. The name is a guest's bytes, so a hostile name could panic the gate's warn path. The cut now lands on the largest char boundary at or below the bound (str::floor_char_boundary, stable on the pinned 1.97.0 toolchain). Adds render_record_cuts_names_on_a_char_boundary: 63 ASCII bytes then a 3-byte character, exactly 64 bytes of 2-byte characters, and every ASCII run length up to the bound, each rendering without a panic to at most 64 bytes of valid UTF-8. The test panicked at the old truncate before the fix. * fix(sessions,minvmd): refuse a guest withdrawal of a declared port The pure decision applied a Retract when the row at the retraction's attributed address HELD the record, and a row's held set is its declaration, so a guest unexpose of a declared forward was applied and the forward withdrawn. Design $7.1 binds a declared port's forward for the session's lifetime, unbound only by host-side ingress revocation (NET-121); a guest request withdraws only what its own listens published (NET-016, NET-017). SwitchRow gains a runtime-published port set, empty from SwitchRow::of (every call site compiles unchanged) and filled by with_published, which the Retract arm now decides against instead of the declaration: applied only when every record is in the runtime set, refused with Refusal::Unheld otherwise. The unheld-address arms are unchanged, so the announced interim still applies an in-plan retraction no row holds. minvmd's projection leaves the set empty until listen-publishing lands, so today no guest retraction at a held address is applied. Tests: switch_request's retract_of_declared_port_refused pins the refusal at the row's own address for a declared port and the node's assigned port, with and without other runtime publications, and the applied case moves to a listen-published port; egress_gate's retract_of_declared_port_refused publishes a declared forward, has the guest unexpose it on the shuttle, and sees it refused and logged with the row still admitting 8080 and the forward publishing again. The keyed-retraction test now reads the attribution off the refusal's source. The Kani harness kani_switch_request_applied_iff_table_admits models the published set as a second symbolic list and restates "admits" per verb: declaration for a publish, runtime set for a retraction. The harness count is unchanged. * fix(minvmd): bound the control-body read like the head `serve_connection` bounded only the handshake, so a guest that sent a control head declaring a `Content-Length` and then withheld the body held the relay task, the gate's dial, both socket halves and one of the `MAX_LIVE_RELAYS` slots for the gate's lifetime; 64 such connections had `accept_loop` refuse every guest connection after them, frame relays included. `relay_control` now reads the body under `drain_timeout`, the bound the leg already gives a guest gone quiet past its request (the same value `serve_connection` reads the head under). A read that times out is handled like a guest that closed mid-body: nothing is written on, one rate-limited line names `egress-undeclared-verb`, and the relay ends so the slot is returned. `accept_loop` takes the bound as a parameter so a test can shrink it; `EgressGate` still passes `HANDSHAKE_TIMEOUT`. The new test fills the cap with withheld bodies and checks each is closed within the bound with nothing written to the switch, then that the next connection is served. * fix(minvmd): accept a leased-loopback local in the publish gate The gate's loopback_port parsed only 127.0.0.1:<port>, so every own-address box's ingress expose - and its teardown - was refused as malformed before the row decision: on a VM-backed host the daemon's client builds local from the box's granted address in the reserved local range (NET-010, NET-129), never 127.0.0.1. Accept both spellings the client builds - 127.0.0.1 and the reserved local range - and keep refusing every other spelling as malformed, with a test on each side of the line. * test(minvmd): prove a name-resolving row keeps every other host-side drop The gate defers one drop class for a row that declared DNS hosts: the undeclared destination, decided in the guest where the resolution-time pins are. Pin the edge of that deferral from the other side: for the same row shape, a destination in its own deny_subnets, the switch's own address, a protocol its rules do not allow, and a source no row holds are all still dropped on the host, under their own rules, with no undeclared-destination line. Say so at registration: BoxRegistry::register emits one info line per name-declaring row, naming its switch address, so the host's log carries the deferral rather than leaving it to be inferred from a frame that reached the switch. The line goes away with the deferral; moving the decision host-side is gominimal#1808. * docs(minvmd): name the withdrawal drainer as process-lifetime The drainer's doc comment claimed the thread runs until the reports' senders are all gone — the gate and every table handed out. That exit is unreachable: the thread holds a clone of the registry, and a clone carries a withdrawal_reports Sender — the very channel the thread is draining — so recv() never reports the channel dead and the loop never exits. Say the process-lifetime truth instead, and say outright that teardown cannot rely on the thread exiting, so no future reader builds teardown on an exit that cannot happen. * feat(minvmd): refuse infrastructure destinations at the host gate for every row The design's infrastructure deny set (section 5.3) applies to every box-plane packet, CIDR-admitted direct-IP flows included, so the host-side gate now decides it as a frame rule for every row, between the source's routing and the row's own rules: link-local and the metadata services in it, loopback, the fabric plane outside the node's own switch block, and RFC 1918 space the row's allow_subnets does not cover. A row that declared DNS hosts defers only its undeclared-destination drop; this one is the host's own, whatever the row. The switch's gateway stays with the control-surface rule, and the node's own block is local reach the row's CIDR rules and the target's ingress decide. The host rule is built beside the sessions crate's InfrastructureDenySet rather than from it: that set keeps its ranges private, names the gateway's addresses, and admits or refuses the whole plane by whether a name is a box-zone name, which the rebinding intersection relies on and a frame cannot answer. An undeclared allow_subnets counts as covering private space, as the rebinding exemption holds it, so the two allow-all spellings keep the same reach. The drop is egress-infrastructure-destination, rate-limited per source like the other drops and naming the destination and port. The spoof-union test's db box moves from 192.168.0.0/16 to TEST-NET-1 so its own subnet is not private space, and its two spoofs into web's private subnet are pinned to the new rule. * fix(minvmd): apply the infrastructure rule to interim-admitted sources A source the announced interim admits without a row returned before the infrastructure check, so its frames reached link-local and other nodes' blocks unrefused. The set applies to every box-plane packet: the interim now takes it too, as allow-all for RFC 1918 space, and the infrastructure test covers such a source. * fix(minvmd): key the publish ledger by loopback address and port A forwarder listener is its loopback address and port. Two boxes publishing one port at their own reserved-range leases are two listeners, so the ledger now keys each publish and retraction by the pair the body's local names. --------- Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>
T41 — Bound a root-in-VM spoofer to the resident union
slice: S9
kind: task
covers: NET-085
size: M
est: 60 code, 400 tests
executor: agent
difficulty: needs care
model: standard
diagnostics: the test prints the host-side gate's drop lines per source after the spoof, so the bound can be read off
observability: the test records each spoofed destination attempt and the gate's verdict
layers:
- vmhost:
- crates/minvmd/tests/vm_escape_integration.rs (new): a root process in the guest spoofs another box's address, on the guest's own interface and on the shuttle port directly
- crates/minvmd/src/net/egress_gate.rs: a unit case for a spoofed source against the resident union
test (deferred: vm): cargo nextest run -p minvmd vm_escape_bounded_to_resident_union
test: cargo nextest run -p minvmd spoofed_source_bounded_to_resident_union
produces: none
consumes: none
proof: A root process inside the VM that spoofs another box's address reaches only destinations in the union of resident boxes' declared egress plus the baseline set.
depends on:
- blocked by #1640
- blocked by #1650
Proof
The decision-level half of the bound is now pinned in the tree: the egress-gate unit case
spoofed_source_bounded_to_resident_unionrecords every decision the gate gives a frame whose source is a spoofed box address — one recorded attempt table per arm (shipped interim and baseline in-force), grouped by source in the gate's own drop-line shape with the verdict per attempt — and asserts, attempt by attempt, that a spoofed source is decided by the row that holds the address it wears (or the node row, or the baseline), that every destination admitted is inside the union of resident boxes' declared egress plus the baseline set, that a spoof of a deny-all box's address buys no reach beyond the resolver carve-out, and that a spoofed ARP claim is admitted as a declared per-box path and buys no reach. The VM-boundary half is written:vm_escape_bounded_to_resident_unionincrates/minvmd/tests/vm_escape_integration.rsdrives a spoofer speaking the shuttle's own protocol at the egress gate socket (the/connecthead plus length-framed frames, byte-identical to what a root escapee's vsock connection carries), with a spoofed source on the shuttle port directly, the CapEff precondition on the guest's own interface, positive controls for each resident box's own declared traffic, an attempt record printed with the gate's drop and i 8000 nterim lines per source. Honest caveat a reader of the PR should see: at this branch the end-to-end bound for a spoofed IN-PLAN source is not yet enforced at the VM boundary — production publishes only the node row, and the gate's shipped interim admits an in-plan source no row holds with no rules consulted (the rows that would bound it are T66's, #1711; the baseline's in-force flip is #1786). The unit test pins both arms with flip comments; the integration test pins the shipped interim's admits end-to-end with the flip named, plus what already holds at the VM boundary under either phase: a source outside the plan's lease block is refuse…[truncated]Tests
cargo nextest run -p minvmd spoofed_source_bounded_to_resident_unionVerify
min task run agent-verifypassed ([tasks.verify]).Deferred
The plan marked these test lines deferred: the executor lacks the lane, so nothing here ran them and they still owe evidence.
MINVMD_E2E=1 MINVMD_GVPROXY_BIN=<gvproxy> cargo nextest run -p minvmd --run-ignored all vm_escape_bounded_to_resident_union(thejust test-vmenv: kernel, rootfs, initramfs paths and the switch binary)The test is
#[ignore]-gated, so the plan's bare line runs nothing and reports an empty green run; the runnable form above is what owes the evidence. WithoutMINVMD_GVPROXY_BINit skips with a printed reason (the KVM lane's nextest step).Closes #1659