Skip to content

sandbox: contain the interface the packets actually leave by, and put it in the launch path - #996

Merged
pmilic021 merged 58 commits into
MakePrisms:mainfrom
maxy-player:w-gvisor-interface-impl2
Sep 18, 2026
Merged

pmilic021 merged 58 commits into
MakePrisms:mainfrom
maxy-player:w-gvisor-interface-impl2

Conversation

@maxy-player

@maxy-player maxy-player commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Job-local veth containment for gVisor jobs: the rendered policy is translated onto the interface the packets actually leave by, and applied in the launch path rather than beside it.

Why. Under runsc the job's network stack is in userspace; the host OUTPUT chain never sees its connections, so an iptables-only policy does not contain it. tc/flower on the job's own veth does, because every packet crosses that link whatever stack produced it.

Head is d6774e4e, on base b9ee77f1 (27 commits) — which is the current main of the base repo. The branch contains it, is 0 commits behind, and git merge-tree reports a clean merge. An earlier revision of this description said main had moved to b45f8651 and that this branch was not rebased onto it; that was the fork holding the head branch, not the PR's base repo, and the statement was wrong.

Renewed corrective round 1 — head c57e6c2f

Five residual production-path defects, each closed with a test whose negative control was observed RED before it was observed green, and whose file was then restored byte-identical.

  • Creation fencing reached only the holder. establish's sidecar creates were unfenced, so cleanup's settle-wait could pass at count zero and issue removes for a container that did not exist yet; the container then arrived with nobody holding it, pinning the namespace. Control: cleanup returned in 302.6ms mid-create.
  • Custody could end on the client alone. A SIGKILLed client is reaped with a status and no exit code, and the old rule let that flag release the name — though a killed client proves nothing about what the daemon did. Custody now ends only on daemon-side absence.
  • Evidence path confinement was lexical. A log path with no .. and no leading / could still be a symlink to another run's green log. corroborate now resolves both sides, requires containment, and reads the path it just checked.
  • establish had no test at all — the fault behind the proxy and cancellation findings, since a fixture can agree with a bug the production path does not survive. Two gates now drive the real function: one proves the address establish measures is the address its rendered plan pinholes; the other cancels mid-create and proves cleanup outlasts the create and removes the holder it made. They run through a spawn-site seam, so every rendered argv the plan tests assert is the argv production runs. The cancellation gate cancels on a marker written as the create starts, not on a stopwatch — a fixed deadline raced the probe and measured nothing.

The seam is deliberately named outside the MAXPLAYER_ prefix, which is reserved for config and refuses unknown variables fail-closed; the first version was caught by the full suite refusing to start 14 unrelated tests.

Correction at d6774e4e: the test seam was a production boundary

At c57e6c2f the docker client this crate spawns was selectable by an environment variable, in shipped builds. Anyone able to set a variable on the process could have redirected every containment command — create, inspect, remove — at a binary of their choosing: a security regression acquired through testing, which is the worst way to acquire one.

Production selection is now the constant docker under #[cfg(not(test))] with no environment read on the path at all; the override is a process-local static compiled only under #[cfg(test)] and restored on drop. Both gates that run through the seam were re-observed RED against the new mechanism before being accepted green — plan rendered from a literal instead of the measured address, and fence released before the create (cancellation returned in 173.3ms) — with the source restored byte-identical after each.

What the two establish gates do and do not prove

They drive the real establish — its real ordering of probe, fence, create, render, apply, read back, and its real cancellation and cleanup — and they catch real defects in it, as the controls above show. That is offline unit credit for ordering, cancellation and plan-address propagation.

They are not evidence of runtime proxy connectivity, of a real launch, or of cancellation against a real daemon: a stand-in client answers as it is scripted to and cannot prove a packet reached a proxy. Claims about actual launch/proxy/cancellation behaviour rest on the live matrix below, not on these.

Gates at this head (lima gvisor-repro, 6.8.0-139-generic aarch64, runsc-release-20260817.0, docker-29.1.3): live matrix 20 passed / 0 failed (86.97s) · 995 DNS 9/0 (44.77s, functional lines payload runtime=runsc, resolvers 172.29.7.53, fd00:d57::53, 2001:db8:bb::53) · numeric egress 1/0 (17.54s) · offline suite 1646 passed / 0 failed / 13 ignored · acceptance ACCEPTED (offline), exit 0.

Both test binaries were rebuilt at this head and their content hashes differ from the previous head's — matrix e8bc681b… (was b8136713…), lib 0e7063b3… (was 1e001c03…) — which is what rules out a stale artifact. The sidecar image sha256:43d80f022dce… is tc-capable (/sbin/tc, iproute2-v7.0.0). Running the matrix without --ignored exits 0 printing 1 passed; 20 ignored: a green that measures nothing.

Sidecar image. The tc-capable image is built from docker/maxplayer-netfilter/ on this branch and is not published by this lane; a deploying owner builds or loads it under its own authority. Identity of the image these gates ran against: sha256:43d80f022dce98ccceb3d299ce92e3859279cb17d93e990927e74644c2fd2d6c, arm64/linux, Alpine, /sbin/tc iproute2-v7.0.0. It has no registry digest because it was never pushed. Production prepare_launch hardcodes …:v0.5.9, so that tag was applied to the local image inside the VM only, used, and then removed (0 such tags remain).

What is here

  • sandbox_iface — renders a NetPolicy into a per-job IfacePlan (clsact egress, flower drops, both families, ordered), with an argv builder for the sidecar and a readback verifier.
  • sandbox_netns::establish — measures the job's link by unprivileged probe, derives the plan, applies it through the netfilter sidecar, cross-checks the applied count and then verifies by independent readback. Fail-closed: any ? on the path destroys the namespace via the holder guard, so a job never starts with partial filters.
  • docker/maxplayer-netfiltertc/iproute2 added, plus apply-iface.
  • A neighbour-discovery exception, narrowed on three axes together (ICMPv6 type, destination, hop limit 255) and enforced at both hooks. Without it a contained job cannot resolve its own gateway; with it widened, the counter-control below goes red. No destination denial was removed to get there.
  • A 20-leg live containment matrix, #[ignore]d, replayed on a real gVisor host.

Gates that ran

Offline, this head:

cargo test  -p maxplayer-core --features acp,wallet --lib -- sandbox   # 124 passed, 0 failed
cargo check -p maxplayer-core --features acp,wallet --all-targets      # clean

Live, in a disposable lima VM (Docker 29.1.3, runsc registered), with every binary and the sidecar image built from this head before the run:

live gate result
containment matrix (sandbox_netns_live) 20 passed, 0 failed
DNS gates (sandbox_dns_live) 9 passed, 0 failed
egress gate (sandbox_egress_live) 1 passed, 0 failed

The branch's own acceptance gate, scripts/sandbox-acceptance.sh, run against the saved record for this head: ACCEPTED (offline), exit 0, with 1676 passed / 0 failed in the offline suite it runs first. That gate now opens each log the record names and requires the named test to appear in it with an ok result.

  • matrix binary sha256 a4349e53b1c7c5ab9ad7e3c77ddaade176ba3697122a5b544430c4c4d30086d4
  • lib binary sha256 930cd590f74fcd34a3bd721218dc71e04a206993554edd7bb436ad6053acc246
  • sidecar image id 43d80f022dce…, built from this branch's docker/maxplayer-netfilter/

It can still fail

A green matrix is worth only the failure it can produce. The rule that admits the DNS pinholes was re-pointed at a resolver the gate does not configure, leaving production behaviour untouched, and the leg went red:

production installed a pass rule the operator did not write:
  [("53", "192.0.2.53"), ("53", "192.0.2.53")]
  — a pinhole wider than its configuration is a hole.

The matrix also carries a counter-control (integrated.denied.v6-link-local) that fails if the ND exception is "fixed" by widening it.

Relationship to the DNS lanes — corrected

An earlier revision of this description said this branch was "independent of the DNS lanes #991 / #995 — different layer, no shared files." That is no longer true and is withdrawn. #995 has merged, and this branch is now rebased across it and integrates with it:

Rule shape is not working DNS

The containment matrix configures 192.0.2.53 — TEST-NET-1, routed nowhere, answering nothing. It exists to make the rendered rule set deterministic. A rendered --dport 53 -j ACCEPT and an enforced one produce the same green there, so nothing in that file should be cited as evidence that a contained job can resolve a name. f8b075ef writes that limit into the doc comments (comments only, no assertion changed).

What establishes DNS is 995's own live suite, run on this head against a real dnsmasq fixture that answers — in-crate modules, so the integration-test binary never contained them:

  • real resolution over private v4 and v6 through the written resolv.conf
  • a truncated UDP answer falling back to TCP 53 inside containment
  • denied-neighbour controls: another private address, and a non-53 port on the resolver itself
  • host-stub discovery handing the job a canonical upstream, not the stub
  • refusal when no usable resolver exists, before a holder or payload exists
  • an end-to-end git fetch/push of an exact commit over DNS

These are gVisor results: the harness cross-checks docker's view of the runtime against the payload's own kernel banner and refuses to count a runc run.

One note in the interest of not overclaiming: 995 marks known_open_preexisting_defect_other_private_and_non53_must_be_denied as KNOWN OPEN, failing under runsc. It passes on this head. That is consistent with veth filters enforcing what an iptables-only policy cannot under runsc, but I did not run the base baseline needed to attribute it, so it is recorded as observed and unattributed, and 995's ignore attribute is left exactly as written.

