fix(sandbox): make DNS and delivery work under gVisor, and bind a runsc job that the netns plan never did - #991
Open
maxy-player wants to merge 32 commits into
Open
Conversation
added 30 commits
September 9, 2026 17:42
….0.0.11 unreachable in sandbox
…oles, blocking sandbox-route preflight
Docker's embedded resolver at 127.0.0.11 never answers inside a runsc sandbox (measured: EAI_AGAIN under runsc, OK under runc on the identical image and named network, raw UDP to 127.0.0.11:53 timing out), and --dns cannot move it because the daemon writes 127.0.0.11 into every container on a user-defined network. So a contained job is handed its own read-only /etc/resolv.conf naming real upstream resolvers, and NetPolicy opens port 53 to exactly those addresses as single hosts. Resolvers come from [sandbox] dns_servers, else the host's own upstreams (resolvectl when the host names only the systemd stub), else the job is refused — never a guessed public resolver.
…b namespace
Two measured findings, both in the evidence:
A gVisor sandbox's network namespace cannot be joined. A runsc container
joining a runsc holder sees `lo` only — no eth0, no route, every lookup
EAI_AGAIN — because the netstack lives inside the sandbox. Joining a runc
holder, the same runsc job gets the holder's own interface and address, so
the host kernel's rules govern it. iptables-nft also fails inside gVisor
("Failed to initialize nft: Protocol not supported"), so the sidecar must
apply the plan on the host runtime.
So the containment plane (holder + sidecar, our own images, no stranger
code) stays on the host runtime and only the job runs under runsc. With
that and the mounted resolver file: lookup OK, TLS 200 with a verified
chain (authorized=true, CN=relay.maxplayer.ai), on a fresh namespace and
again after it was destroyed and rebuilt, while 169.254.169.254 stayed
denied. The plan installed is rendered by NetPolicy itself, not
transcribed — hence the new example.
aarch64, runsc release-20260817.0. x86_64 validation OUTSTANDING.
A runsc container joining a runsc holder's namespace sees lo only — no eth0, no route, every lookup EAI_AGAIN — because a gVisor sandbox's netstack cannot be entered by a second sandbox, and iptables-nft will not initialise inside gVisor either. holder_argv and sidecar_argv already emit no --runtime; this pins that, so threading the seat's runtime through later cannot silently return a job with no network and a policy nothing enforces.
… ragged No behaviour change. The probe stays uncontained, so it is handed no resolver file — a resolver it holds no port-53 pinhole for would only add a failure mode that says nothing about the filesystem question it asks.
… host's Every other network check here is answered by the HOST, and that is the hole this closes: a seat whose host resolves and fetches perfectly while its gVisor jobs die on EAI_AGAIN looks ready, advertises, wins a job and fails it. check_sandbox_delivery_route builds the real namespace with the product's own argv builders (holder_argv, sidecar_argv_for, plan_stdin, SandboxPolicy::launch), hands the job the resolver file, and requires the JOB to resolve a name and complete a certificate-VERIFIED handshake with the relay. A Fail keeps readiness_ok false; it is transient so the boot gate's bounded retry covers a daemon blip, while an unfindable resolver is refused immediately because no retry conjures one. A route that could not be measured is a Fail, never a pass, and a silent probe is not consent. Missing docker stays the launcher check's verdict. The advisory check_sandbox_egress is untouched (petar, 2026-08-18). cargo test -p maxplayer --bins doctor: 55 passed, 0 failed.
…andbox Two legs, both from the job container under runsc, non-root, cap-drop ALL, no-new-privileges, inside the shared job namespace. READ: cloned a real public repository over https and delivered 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d — identical to `git ls-remote` taken independently outside the sandbox. WRITE: committed in the container and pushed to a disposable bare remote living outside it; the remote's ref now holds a4a883b087273c86480ea1269f7e50ff965296b2, exactly the hash the container produced. Not a mock and not a host-side upload. The write remote is reached at the namespace gateway on a port inside the policy's proxy pinhole, so the push crosses the job's egress policy rather than sidestepping it. It is unauthenticated on purpose: a credentialed container-side push would mean putting a secret inside a stranger's sandbox, which is the one thing the containment design exists to prevent. No credential is used, needed, or logged. Two script bugs found and fixed by their own red result: --depth 1 cannot be pushed on, and rev-parse without --verify returns the ref NAME for a missing ref, which reads as a mismatch rather than an absence. aarch64, runsc release-20260817.0. x86_64 validation OUTSTANDING.
Measured, twice, on two subnets: from a runsc job inside a namespace carrying the full 26-rule plan, a listener at 172.31.x.x — inside -d 172.16.0.0/12 -j DROP — is REACHED. gate5b isolates the variable: same namespace, same plan (read back from the netns, the DROP is present), same probe; runc gets 'timeout', runsc gets 'REACHED'. gVisor's netstack terminates the network inside the sandbox and writes frames to the veth itself, so the host kernel's OUTPUT chain in that netns never sees the job's packets. The chain is installed, correct, readback-verified, and irrelevant to the runtime it exists to constrain. This predates the branch; the DNS work opens port 53 in a chain that was already not binding the job. It also devalues part of my gate-2 evidence, corrected in the runlog: the 'metadata: denied (ENETUNREACH)' line is not proof of policy, because nothing listens on that address here and absence looks exactly like enforcement. Only a denial against a live listener is a real denial. Two script bugs found and fixed in red first: node -e passes extra args from argv[1], not argv[2]; and 'no FAIL lines printed' was scored as a public-route pass when the payload had crashed before reaching those legs — both halves now assert positively. aarch64, runsc release-20260817.0. x86_64 OUTSTANDING.
gate5c returned ENETUNREACH for everything late in its run, including the runc CONTROL that had worked minutes earlier in the same namespace and including DNS to 1.1.1.1, which no rule under test touched. gate5d settles it: read with os.networkInterfaces(), the namespace has eth0=172.31.16.2 and working DNS before and during the runsc job, and lo-only with EAI_AGAIN after it exits — for a following runc container and a following runsc container alike. A gVisor container takes the namespace's addresses into its netstack and does not return them on exit. The namespace is usable exactly once. Voids gate5c (both candidate enforcement sites measured against a dead namespace) and gate 5's git leg, which was the second runsc container in its namespace rather than a DNS bug. gate5b survives: its runc control ran first while the namespace was healthy and was correctly dropped, and its runsc probe was that namespace's first gVisor container — the plan binds runc, not runsc. Gates 2 and 4 survive; one runsc container per namespace in each. Rule adopted for the rest of the branch: one gVisor container per namespace, and a namespace health check immediately before any leg meant to be evidence. aarch64, runsc release-20260817.0. x86_64 OUTSTANDING.
…ridged gVisor job, a per-job network can Every leg health-checked immediately before the probe and aimed at a live listener. Same bridge, no host rule: runc timeout, runsc REACHED. Same bridge WITH DOCKER-USER -s <ns>/32 -d 172.16.0.0/12 -j DROP: runc timeout, runsc still REACHED. Other bridge: runsc timeout. br_netfilter is absent on this host, so frames switched between containers on one bridge never enter iptables' FORWARD path and DOCKER-USER cannot see them. The runc 'timeout' in the DOCKER-USER row is its own netns OUTPUT plan, not the rule under test — the reason a control that only ever shows denied proves nothing about the site being measured. A per-job network makes cross-job traffic routed rather than switched, and routed packets from a gVisor sandbox do meet the host's FORWARD path. That is the enforcement point; shared bridge plus gVisor has none. The DOCKER-USER rule costs nothing on the public side: dns, cert-verified tls 200 and a real git clone all still pass with it installed. Not yet measured, and named rather than assumed: whether a source-keyed policy denies a runsc job a ROUTED private destination docker isolation does not already block, whether it denies the metadata address, and what covers host-directed traffic, which lands in INPUT rather than FORWARD. aarch64, runsc release-20260817.0. x86_64 OUTSTANDING.
…USER binds routed, per-job networks bind cross-job Fresh namespace per probe, one gVisor container in each, health beside every leg, live listeners. A runsc job can reach the VM's own LAN address (192.168.5.15:49252, real listener): runc bare timeout, runsc bare REACHED, runsc with a DOCKER-USER source-keyed DROP still REACHED, runsc with the same rule in INPUT timeout. Host-directed packets are delivered locally and never traverse FORWARD, so DOCKER-USER cannot see them and INPUT can. Metadata: nothing listens on it, so only a difference counts, and there is one — bare ECONNREFUSED, ruled timeout. DOCKER-USER does bind the metadata address for a runsc job, which is reached by route through the gateway. Cross-job with per-job networks: runsc to job 2's live listener, timeout. With both rules installed the public route is untouched: dns, cert-verified tls 200, and a real git clone all pass. The fix is now specified by measurement rather than by guess: a per-job network, plus a daemon-side source-keyed policy in DOCKER-USER and INPUT, plus the existing netns OUTPUT plan kept as what binds a runc job. aarch64, runsc release-20260817.0. x86_64 OUTSTANDING.
…annot bind HostPolicy renders the DROPs on the HOST side of the veth, keyed to the job namespace's own address, where the host kernel handles the packet whatever produced it. NetPolicy stays exactly as it is: it binds a runc job, and it costs nothing as defence in depth. Two chains, because the measurements say neither covers the other. Packets addressed to the host are delivered locally and never traverse FORWARD, so DOCKER-USER read REACHED against a live listener on the host's LAN address while the same rule in INPUT read timeout. Packets routed onward do traverse FORWARD, and DOCKER-USER is where the metadata drop bit (bare ECONNREFUSED versus ruled timeout). DOCKER-USER rather than a bare -I FORWARD because docker jumps to it before its own rules and does not rewrite it. -I rather than -A: DOCKER-USER is shared and docker appends to it, so an appended policy sits behind whatever is already there. teardown_argv exactly inverts install_argv in reverse order — these rules outlive the job's container in a chain nothing else cleans up, so a missed teardown leaks a ruleset per job and lets a recycled address inherit a dead job's policy. Not covered, deliberately and by measurement: a peer on the job's own bridge, which is switched rather than routed and on a host without br_netfilter enters no chain at all. A per-job network is that fix, next. IPv6 is not rendered host-side: ip6tables has DOCKER-USER only when the daemon has IPv6 enabled, and a missing chain would fail every launch on a v4-only host; the netns plan still carries the v6 drops and host-side v6 stays UNMEASURED. Seven rendering tests, including the one that matters on a shared host: every rule carries this job's -s key, because a rule that lost it would deny the range to the whole host. cargo test -p maxplayer-core --lib sandbox_net: 48 passed.
… guard establish() now creates the job its own bridge, named under the operator's configured network as a prefix so that name keeps its identifying role and loses its SHARING role. Measured reason, not preference: with every job on one shared bridge a gVisor job REACHED a live listener in another job's namespace, and no host rule stopped it, because two containers on one bridge are switched rather than routed and on a host without br_netfilter those frames enter no iptables chain at all. Moving the neighbour to its own network changed the result to timeout. The per-job network is also what makes host-side enforcement possible for everything else: with no on-link peer but its gateway, all the job's other traffic is routed, and routed packets from a gVisor sandbox do traverse the host's chains. The host-side policy is keyed to the address docker actually gave the holder, read with docker inspect rather than computed from the subnet: a policy keyed to a guess denies some other container and leaves this job open. An empty address is a hard error rather than a rule with no source key, which would deny the range host-wide. It is applied by the same applier image the sidecar uses, reading the same <binary> <args...> plan on stdin, with the same count cross-check for a truncated plan — one applier in this design, not two. It runs with --network host because these rules must land in the ROOT namespace's chains; that is safe only because nothing untrusted is ever in that container. It runs our own image, on a plan rendered in Rust, for milliseconds, and it is gone before the job starts. The job never comes near --network host. Three guards now, each adopting its resource the instant it exists: JobNetwork and HostRules join NetnsHolder. HostRules is the one that matters most because its resource is invisible — a leaked container gets noticed, a leaked rule in a shared chain does not, and without the teardown DOCKER-USER grows by one ruleset per job while a recycled address inherits a dead job's policy. It is adopted BEFORE the install result is examined, since a plan that failed part-way has already installed rules. Containment's field order is load bearing: holder drops before network, because docker refuses to remove a network that still has an endpoint. cargo test -p maxplayer-core --lib sandbox_: 60 passed. cargo test -p maxplayer --bins doctor: 55 passed.
… runsc job Regression proof for 6bc0117 and 3911afd, with the rules rendered by the product (new render_host_plan example, from HostPolicy) rather than transcribed into the script, and installed through the same applier image the sidecar uses in a --network host container, so the product's own path is what is under test. The delta that matters: runsc -> the host's own live listener went REACHED to timeout once the rendered INPUT rules were installed. That is exactly the hole gate5e found and DOCKER-USER cannot close, host-directed packets never traversing FORWARD. With gate5e's metadata delta for DOCKER-USER, both chains of the rendered plan are now caught working against a gVisor job. Recorded just as carefully: the legs that did NOT change. Cross-job on per-job networks was already denied before the policy went in, so this gate does not prove the host rules deny it — the per-job network does, and saying otherwise would repeat the gate-2 error of reading absence as enforcement. The same-bridge leg stays REACHED because switched frames enter no chain without br_netfilter, which is the measured reason every job gets its own network. Public route survives the policy: dns + cert-verified tls 200 + git clone. Teardown returned DOCKER-USER and INPUT to the exact depth they started at, 0 rules left for the job address. The script pins the namespace address and refuses to run unless the rendered plan carries that source key: a plan keyed to the wrong address denies some other container and leaves this job open, invisibly.
The field's doc-comment still described one dedicated bridge every job joins. That stopped being true when establish() started deriving <configured>-job-<id> per job, and a config comment that describes the previous architecture is worse than none: it is the thing an operator reads before deciding what the setting protects them from. Records the measurement behind the change rather than just the new behaviour — a gVisor job REACHED a live listener in another job's namespace across a shared bridge, switched frames entering no chain on a host without br_netfilter — and keeps the older reason a NAMED network exists at all, which is that the seat's own LAN and host addresses fall inside the destination denies. Also corrects the DNS paragraph. It claimed a user-defined network keeps DNS working through docker's embedded resolver at 127.0.0.11. Under gVisor that resolver never answers: it is a daemon-side socket reached by NAT inside the netns, and the sandbox terminates loopback in its own netstack. DNS comes from the generated read-only resolv.conf instead. cargo check -p maxplayer-core --features acp: clean. cargo test -p maxplayer-core --lib sandbox_: 60 passed.
… taught The first version FAILED and its evidence is kept. It was unsound in three ways the sub-experiments then exposed, and the rewrite names all three rather than quietly fixing them: 1. It read timeouts against addresses where nothing listened as denial. Absence and enforcement are indistinguishable that way — the same error cost gate 2 its "metadata denied" line. Every denial leg here aims at a LIVE listener, or is reported as NO EVIDENCE unless bare and ruled runs differ. 2. It reused one namespace across gVisor probes. The namespace is single-use for gVisor (gate5d): runsc takes the addresses into its netstack and never returns them, so later legs ran in a namespace holding lo only and "passed" by being broken. One gVisor container per namespace now, health check printed beside every leg, UNSOUND instead of a verdict when sick. 3. It double-counted a request timeout and scored a success as PUBLIC-FAIL. New coverage no earlier gate had: reaching a denied address BY NAME, via a mounted hosts file rather than a third-party wildcard DNS service, so the leg does not fail for an unrelated reason. IPv6 is measured and reported honestly — the host plan renders no ip6tables rules, so v6 denial for a runsc job is recorded UNPROVEN rather than claimed. Rules come from the product's own renderers, and the gate5f stale-plan guard is applied to all five namespaces: a host plan keyed to the wrong address denies someone else's container and leaves this job open, invisibly.
… jobs deliver at once 0 denial legs failing. Two legs are new evidence, and two are deliberate non-claims, which is the part of this gate I care about most. New: reaching a denied address BY NAME is denied exactly as dialling the address is (REACHED -> timeout), coverage no earlier gate had and the shape every real exfiltration attempt actually takes. And the metadata address, which counts only because bare and ruled runs DIFFER (ECONNREFUSED -> timeout); with nothing listening there an identical result would prove nothing, the same reasoning that made me retract gate 2's claim rather than defend it. Not claimed: the neighbour-job leg was already denied before the policy went in, so the host policy is not credited with it — the per-job network is. And IPv6, where the namespace has no global address at all, so nothing was denied and nothing was proved; the host plan renders no ip6tables rules and gate5b showed the netns plan does not bind runsc, so on a host whose jobs DO get v6 this is an open hole, recorded as one. Concurrency: three jobs, own networks, own holders, 17 host rules each installed at once, all three resolved + verified a certificate + cloned. Denial and delivery are not in tension. Teardown returned every chain to the depth it started at.
The gates are the deliverable, but a gate nobody can re-run is a claim rather
than evidence. This is how someone reproduces the set on a fresh VM without
knowing which script needs which rendered plan.
Two rules it will not bend. BOUNDED: every gate gets a timeout and the run
gets a total budget, so a hung gate is a FAIL with a reason instead of a run
that sits there until a watchdog kills the session and leaves no evidence at
all. And A GATE THAT DID NOT RUN IS NOT A PASS: missing prerequisites print
SKIPPED with the reason and count against the run. That second rule is aimed
at the exact class of bug gates 2 and 5 were caught making about their own
probes — a green summary produced by a suite that quietly executed nothing.
For the same reason it greps each gate's own verdict line rather than
trusting an exit code: several of these scripts run under set -uo pipefail
without -e and exit 0 while reporting a failure inside.
It does not render plans. Rendering them here would mean this script decides
what the rules are, and the gates would be testing the script instead of the
product.
Proof run attached (gate1 + gate5f, aarch64): ALL GATES: PASS in 61s. I
checked the per-gate logs rather than the summary, because 61s looked too
fast — both logs are complete runs with real measurements, the speed being
warm images. Evidence:
run-all-gates-proof-{summary,run}-20260910T0316Z.txt.
…h does not prove The limitations section is the part that decides whether the rest is honest, so it names each thing a reader should not assume: x86_64 untested (arm64 host, no aarch64 artifact for a newer runsc), br_netfilter absent here and shaping the same-bridge measurement, host-side IPv6 unrendered and v6 denial for a runsc job UNPROVEN (an open hole where jobs get a global v6 address), gate 4's write leg unauthenticated by design, the helper runtime boundary being baseline rather than new, and the --network host applier as the one privileged surface this branch adds. Also states plainly what the runner proof does and does not cover: two gates through the runner, five gates with their own direct-run evidence, and no single five-gate run through the runner in one sitting. Retractions stay in the record rather than being tidied away: gate 2's metadata line is withdrawn, gate5c's first run is VOID, and the first gate 5 FAIL is kept beside the passing rewrite.
…n/main Maxie ruled that the prior-baseline vulnerability claim needs a reproduction or a source citation, not an assertion. This is the reproduction, on b45f865. Baseline's own rules (24, rendered by an example compiled against baseline's three-field NetPolicy, because a renderer built against the fix would reproduce the fix), baseline's own arrangement (one shared network, no HostPolicy — origin/main has none), runc as the positive control on every leg, and a kernel readback per namespace so a REACHED cannot mean 'no rules were installed'. CONFIRMED, 2 findings: cross-job to a live victim and private egress to the live host both REACHED under runsc while runc timed out. The branch does not introduce this; it repairs it. The first run is kept as CONFOUNDED evidence. It read timeout on the cross-job leg and looked like baseline containment working — but the victim carries the plan too and its listener was runc, so the plan bound the VICTIM and its own OUTPUT rules dropped its replies. It measured a victim that could not answer. Fixed by running the victim under runsc, which is the real arrangement anyway since every job is a gVisor job, and by proving VICTIM-SERVING from inside its namespace before drawing anything from a timeout. Third time this branch has hit the same failure in different clothes (gate 2's metadata line, gate5c's void, this). Same lesson: a timeout is evidence only when something was proved able to answer.
Gate 5 proved the rules deny; it never proved they go away. Docker hands addresses back, so a leftover rule keyed to a dead job's address becomes a stranger's firewall — denied traffic that should be allowed, or an ACCEPT pinhole that was A's proxy and is now someone else's open door. PASS, 0 failing checks. A establishes (0 -> 17 product-rendered rules), is contained, tears down to 0 rules and no network; B then genuinely recycles A's address 172.31.55.2, inherits 0 stale rules, REACHES the live host bare and gets ENETUNREACH once its own rules install; same job id comes up twice without wedging; chains return to starting depth 2 -> 2. The recycled-address leg is the one that carries weight: a bare-vs-ruled difference against a live listener, on a previously-owned address. Leg 1 has no bare control and is recorded as corroboration only. The two denials read differently (timeout vs ENETUNREACH) and that is logged as an unexplained observation rather than smoothed over.
establish() already refuses a job at four points -- unreadable address, empty address, applier failure, and count mismatch -- and adopts HostRules before examining the result so a part-applied plan still unwinds. All four were held by reading the source; none was held by a test. Four rendering tests now hold them: teardown exactly inverts install (same length, -I<->-D, reversed), every rule in both directions carries the job's /32 source key, the rendered count equals the plan's line count (the guard the truncation cross-check depends on), and an empty address renders a bare /32 -- asserting the hazard, so the empty-address guard is never relaxed to a warning. Also corrects the test-count record. 'doctor tests' are not in maxplayer-core; that crate's doctor module has zero #[test]. They are in the maxplayer binary target. Verified: core lib 387 passed, sandbox filter 65 passed, doctor 55 passed, 0 failed anywhere. The four new tests were confirmed by name rather than inferred from the total changing.
…torn down Fail-closed gate. A and B pass: an applier that cannot start, and one without NET_ADMIN, install nothing. C confirms by measurement what the code comment claimed -- a truncated plan exits 0 and under-installs (9 of 17), so only the count cross-check reveals it -- and adds the part that matters: with 9 of 17 rules, runsc REACHED the live host. A partial policy is not a policy. D fails, and the defect is mine. Teardown of a partial install left all 9 rules in the shared chains; depth 2 -> 11. apply-policy aborts on first failure by design (confirmed directly against the image), and teardown is the exact inverse in reverse order, so it starts with a rule the partial install never created, aborts there, and removes nothing. The applier's exit-3 contract says 'destroy the holder'. That is a complete remedy for the NAMESPACE plan, whose rules die with the netns. My host-side plan writes to the root netns DOCKER-USER and INPUT, where destroying the holder removes nothing -- so the host path reuses a failure contract that does not hold for it. This defeats the documented intent of adopting HostRules before examining the result. Gate 5h passed only because its install was complete and its teardown matched rule-for-rule. Committing the FAIL and its evidence before any fix.
Gate 5i found that a partially installed host policy could not be removed: the applier aborts on the first failed rule, and the teardown plan is the exact inverse in reverse order, so it began with a rule the partial install never created and removed nothing. Measured: 9 installed, 9 still in DOCKER-USER afterwards, chain depth 2 -> 11. HostRules now carries a `complete` flag, set only after the count cross-check, never at construction. When complete, teardown is the one-shot inverse plan as before. When not, each rule gets its own applier invocation, so a failure means only "that rule was not there" -- expected on this path -- and a missing rule can no longer strand the ones that are present. Adoption before examining the result was always correct; what changed is that the teardown it runs can cope with the state adoption exists to clean up. Two unit tests hold it: adoption must assume a partial install (the fast path is earned, not the default), and every per-rule plan is one line, a delete, still keyed to the job's /32 -- the applier refuses an empty plan (exit 4) and a non-iptables binary (exit 5). Gate 5i now PASSES with 0 failing checks and chain depth 2 -> 2, and runs both strategies back to back on the same partial install: the one-shot plan leaves 9 of 9, the per-rule path removes all 9. The pre-fix leg is kept as a live regression witness. FAIL evidence preserved alongside the PASS.
Gate 5 left IPv6 as NO-V6/UNPROVEN and maxie asked for applicable IPv6. Measured on gvisor-repro (aarch64, docker 29.1.3): the daemon declares no IPv6, and a job-shaped holder on a per-job network has only ::1/128 -- zero global v6 addresses. So a job here has no v6 egress path and host-side v6 containment is not exercised. That is a fact about this host, not a safety property of the branch: a host WITH IPv6 enabled is not covered, and jobs there would have an egress path with no host-side containment in front of it. One measurement contradicts the code. The doc comment justifies omitting v6 rules by saying ip6tables has a DOCKER-USER chain only when the daemon has IPv6 enabled, and that a missing chain would fail every job launch on a v4-only host. On this v4-only host `sudo ip6tables -S DOCKER-USER` prints -N DOCKER-USER: the chain exists anyway. The stated premise does not hold, so it is not the reason the omission is safe. The honest reason is that host-side v6 is UNMEASURED. The comment is left unchanged and named instead, because altering v6 behaviour is outside the minimum repair that was scoped. Also adds the Security impact section to the PR body: the one privileged surface (--network host --cap-add NET_ADMIN applier) and what bounds it, the teardown defect and its fix, gate5g's baseline confirmation, x86_64, IPv6, the host-specific same-bridge result, gate 4's unauthenticated write leg, and no production-ready claim pending advisor review.
Maxie ruled: "missing --runtime means daemon default, not guaranteed runc;
test unsupported defaults fail closed." The correction lands on the product.
holder_argv() deliberately passes NO --runtime, and a test locks it there,
because a runsc holder's namespace is unusable (a job joining it sees lo
only, gate2a). So the design ASSUMES the daemon default is runc and nothing
verifies that assumption.
gate5k measures what happens when the assumption is false. This first run
proves NOTHING and is committed as-is because the harness, not the product,
failed:
* The listener was started with `docker exec --detach` into a runsc holder.
runsc refused it ("executing processes for container"), so the target was
never serving -- in BOTH legs, including the runc control.
* `ip` is absent from the sandbox image, so every interface listing read
"exec failed" instead of showing whether the job had eth0.
Leg 2's "no-answer" is therefore NOT a fail-closed result; it is a dead
target, the same confound that spoiled gate5g's first run. failing_checks=2
is the harness reporting its own liveness checks, exactly as designed.
What the run does establish, incidentally: the default runtime can be
switched to runsc and restored (verified back to runc by the EXIT trap), and
under that default the holder is created with `holder runtime: runsc`, which
is the configuration the product cannot use.
Next: start the listener as a SEPARATE container joined with
`--network container:<holder>` (the pattern gate5g already uses) and read
interfaces from /proc/net/dev and /proc/net/route rather than `ip`.
… harness bug named v2 still could not serve its listener. Root cause, verified by running the image directly: ghcr.io/makeprisms/maxplayer-sandbox:v0.5.8 has NO nc and NO wget. Both my gate5k drafts built listeners and probes out of tools that do not exist in the image, so every liveness check was doomed before it ran. I checked whether that poisons the earlier evidence. It does not: gate5f/5g/5h/5i never use nc, wget or curl — grep across them returns nothing. They probe with `--entrypoint node` and inline JS (http.createServer, net.connect), which is why their SERVING and REACHED readings are real. This image is a Node sandbox; node is the tool that exists. gate5k failed because I wrote it fresh instead of reusing the in_ns helper the other gates already had. WHAT v2 DOES ESTABLISH, and it stands without a live listener, because it is read from /proc/net/dev and /proc/net/route INSIDE the job: daemon default = runc (supported) -> job ifaces: lo eth0 | routes: eth0 daemon default = runsc (unsupported) -> job ifaces: lo | routes: none With an unsupported default the holder is created under runsc and the job joining its namespace has no interface and no route at all. There is no egress path to contain, so the unsupported default FAILS CLOSED. That is an availability failure, not a containment bypass — which is the answer to maxie's question, at the structural level. WHAT REMAINS UNPROVEN: the reachability control. "no-answer" in both legs is NO EVIDENCE, since the target was never serving. failing_checks=2 is the harness correctly reporting that about itself. Both failed runs are kept: gate5k-CONFOUNDED-harness-defect-*.txt (v1) and gate5k-v2-daemon-default-runtime-*.txt (v2). Next: v3 rebuilt on the node primitives so the reachability leg is measured rather than asserted.
added 2 commits
September 9, 2026 21:12
… the host-network exception Two of maxie's ruling items. PIN. Every baseline citation in RUNLOG.md and in the gate5g script now names the full commit b45f865 instead of the short form or the branch ref. A moving ref makes a reproduction unverifiable later: a reader who fetches origin/main next week is not looking at what was measured. Zero references to the branch ref remain in either file. The evidence FILES are deliberately left alone. They print the abbreviated hash because the runs printed it, and editing a recorded run's output to look tidier would falsify the record. RUNLOG says so explicitly and points a reviewer at `git rev-parse` to confirm the abbreviation resolves. EXCEPTION. The PR body now OPENS with the host-network exception rather than burying it. maxie's ruling: the --network host applier is an explicit exception to the brief's no-host-networking constraint, not something implicitly approved by minimum-repair scope. The section covers the seven terms review asks for -- exact capabilities and mounts (no bind mounts, no volumes, no docker socket; the plan arrives on stdin), image identity with the measured digest, plan validation (any binary other than iptables or ip6tables is refused, exit 5), privilege lifetime, partial-apply cleanup including the gate5i defect and its fix, restart/concurrency/address-reuse including the new gate5k daemon-default finding, and preservation of unrelated host rules -- and states plainly that no readiness or acceptance claim is made, that Advisor has issued no verdict, and that all measurement was confined to the disposable VM with nothing installed on a shared host.
…ils closed Third attempt, and the first that measures anything. Both legs had a SERVING listener, so failing_checks=0 and the denial is evidence rather than silence. daemon default runc (supported) -> job HEALTHY 172.18.0.3, target=REACHED daemon default runsc (unsupported) -> job SICK no-address, target=ENETUNREACH Leg 1 is the positive control: it proves the harness can observe reachability at all, which is exactly what v1 and v2 could not do. With an unsupported default the job has no address and no route, so there is no egress path to contain. The answer to maxie's question is that this FAILS CLOSED — an availability failure, not a containment bypass. Built on the primitives the other gates already proved work in this image: --entrypoint node with inline JS. v1 and v2 are kept as failures; v2 is where the root cause turned up (the sandbox image has no nc and no wget), and it is why gate5f/5g/5h/5i were re-checked and found sound. The daemon.json edit is reverted by an EXIT trap and the default verified back to runc in the evidence. Names a gap it does not fix: nothing asserts the daemon default at startup, so such a seat fails every job with no explanatory signal. A doctor row checking DefaultRuntime is the honest remedy and is a product change outside the minimum containment repair that was scoped, so it is recorded in RUNLOG for follow-up instead.
|
Someone is attempting to deploy a commit to the MakePrisms Team on Vercel. A member of the Team first needs to authorize it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Acceptance blockers — read before reviewing
This PR makes no readiness, acceptance, or production-ready claim, and no aggregate
"all gates pass" verdict. That aggregate was offered earlier and has been withdrawn:
it is not defensible while the items below are open. Per-gate verdicts stand on their
own evidence, which is committed under
docs/gvisor-dns-delivery/evidence/. Nothinghere is withheld.
1. Host-side IPv6 is not rendered at all — open hole.
HostPolicy::argvusesFamily::V4.binary()unconditionally, so this branch installs no v6 host rules. Thetest host gives jobs no global v6 address, which is a property of my host and closes
nothing. A host with IPv6 enabled is not covered by this branch: such a job would
have an egress path with no host-side containment. The in-source comment justifying the
v4-only choice claims
ip6tableshas noDOCKER-USERchain on a v4-only host; thatpremise is measurably wrong here (
ip6tables -S DOCKER-USERreturns-N DOCKER-USER).I flagged it rather than rewriting it, because rewriting it was outside the scoped repair.
The ordering seat's standing ruling is that IPv6 must be enforced, or unsupported
configurations must fail closed, before any launch or readiness claim.
2. The bounded gate runner has not been executed end to end.
run-all-gates.shis areproducibility deliverable; it was syntax-checked and exercised on a subset, not run in
full. Treat its coverage as unproven.
3. x86_64 is entirely unvalidated. All results are aarch64 with runsc
release-20260817.0.release-20260831.0— the version in the original bug report — hasno aarch64 artifact, so the exact reported build was never run.
4. The same-bridge result is host-specific.
br_netfilteris absent here. Where it isenabled the
INPUTvsDOCKER-USERsplit may read differently and a same-bridge peer maybecome bindable. The per-job network makes the product correct either way, but the
measurement does not transfer.
5. Gate 4's write leg is unauthenticated by design. Container-side git delivery was
proved against a disposable remote; a credentialed push would place a secret inside a
stranger's sandbox.
6. The daemon default runtime is assumed, not verified, by the product. The holder
deliberately carries no
--runtime(a runsc holder's namespace is unusable — a job joiningit sees
loonly), so the design assumes the daemon default isruncand nothing checksit. Gate 5k measured the unsupported case: with default
runcthe job isHEALTHYandreaches a serving listener; with default
runscthe job has no address and no route(
ENETUNREACH). It fails closed — an availability failure, not a containment bypass.The unfixed gap: no doctor/readiness row asserts
DefaultRuntime == runc, so such a hostfails every job with no explanatory signal. That check is a product change outside the
scoped repair and is recorded for follow-up rather than smuggled in here.
7. Gate 5k took three attempts and the two failures are committed, not discarded.
v1 (
gate5k-CONFOUNDED-harness-defect-*.txt) started the listener withdocker execintoa runsc holder, which runsc refuses — the target never served in either leg, including
the control, so it proved nothing. v2 (
gate5k-v2-daemon-default-runtime-*.txt) found theroot cause: the sandbox image ships no
ncand nowget, so every probe was doomedbefore it ran. v3 rebuilt the harness on
--entrypoint nodeinline JS and is the only runthat measures anything. This also forced a re-check of the earlier gates: 5f/5g/5h/5i never
used those tools, so their
SERVING/REACHEDreadings stand.8. One privileged surface is added, as an explicit exception. The
--network host --cap-add NET_ADMINhost-rule applier is an exception to the brief's no-host-networkingconstraint and is not implicitly approved by minimum-repair scope. It is described in
full in the section immediately below.
9. No security review has been performed. The reviewing advisor has issued no verdict.
This upstream object is the artifact offered for that review; review is not routed anywhere
else.
10. Repository scope. The governing brief required branch and PR in this repository.
The delivering fleet identity has
push=falsehere and its checkout is configuredfork-only, so the branch could not be created here directly. This cross-repo PR fulfils the
order without upstream push permission. Remotes were not altered, nothing was pushed to
main, and the fork PR was not closed.11. All measurement was confined to an isolated, disposable Lima VM (
gvisor-repro),which is preserved for acceptance testing. Nothing was installed on any shared or
production host.
12. This branch is not rebased onto current
main. The merge base isb45f8651dc9cab5b71c962eadd7b84840f579106;mainhas since advanced to3faf8950896470d2ffe8d51852a4c0e837ea5196. Every gate was measured against the mergebase, so no result here speaks to code merged into
mainafter it. A reviewer shouldtreat the evidence as pinned to the merge base, and a rebase should be expected before
any merge consideration.
The exception, in the seven terms review asks for
1. Exact privilege, capabilities and mounts. One short-lived container per install and per
teardown:
--network hostputs it in the root network namespace;CAP_NET_ADMINis the onecapability retained, everything else dropped, no privilege escalation. No bind mounts, no
volumes, no docker socket — the rule plan arrives on stdin and nothing else is shared.
2. Image identity.
ghcr.io/makeprisms/maxplayer-netfilter:v0.5.8, digest as measured onthe test host:
This is the image the product already ships and already runs for the namespace plan. The
exception is the
--network hostinvocation of it, not a new image.3. Plan and input validation.
apply-policyreads one rule per line and refuses anybinary other than
iptables/ip6tables, exiting 5 — it executes no arbitrary command.Every rule is keyed to the job's own
/32, and that address is read fromdocker inspect,never computed: a guessed key would deny some other container and leave the job open. The
test
every_host_rule_is_keyed_to_the_job_addressenforces this on every build, and the gatescripts refuse to run against a plan that lacks the expected source key.
4. Privilege lifetime. Milliseconds-to-seconds, twice per job: once to install, once to
tear down. The container is
--rmand holds no state. There is no long-lived privilegeddaemon and nothing listens.
5. Failure and partial-apply cleanup — a defect this branch introduced and fixed. Gate 5i
measured that a truncated plan exits 0 having installed 9 of 17 rules, and that a
runscjob REACHED a live host listener through that partial firewall. Worse, teardown of a
partial install removed nothing (chain depth 2 → 11): teardown is the exact inverse in
reverse order, so it began with a rule the partial install never created and aborted there.
Root cause was mine — I reused the applier's exit-3 "destroy the holder" contract, which is a
complete remedy for namespace rules that die with the netns, on root-netns rules where
destroying the holder removes nothing. Fixed in
676052d:HostRulescarries acompleteflag set only after a count cross-check; an incomplete install tears down one applier
invocation per rule. Gate 5i now passes, depth 2 → 2, and the pre-fix strategy still runs in
the gate as a live regression witness. Both FAIL and PASS evidence are committed.
6. Restart, concurrency and address reuse. Gate 5h: teardown returns the chain to 0 rules
and removes the network; a second job genuinely recycled the first job's address
172.31.55.2, inherited 0 stale rules, reached the host bare and gotENETUNREACHonceits own rules installed; the same job id ran twice without wedging. Guards adopt each resource
the instant it exists, so an early return still tears down. Newly found (gate 5k):
holder_argv()deliberately passes no--runtime, so the holder runs on the daemondefault, which the product never verifies. Measured: with the default set to
runscthe jobgets
loonly and no routes — it fails closed (an availability failure, not a bypass).The reachability control for that leg is still unproven; two harness attempts are committed
as failures.
7. Preservation of unrelated host firewall rules. Every rule is inserted keyed to a single
job
/32and removed by exact inverse; no chain is flushed and no policy is changed. Everygate asserts chain depth returns to its starting value (2 → 2), which is how the gate 5i
teardown defect was caught in the first place.
What is still not proven
x86_64 is entirely unvalidated (aarch64 only, runsc
release-20260817.0; no aarch64 artifactexists for
20260831.0). IPv6 is not covered — no v6 rules are rendered at all; the testhost gives jobs no global v6 address, which is a fact about the host and not a safety property,
and a host with IPv6 enabled would have an egress path with no host-side containment in front
of it. The same-bridge result is host-specific (
br_netfilterabsent). Gate 4's write leg isunauthenticated by design. The baseline vulnerability is reproduced at immutable commit
b45f8651dc9cab5b71c962eadd7b84840f579106, not at a moving branch ref.Evidence tables, the baseline reproduction and the gate-by-gate record are in Security impact further down.
The bug
A job under
--runtime runsccannot resolve anything:EAI_AGAIN, every time, while the same container underruncon the same network resolves fine.Root cause. Docker's embedded resolver at
127.0.0.11is not a server in the container — it is a daemon-side socket reached by NAT inside the network namespace. gVisor terminates loopback in its own netstack, so the packet never leaves the sandbox. Measured directly (gate 1): raw UDP to127.0.0.11:53under runsc times out, under runc it answers.--dnsdoes not help, because docker still rewritesresolv.confto point at the embedded resolver.The fix, in three parts
/etc/resolv.confnaming real upstream resolvers, mounted into the job, with port 53 opened to exactly those addresses as/32//128pinholes. Resolver order:[sandbox] dns_servers→ hostresolv.confnon-loopback →resolvectl→ hard error. No guessed8.8.8.8; a loopback resolver is refused rather than mounted to fail later.<configured>-job-<job_id>) instead of one shared bridge. The[sandbox] networksetting keeps its naming role and loses its sharing role.DOCKER-USER(routed destinations) andINPUT(host-directed ones), keyed to the address docker actually gave the holder. The existing netns OUTPUT plan is kept — it is what binds aruncjob.Parts 2 and 3 are not tidiness. They are there because of a finding this work turned up, described next.
What the gates found beyond the DNS bug
The netns plan does not bind a gVisor job at all (gate5b). From a job carrying the full 26-rule plan, a live listener inside
-d 172.16.0.0/12 -j DROPwas REACHED. Same namespace, same plan, same probe:runc→ timeout,runsc→ REACHED. gVisor's netstack writes frames straight to the veth; the hostOUTPUTchain in that namespace only ever sees host sockets. This predates this branch.Two further findings, both measured:
br_netfilterabsent those frames enter no chain —DOCKER-USERwith the correct source key still read REACHED. Moving the neighbour to its own network changed it to timeout. That is why part 2 exists.loonly and both runtimes getEAI_AGAIN: runsc takes the addresses into its netstack and never returns them. This voided an earlier gate5c run, which is recorded rather than quietly re-run.Retraction
Gate 2 originally reported
metadata denied (ENETUNREACH). That claim is withdrawn. Nothing listens on the metadata address, so absence of a listener and enforcement of a rule are indistinguishable. Every denial claim in this branch now aims at a live listener, or is reported only as a measured difference between bare and ruled runs. The retraction is kept inRUNLOG.mdrather than tidied away, and the failing evidence files are kept beside the passing ones.Gates
EAI_AGAINvs runc OK; raw UDP to127.0.0.11:53times outevidence/gate1-*.txtevidence/gate2-*.txtcrates/maxplayer/src/doctor.rsevidence/gate4-*.txtevidence/gate5-denial-and-concurrent-success-PASS-20260910T0311Z.txtevidence/run-all-gates-proof-*.txtGate 5's denial legs, before → after the policy: host
192.168.5.15:49254(live) REACHED → timeout; the same listener by name REACHED → timeout; metadataECONNREFUSED→ timeout (a real difference, which is why it counts). Concurrently, three jobs each resolved, verified a certificate and cloned:tls=200 verified=true,git 7fd1a60b…. Teardown returned every chain to the depth it started at.Not credited to the host policy: the cross-job leg was already denied before the rules went in. The per-job network earns that one, and saying otherwise would repeat the gate-2 error.
Runtime boundary — please read this part
Holder, sidecar and the new host-rule applier carry no
--runtimeand inherit the daemon default; only the JOB carries the configured runtime. This is baseline behaviour, not a change in this PR:origin/main:crates/maxplayer-core/src/sandbox_netns.rs— no--runtimeanywhere inholder_argvorsidecar_argv.origin/main:crates/maxplayer-core/src/seller_exec.rsrun_argv, lines 681–687 —if let Some(runtime) = &self.runtime { argv.push("--runtime"); argv.push(runtime.clone()) }, on the job's argv alone.This PR adds the test that pins it:
the_containment_plane_never_carries_the_jobs_runtime. The job is never downgraded from runsc, and there is no runc fallback for jobs. An operator who setsdefault-runtime=runscgets a runsc holder; measured, that fails closed (the job seesloonly). No helper executes seller- or task-controlled input — the holder is--entrypoint sleep <our image> infinity, the other two read a<binary> <args…>plan rendered in Rust.Advisor review requested, specifically on the
--network hostrule applier. It must be--network hostbecause the rules have to land in the root namespace's chains — the only place a gVisor job's packets can be seen. It runs our own image, on a Rust-rendered plan, for milliseconds, and is gone before the job starts; the job never touches it. That is the one privileged surface this branch adds, and the judgement should not be only mine.Limitations
br_netfilterabsent on the test host — it is why a same-bridge peer is unbindable here. Where enabled, that leg may read differently. The per-job network makes the product correct either way, but the measurement is host-specific.DOCKER-USERis not guaranteed to exist in the v6 table. The netns plan covers v6, but gate5b showed it does not bind runsc. This VM's job namespace has no global v6 address, so nothing was proved. On a host whose jobs get one, this is an open hole.Verified first-hand
cargo check -p maxplayer-core --features acpclean ·cargo test -p maxplayer-core --lib sandbox_60 passed ·cargo test -p maxplayer --bins doctor55 passed. All Linux/gVisor results come from an actual run in a disposable Lima VM (Ubuntu 24.04 aarch64, kernel 6.8.0-134, docker 29.1.3, runsc release-20260817.0), with the raw output committed underdocs/gvisor-dns-delivery/evidence/.Every gate installs rules rendered by the product (
--example render_net_plan,--example render_host_plan), never transcribed into a script — a transcription drifts from the policy and the gate then passes against a firewall the product no longer builds.Security impact
Read this section before the diff. It is written to be useful to a reviewer looking for
reasons to reject, not to reassure.
The one privileged surface this branch adds
Host-side rules are installed by running the existing
maxplayer-netfilterimage with--network host --cap-add NET_ADMIN. That is a container in the root network namespaceholding
CAP_NET_ADMIN, and it is the single new privileged surface here.What bounds it:
apply-policyreads one rule per line and refuses anybinary other than
iptables/ip6tableswith exit 5, precisely because it is the one thing inthe design holding
CAP_NET_ADMIN./32, and that address is read fromdocker inspect, never computed. A guessed key would deny some other container and leavethe job wide open.
every_host_rule_is_keyed_to_the_job_addressholds this on every build.establish()refuses the job on an unreadable address, an emptyaddress, an applier failure, or a count mismatch. Gate 5i measured the last of these: a
truncated plan exits
0having installed 9 of 17 rules, and with those 9 rules arunscjobstill REACHED a live host listener. A partial policy is not a policy, so refusing is the
only safe response.
Reviewers should weigh this against the alternative it replaces: nothing. Before this branch
there was no host-side enforcement at all (see below).
A defect this branch introduced, found and fixed
Gate 5i found that a partially installed host policy could not be torn down.
apply-policyaborts on the first failed rule; teardown is the exact inverse in reverse order, so it began
with a rule the partial install never created, aborted there, and removed nothing — 9 rules
stranded in
DOCKER-USER, chain depth 2 → 11.The root cause is mine and worth naming: the applier's exit-3 contract says destroy the
holder, which is a complete remedy for the namespace plan because those rules die with the
netns. My host-side rules live in the root netns, where destroying the holder removes
nothing. I reused a failure contract that does not hold for the surface I added.
Fixed by making
HostRulescarry acompleteflag set only after the count cross-check; anincomplete install now tears down one applier invocation per rule, so a rule that was never
created cannot strand the ones that were. Gate 5i now passes with chain depth 2 → 2. Both the
FAIL and the PASS evidence are committed, and the pre-fix strategy still runs in the gate as a
live regression witness.
This vulnerability predates the branch (reproduced, not asserted)
Gate 5g reproduces the containment failure on
origin/main@b45f865, using baseline's own24 rules (rendered by an example compiled against baseline's three-field
NetPolicy) andbaseline's own arrangement — one shared network, and no
HostPolicy, becauseorigin/mainhas none. With
runcas the positive control on every leg:The netns plan binds a runc job and not a gVisor one, and baseline has no second line of
defence. This branch repairs that; it does not introduce it.
What is NOT proven — please do not read this as production-ready
release-20260817.0.There is no aarch64 artifact for
20260831.0, so that version is untested too.HostPolicyrenders no v6 rules at all. On the test host jobs getno global v6 address (only
::1), so v6 is not exercised — a fact about the host, not a safetyproperty. A host with IPv6 enabled is not covered by this branch, and jobs there would have
an egress path with no host-side containment in front of it. Note also that the code comment
justifying the omission says
ip6tableshas aDOCKER-USERchain only when the daemon hasIPv6 enabled; measured on this v4-only host, the chain exists anyway, so that stated premise
is wrong even though the omission itself is defensible as unmeasured.
br_netfilteris absent on the test host, soframes between peers on one bridge enter no iptables chain. The per-job network is what
actually removes the on-link peer; the measurement itself would differ on a host with
br_netfilterloaded.is rejected for credentials, not that a push succeeds.
code.
No production-ready claim is made. This wants a full security review by the advisor before
it goes anywhere near a protected branch — specifically of the
--network host --cap-add NET_ADMINapplier, the teardown paths, and the IPv6 gap above.