microVM Runner V3 — egress-in-guest
Status: PROPOSED — details the V3 milestone under the frozen parent microvm-runner.md (its Plan § V3, microvm-runner.md:492-506) and its frozen networking decision D6 (microvm-runner.md:733-750).
Ledger impact: none. V3 fills behavior the parent’s own D6 already ratified
(the in-guest nft arm, guestd-as-root, agent-without-NET_ADMIN); nothing here
is a new cross-cutting decision, and docs/designs/DECISIONS.md is untouched.
Problem / Intent
Section titled “Problem / Intent”V2b built the gate but not the arm: guestd’s Provision transitions
ready → provisioned and opens the exec gate, but a non-empty nft_script is
a hard CodeUnimplemented error (“nft egress arm is V3”,
go/internal/guestd/supervisor.go:146-149), and MicroVMRuntime.Start sends
ProvisionRequest with only default_exec_uid + base_env — no script
(go/internal/runtime/microvm_lifecycle.go:304-310). So today a microVM
session boots with open egress: passt does no filtering (D6,
microvm-runner.md:739-741), and nothing arms nft inside the guest.
V3 closes that: deliver EgressPolicy.NftScript() (consumed unchanged,
go/internal/runtime/egress.go:71-107) to guestd, run it as guest root
before the exec gate opens, fail the boot when arming fails, and make the
host’s arm path backend-correct while leaving the podman path byte-identical
(parent § V3, microvm-runner.md:494-496). The proto surface already exists —
ProvisionRequest.nft_script = 1 was seeded for exactly this
(proto/compass/v1/guest_control.proto:177-180) — so V3 is a behavior fill,
not a wire change (§(e)).
Approach
Section titled “Approach”Each subsection resolves one fork the parent’s V3 plan leaves to detailing.
The load-bearing arm-routing fork is (a)-(c); every resolution is also listed
in ## Open Questions for the pre-freeze batch, and the body designs against
the recommended option.
(a) How NftScript() reaches the backend: ContainerSpec.Egress
Section titled “(a) How NftScript() reaches the backend: ContainerSpec.Egress”Today the policy stops at the AgentRuntime layer: AgentSpec.Egress
(go/internal/runtime/agent.go:40-42) is consumed only by
AgentRuntime.armEgress, which execs the script into the running container
(agent.go:303-309). ContainerSpec — the only thing a ContainerRuntime
backend ever sees (go/internal/runtime/podman.go:88-114) — carries no egress
field. The V2b record already recorded this exact gap as V3’s inheritance:
“the intended data path is ContainerSpec growing an egress field captured at
Create and delivered by Start’s Provision call”
(microvm-v2b-guest-supervisor-exec.md:214-226).
Resolution: ContainerSpec grows Egress EgressPolicy.
AgentRuntime.createAndStartsets it fromspec.Egresswhen assembling theContainerSpec(agent.go:262-272).PodmanCLIignores the field entirely:createArgsis untouched, so the podman argv — and the whole podman path — stays byte-identical. Podman keeps arming via the post-startarmEgressexec as before ((c)).MicroVMRuntime.Createrecordsspec.Egress.NftScript()on themicrovmSession(beside theuid/envit already records “for the Provision RPC Start issues”,microvm_lifecycle.go:96-98,164-172).
The alternative — a distinct host→guest arm call after Start — is rejected in (b); a distinct host-side delivery channel (e.g. a file in the workspace share) is rejected outright: the script is host-assembled trusted input for a guest-root shell, and the vsock control channel is the only surface with the right trust direction (parent §(c), microvm-runner.md:144-151).
(b) Who arms: MicroVMRuntime.Start, intrinsically — the load-bearing fork
Section titled “(b) Who arms: MicroVMRuntime.Start, intrinsically — the load-bearing fork”Podman’s shape is a post-start root-capable exec: AgentRuntime.provision
runs armEgress first among the post-start steps, exec-ing
sh -c NftScript() as the image default user (uid 1000) holding the
container’s CAP_NET_ADMIN grant (agent.go:288-309, the grant at
agent.go:266). The microVM backend cannot reuse that seam verbatim: the
guest supervisor refuses uid-0 execs before any spawn
(guest_control.proto:99-102, supervisor.go:428-441), every spawned child
gets an empty capability set (supervisor.go:55-61), and spec.CapAdd is
ignored — CAP_NET_ADMIN is never granted to the workload boundary
(microvm_lifecycle.go:138-143; D6, microvm-runner.md:741-743). The arm must
run as guest root inside guestd, before the gate opens.
Two candidate owners for issuing the arm:
- Option 1 (recommended): arming is intrinsic to
Start.MicroVMRuntime.Startalready issues theProvisionRPC as its final transactional step (microvm_lifecycle.go:269-310); V3 addsNftScript: session.nftScriptto that same request. One RPC provisions and arms; the gate opens only when both succeed.- Pro: the
ContainerRuntimecontract identity holds — on podman,StartthenExecworks with no intermediate call, and the V2b contract suite asserts exactly that identity on both backends (contract_microvm_test.go:5-9, microvm_lifecycle_test.go’s Create→Start→Exec shape). Any design where the gate opens outside Start breaks that: anExecright afterStartwould be gate-refused on microVM only. - Pro: fail-closed teardown comes for free — Start’s existing
tear-down-on-any-failure posture (
microvm_lifecycle.go:286-295) makes a failed arm tear the VM down with no new code path (§(d)). - Con: the arm’s timing moves relative to podman (during Start, not during
AgentRuntime.provision). This is strictly earlier, so “armed before any exec” is preserved with margin; flagged against the parent’s phrasing in OQ-1.
- Pro: the
- Option 2 (rejected): a distinct arm call between Start and the first
exec — e.g.
Provisionsplit into a gate RPC and an arm RPC, or the arm riding a second host call. This keeps the podman sequence (start, then arm) but leaves a window where the gate design must choose between (i) gate open before arm — violating the parent’s “Only after a successful arm does the supervisor accept exec requests” (microvm-runner.md:152-155) — or (ii) gate closed until the extra call — breaking the Start→Exec contract identity above. Both horns lose; rejected.
(c) What the host armEgress does on the microVM backend: probe-and-skip
Section titled “(c) What the host armEgress does on the microVM backend: probe-and-skip”With (b), the arm has already happened by the time AgentRuntime.provision
runs. armEgress’s exec — nil User, so the session-default uid 1000
(podman.go:119-121, microvm_lifecycle.go:432-438) with an empty capability
set on this backend (supervisor.go:55-61) — would not just be redundant, it
would fail (nft as a capability-less uid), failing every microVM provision.
It
must not run on this backend. Three candidates:
-
Option A (rejected): grow
ContainerRuntimewith anArmEgress(ctx, id, EgressPolicy) errorverb (podman impl = today’s exec moved verbatim; microVM impl = no-op). Clean in the abstract, but it violates the interface’s freeze discipline — the surface was deliberately completed at S1 so “no interface change lands after S1” (podman.go:379-388, theResizereservation) — and it touches every fake and the shared contract suite for a verb one backend no-ops. Largest blast radius of the three. -
Option B (rejected): hoist arming into both backends’
Start(podman gains a session table to carry the spec from Create to Start, and issues the same exec from insidePodmanCLI.Start). This deletesarmEgressentirely and makes always-armed a property of every backend — butPodmanCLIis stateless today and the podman path is under an explicit changed-nothing constraint (parent § V3 “the podman path unchanged”, microvm-runner.md:496). Restructuring podman internals to satisfy a microVM milestone is the opposite of small blast radius; rejected. -
Option C (recommended): a backend capability probe in
AgentRuntime.provision.MicroVMRuntimegains one exported marker method,EgressArmedInGuest() bool(returns true), NOT on theContainerRuntimeinterface.AgentRuntime.provisiontype-asserts an unexported single-method interface and skipsarmEgresswhen the backend self-arms:// in agent.gotype inGuestEgressArmer interface{ EgressArmedInGuest() bool }func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error {if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() {if err := r.armEgress(ctx, id, spec.Egress); err != nil {return err}}// installCredentials, ensureCheckoutDir unchanged (agent.go:294-297)PodmanCLIdoes not implement the probe, so the assertion fails and the existingarmEgressexec runs byte-identically (agent.go:300-309); fakes don’t implement it either, so every existing hermetic test (e.g.TestEgressIsArmedAsRootNotTheAgentUser, agent_test.go:213-216) keeps passing unchanged. The frozen interface is untouched. Smallest blast radius: one marker method + one guarded call site.
(d) The guest arm: guestd runs the script as root, fail-closed
Section titled “(d) The guest arm: guestd runs the script as root, fail-closed”Provision (supervisor.go:141-169) replaces its CodeUnimplemented branch
with the real arm. Handler order (all before the stateProvisioned
transition):
- Validate
default_exec_uidnon-zero (existing,supervisor.go:150-153). - Check state: still refuse when not
stateReady(supervisor.go:161-163) — this also guarantees the arm’s preconditions:stateReadymeans net bringup completed, so the guest IP, default route, and/etc/resolv.confexist (go/internal/guestd/net.go:24-34), which the script requires (it parses resolv.conf for the DNS carve-out and resolves allowlisted hosts viagetent,egress.go:71-79,109-115; D6, microvm-runner.md:747-748). - Arm when
nft_scriptis non-empty: spawn/bin/sh -c <script>as a direct child with nosyscall.Credential— it inherits guestd’s own identity, guest root with full capabilities. This is deliberately a separate spawn path from exec children: it never passes throughresolveUID/newCredential(supervisor.go:53-61,428-441), is never entered in the exec table, and is not reachable from the wire as an exec. The child is bounded by a 120 s deadline (mirroring podman’s per-commanddefaultCommandTimeoutthat bounds the same script today,podman.go:391-394) and by the RPC context.nft,getent, andawkship in the guest rootfs (guest-image/default.nix:321-323); the/bin/shthe arm spawns is added as a W1 deliverable (not currently linked — the toolchain provides/bin/bash, so always-arm §(e) makes an explicit/bin/shlink load-bearing; see W1). - On a non-zero exit, timeout, or spawn failure: return a
connect.CodeInternalerror carrying the script’s exit status and a bounded stderr tail, and do not transition state — the gate staysstateReady, sorequireProvisionedkeeps refusing every exec (supervisor.go:415-423). The script’s ownset -eumakes partial installs abort non-zero (egress.go:76-83), so a failed arm never leaves a half-open ruleset presented as success. - Only after a successful arm (or an empty script, §(e) hermetic carve-out):
stateProvisioned, record uid/env, respond OK.
The end-to-end fail-closed contract, restating the parent’s
(microvm-runner.md:144-155) in as-built terms: a non-empty nft_script that
fails to arm ⇒ Provision returns an error ⇒ MicroVMRuntime.Start returns
an error and its deferred vm.Shutdown tears the freshly booted VM down
(microvm_lifecycle.go:286-295,305-310) ⇒ AgentRuntime.createAndStart’s
caller sees a failed launch and the partial container is removed
(agent.go:274-284, proven by TestFailedProvisionRemovesThePartialContainer,
agent_test.go:256-259) ⇒ session start fails. At no point does an armed-less
VM serve an exec: the gate is closed the entire time, and Provision’s
already-provisioned refusal (supervisor.go:157-160) means a successful arm
can never be re-run or replaced from the wire — the re-arm surface the V8
escalation probe attacks stays closed (microvm-runner.md:618-621).
Holding supervisor.mu across the arm is acceptable and simplest: Provision
is once-per-session and Health is lock-free (supervisor.go:122-125). An exec
arriving mid-arm blocks on s.mu in requireProvisioned
(supervisor.go:415-423) and, after a successful arm, proceeds — it serializes
behind the once-only arm rather than being refused; a graceful Signal/Stop
also takes s.mu (supervisor.go:558-563), so it blocks for up to armTimeout
behind a wedged arm, bounded by the host Stop escalating to a hard VMM kill
(OQ-4).
(e) Always-arm: the zero-value EgressPolicy is default-deny
Section titled “(e) Always-arm: the zero-value EgressPolicy is default-deny”EgressPolicy is fail-closed by construction: the zero value’s NftScript()
is the full default-drop base ruleset with an empty allowlist
(egress.go:29-34,109-115) — there is no “no policy” representation.
MicroVMRuntime.Start therefore always sends
session.nftScript (never empty for a session created through
ContainerSpec), and every microVM session boots default-deny even when a
direct ContainerRuntime caller never set Egress. That is a deliberate
divergence from podman, where a caller that skips armEgress gets an
unfirewalled container: on this backend a silent open-egress VM is
structurally impossible, which is the stronger reading of the parent’s
integrity model (microvm-runner.md:141-151).
Consequences, handled explicitly:
- The V2b conceded-divergences list (microvm-v2b-guest-supervisor-exec.md:
580-593) gains row (7): “microVM sessions are always armed default-deny at
Start; podman containers are armed only by
AgentRuntime.” Asserted by a contract-suite row like the other six. - Existing KVM-gated suites (the contract suite, the lifecycle e2e, the boot
benchmark — contract_microvm_test.go:28-32,73-80) will now boot armed
guests. Their exec traffic is loopback/vsock-only, which the base ruleset
carves out (
egress.go:109-115loopback accept; vsock is not IP, microvm-runner.md:166-171), so no row regresses; W3 verifies this claim on hardware rather than assuming it (OQ-3). - Always-arm makes first-boot netfilter module autoload load-bearing. The
first
nftinvocation post-boot triggers on-demand netfilter kernel-module autoload, which the guest image supports via its/lib/modulestree (guest-image/default.nix:338). Because every microVM Start now arms, a broken autoload path fails every microVM launch, not only egress-using ones — always-arm converts a latent packaging bug into a total-backend outage. W3(1)/(4) is the first hardware exercise of this path and is exactly the test that proves the assumption holds (OQ-3). - guestd still accepts an empty
nft_script(skip the arm, provision the gate) — the hermetic V2b supervisor tests and any non-Linux harness driveProvisionwithout an in-guest nft. The host production path never sends empty; the acceptance is a test seam, documented in the proto comment (§(f)). Because it opens the gate with no arm, it is a deliberate, test-only divergence from the parent’s arm-before-gate sentence — flagged for the pre-freeze ruling in OQ-7, not resolved silently here.
(f) Proto: no wire change — confirmed
Section titled “(f) Proto: no wire change — confirmed”Provision(ProvisionRequest) returns (ProvisionResponse) and
ProvisionRequest{nft_script = 1, default_exec_uid = 2, base_env = 3} exist
as seeded (guest_control.proto:76-80,177-188). V3 adds no field, no RPC,
no semantic change to any existing field — only the doc-comments that
currently say “a non-empty script is unimplemented in V2b”
(guest_control.proto:77-79,178-180) are updated to describe the arm.
Comment-only, buf breaking-safe by construction, internal-go lane only, no
regeneration semantics change.
Global Constraints
Section titled “Global Constraints”Every task below inherits these.
- Fail-closed arm. A non-empty
nft_scriptthat does not arm successfully failsProvision, leaves the supervisor instateReady(exec refused,supervisor.go:415-423), failsStart, and tears the VM down (microvm_lifecycle.go:286-295). No code path may open the exec gate before a requested arm has succeeded (parent, microvm-runner.md:152-155). - The agent never holds NET_ADMIN. The arm runs as guestd’s own root
identity via a spawn path unreachable from the exec surface; every wire exec
is non-root with an empty capability set (
supervisor.go:55-61,guest_control.proto:99-102; D6, microvm-runner.md:741-743).spec.CapAddstays ignored on this backend (microvm_lifecycle.go:140-143). - The podman path is byte-identical. No change to
createArgs, toarmEgress’s exec (agent.go:300-309), or to any podman argv;PodmanCLIignoresContainerSpec.Egressand does not implement the (c) probe. The existing podman suites run unchanged. EgressPolicy/NftScript()consumed unchanged (egress.go:71-107) — same script on both backends, per the parent’s V3 Interfaces (microvm-runner.md:498-502).- No proto wire change. Doc-comment updates only (§(f));
buf lint+buf breakinggreen; internal-go lane only. - Frozen
ContainerRuntimeinterface untouched. The (c) probe is a marker method onMicroVMRuntime+ an unexported assertion inAgentRuntime, never an interface verb (podman.go:379-388discipline). - KVM-gated vs hermetic split (V2b GC, microvm-v2b-guest-supervisor-exec.md:
605-615): everything booting a VM carries the microvm build tag and
microvmtest.Require(t); the guestd arm logic, the spec→request threading, and the probe/skip logic are tested hermetically. - External-reference gate. Compass-tracked files only; no private names beyond RIG-NNN.
W1 (guestd arm) and W2 (host threading) are independent until W3 integrates them; both are hermetically testable. W3 (KVM-gated egress integration) is the milestone’s acceptance gate and consumes W1+W2.
W1 — guestd: arm nft_script as guest root, fail-closed
Section titled “W1 — guestd: arm nft_script as guest root, fail-closed”Replace the CodeUnimplemented branch in supervisor.Provision
(supervisor.go:146-149) with the §(d) arm: root spawn seam, 120 s bound,
CodeInternal + stderr tail on failure, state untouched on failure,
stateProvisioned only after success. Update the proto doc-comments (§(f))
and the Provision handler comment (supervisor.go:137-140).
- Interfaces: produces the arm seam on the supervisor,
armFunc func(ctx context.Context, script string) error(a struct field besidenewCredential credentialFunc,supervisor.go:96-98), with the production implementationrunNftScript(ctx context.Context, script string) errorspawningexec.CommandContext(ctx, "/bin/sh", "-c", script)with noSysProcAttr.Credential(inherits guestd’s root),CombinedOutputcaptured into the returned error, ctx bounded byarmTimeout = 120 * time.Second. ConsumesProvisionRequest.nft_script(guest_control.proto:177-180) unchanged. Handler contract: empty script ⇒ no arm (hermetic seam, §(e)); non-empty ⇒ arm before the state transition; arm error ⇒connect.NewError(connect.CodeInternal, …)and state staysstateReady. - Test cycle (hermetic, no KVM): with an injected
armFunc— (1) non-empty script invokes the seam with the exact script bytes, and success transitions tostateProvisioned; (2) seam error ⇒ Provision returnsCodeInternal,requireProvisionedstill refuses Exec/ExecStream, and a retried Provision may run (state stillstateReady); (3) empty script skips the seam (the V2b hermetic behavior, preserved). This task REPLACES the existing non-empty→CodeUnimplementedassertion (supervisor_test.go:114-121): post-W1 a non-empty script invokesarmFunc, so that row is rewritten into the seam-invoked/CodeInternalassertions of (1)/(2) — W1 must not leave the oldCodeUnimplementedexpectation, which would fail against the new arm. (4) already-provisioned refusal still holds after a successful arm (no wire re-arm). Plus a real-/bin/shrow:armFunc = runNftScriptwith anexit 7-style script ⇒ error carries the exit status (no root or nft needed to prove the failure path; the host’s/bin/shalways exists, so this row cannot guard the guest’s/bin/sh— only W3/KVM can, which is why the guest link below is a MUST, not a test-guarded SHOULD). W1 MUST also add the/bin/shcontract link to the guest image beside thenft/getent/awklinks (guest-image/default.nix:321-323):ln -sf ${pkgs.bashInteractive}/bin/sh $out/bin/sh(bashInteractive is already in the rootfs closure via the toolchain, so zero added closure — W1 verifies it providesbin/sh; else the busyboxshthe initrd already uses,guest-image/default.nix:221). Under always-arm (§(e)) EVERY microVM Start spawns/bin/sh -c <script>, so a missing guest/bin/shis a total-backend outage, not an egress-only one — the same hazard class as the netfilter-autoload assumption, so the link is a load-bearing W1 deliverable.
W2 — host: thread spec.Egress to ProvisionRequest.nft_script; probe-and-skip armEgress
Section titled “W2 — host: thread spec.Egress to ProvisionRequest.nft_script; probe-and-skip armEgress”The §(a)+(c) host half: ContainerSpec.Egress, the session capture, the
Start-intrinsic delivery, the AgentRuntime probe.
- Interfaces: produces
ContainerSpec.Egress EgressPolicy(new field,podman.go:88-114; doc-comment states podman ignores it — the podman arm ridesAgentRuntime.armEgress);microvmSession.nftScript stringrecorded inMicroVMRuntime.Createasspec.Egress.NftScript()(microvm_lifecycle.go:164-172);MicroVMRuntime.Start’s Provision call gainingNftScript: session.nftScript(microvm_lifecycle.go:305-308);- launch + client seams on
MicroVMRuntimesoStartis hermetically testable — two unexported fields mirroring the supervisor seam pattern (newCredential credentialFunc,supervisor.go:96-98; W1’sarmFunc):launchFunc func(context.Context, microvm.BootConfig) (guestVM, error)defaulting to a thin adapter overmicrovm.Launch(real signaturefunc(context.Context, microvm.BootConfig) (*microvm.VM, error),microvm/launch.go:111— NOT*MicroVMConfig).guestVMis a new unexported interface. Its method set is not just whatStartcalls — it retypes the shared fieldmicrovmSession.vm(*microvm.VM→guestVM), so it MUST cover every method invoked on that field across the package (across ALL build tags, incl.//go:build microvmtests). That set is four:Health(context.Context) (*compassv1.HealthResponse, error)andShutdown(context.Context) error(called byStart/awaitHealthy,microvm/launch.go:335,350);WaitVMMExit(time.Duration) bool(microvm/launch.go:413), whichMicroVMRuntime.Stopcalls onsession.vmbeforeShutdown(microvm_lifecycle.go:531); andPSS() (map[string]int64, error)(microvm/launch.go:456), which the Q-budget contract test calls assession.vm.PSS()(contract_microvm_test.go:108,//go:build microvm && unix, packageruntime) — omitting either breaks that caller’s compile (the same field-retype hazard for both, one in prodStop, one in a build-tagged test).*microvm.VMsatisfies all four as-is.awaitHealthy’s parameter also retypesvm *microvm.VM→vm guestVM(microvm_lifecycle.go:334), sinceStartpasses thelaunchFunc-returnedguestVMinto it (it calls onlyvm.Health, already in the interface). The interface is required, not cosmetic:vm.Healthitself re-dials viaGuestClient(microvm/launch.go:336), and*microvm.VMhas all-unexported fields with no exported test constructor — so a fake handle behind the interface is the only way to answerHealthfrom packageruntime;newGuestClient func(socket string, port uint32) compassv1internalconnect.GuestControlClientdefaulting tomicrovm.GuestClient(microvm/dial.go:116, already returns the interface), routing the Provision clientStartbuilds fromsession.cfg(microvm_lifecycle.go:304) so a fake client answersProvisionwith no real vsock dial. Scope: this seamsStart’s Provision client only;Stop’s ownGuestClientdial (microvm_lifecycle.go:525, for the gracefulstopGuestSignal) stays a direct real dial, out of W2’s hermetic-Start scope — a future hermetic Stop test would seam it then. Together the fakeguestVM+ fakeGuestControlClientmake row (4) build and run with no real VMM and no real dial. This is the fold for the BLOCKER: todayStartunconditionally spawns a real cloud-hypervisor and re-dials the guest, so test row (4) below is not buildable without both seams. Rejected alternative: move row (4) to W3 KVM — that surrenders W2’s independent hermetic proof of the script-delivery + fail-Start contract, so the seams are preferred;
func (m *MicroVMRuntime) EgressArmedInGuest() bool { return true }(marker, NOT onContainerRuntime);- the unexported probe in
agent.go:type inGuestEgressArmer interface{ EgressArmedInGuest() bool }, checked at the top ofAgentRuntime.provision(agent.go:290-293) to skiparmEgresswhen satisfied;armEgressitself unchanged (agent.go:300-309); plus a one-line pointer comment beside theContainerRuntimefreeze note (podman.go:379-388) naminginGuestEgressArmer, so a future backend — or aContainerRuntimedecorator, which would otherwise swallow the marker and silently re-enablearmEgresson the microVM backend (a loud but hard-to-diagnose launch failure) — discovers the probe; AgentRuntime.createAndStartsettingEgress: spec.Egressin theContainerSpecliteral (agent.go:263-272). ConsumesEgressPolicy/NftScript()unchanged.
- Test cycle (hermetic): (1) a fake runtime WITHOUT the marker still
receives the
armEgressexec (existingTestEgressIsArmedAsRootNotTheAgentUserandTestLaunchOrdersStagesEgressBeforeCheckoutDir, agent_test.go:146-149, 213-216, green unchanged — the podman-shape regression guard); (2) a fake runtime WITH the marker receives NO nft exec and provision proceeds to credentials/checkout; (3)MicroVMRuntime.Createrecords the zero-value policy’s full default-deny script (never empty, §(e)); (4) via thelaunchFunc+newGuestClientseams — a fakeguestVMwhoseHealthreports ready with the minted nonce, and a fakeGuestControlClientthat records theProvisionRequest— aStarttest asserts theProvisionRequestcarries the recorded script verbatim and that a Provision error failsStartwith the fake handle’sShutdowncalled; hermetic, no real VMM and no real vsock dial (NOT modelled onserveFakeGuest, which returns a*GuestExecover a plain unix listener that does not speak the vsock CONNECT preamble and cannot answerHealth); (5) podman argv snapshot:createArgsoutput for a spec withEgressset is byte-identical to before the field existed.
W3 — KVM-gated in-guest egress integration + V8 alignment
Section titled “W3 — KVM-gated in-guest egress integration + V8 alignment”The parent’s V3 test cycle verbatim (microvm-runner.md:503-506) plus the §(e)
always-arm verification, as a //go:build microvm-tagged suite beside
microvm_lifecycle_microvm_test.go / contract_microvm_test.go, each test
opening with microvmtest.Require(t).
- Interfaces: consumes W1+W2 through the public
MicroVMRuntime/AgentRuntimesurfaces and themicrovmpackage’s direct-dial harness (microvm.Launch+GuestClient, the boot_microvm_test.go:155-159 pattern) — no new production code. Produces the KVM-gated test files only. - Test cycle (KVM-gated):
- Allowlisted reachable / non-allowlisted blocked, both families: boot
a session whose
ContainerSpec.Egressallowlists one real host; in-guest execs (agent uid) show the allowlisted host connects and a non-allowlisted raw IPv4 and IPv6 destination time out — mirroring the podman lifecycle proof (lifecycle_test.go:137-140) inside the guest netns. - Arm-failure ⇒ teardown ⇒ start fails: drive guestd directly
(
microvm.Launch+GuestClient) with a Provision whose script isexit 1: the RPC errors, a follow-up Exec is gate-refused; then at the runtime layer assert a failed Provision propagates out of Start with the VM torn down (no VMM process, socket dir cleaned — the Start-teardown assertions of microvm_lifecycle_microvm_test.go:5-9). - Post-arm agent-uid exec cannot alter the ruleset:
nft flush rulesetas the agent uid exits non-zero (empty capability set,supervisor.go:55-61); the allow/deny behavior of (1) still holds afterward. - Always-arm holds and regresses nothing: a session created with the zero-value policy boots default-deny (external egress blocked), and the existing KVM contract suite + lifecycle e2e stay green over armed guests (the §(e)/OQ-3 verification); the V2b divergence list gains its row (7) contract-suite assertion.
- V8 alignment: confirm rows (2) and (8) of the parent’s V8 cycle (microvm-runner.md:609-621) are satisfiable by these tests — (2) is this suite run under the full backend; (8)’s re-arm half is covered by W1’s already-provisioned refusal plus the peer-CID listener V2b built. No new V8 scope lands here; W3 leaves a pointer comment in the suite header.
- Allowlisted reachable / non-allowlisted blocked, both families: boot
a session whose
- W1 — guestd
Provisionarmsnft_scriptas guest root (replacesCodeUnimplemented), fail-closed, gate stays closed on failure - W2 —
ContainerSpec.Egressthreaded Create→Start→ProvisionRequest;AgentRuntime.provisionprobe-and-skipsarmEgresson self-arming backends (podman path byte-identical) - W3 — KVM-gated in-guest egress integration suite (allow/deny both
families, arm-failure teardown, agent-uid
nft flushrefused, always-arm verification) + V8 alignment
Open Questions
Section titled “Open Questions”Batched for the pre-freeze ruling; the body designs against each recommendation.
- OQ-1 (load-bearing) — arm timing vs the parent’s phrasing. The parent
describes V3 as “
AgentRuntime.provision’s arm path routed through it [the Provision step] on the microVM backend” (microvm-runner.md:494-496), which could be read as the arm happening atAgentRuntime.provisiontime (post-Start), as on podman. As built, V2b buried the gate-opening Provision RPC insideStart(microvm_lifecycle.go:304-310), and the contract suite freezes Start→Exec identity across backends — so the only consistent completion is arming inside Start andprovisionskippingarmEgress(§(b) Option 1, §(c) Option C). This strengthens the parent’s “armed before exec acceptance” (microvm-runner.md:152-155); it is flagged because the parent record is frozen and the literal routing differs. Recommendation: ratify Start-intrinsic arming as the correct reading. - OQ-2 (load-bearing) — the (c) probe mechanism. Marker-method probe
(recommended, §(c) Option C) vs growing the frozen
ContainerRuntimeinterface (Option A). The probe keeps the interface frozen and the blast radius at one call site; the interface verb is the more discoverable shape but contradicts the S1 no-interface-change discipline (podman.go:379-388) and touches every fake. Recommendation: Option C. - OQ-3 (load-bearing) — always-arm on the microVM backend (§(e)). Every
microVM Start arms at least default-deny, including direct
ContainerRuntimecallers (the KVM contract/e2e suites), a conceded divergence (7) from podman. Risk: an existing KVM row that needs external egress would start failing — believed none (exec traffic is loopback/vsock), verified on hardware by W3(4) before freeze is exercised. Fallback if a row genuinely needs egress: that row sets an allowlistingEgresson its spec, not a bypass. Recommendation: always-arm. - OQ-4 (non-load-bearing) — holding
supervisor.muacross the arm. Simplest and safe (§(d)): Provision is once-per-session and Health is lock-free (supervisor.go:122-125). Two precise notes on the lock’s reach, neither changing the tag: an exec arriving mid-arm blocks ons.muinrequireProvisioned(supervisor.go:415-423) and, after a successful arm, proceeds — it serializes behind the once-only arm rather than being “refused anyway”; and a gracefulSignal/Stop takings.mu(supervisor.go:558-563) blocks for up toarmTimeoutbehind a wedged arm, bounded by the host Stop escalating to a hard VMM kill. The alternative (an intermediatestateArming) adds a state for no observable benefit. Recommendation: hold the lock. - OQ-5 (non-load-bearing) — arm bound = 120 s. Mirrors podman’s
defaultCommandTimeoutbounding the same script today (podman.go:391-394); the dominant cost isgetentDNS resolution of the allowlist against passt’s resolver. Also bounded by Start’s ctx/boot deadline. Recommendation:armTimeout = 120 * time.Second, a guestd const. - OQ-6 (non-load-bearing) — error code for a failed arm.
CodeInternalwith exit status + bounded stderr tail (distinct from theCodeInvalidArgumentuid refusal andCodeFailedPreconditiongate/state errors,supervisor.go:150-163). The host wraps it into Start’s error either way. Recommendation:CodeInternal. - OQ-7 (load-bearing) — the empty-
nft_scriptgate-open seam vs the parent’s arm-before-gate sentence (§(e)). guestd still accepts an emptynft_script— skipping the arm and opening the exec gate with no ruleset — as a hermetic test seam (the V2b supervisor tests, non-Linux harnesses). This literally contradicts the frozen parent’s “Only after a successful arm does the supervisor accept exec requests” (microvm-runner.md:152-155). It is production-safe: the host path never sends an empty script (NftScript()has no empty representation — it always emits at least the default-deny base ruleset,egress.go:88-90), so no real session opens the gate unarmed. The contradiction is therefore test-seam-only and deliberate, but under the RIG-2675 posture a detailing record must flag any contradiction with the frozen parent rather than resolve it in body prose. Recommendation: ratify the empty-script acceptance as a test-only carve-out (production stays always-armed), decided alongside OQ-1’s timing reading.
Ledger assessment
Section titled “Ledger assessment”Ledger impact: none. The egress substrate (in-guest nft, guestd-as-root arm,
no NET_ADMIN on the workload, userspace net backend) is D6, already frozen in
the parent (microvm-runner.md:733-750); this record fills its behavior and
resolves implementation-shaped forks (OQ-1..3, OQ-7) that stay inside the microVM
record’s own decision numbering. No new cross-cutting decision;
docs/designs/DECISIONS.md untouched (verified: it has no microvm/egress
rows).