Still independent of #991 (host plane); no host-plane work here.

Limits

  • TCP only; no UDP claim either way.
  • IPv6 is covered by rule readback, the ND legs and unit tests; the live connection legs are IPv4 (the fixture network is v4-only).
  • One host, one kernel, one runsc build.
  • This must ship with a new sidecar image. The sidecar pin is not a literal — sandbox_netns.rs builds it as concat!("ghcr.io/makeprisms/maxplayer-netfilter:v", env!("CARGO_PKG_VERSION")) — so the v0.5.9 release cut moved it automatically. main's docker/maxplayer-netfilter/Dockerfile still installs no iproute2, so the published v0.5.9 image has no tc: until an image carrying this branch's Dockerfile is built and rolled, merging this protects nothing in production. No image was published by this branch; the gate ran against a VM-local tag that shadowed nothing (no image existed under it beforehand).
  • Binary provenance is reproducible, not merely corroborated. At the preceding head, reverting the negative-control edit rebuilt the matrix binary to the same sha256 75b17b5a… bit-for-bit, which is what makes the run attributable to committed source rather than a mutated tree. There is still no signed commit-to-artifact chain.
  • Still not closed from the round-2 review, and not claimed as closed: the startup-injection matrix is still only the nonexistent-network case; IPv6 prefix comparison still strips only /32 or /128 rather than normalizing equivalent spellings; known_open_preexisting_defect_other_private_and_non53_must_be_denied passes here under proven runsc but attribution to this branch is unestablished (it needs a baseline build of b9ee77f1, and the VM had ~1.2G free). The VM-host before/during/after egress measurement is now closed — see below.
  • Build environment. The current Linux test binary and sidecar were built inside the disposable VM. An earlier revision of this branch cross-built its binary on a shared colima daemon this lane was not authorized to use; that was ruled a scope breach during review and is disclosed here rather than quietly repaired. No gVisor runtime work ever ran on colima.
  • seller_node::lock::tests::second_acquire_fails_closed_while_first_is_held failed once under a parallel full-suite run and passed in isolation and on one full re-run; provenance not established. An earlier revision called it a baseline flake — that attribution is withdrawn: one failure and two passes cannot separate a pre-existing flake from one this branch introduced. Repetitions to settle it were not authorized, so nothing is claimed about its cause.

No merge, no tag, no main push, no deployment, no image publication.

Round-2 review fixes (f8b075ef3a43ad045d7837dfdf93a558)

Three commits, each a code fix with a control that fails without it.

1. A sidecar could lose custody of a client that was still alive. run_bounded reported only the command's Result, so a client that was killed or timed out looked like one that exited, and run_sidecar deregistered custody on both — a live container could stop being a cleanup target. run_bounded_tracked now returns (Result, bool child_exited) and custody ends only on a reaped client.

Worth stating: my first control for this stayed green when I reverted the fix — it tested the flag, not the decision that consumes it. 5d7837df adds run_sidecar_with_deadline (deadline parameterised so the timeout path is reachable offline) and asserts at the decision site; reverting the fix now fails it at sandbox_netns.rs:1799.

2. Malformed readback lines were silently tolerated. The parser accepted repeated ! flags, a dangling trailing !, and a bare value before any flag — so a rule whose shape it did not understand could read back as something benign. ReadbackRule now carries malformed, value() returns None for such a line, and verify_readback refuses the whole readback. Malformed lines are kept, not dropped: dropping them would shrink the rule count and hide an unexpected rule, which is the failure this is meant to catch. Four controls; reverting the parser change turns three of them red.

3. A saved row named a log, and nothing checked the log. The record named a case id, an outcome and a log file, and the validator only checked that the log field was nonempty text — it never opened it. SavedCase now carries a test field, and sandbox_evidence::corroborate opens each named log and requires test <name> ... ok to appear in it (refusing absolute or .. paths). It is wired into the acceptance gate.

This does not establish that a test's assertions are the right ones for its case id; only reading the test body settles that. Two mappings are named rather than buried: integrated.denied.neighbour-port and integrated.exception.proxy-pinhole are both asserted by a test that installs the full plan through the sidecar against the canary rather than through a production launch, so the integrated. prefix overstates the path.

What fix 3 immediately caught: a false row

host.unaffected.during-cleanup is required by REQUIRED_CASES, and that id appeared in exactly one place in the repo — the requirement list. No test asserted it, and the round-2 record scored it connected anyway, on the strength of the sibling cleanup test running nearby. That test probes only from inside containers, and a container-side probe cannot observe the host's own egress. It was a claim, not a measurement.

The leg is now authored: the_hosts_own_egress_is_unaffected_before_during_and_after_a_jobs_cleanup connects with TcpStream::connect_timeout from the test process on the VM host, in no namespace, before / during / after a job's cleanup — opening with a negative control on a port nobody listens on, so an instrument that cannot say "no" fails instead of passing three times. That is the 20th matrix leg.

DNS through the tc-capable sidecar

The round-2 ask was functional DNS through the integrated v0.5.9 path, not rule shape:

  • the tag resolves to sha256:43d80f022dce… and carries /sbin/tc (iproute2-v7.0.0);
  • the production path can use nothing else — sandbox_netns.rs reads no env var for the sidecar image, and seller_exec.rs passes the hardcoded DEFAULT_NETFILTER_IMAGE into establish;
  • 995's suite is green on this head through that path (9/9), with payload runtime=runsc and resolvers that answer (172.29.7.53, fd00:d57::53, 2001:db8:bb::53) — not TEST-NET-1;
  • negative control: with the local v0.5.9 tag removed, the functional DNS test fails at containment establishment with …maxplayer-netfilter:v0.5.9: not found. The green run could not have gone through any other image. Tag restored, same test green, then the local tag removed again — it was never pushed.

Round-3 review fixes (df93a55813745fd8)

One commit, three source fixes, each with a control that was reverted, run, and observed RED before being restored — because the defect that killed rounds 1 and 2 was a test that passes without reproducing the failure it names.

fix control applied test that went RED
creation fencing removed the wait_until_settled call from Drop cleanup_does_not_remove_ahead_of_a_create_that_is_still_in_flight
Err custody released custody on client exit alone a_reaped_client_whose_container_is_still_there_keeps_custody
ND unconsumed tokens parser stopped marking malformed 4 readback tests, incl. the new canonical-ND one

1. A delayed create was never fenced against cleanup. Adoption supplies a name to remove; it says nothing about when the container appears. The create runs on a blocking thread and cancelling the future above it does not stop that thread, so cleanup ran immediately, asked docker to remove a container that did not exist yet, was told No such container — which cleanup correctly treats as benign — and returned satisfied. The create then landed and left a container nobody held. No ordering of removes fixes that, so a CreationFence moves the removes to the far side of the create settling; the ticket lives inside the spawn_blocking closure and never in the future, so a cancellation cannot release it early. The test asserts that Drop had not returned before the create settled, not that the helper works.

2. Custody ended on a reaped client rather than a gone container. --rm is a request to the daemon, not a receipt from it: a nonzero exit, a deadline kill, and a stdin write that failed before the wait all return from the same call, and none of them means the container is gone. Custody now ends only when the client was reaped and absence is confirmed; a confirmer that cannot answer keeps the name a cleanup target, at the cost of one docker rm that says No such container, which cleanup already treats as success. This also removed a daemon dependency from an offline test — the previous custody test reached a live docker inspect and passed only because a daemon happened to answer.

3. The ND readback tolerated tokens it could not consume. A canonical ND ACCEPT carrying a leading unflagged token after OUTPUT, or a dangling final !, verified with the same accepted predicates — the unconsumed tokens changed nothing the checker went on to read. The prior tests asserted this at the parser; the new one asserts it at the decision site, running verify_readback over the real live-measured v6 readback mutated only in those two ways, with the unmutated readback as a positive control. Malformed lines are kept rather than dropped so the rule count stays honest.

Live gates re-run at this head against a real gVisor daemon: containment matrix 20/0 (87.65s), 995's DNS suite 9/0 (44.42s, functional — three resolvers answered, payload runtime=runsc), egress 1/0. Both test binaries were rebuilt and their content hashes differ from the previous head's (matrix a4349e53…470c733f…, lib 930cd590…73705c4b…), which is what rules out a stale artifact.

Worth writing down: running the matrix binary without --ignored exits 0 and prints 1 passed; 20 ignored — a green that measures nothing, since every live case is #[ignore]d. That false green was produced during this round and discarded; the recorded run is the one reporting 20 passed … 1 filtered out.

cargo fmt --check is not applied here: it reports diffs in 80 files, nearly all untouched by this branch, which is toolchain drift rather than this branch's doing. Reformatting the crate would be exactly the out-of-scope diff earlier rounds were marked down for.

Still not claimed, unchanged: attribution of known_open_preexisting_defect_other_private_and_non53_must_be_denied to this branch (needs a baseline build, out of scope this round); that a saved row's assertions fit its case id (that mapping is hand-made); and integrated.denied.neighbour-port / integrated.exception.proxy-pinhole are asserted by a canary, not a production launch.


Renewed revise, round 2 of 3 — head e725c38f

Three production-path defects the round-2 verdict named, all closed.

