8000 T41 — Bound a root-in-VM spoofer to the resident union by minimal-foundry[bot] · Pull Request #1792 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

T41 — Bound a root-in-VM spoofer to the resident union - #1792

Merged
norrietaylor merged 6 commits into
mainfrom
agent/1659-bound-a-root-in-vm-spoofer-to-the-resident-union
Oct 1, 2026
Merged

norrietaylor merged 6 commits into
mainfrom
agent/1659-bound-a-root-in-vm-spoofer-to-the-resident-union

Conversation

@minimal-foundry
@minimal-foundry minimal-foundry Bot commented Oct 1, 2026 •
Copy link
Copy Markdown
Contributor

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_union records 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_union in crates/minvmd/tests/vm_escape_integration.rs drives a spoofer speaking the shuttle's own protocol at the egress gate socket (the /connect head 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

line exit tail
cargo nextest run -p minvmd spoofed_source_bounded_to_resident_union 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-c8ad... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/76a1c/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/76a1c/tree/crates/ot) C

Verify

min task run agent-verify passed ([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.

line lane
MINVMD_E2E=1 MINVMD_GVPROXY_BIN=<gvproxy> cargo nextest run -p minvmd --run-ignored all vm_escape_bounded_to_resident_union (the just test-vm env: kernel, rootfs, initramfs paths and the switch binary) vm

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. Without MINVMD_GVPROXY_BIN it skips with a printed reason (the KVM lane's nextest step).

Closes #1659

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.
@coderabbitai
coderabbitai Bot commented Oct 1, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: c12477ad-a1ab-47bd-9d4a-684f1d9ffb73

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@minimal-foundry
minimal-foundry Bot commented Oct 1, 2026 •
Copy link
Copy Markdown
Contributor Author

Reviewed: 1 finding, none blocking at cf629de73c16.

Address the comments on the diff.

Clobber: none: git log on both files shows only this PR's own commits above the baseline.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 3 findings, 3 inline.

Comment thread crates/minvmd/src/net/egress_gate.rs Resolved
@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/minvmd/src/net/egress_gate.rs Resolved
Comment thread crates/minvmd/src/net/egress_gate.rs Resolved
@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 1, bounded to the three threads on review 5380601783. Nothing else is in scope for this round.

  1. crates/minvmd/tests/vm_escape_integration.rs:1239: make the in-plan spoof arm phase-tolerant (on refusal, require no marker seen plus the egress-unknown-source and source={src} gate lines; on success keep the interim assertions), so the verify line survives the UNREGISTERED_SOURCE_PHASE flip.
  2. crates/minvmd/src/net/egress_gate.rs:4268: assert that no attempt decided in force has verdict AdmittedByNodeRow or AdmittedByInterim.
  3. crates/minvmd/src/net/egress_gate.rs:4094: give the out-of-plan source its own literal, distinct from the outside destination.

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.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 1 of 10 on the runtime's review findings, pushed as 99de220d04a1.

line exit tail
cargo nextest run -p minvmd spoofed_source_bounded_to_resident_union 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-0c52... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/7a04a/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/7a04a/tree/crates/ot) C

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — crates/minvmd/tests/vm_escape_integration.rs: Fixed: BoxSession::open now mints a unique session name per invocation (the minimald_session_integration.rs convention), so the open_box retry loop can no longer collide on AlreadyExists after a transient failure past CreateSession.
  • 👍 valid — crates/minvmd/tests/vm_escape_integration.rs: Fixed: BOOT_TIMEOUT is 150 s now, the justfile's exported MINVMD_READY_TIMEOUT_SECS, so the harness waits past the daemon's own READY wait (60 s default, 150 s under just) and a cold 40-70 s boot lands inside it.
  • 👍 valid — crates/minvmd/src/net/egress_gate.rs: Fixed: verdict_of keys the port too, so every pinned verdict is matched by the whole attempt record and a future attempt sharing phase/src/proto/dst cannot answer for another's verdict.

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.

@norrietaylor
Copy link
Copy Markdown
Member

/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.

  1. Extend the universal from round 1: beside "no in-force verdict is AdmittedByNodeRow or AdmittedByInterim", assert that every admit under any phase is by the row that holds the source (AdmittedByRow only when table.by_source(src) is the row whose rules admitted the destination), never by another box's row.

  2. Add a comment beside the node-row attempt rows (next to the #1786 reference) recording that the node row stands in for the host_ip cohort identity today, and that once the cohort address exists the table gains attempt rows asserting the cohort's any-admits rule across live host_ip members and that a revoked member's destinations no longer admit (design §4.1).

  3. Wording in the test's thesis comment: the gate bounds a spoofer to the spoofed box's own reach; the "resident union" is what an attacker assembles by choosing sources (design §4.3 rule 2, §8). The plan's proof sentence stays as written.

  4. crates/minvmd/tests/vm_escape_integration.rs:348-349: clippy field_reassign_with_default fails the macOS unit lane (-D warnings); initialise SessionPolicy { egress: Some(egress), ..Default::default() }.

  5. Any of round 1's three threads (review 5380601783: phase-tolerant in-plan arm at vm_escape_integration.rs:1256, the in-force universal at egress_gate.rs:4268, the out_of_plan literal at :4094) not already addressed by round 1.

  6. crates/minvmd/tests/vm_escape_integration.rs:118-140 (e2e_enabled): the Linux/KVM lane runs the ignored tests with MINVMD_E2E=1 but fetches gvproxy only for the later session-e2e step, so the assert! on MINVMD_GVPROXY_BIN fails the lane (test-kvm red at cebac24: "MINVMD_GVPROXY_BIN must be set when MINVMD_E2E=1"). When the variable is absent, print the skip reason and return false like the MINVMD_E2E != 1 arm, so the bound is proved where the switch is provided (just test-vm, nightly) and the lane stays green; the workflow is frozen and cannot be changed for it.

Re-run the plan's unit test line at the new head.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

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().
@minimal-foundry
8BD0 Copy link
Copy Markdown
Contributor Author

Round 2 of 10 on a /revise note, pushed as 63f39fdbe140.

line exit tail
cargo nextest run -p minvmd spoofed_source_bounded_to_resident_union 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-aa65... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/47138/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/47138/tree/crates/ot) C

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6n-4CU: Fixed in round 1 (99de220): BoxSession::open mints a unique per-invocation session name, the minimald_session_integration.rs convention, so the open_box retry can no longer collide on AlreadyExists after a transient failure past CreateSession. (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6n-4Ce: Fixed in round 1 (99de220): BOOT_TIMEOUT is 150 s, the justfile's exported MINVMD_READY_TIMEOUT_SECS, so the harness waits past the daemon's own READY wait and a cold 40-70 s boot lands inside it. (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6n-4Cm: Fixed in round 1 (99de220): verdict_of keys the port too, so every pinned verdict is matched by the whole attempt record and a future attempt sharing phase/src/proto/dst cannot answer for another's verdict. (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — crates/minvmd/tests/vm_escape_integration.rs: Fixed this round: the in-plan arm is phase-tolerant — on Err from spoofed_flow it requires the marker's absence at the host listener plus the egress-unknown-source and source={src} lines and records that as the in-force verdict; on Ok it keeps today's interim assertions (the interim-line check moved
  • 👍 valid — crates/minvmd/src/net/egress_gate.rs:4279: Fixed this round, with the extension: the universal asserts no attempt decided against baseline_in_force has verdict AdmittedByNodeRow or AdmittedByInterim, and every AdmittedByRow admit is the row table.by_source(src) holds admitting the attempt's frame re-decided by that record's rules — never ano
  • 👍 valid — crates/minvmd/src/net/egress_gate.rs: Fixed this round: the source kept a literal of its own (out_of_plan_src = 198.51.100.7, TEST-NET-2) with a comment saying outside is a destination and this a source, so the shared TEST-NET spelling no longer reads as a relationship between them.

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 cargo clippy -p minvmd --all-targets -- -D warnings (the macOS unit lane's gate): clean. Deferred: cargo nextest run -p minvmd vm_escape_bounded_to_resident_union (lane: vm) — the test was written in the earlier rounds of this PR and is unchanged in kind this round; nothing to run on this box, the vm lane still owes that evidence. Consequence of (6): on the harness lanes that fetch gvprox…[truncated]

@norrietaylor
Copy link
Copy Markdown
Member

/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.

  1. crates/minvmd/tests/vm_escape_integration.rs (lease_from_fib_trie, consumer at :1227): the parse excludes only the four infrastructure addresses, and /proc/net/fib_trie also lists the 100.64.0.0/16 route prefix, which sorts before any box lease, so the "lease" can be the network address. Read the lease as the box's own /32 (the |-- <addr> line followed by /32 host), or from ip -4 -o addr show on the tap. Then either drive the cross-box spoof from the real pair instead of the made-up 100.64.0.99 / 100.64.0.88, or drop the dead read and keep the hardcoded in-plan sources with a comment saying why.

Re-run the plan's unit test line at the new head.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author
10000

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 1, 2026
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.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 3 of 10 on a /revise note, pushed as cf629de73c16.

line exit tail
cargo nextest run -p minvmd spoofed_source_bounded_to_resident_union 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-072c... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/dc2ee/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/dc2ee/tree/crates/ot) C

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6n-7yR: Fixed on the branch head in round 2 (63f39fd), which the reviewer confirmed: the universal loop beside the membership loop in spoofed_source_bounded_to_resident_union asserts no attempt decided against baseline_in_force carries AdmittedByNodeRow/AdmittedByInterim, and every AdmittedByRow admit is r (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6n-7yY: Fixed on the branch head in round 2: the spoofed source now keeps its own literal out_of_plan_src = [198, 51, 100, 7] (TEST-NET-2) with a comment saying outside is a destination and this a source, so the shared TEST-NET spelling no longer reads as a relationship. Nothing further this round; verifi (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6n__-H: Dropped the dead read this round (cf629de): lease_from_fib_trie and the fib_trie probe block are gone, removing both the trivial assert_ne and the /16-route-prefix misread the finding named; the hardcoded in-plan sources stay, now with a comment saying why they are written rather than read off the (not posted on the thread: not an open thread on the pull request)

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.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

@minimal-foundry minimal-foundry Bot removed the review:approved minimal-review approved the current head label Oct 1, 2026
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

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:

  • 👎 invalid — PRRT_kwDOPdfcxM6oBtNE: The harm does not hold on the toolchain that runs the line. Checked on this box (nextest 0.9.145; CI installs nextest unpinned-latest via taiki-e): with the default --no-tests=auto, 'cargo nextest run -p minvmd vm_escape_bounded_to_resident_union' FAILS when its only match is the ignored test — veri (not posted on the thread: not an open thread on the pull request)

@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@norrietaylor
norrietaylor merged commit eb471e4 into main Oct 1, 2026
34 checks passed
@norrietaylor
norrietaylor deleted the agent/1659-bound-a-root-in-vm-spoofer-to-the-resident-union branch October 1, 2026 16:33
norrietaylor pushed a commit that referenced this pull request Oct 1, 2026
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.
minimal-foundry Bot added a commit that referenced this pull request Oct 1, 2026
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).
norrietaylor pushed a commit that referenced this pull request Oct 1, 2026
* 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>
yermakoffivan pushed a commit to yermakoffivan/minimal that referenced this pull request Oct 2, 2026
…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>
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.

T41 — Bound a root-in-VM spoofer to the resident union

1 participant

0