1. Production could be pointed at another docker executable. MX_SANDBOX_DOCKER_BIN and docker_program() are removed repo-wide — a grep for either name returns nothing. DockerCli::system() holds the constant "docker", and the client is now threaded explicitly through run_docker, run_docker_fenced, run_bounded*, container_is_absent, force_remove, reapable_holders_live, reap_orphans and NetnsHolder::adopt{,_bounded}. sandbox_netns.rs performs zero env::var reads; the stand-in constructor is #[cfg(test)], so it does not exist in a production build.

2. The test seam was a process-global. DOCKER_INJECT_LOCK, INJECTED_DOCKER, injected_docker_program() and the RAII guard are deleted. Tests call establish_with(client, bounds, ..); establish() is a thin wrapper passing DockerCli::system() and FenceBounds::production(). An argument rather than a global was the point: under the old seam, cleanup issued from Drop could read another test's client. The three establish gates now run together in parallel with no serialising lock (2.19s).

3. Cleanup raced the create it had to outlast. Drop no longer removes anyway when the fence expires — it hands custody to HolderCleanup, which sweeps only after the create has settled and then polls until the daemon confirms absence (20ms→500ms backoff), on thread mx-holder-cleanup with an inline fallback. FenceBounds makes the delayed path measurable in under a second while production keeps its DOCKER_DEADLINE-anchored waits. The gate asserts orderingrm after create-end in the stand-in's event log — not elapsed time, so a build that merely slept longer cannot pass it.

Gates at this head. Offline suite 1681 passed / 0 failed / 36 ignored, exit 0. Live containment matrix in the approved VM under runsc against a real daemon: 22 passed / 0 failed (90.84s), up from 20 for the two gates added here — a contained job actually connecting to a host proxy through the pinhole and being refused on a port outside it, and a cancelled establish leaving no holder behind.

Both live gates were observed RED first, and the controls are production-side. With the pinhole rendered for a different address, the job could not reach the real proxy at 172.17.0.1:49221. With the fence ticket held by the future, a cancelled establish left a real container running on the real daemon — the exact orphan the fix exists to prevent, not a simulated one. Source was restored byte-identical after each control (sha256 79113d63…), and the leaked debris was removed and the VM re-verified clean.

A defect found on the way, reported rather than fixed. A single-port proxy range is a supported configuration — PortRange::new accepts start == end and the parser documents "49200" — but it renders dst_port N-N, which the tc flower classifier rejects outright (max value should be greater than min value), leaving the namespace partially filtered and the job refused rather than contained. Every existing tc-path test uses a multi-port range, so nothing had exercised it. It is outside the three blockers this round was sent to close, so the new gate uses the multi-port shape production ships (49220-49229); pinning it to one port would have measured that bug instead of the proxy leg.

Unchanged from earlier rounds: cargo fmt --check is still not applied, and the attribution of known_open_preexisting_defect_other_private_and_non53_must_be_denied is still not claimed.


Renewed revise, round 3 of 3 (final) — head f1a655c5

The three residuals the renewed round-2 verdict left open, each closed with a gate observed RED before it was allowed to pass. All credited repairs from earlier rounds are preserved.

1. Outer cleanup released too early. It could release on a timeout, or on a holder that was momentarily absent while creation had not settled. confirm_absent becomes confirm_all_absent: every owned joiner is confirmed before the holder, and the names it could not confirm come back instead of a bool, so a partial sweep can no longer read as a clean one. A deadline that expires while a create is still in flight now reports a KNOWN leak rather than releasing. run_bounded_blocking starts its clock before the spawn and writes stdin on its own thread, joining after reap — so the whole create flow is bounded, stdin and output and queue included. RED first: a blocked stdin write ran 30.48s against a 300ms deadline.

2. The cancellation oracle proved nothing. It could pass on an empty output or a first absent list. It now credits cancellation only when cancellation actually hit a create that was still running: cancelled_in_flight > 0 is asserted, so a run where every establish finished first fails instead of quietly scoring cleanup-after-success. Absence is credited only from a docker ps that really answered, covers the owned holder and its sidecars by name prefix, and must hold for 5 consecutive answers within 30s, with a failed query resetting the streak. Reach and docker_exit separate a refused connection from a tool that could not run, so a DENIED is credited only when the probe actually ran — the tool-failure control the verdict asked for. The real host TCP route credit from round 2 is retained unchanged.

3. A supported single-port pinhole could not be installed at all. This is the defect round 2 reported rather than fixed. iptables renders 49221:49221; the tc translation turned that into dst_port 49221-49221; the flower classifier rejects it (Illegal "dst_port" / max value should be greater than min value), so the filters never installed and establishment refused the launch. Fail-closed, but a supported configuration that cannot start is still broken. Equal endpoints now collapse to the bare port, with positive and negative offline coverage and a live gate that a real tc has to accept. No scope expansion: multi-port rendering is unchanged and separately gated.

Gates at this head. Offline suite 1687 passed / 0 failed / 36 ignored, exit 0. Live containment matrix in the approved VM under runsc against a real daemon: 23 passed / 0 failed (101.39s). cargo check --all-targets exit 0. The acceptance script run against the saved record for this head: ACCEPTED, exit 0 — leg 0 head containment, leg 1 offline suite, leg 2 saved matrix.

The evidence contract that failed last round is satisfied. Round 2's record exited on a missing source_head; this one carries all four required headers, and its matrix log is the complete run — 23 named ... ok lines in a 29-line file. The round-2 log had been truncated by tail -20, which cut a_job_prepared_and_launched_by_production_is_contained_on_its_veth, the test three rows depend on; nothing here is tailed or filtered. The validator was not weakened — REQUIRED_CASES and REQUIRED_HEADERS are untouched — and no earlier DNS run was relabelled.

RED controls, both production-side. Reverting the equal-endpoint collapse made the live singleton gate fail on a real kernel with Illegal "dst_port" and a refused establish. Delaying cancellation so every establish won made the oracle refuse to pass. Both sources were restored byte-identical (diff plus sha256) before the final binary was built.

Still not claimed, named rather than left to be discovered. cargo fmt --check is still not applied across the branch (~80 files, toolchain drift). Attribution of known_open_preexisting_defect_other_private_and_non53_must_be_denied still needs a baseline build of b9ee77f1…. seller_node::run::tests::a_settled_offer_won_by_another_seat_is_not_claimed is a pre-existing AddrInUse flake under parallel load (3/3 isolated passes, and it passed in the clean final run); it is unrelated to the three changed files.

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the MakePrisms Team on Vercel.

A member of the Team first needs to authorize it.

w-gvisor-interface-impl added 20 commits September 14, 2026 10:59
…ctually leave by

`sandbox_net`'s rules sit on the host kernel's OUTPUT chain. A gVisor payload
never traverses it: runsc runs its own netstack and hands finished packets to
the namespace's veth, so the denied destinations stay reachable while the
readback proves the rules are installed. Measured on this repo's fixtures,
both families, over TCP.

Adds `sandbox_iface`: the same rendered `NetPolicy`, translated into clsact
egress `flower` filters on that veth. It holds no policy of its own — every
prefix, every exception and their order are derived from `NetPolicy::rules`,
so a range added there appears here without anyone remembering to, and the
parity tests fail if a translation is ever dropped.

The drops carry no protocol match, deliberately: the demonstrated leak was
TCP, and a TCP-only filter passes the very fixture that found the hole.

Ships `tc`/`ip` in the netfilter sidecar behind `test -x` assertions, and a
second dumb applier (`apply-iface`) that may run nothing but `tc` — the
interface, the prefixes and the order are chosen in Rust and read back out of
the kernel by a different container.

Not wired into `establish` yet; that is the next commit.
`sandbox_iface` rendered, applied and verified a plan nothing called. This wires
it into `sandbox_netns::establish`, after the iptables plan and its readback and
before the holder is handed back, so every contained job gets both layers or
gets no launch.

The device is measured, never assumed: an unprivileged probe container
enumerates the namespace's links and `select_egress_link` refuses anything that
is not a job's own namespace — a bridge among the links, a missing loopback,
more than one candidate. The one container that holds `NET_ADMIN` therefore does
not also choose what to filter. The link that was filtered is carried out on
`Containment::egress_dev` so a caller names the interface that is actually
filtered rather than the one everybody assumes is `eth0`.

Failure discipline is the chain's: the applier's count is cross-checked against
the rendered step count (a truncated stdin applies perfectly and exits 0), the
filters are read back by a second container running a different verb, and every
`?` leaves through the holder guard, which destroys the namespace. Exit 6 (an
image built without iproute2) and exit 3 (a partially filtered interface) are
named in the error, because "which refusal" is what the operator needs.

Unconditional, not runsc-only: `establish` is not told which runtime the caller
will launch under, and "contained under one runtime" is the state being closed.

Tests, in the live file that executes plans against a real kernel rather than
asserting a render:

* `establish_filters_the_veth_the_packets_actually_leave_by` — the shipped entry
  point, then the kernel is asked what that namespace holds.
* `a_namespace_missing_one_egress_filter_is_refused` — the red-prove: remove one
  filter from a live namespace and the readback must name it. A verifier that
  cannot fail is worth nothing, and every other case here would stay green.
* `the_output_chain_alone_lets_a_runsc_job_out_and_the_veth_filters_stop_it` —
  the regression gate. Leg 1 asserts the LEAK as a success: with the iptables
  policy installed and verified, a gVisor job reaches a denied destination that a
  `runc` job in the same namespace cannot. Leg 2 installs the veth plan and the
  same connection is refused, with an allowed destination still reachable from
  inside and the denied listener still answering from outside.

The runtime is taken from `MAXPLAYER_RUNSC_RUNTIME` and not defaulted, for the
reason the image is not defaulted: a guess would measure `runc` on a host without
gVisor and report the leak closed by rules that never had to stop anything.
Three corrections, each one measured on the gvisor-repro VM rather than reasoned
about — the first draft of these tests passed type-check and failed the machine.

1. `tc filter del` does not take the `clsact` keyword that `filter show` takes:
   iproute2 answers `Unknown filter "clsact", hence option "egress" is
   unparsable`. The red-prove now deletes through the same hook spelling the
   daemon's own argv uses.

2. **One namespace per gVisor payload.** `runsc` claims the namespace's links
   when it starts, so a SECOND joiner gets ENETUNREACH — a refusal no rule
   caused, which reads exactly like containment. It cost this gate a *control*
   before any rule existed. Every probe now builds its own holder, which is also
   what production does per job.

3. **One network, because a job holder has one.** The canary fixture attaches
   its holder to three networks; `select_egress_link` then refuses to pick one
   of three links rather than leave two open, and the gate failed with "expected
   exactly one non-loopback link, found 3". That is the product being right and
   the fixture being unrealistic. The topology is now the one a contained job
   gets: one network, one veth, and the denied destination is a second address
   on the same listener inside 198.18.0.0/15, routed on-link into each namespace
   so a refusal is a drop and never a missing route.

Measured on the VM, `runsc` release-20260817.0 / iproute2 in the first-party
sidecar image, all three live tests green:

    establish_filters_the_veth_the_packets_actually_leave_by ... ok
    a_namespace_missing_one_egress_filter_is_refused ... ok
    the_output_chain_alone_lets_a_runsc_job_out_and_the_veth_filters_stop_it ... ok

The last one is the regression gate, and its leg 1 asserts the LEAK as a
success: with the iptables policy installed and verified, a gVisor job reaches
198.18.7.2 while a runc job in an identically prepared namespace cannot. Leg 2
installs the veth plan and the same connection is refused, with the allowed
destination still reachable from inside and the listener still answering from
outside.
…y, owned fixtures, integrated launch matrix

Round 2/3 against the advisor verdict 11a5a8fb. F1/F4/F5 complete and gated;
F2 authored but never executed (live runtime held); F3 module written but NOT
registered in lib.rs and with no acceptance script yet.

Committed to preserve the lane at close. Not a claim of completeness.
The live matrix measured it: with ff00::/8 dropped, a job reaches NO v6
address. Neighbour Solicitation goes to a solicited-node MULTICAST address,
so that one rule starves ND and every v6 destination fails alike — denied and
allowed. A refusal measured on a dead stack proves nothing about fc00::/7.

Both enforcement hooks now permit exactly two ICMPv6 control messages and
nothing else: solicitation to ff02::1:ff00:0/104 and advertisement, each at
hop limit 255 (RFC 4861 §11 — a router decrements, so 255 can only come from
this link). Every destination denial is unchanged.

The narrowing is enforceable at both hooks, measured not assumed: iptables
takes --icmpv6-type with -m hl --hl-eq, tc flower takes ip_proto icmpv6 with
type and ip_ttl, and both were installed and read back on a live kernel.

A payload cannot use the exception: types 135/136 need a raw socket, jobs run
--cap-drop ALL (CapEff 0), and a ping socket sends only echo, which stays
dropped.

Restores integrated.allowed.v6 to Connected and adds the counter-control the
lazy fix would fail: integrated.denied.v6-link-local, where the listener's own
link-local answers an unfiltered joiner and must refuse a contained job.
The partial-install test cut a fixed four lines off the end of the plan. The
plan is every v4 rule then every v6 rule, so that meant 'drop the three v6 rules
and one v4 rule' only while v6 had three rules. With the two neighbour-discovery
exceptions v6 has five, the same cut removed four v6 lines and no v4 line, the
v4 readback verified, and the test that exists to catch a partial install passed
against a complete v4 policy.

Cut is now v4_rules - 1, so the shortfall cannot be absorbed by rules that
follow it, and both families are asserted to refuse: v4 short by exactly one
rule, v6 never reached.
Rebasing onto 995 replaced the readback model this control was written
against. The ND exceptions are deliberately routed into 995's `matched`
list so its ordering check governs them rather than a second, parallel
check of my own -- which is what the control wants, but it means the
refusal is now worded by that check: the rule, its index, and the drop
that precedes it.

The behaviour never regressed. An ND ACCEPT appended below the range
drops is still refused; only the adjective in the message changed, and
the assertion was pinned to the adjective. Pinned instead to the same
wording its sibling ordering tests assert on, and additionally to the
advertisement role and its inertness, so an unrelated refusal cannot
satisfy it.
Advisor R2, F3 evidence fidelity: `parse_case` assigned each field by
overwriting, so a repeated field let the last word win.
`outcome=refused outcome=connected` parsed as Connected -- a record
could carry a refusal and the pass contradicting it, and the pass is
what got counted. The same held for `log`, which decides which file a
reader would open to check the claim.

Each field is now assigned once and a repeat is refused, naming both
values. There is no honest reading of a case that states two outcomes,
and refusing is the only answer that cannot be gamed by field order.

Test asserts the outcome and the log spelling. Verified meaningful: with
last-wins restored it fails, and the log leg passes validation outright.
Advisor R2, F4: `SidecarGuard::drop` deregistered unconditionally, so
cancellation itself destroyed the record cleanup depends on. The future
is dropped mid-command, that drops the guard, the guard strikes the name
from the registry, and the holder's `Drop` -- reading that registry a
moment later -- finds nothing to remove. The container the blocking
docker client had already created stays joined to the namespace with no
guard and no remover. The doc comment claimed the name stayed with the
holder on cancellation; the code did the opposite, and the comment was
the only place it was true.

The guard now deregisters only a command that returned, marked on the
far side of the await where the command is provably no longer in flight.
A cancelled command leaves its name deliberately: keeping a name whose
container was never created costs one `docker rm` answering "No such
container", which is already treated as success, while dropping a name
whose container does exist costs a pinned namespace nothing cleans up.

Adds the cancellation witness that was missing -- the future is polled
once so the registration exists and the command is in flight, then
dropped, which is how tokio cancels. No runtime and no docker involved,
so it measures the custody rule itself. Verified meaningful: restoring
unconditional deregistration turns it red. The sibling finishing test
now says finishing explicitly.
Advisor R2, F4: `force_remove` used a blocking `output()`, which has no
timeout, under a comment promising a bounded removal. This runs inside
`Drop`, so a docker client talking to a daemon that has stopped
answering held the teardown thread for as long as the daemon stayed
wedged -- and teardown is the path a panicking or aborted job takes.

Removal now spawns and waits against a deadline. On expiry the child is
killed and reaped, and the wait is reported as a failure naming the
container as possibly still present, rather than as a removal that
worked. Twenty seconds: far longer than a real removal, which finishes
in well under a second, and short enough that a wedged daemon ends the
job instead of pinning the caller forever.

Two tests, because a deadline that fires unconditionally would pass the
first alone: a child guaranteed not to exit is abandoned, reported as a
possible leak, and proven killed rather than left running; a child that
exits at once is waited on normally.
Advisor R2, F2: the oracle turned every nonzero payload status into
`Refused`. An image that lost `nc` exits 127 through `sh`, 126 says not
executable, 2 says the tool rejected its own arguments, 128+n says a
signal killed it -- and each of those scored as containment. That is the
most flattering way this matrix could be wrong: a fixture that silently
lost its payload would have reported perfect containment on every denied
leg, and the record would have looked its best at the moment it measured
nothing.

Only exit 1 now means refused, which is what BusyBox `nc` -- the Alpine
fixture's tool -- returns for a refused connection and for the `-w`
timeout. Everything else nonzero becomes `ToolFailed(code)`, which is
never containment evidence and is printed loudly, because unlike
`NeverStarted` such a leg did start and so looks like it measured
something.

Also asserts the neighbouring-port leg as Connected, which is what its
own comment and the saved record claim. It asserted only `!=
NeverStarted`, so Refused passed -- meaning a filter that had silently
become port-scoped, the one failure the leg exists to rule out, would
have satisfied it.

The classifier's witness is offline: it is a property of the classifier,
not of any network, and every denied row in the record is its verdict.
Advisor R2, F1: `check_counter_line` scanned for recognised predicates
rather than reading the line. Anything absent from both token lists rode
through untouched, and `src_ip` is absent from both -- so appending
`src_ip 192.0.2.123` to a faithful `Sent` line left the parsed rule and
the verification unchanged, on a line the parser skips, using a key that
narrows the rule to a single source. A list of what is forbidden cannot
refuse what nobody thought to forbid.

Statistics lines are now read to the end against the shape iproute2
prints -- `Sent` in its eleven tokens, `backlog` in its five, the
`Action statistics:` header with nothing after it -- with counts checked
as counts. Anything not positively read is refused, predicate or not.

Four counterexamples added to the existing strict-accounting table: the
advisor's `src_ip` in v4 and v6 spellings, arbitrary text no list will
ever contain, and a counter replaced by something that is not a count.
Verified meaningful: restoring the blacklist turns the first red.
Two legs failed against the rebased head for reasons the merge introduced,
not for reasons the policy changed.

`the_pinhole_production_installs_is_the_one_the_policy_names` asserted that
every `pass` rule on the veth carried the configured proxy range. After 995,
production also installs the resolver pinholes the config asks for, so the
leg read `["49200-49299", "53", "53"]` and called the configuration a hole.
Widening it to "ignore port 53" would have retired the assertion: a port-53
rule to *any* destination is exactly the wide-open case the leg exists to
catch. The recorder now pairs each pass rule's port with its `dst_ip`, and a
rule is admissible only if it is the proxy range, or port 53 to the resolver
the gate itself named. A port-53 rule to anywhere else still fails, and a
missing resolver pinhole now fails too — a job that cannot reach its resolver
resolves nothing.

`a_job_prepared_and_launched_by_production_is_contained_on_its_veth` asserts
the allowed destination stays reachable on the neighbouring port, so that a
port-scoped denial cannot hide. `RunscNet`'s listener ran a single `nc -l` on
PORT, so nothing was listening on OTHER_PORT and the leg measured an absent
listener rather than policy. The listener now binds both ports, as the
`Canary` fixture already did, and readiness covers both: an unchecked second
`nc -l` would let the control pass for the wrong reason.

`dns_servers` is now a named constant shared by the gate configs, documented
as TEST-NET-1 and explicitly not left empty — empty falls back to the host's
resolv.conf and resolvectl, which would make these legs depend on the DNS
configuration of whichever machine ran them.
@maxy-player
maxy-player force-pushed the w-gvisor-interface-impl2 branch from 7d14c51 to c01e35a Compare September 14, 2026 18:17
w-gvisor-interface-impl3 added 7 commits September 14, 2026 11:27
`GATE_DNS_RESOLVER` is 192.0.2.53 — TEST-NET-1, routed nowhere, answering
nothing. It exists to make the rendered rule set deterministic so the pinhole
leg can check the SHAPE of what production installed, and that is all it can
do: a rendered `--dport 53 -j ACCEPT` and an enforced one produce the same
green in this file.

The previous commit message here said this file was made to "measure the
merged DNS seam". That overstates it. It measures the seam's rule shape —
that the resolver exceptions exist, go to the configured resolver and nowhere
else, and that nothing wider was installed. It does not, and cannot, show
that a contained job resolves a name.

Functioning resolution is measured against a resolver that actually answers,
in `sandbox_dns_live`: real dnsmasq fixture through the same production path,
v4/v6 resolution through the written resolv.conf, UDP truncation falling back
to TCP 53, host-stub discovery, and the denied-neighbour controls (another
private address, and a non-53 port on the resolver itself).

Both doc comments now say so, so neither this constant nor this file's
readback can be cited as evidence of working DNS. No assertion is changed and
no control is weakened; this commit is comments only.
… and refuse a readback this parser cannot account for

Two residual defects from the same review, both of the same shape: a thing that
was not established being treated as a thing that was.

**Sidecar custody (F4).** `run_sidecar` called `registration.completed()` after
every returned result, reasoning that `docker run --rm` has removed the
container by then. That is true of a client REAPED with a status, including a
nonzero one. It is not true of a client killed on our own 120s deadline, killed
by a signal, or failed before the wait — a stdin write error, a wait error, a
panicked task. In each of those the daemon may still be creating, running or
removing the container, and striking the name from the registry removed the one
cleanup target for a container that outlived its client. The cleanup path was
producing the orphan the registry exists to prevent.

`run_bounded_tracked` now reports whether the child was reaped, and custody ends
only on that. Both halves are asserted: a nonzero exit DOES end custody (else
every sidecar reports a phantom leak), a deadline kill does NOT.

This does not claim to close the whole cancellation/daemon-cleanup bound. A
delayed blocking create can still land after cleanup has run, and one attempted
`docker rm` is still not a completion fence. Those remain open.

**Readback accounting (ND parser).** `ReadbackRule::parse_all` silently dropped
an unflagged token appearing before any flag, and discarded a trailing `!` when
the line ended. So `-A OUTPUT garbage -p ipv6-icmp …` and a line ending in `!`
parsed to predicates IDENTICAL to their canonical twins and were accepted as
those rules. That is static malformed-readback acceptance — not a demonstrated
kernel-emittable bypass, and no packet escape is claimed — but a readback that
is not read whole is not evidence about the namespace.

Malformed lines are now recorded and refuse the whole readback. They are kept in
the parsed list rather than dropped: dropping would shrink the rule count and
hide an unexpected rule from the check that exists to notice one. `value()`
refuses to interpret them, so one can never satisfy a required rule.

Tests use the reviewer's own counterexamples and assert the retained predicates
are identical to the canonical form — which is why this was invisible.
…not only the flag that feeds it

A negative control on the previous commit found its own test too weak. Reverting
`run_sidecar` to the defective `registration.completed()`-on-every-result left
`only_a_reaped_client_may_end_a_sidecars_custody` GREEN: that test calls
`run_bounded_tracked` directly, so it proves the child_exited flag is computed
correctly and proves nothing about what the caller does with it. The defect
lives in the caller.

`run_sidecar` now delegates to `run_sidecar_with_deadline`, whose bound is a
parameter purely so the rule can be measured offline -- a test cannot wait out
the production 120s deadline, and the assertion has to be about the registry
after a kill, not about a boolean.

The new test drives `run_sidecar` itself with two temp scripts and asserts the
holder's registry: a client killed on the deadline keeps its name as a cleanup
target, a client reaped normally has its name struck. Both halves again, so
"never deregister" cannot pass. No docker and no daemon are involved.

Verified by reverting the fix: the new test fails on the registry assertion
(sandbox_netns.rs:1799) while the flag test still passes. Fix restored, both
green.
…easure the host leg that nothing measured

F3 saved-row fidelity. The validator checked that a case's log field was
nonempty TEXT. It never opened the log. So a complete, attributable record could
cite a real green run that did not contain the test the row claims — and the map
from test function to case id was mine, by hand, stated in the record itself as
not machine-checked.

Case records now carry `test=`, the function that asserts the case, and the new
`sandbox_evidence::corroborate` opens each named log and requires that test to
be recorded `ok` there. It matches the `test <name> ... ok` line rather than the
bare name, so cargo's `failures:` block cannot make a red run look green, and it
refuses absolute or `..` log paths — evidence reaching outside the run is not
attributable to it. Wired into the acceptance gate's own leg, so the one command
checks it.

What corroboration establishes: the named log exists, names that test, and shows
it passing. What it does NOT establish: that the test's assertions are the right
ones for that case id. Reading the body is still the only thing that settles
that, and the doc comment says so.

**And it immediately caught a false row of my own.** `host.unaffected.during-cleanup`
is REQUIRED by the matrix, and grepping the whole repo finds it in exactly one
place: the requirement list. No test asserted it. The round-2 record scored it
`connected` anyway, on the strength of the sibling cleanup test running nearby —
but that test probes only from inside containers, so it cannot observe the
host's egress in either direction. The row was a claim, not a measurement.

So the leg is now authored. `host_can_reach` connects from the test process
itself, on the VM host, in no namespace — the only instrument that can see
host-global mutation, which every container-side probe here is blind to by
construction. It asserts the host reaches the canary before a contained job
exists, while one is contained (with that job asserted Refused, so the host leg
sits beside real containment), and after teardown. A port nobody listens on must
come back unreachable first, or all three legs would pass on a probe that cannot
say no.

The old record does not validate under this gate, which is correct: it carries
no test= fields and one row nothing measured. A fresh live run at this head
replaces it.
…r is confirmed gone, refuse an ND line this parser cannot consume

Three defects the R2 final named, each with a control that reproduces the
failure rather than exercising the flag beside it.

1. A create that outlives its future was never fenced. Adoption supplies a
   NAME to remove; it says nothing about WHEN the container appears. The
   create runs on a blocking thread, cancelling the future above it does not
   stop that thread, and cleanup ran immediately: it asked docker to remove a
   container that did not exist YET, was told "No such container" -- treated
   as benign, correctly -- and returned satisfied. The create then landed and
   left a container nobody held. No ordering of removes fixes this, so
   CreationFence moves the removes to the far side of the create settling.
   The ticket lives inside the blocking closure, never in the future, so a
   cancellation cannot release it early.

2. Custody ended on a reaped client. `--rm` is a request to the daemon, not a
   receipt from it: a nonzero exit, a deadline kill, a stdin write that failed
   before the wait all return from the same call, and none of them is the
   container being gone. Custody now ends only on CONFIRMED absence, and a
   confirmer that cannot answer keeps the name a cleanup target -- the cost
   being one `docker rm` that says "No such container", which cleanup already
   treats as success.

3. The ND readback tolerated tokens it could not consume. A canonical ND
   ACCEPT carrying a leading unflagged token after OUTPUT, or a dangling
   final `!`, verified with the same accepted predicates. The whole readback
   is now refused, and the malformed line is kept rather than dropped so the
   rule count stays honest.

The custody test was also made hermetic: it reached a live `docker inspect`
from an offline unit test and passed only because a daemon happened to answer.

Controls, each reverted and observed RED before restoring:
- remove the fence wait   -> cleanup_does_not_remove_ahead_of_a_create... fails
- release on client exit  -> a_reaped_client_whose_container_is_still_there... fails
- stop marking malformed  -> 4 readback tests fail, incl. the canonical ND one

Offline: 1641 passed, 0 failed.
… kill, confine evidence paths, and gate establish itself

The R3 verdict's remaining production-path faults, each closed with a test whose
negative control was observed RED before it was observed green.

Creation fencing reached only the holder. `establish`'s sidecar creates went
through an unfenced path, so cleanup's settle-wait could pass at count zero and
issue removes for a container that did not exist yet; the container then arrived
with nobody holding it, pinning the namespace. The ticket now travels into the
blocking closure on that path too, so a cancelled future cannot release it while
the create is still running.

Custody could still end on the client alone. A SIGKILLed client is reaped with a
status and no exit code, and the old rule let that flag release the name even
though a killed client proves nothing about what the daemon did. Custody now ends
only on daemon-side absence.

Evidence path confinement was lexical. `raw/x.log` is neither absolute nor `..`
and could still be a symlink to another run's green log, so `corroborate` now
resolves both sides and requires the result to stay under the record's own
directory, reading the path it just checked.

`establish` itself had no test at all, which is the fault behind the proxy and
cancellation findings: a fixture stood in for the production path and could agree
with a bug the production path does not survive. Both gates now drive the real
function through a spawn-site seam, so the argv every plan test asserts is the
argv production runs. One proves the address `establish` measures is the address
its rendered plan pinholes; the other cancels mid-create and proves cleanup
outlasts the create and removes the holder it made.

The seam is named outside the `MAXPLAYER_` prefix on purpose: that prefix is
reserved for config and refuses unknown variables fail-closed, which the full
suite caught by refusing to start 14 unrelated tests.

Offline suite: 1646 passed, 0 failed, 13 ignored.
The seam that lets the two new gates drive the real `establish` without a
daemon was selected by an environment variable. That is a privilege boundary,
not a convenience knob: anyone able to set a variable on the process could
redirect every containment command -- create, inspect, remove -- at a binary of
their choosing, in a shipped build.

Production selection is now the constant `docker` under `cfg(not(test))`, with
no environment read on the path at all. The override lives in a process-local
static compiled only under `cfg(test)`, restored on drop so a panicking test
cannot leave it set for whatever runs next.

Both gates were re-observed RED against this mechanism before being accepted
green, since the mechanism they run through changed:
  - plan pinholes a literal instead of the measured address -> FAILED
  - fence released before the create -> "cancellation returned in 173.3ms"
and the source was restored byte-identical after each.

Offline suite 1646 passed / 0 failed / 13 ignored.
w-gvisor-interface-impl3 added 19 commits September 14, 2026 19:24
…rop a live obligation

The callback existed; its termination paths did not hold. Three orderings ended
with cleanup silently doing nothing, and each is closed here at the point where
ownership actually ends.

REGISTRATION IS NOW ATOMIC WITH THE ZERO TRANSITION. `register` checks
`in_flight` and installs the job under one guard, and the last ticket drop takes
the slot while still holding that same guard, in the same lock order. The
ordering that used to lose the job outright -- wait times out, last ticket drops
and finds an empty slot, cleanup installs a callback afterwards -- is now
exhaustive instead: either a drop is still coming and the fence keeps the job,
or none is, and `Registration::AlreadySettled` hands it back and it RUNS. A
registration on an already-idle fence can no longer be a parked closure nothing
will ever fire. The earlier test could not see this because it held the last
ticket alive across registration, which is the one ordering where the bug cannot
happen.

A FENCE BEING DESTROYED RUNS WHAT IT STILL HOLDS. The slot holds a `FnOnce`, and
a `FnOnce` that is merely dropped does nothing at all, so a fence that died still
holding cleanup discarded it in silence -- indistinguishable downstream from
never having owned the container. The job deliberately keeps no `Arc` back to its
own fence, because that is a cycle and the fence would never be destroyed at all;
`Drop for CreationFence` is what makes that safe.

A FAILED CONFIRMATION STAYS OWED. The job now returns `Custody`, so a removal
that was issued and never confirmed cannot end by returning quietly: it hands
back the work over exactly the names still outstanding, and the runner puts it
back in the slot. A later settlement runs it again; if none comes, destruction
does. Retries are bounded by the runner, not by the job, and nothing new waits --
each attempt is the existing removal and confirmation under `bounds.confirm`.

No registry, no journal, no proxy, no extra wait window, no log-only remedy. One
slot, one job, as before.

Three gates, each observed RED against the code it replaces: the
settle-before-register race, loss of every `Arc` to the fence, and a failed
confirmation followed by the later real removal. They assert the CONTAINER and
the daemon's own log -- presence markers gone, removals issued, the owner looking
again -- not `holds_retained_owner`, which is only the code's opinion of itself.
The four round-2 controls were re-run at this head and are still RED. Source
restored byte-identical after every control.

Offline lib suite green at default threads: 1663 passed, 0 failed.

What this still does not claim: tickets track this process's work, not daemon
settlement, so nothing here proves a deferred create landing after destruction is
observed. Once the last reference to a fence is gone with a name still
unconfirmed, in-process ownership genuinely ends and the boot reaper is the
backstop -- now said out loud, with the names, rather than by returning quietly.
Bob-renewal round 1 of 3 for PR 996, answering the R3 verdict's three
open findings on cleanup custody termination.

1. Finite destructor retries no longer terminate custody. A fence being
   destroyed runs its owed jobs RETAINED_FINAL_RUNS times and then
   TRANSFERS whatever is still owed to a process-lifetime
   CleanupSupervisor (one in-memory queue, one thread, woken by its own
   schedule and by every adoption). Bounded work per attempt, doubling
   backoff capped at 8x `reschedule`. Nothing is released by a clock.

2. Local completion is no longer taken for daemon completion. A create
   client that is killed at its bound, signalled, or loses the daemon
   mid-request is recorded on its fence as UNANSWERED, and at settlement
   becomes a watch-for-landing obligation owned by the supervisor. An
   absent inspect does not discharge it; only a daemon observation does:
   the container seen present and then removed and confirmed, or the
   daemon's event log since the request showing the exact name. Query
   failure never counts as absence. The API has no "never applied"
   observation, so a request that was never delivered stays watched at
   bounded cost for the process lifetime; that limitation is documented.

3. Collisions preserve every obligation. The fence holds a Vec of owners;
   registration pushes, and a still-owed job is pushed back next to the
   others rather than dropped or assigned over.

RetainedOwner is now data (names, client, bounds, what is owed) rather
than a FnOnce, so any owner can run one attempt and report exactly what
is still outstanding.

Gates (stand-in daemon, unit evidence): >3 refused removals with every
original reference gone then recovery; unanswered holder AND joiner
landing after local completion and initial absence; daemon unreachable
keeps both kinds of obligation; two jobs on one fence both kept and
discharged; landed-and-gone discharged on the event log. Two lib tests
marked `#[ignore]` run the same paths against the real daemon on the VM.
…ad-free scheduling, supervised fast Drop

Round 2 of the Bob-renewal cycle on PR 996; the four failures of the round-1 verdict, nothing wider.

F1  A watch that saw a name absent no longer discharges on "an event under this name". The daemon's
    event log is read as id/name/action, paired per container id, and the name is discharged only
    when every container created under it since the request was destroyed AND a fresh inspect says
    absent. A created id with no destroy is LANDED and stays owed. Deterministic race test: the
    stand-in lands the container the instant after the absent inspect answers, before the event query.

F2  Whether the daemon ANSWERED a client that ended is decided from positive proof only — exit 0, its
    own refusal text in a stderr read to EOF, or a `run` exit that is the contained command's — and
    decided BEFORE the pending-drain return. Unknown stderr, early drain returns and read errors all
    record the create as unanswered. The eight-substring whitelist is gone.

F3  The supervisor worker is started at fence creation and persists; a refused spawn no longer waits
    for a future adoption — every cleanup event (ticket release, fence or holder destruction, adoption)
    retries the thread and otherwise runs one due attempt inline. The event-log reader is bounded by
    the owner's confirm bound, so a descendant holding that pipe costs one name one attempt, not the
    whole queue. Tests: spawn refused throughout; a held event pipe with another owed name.

F4  The ordinary, settled `NetnsHolder::drop` hands every refused holder/joiner removal to the process
    supervisor instead of sweeping, logging and returning. Gated on the actual `Drop` (unit and live)
    and on the production fence reporting to the process supervisor.
Continues the stood-down lane's uncommitted work at 93bc9a9 on its merits:
per-job deadline+grace label written at create, seat/role labels, listing and
parse, expired_owned predicate, bounded sweep_expired, and the deadline traced
from prepare_launch into the holder create.

Checkpoint: not yet wired to a periodic caller, helpers not yet labelled.
…s their holder

Helpers are labelled at the single run_sidecar funnel, not per argv builder, so a
helper added later cannot be born unsweepable. Helper job id is a distinct label
from HOLDER_LABEL so the boot reaper's selection still sees holders only.
First tick fires at startup, so a seat that was killed mid-job rediscovers its
leftovers from their own labels. Best-effort: a failed pass is retried by the
next tick, never in place.
Boundary at the stamp, per-job deadlines judged separately, unreadable/foreign
stamps never a permission, production deadline traced into the label and out to
the sweep, helper stamp parity, and against a stand-in daemon: seat scoping,
refused removal retried by the NEXT sweep, unread listing is an error not an
empty sweep, late appearance caught with no registry, bounded per pass.
…ontainers

Petar's revision of PR 996 replaces continuous custody with a bounded periodic
sweep keyed on each job's own deadline, so the machinery that kept obligations
alive in memory for the life of the process is removed:

- CleanupSupervisor (its thread, backoff schedule, adoption and outstanding
  reporting), SupervisorState, ScheduledOwner, Outstanding, BACKOFF_CAP_MULTIPLE
  and FenceBounds::reschedule.
- The watch-for-landing obligation: UnansweredCreate, Owed (now the single
  remove-and-confirm job), watch_for_landing, note_unanswered, watch_unanswered,
  and the docker `events --since` reader (Lifecycle, lifecycle_since,
  lifecycle_from_events) with daemon_answered/container_named_by.
- The fence's supervisor/unanswered fields and both drop-site handoffs; the
  refused-removal adoption in the holder's ordinary teardown.

What stays: best-effort removal and confirmation, the retained-owner handoff
across a create that is still in flight, RETAINED_FINAL_RUNS rounds at fence
destruction, and the names printed when that is not enough. Those names are now
left to sweep_expired, which is why nothing has to outlive the fence: every
container carries its job's cleanup-after stamp.

Tests for the retired paths are deleted with it; sandbox_netns:: is 64 passed,
0 failed under --features acp,wallet.
`cargo test -p maxplayer-core --locked` and `cargo test -p maxplayer --locked`
(CI's two default-feature rows) did not COMPILE on this branch before this
commit: `NetnsHolder::drop` and `RetainedOwner::remove_and_confirm` are not
feature-gated, but `HolderCleanup` and `container_is_absent` were `#[cfg(feature
= "acp")]`, so the names were out of scope with default features. Verified
present at 93bc9a9, this lane's base, so it is not from the custody removal.

Removing those three gates is the minimal fix: nothing in either item needs a
feature-gated dependency. Both rows now pass — core 493 passed, maxplayer 162 +
2 + 3 + 3 + 6 passed, 0 failed.
Maxie's acceptance point: 32 removals is a bound on COUNT, not on duration.
The removals are serial and each carries SWEEP_DOCKER_DEADLINE, so a daemon
that accepts the connection and then hangs makes one pass 20s + 32x20s = 660s
-- past two further ticks -- and the old select! arm awaited that pass inline,
so the seller loop served no offer, award, drain or shutdown while it ran.

- SWEEP_PASS_BUDGET (240s, under the 300s interval) bounds the whole pass,
  listing included. Checked before STARTING each removal, never mid-call.
- ReapReport::deferred reports what the pass selected and never asked docker
  about -- the count-bound remainder and whatever the budget stopped -- in
  selection order, budget-stopped names first, so the next pass reaches the
  oldest leftovers first. The run loop logs the count.
- The tick SPAWNS the pass (spawn_local) instead of awaiting it, and an
  in-flight flag drops a tick that lands while a pass is still running, so
  passes cannot stack. A Drop guard clears the flag, so a panicking pass does
  not silence the sweep for the life of the process.

Tests: a spent budget starts no removal and defers all six (deterministic, no
sleep); the following pass removes them; the deferred backlog keeps selection
order; the count-bound test now asserts deferred and that docker was never
asked about those names. sandbox_netns:: 66 passed, 0 failed.
16f9032 says it continues "the stood-down lane's uncommitted work at
93bc9a9". That is wrong, and this commit is the correction, since the branch
is published and its history is not being rewritten.

The predecessor lane w-gvisor-interface-impl3 is at 8c2a8ee, clean, with four
commits above 93bc9a9 -- e643ebb, 565cb72, b940bc3, 8c2a8ee -- and that
work is published on refs/heads/w-gvisor-interface-impl3. Verified read-only at
2026-09-15T14:26Z: rev-parse 8c2a8ee, empty status, and git ls-remote returning
the same object for that ref.

The description was stale, not invented: it came from my brief, which was
accurate when written and overtaken when that lane committed. Nothing about the
code in 16f9032 changes -- it was adopted on its merits, reviewed line by line,
and every gate reported for this branch ran on heads containing it. Only the
sentence about the predecessor's state was false.
Re-cut unadapted from 8c2a8ee (impl3), which wrote them against the same
public API: a_real_holder_carries_its_own_expiry_stamp_on_the_daemon and
the_sweep_removes_an_expired_holder_and_leaves_one_inside_its_deadline. The
merged tree BUILDS -- impl3 never built it -- cargo test -p maxplayer-core
--features acp,wallet --locked --test sandbox_netns_live --no-run produced
target/debug/deps/sandbox_netns_live-0d2739f0d283af32, 0 errors.

New row neither branch had: a_production_stamped_helper_past_its_expiry_is_
swept_against_the_real_daemon. It takes its labels from production's own
helper_label_args -- not from strings the test spells out, which is the
difference that matters -- puts them on a real container joined to the job's
namespace with no --rm, reads all four labels back off the daemon, and then
requires the sweep to select the job's whole expired set, holder and helper,
2 selected, 0 failed, 0 deferred.

Still #[ignore]d, as every row in this file is. Live run in gvisor-repro
follows.
A red that says only 'still listed' cannot distinguish the daemon hiding the
helper from the sweep seeing it and leaving it -- and that distinction is the
whole row. The failure message now carries three things captured before the
cleanup destroys them: the ReapReport, production's OWN listing argv
(list_owned_argv) run against the daemon before the sweep, and what the seat
still holds after it.

Earned, not decorative: this is what identified a stale-binary run as stale
rather than as a flake. cargo judges freshness by mtime, and a source restored
with git archive carries the COMMIT's mtime -- older than the artifacts -- so
the rebuild was skipped and a mutated-control binary answered three further
runs. The listing showed both containers present and stamped while the report
removed one, which is only possible if the binary is not the source.
…g it

The gate reported red on an independent machine at 65 passed / 1 failed. Two
constants in the fixture, not the production drain, decided that result.

The 2s deadline was a guess about how fast a host spawns `#!/bin/sh exit 0`.
Where it was wrong the deadline killed the client before it exited, the run took
the deadline-kill path, and the post-exit drain the gate exists for never ran.
The budget is now eight times a spawn measured on THIS host, seconds earlier,
through this same function.

The descendant held the pipe for a fixed `sleep 6`. That is shorter than the
four-deadline bound asserted below it, so an unbounded drain would have returned
inside the bound and the gate would have passed through its own regression. The
descendant now holds until this test writes a release file, with a two minute
safety cap so a panic upstream leaves nothing holding a pipe.

Verified on this host at this tree: 8 isolated runs and 4 runs under eight busy
loops, all green; module suite 66 passed / 0 failed under the same load.
Negative control — the drain's `recv_timeout(remaining())` swapped for a plain
blocking `recv()` — turns it RED, naming a 421s wait against a 2s bound; the
mutation was reverted and the file re-hashed to its pre-control content.
Three defects from the MakePrisms#996 round-2 review, each with an acceptance test that
was shown to go red when the fix alone is reverted.

D1 — a container the sweep cannot validate was silently filtered, so a leaked
container and a clean host produced the same operator log. `partition_owned`
now gates removal on four conditions (seat, role, job, readable stamp) and
returns what it refused via `ReapReport.skipped`, which the seller's sweep tick
logs per container. Another seat's containers stay neither removed nor
reported: they are not this seat's business either way.

D2 — the pass took the first MAX_SWEEP_REMOVALS candidates every tick, so a
head that permanently failed removal was re-attempted forever and the tail was
never asked about once. The pass now resumes after the last id it ATTEMPTED,
wrapping at the end; advancing on attempt rather than success is what stops a
permanently refused container from parking the cursor on itself.

D3 — the cleanup stamp was rebuilt as `now + remaining` at the create, making
it a fresh measurement of a job deadline that had already been decided. A clock
that stepped backward in between therefore stamped a container EARLIER than the
deadline its job was running under, and the sweep could remove it from under a
job still inside its own time. `AgentRunTimeout::JobDeadline` now carries the
absolute `deadline_unix` alongside the remaining duration, and
`launch_cleanup_stamp` restates it without consulting the clock. Probes, which
have no job deadline to carry, pass None and keep the window fallback.

Test-only: the sweep cursor is process-global and keyed by seat, so sweep rows
sharing one seat string shared a resume point and inherited each other's
position. Each row now takes its own seat, as two seats on one host would.
The live 26-row matrix at 647c457, built and run inside gvisor-repro under
runsc, failed two sweep rows with the D1 gate's own report naming the cause:
skipped: [(84baae..., MissingJob)].

Production launches a holder with HOLDER_LABEL=<job id> and never with
HELPER_JOB_LABEL; only helpers wear the latter. list_owned_argv asked the
daemon for the helper label alone, so every real holder parsed as job: None
and the D1 removal gate refused it as MissingJob on that pass and every pass
after it. The holders this sweep exists to reclaim were the one class of
container it could never remove.

The listing now carries the holder's own job column and the parser reads the
job per role, falling back to HOLDER_LABEL. Absence is still never permission:
a container naming no job in either label is still refused and still reported.

The unit fixtures could not have caught this — write_listing gives every row a
helper job label, which production does not — so the regression row states the
production-shaped listing in full. Mutation control: dropping the fallback
turns a_holder_stamped_the_way_production_stamps_it_is_swept_not_refused red
and nothing else; the file restores byte-identical (710a3894a8dd0b70).
…wins

`parse_owned_listing` resolved the job id with
`field(&mut fields).or_else(|| field(&mut fields))`. That is not per-role
resolution: a present helper-job column short-circuited the holder column, so a
container carrying two CONFLICTING job ids was ACCEPTED, and the sweep then
reasoned about it under the wrong job.

`OwnedContainer` now keeps `helper_job` and `holder_job` as separate fields, and
`resolve_job` selects the column belonging to the row's own role. Columns that
disagree, a job sitting only in the other role's column, and an unknown role are
each SKIPPED and REPORTED through the existing skip channel rather than guessed
at; agreeing columns are accepted. `list_owned_argv` still requests BOTH
columns, because a conflict cannot be detected by declining to look at it.

The test fixture had been hiding this. `write_listing` wrote every row's job
into the helper column whatever the row's role — a shape no production launch
emits — so no existing test ever presented a holder the way the daemon actually
labels one. It now writes per role, which is what makes the three new cases
meaningful: columns disagree, job in the wrong role's column, and each valid
role still resolving from its own column.
…decides the sweep

Item 2 of the PR996 restart round. The existing
the_production_deadline_reaches_the_container_and_decides_the_sweep builds the
cleanup stamp by hand, so it cannot observe the production path losing the
deadline. This adds a SIBLING rather than rewriting it, and says so: the
hand-built test still covers stamp parsing, this one covers provenance.

The new test drives the production functions end to end against a real daemon:
prepare_launch -> launch_cleanup_stamp -> container label -> list_owned_argv ->
parse_owned_listing -> partition_owned. Labels are read back through the
production listing argv, never a hand-written docker ps, so a change to the
argv or the label schema is observable here.

It is #[ignore]d rather than returning early when docker is absent: a test that
returns early on a missing precondition reports as PASSED, which is the failure
mode this round exists to remove. It is wired into the live VPS gate explicitly
by name alongside the 26-row runsc matrix.

Its mutation control is the point: dropping Some(deadline) at the sole
production call site moves the recorded stamp by nearly a day and reds the
stamp assertion. A green that cannot go red is decoration.
@maxy-player
maxy-player force-pushed the w-gvisor-interface-impl2 branch 2 times, most recently from 78060b2 to 806d1d1 Compare September 16, 2026 13:51
w-gvisor-deadline-sweep-r1 added 4 commits September 17, 2026 04:19
…e run

Round 2 items A and B.

A. The existing live test enters at `prepare_launch` with a deadline it builds
itself, so it guards the `launch_cleanup_stamp` argument and nothing upstream.
It cannot see the forwarding edge break, because it supplies the value a
mutation there would remove. Add a sibling that enters at
`run_agent_job_with_env` — the function the orchestrator actually calls —
carrying an `AgentRunTimeout::JobDeadline`, and read the daemon-written labels
back through the production listing, parser and selection. Both arms are kept;
they fail for different reasons. Correct the doc comment that called the old
arm "the production call site", singular: that wording is how the bypassed
caller went unnoticed.

The launch future is not `Send` (`engine::run_job` takes
`sink: &mut dyn FnMut(RunEvent<'_>)`), so it is driven on its own thread with a
current-thread runtime rather than `tokio::spawn`.

B. Every invocation used the same seat constant, so `list_owned_argv(&seat)`
matched other concurrent runs and the pass path force-removed every listed
container. Resources now hang off a per-invocation `LiveScope`: a seat unique
to this run, and teardown in `Drop` so it still happens when an assertion
panics — the path that most needs it. Teardown re-lists under this run's seat
rather than reusing the asserted vector.

Both tests remain `#[ignore]`d; they need a docker daemon.
Round 2 item C: the branch did not merge. Two content conflicts, both
mechanical unions rather than choices between the two sides.

  seller_exec.rs, run_agent_job_with_env: main added the session MCP server
  list and `launch_with_mounts`; this branch added the sixth `prepare_launch`
  argument carrying `timeout.deadline_unix()`. Neither touched the other's
  lines. Kept both.

  seller_node/run.rs: main added two arguments to the job call; this branch
  turned `AgentRunTimeout::JobDeadline` into a struct variant carrying the
  absolute deadline. Kept both.

Main's new code then met this branch's changed signatures at six places, each
resolved to preserve main's behaviour exactly:

  - `with_prepared_launch`'s `JobLaunch` gains main's `mcp_servers`.
  - Two test `DockerPolicy` literals gain main's `mcp_tools`.
  - The credential-bridge and held-tool `prepare_launch` calls pass `None`:
    neither is a job, so neither has a deadline to stamp.
  - Two live acceptance sites constructed `JobDeadline(420s)` as a tuple. They
    stay `JobDeadline` — NOT `HarnessProbe` — because `cleanup_policy` reads
    that variant as CaptureThenRemove, and a probe would silently downgrade it
    to RemoveOnly. The absolute second is taken at construction, the one
    instant where `now + remaining` is the deadline rather than a guess.

No behaviour of main's was dropped or rewritten.
…eft short

The merge at a5fa204 brought main's new fields, but the r2 gate ran --lib only,
so the integration-test target was never compiled against them and did not build:

  E0063 missing `held_tools` and `mcp_tools` in SandboxConfig  (:2218)
  E0063 missing `mcp_servers` in JobLaunch                     (:3194)

The 26-row runsc matrix lives in this target, so no row could run until it built.

Empty is the correct value at both sites, not a placeholder: this matrix measures
network containment only, declares no MCP or held tools, and the argv builder reads
`mcp_servers` solely to decide whether a server needs a mount. Product code is
untouched; the change is three fields in one test file.

Verified on VPS 18.233.168.75 under runsc release-20260831.0 at this tree:
  build EXIT=0, test binary sha256 50fb5015477c1283e4036ba9f3d33cb29205c4de337458ea5f41fa61ead6b61a
  26-row matrix ROWS=26 PASS=26 FAIL=0, permission-denied rows 0
CI was red at a5fa204 on three jobs — default features, money-path, and the
shipped acp+wallet combo — with four instances of:

  error[E0425]: cannot find function `sweep_seat` in this scope
  at :4248 "prod-shape", :4300 "conflict", :4357 "misplaced", :4405 "per-role-ok"

`fn sweep_seat` was defined under #[cfg(feature = "acp")] while those four call
sites are plain #[test] fns with no gate, so the function vanished in any build
without acp. With acp on it compiled; the --all-features gate is a SUPERSET and
could not see the default build break.

The helper is dropped from the gate rather than the four tests being gated: it
is a pure string helper with no acp dependency, and those tests are item 1's D1
regression coverage, which must run in every configuration. The eight call sites
from :4653 keep the gating they already had.

Gates at this tree, both run locally:
  cargo test -p maxplayer-core --locked                  EXIT=0, 511 passed, 0 failed
  cargo test -p maxplayer-core --all-features --locked   EXIT=0, 1768 passed, 0 failed, 46 ignored
Before the fix, the default build failed to compile: EXIT=101, 4x E0425.
w-gvisor-deadline-sweep-r1 added 2 commits September 17, 2026 12:30
…t creates

Round 2/3 FAILING was the live test's own lifecycle, not the deadline path.
Test code only: no production cleanup redesign, no deadline rework, and item 1's
assertions and the caller proof are untouched.

F1 — ownership started too late. `docker network create` and its assert ran
before `create_dir_all` and before the `Self { .. }` value existed, so a panic in
that gap left a created network with no owner. The guard holding both names is
now constructed FIRST; every fallible step happens after it, where an unwind
reaches `Drop`.

F2 — the runner was never joined on a panic. It was a plain JoinHandle owned by
the test body with `join` after all the assertions; the mutation panic proves
that path is reached, so teardown could race a thread still creating containers.
The scope now owns the thread and a cancel channel, and cancels-and-joins it on
both success and unwind BEFORE removing anything. Drop also takes a second
listing pass, so a container that appeared during the first is still removed.

F3 — the name collapsed to the pid. `&seat[..24]` is 16 pid hex digits plus the
first 8 digits of `as_secs()`, which for a ~1.79e9 timestamp are all zero, so
nanoseconds and the sequence were discarded and pid reuse reused the name. The
name now carries its own token of low seconds, nanoseconds and sequence. The
comment that claimed the counter was mixed in is corrected rather than left to
mislead the next reader.

F4 — Drop could abort the process and hid its failures. The docker helper no
longer `.expect`s: a panic while unwinding aborts and destroys the failure the
test was reporting. Removal statuses are reported on stderr instead of being
discarded into `let _`.

Gates at this tree:
  cargo test -p maxplayer-core --locked                        EXIT=0  511 passed, 0 failed
  cargo test -p maxplayer-core --all-features --locked         EXIT=0  1768 passed, 0 failed, 46 ignored
  cargo test -p maxplayer-core --all-features --locked --no-run EXIT=0  15 executables, 0 errors
…absent listing failures

F1: a name is not ownership. LiveScope now records Held::{Proposed,Acquired,Uncertain}
per resource. The network is Acquired only after `docker network create` succeeds; the
workdir uses `create_dir` (not `create_dir_all`, which accepts a directory this invocation
did not create) so ownership is decided by this call. Drop removes ONLY Acquired resources
and reports Uncertain ones instead of deleting on a guess. Cleanup after "network created,
then mkdir failed" still runs.

F4: a failed observation is not absence. Teardown checks `listed.status.success()` before
parsing stdout, keeps exit status and stderr, and reports ownership as UNKNOWN rather than
breaking as if nothing were owned. Every eprintln! reachable from Drop or runner-panic
reporting is replaced by a fallible `writeln!(std::io::stderr(), ..)`, so a failed
diagnostic write can neither skip cleanup nor double-panic during an unwind.

Test code only: all hunks inside `mod tests`; zero production bytes changed.
@pmilic021
pmilic021 merged commit fbde610 into MakePrisms:main Sep 18, 2026
13 of 15 checks passed
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.

2 participants