Skip to content

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
MakePrisms:mainfrom
maxy-player:w-gvisor-dns-delivery-r2
Open

fix(sandbox): make DNS and delivery work under gVisor, and bind a runsc job that the netns plan never did#991
maxy-player wants to merge 32 commits into
MakePrisms:mainfrom
maxy-player:w-gvisor-dns-delivery-r2

Conversation

@maxy-player

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

Copy link
Copy Markdown
Contributor

Cross-repo PR from a fork. Counterpart fork PR: maxy-player#1
(kept open deliberately, not closed). Opened under explicit authorization from the
ordering seat; the delivering identity holds no push permission on this repository,
so the branch lives on the fork and is offered here as a cross-repo head.

  • head (immutable): 0560631f9d5c0eb22015977addf7c5020a699c4amaxy-player:w-gvisor-dns-delivery-r2
  • base (immutable): 3faf8950896470d2ffe8d51852a4c0e837ea5196MakePrisms/maxplayerai@main
  • baseline reproduced against (immutable): b45f8651dc9cab5b71c962eadd7b84840f579106

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/. Nothing
here is withheld.

1. Host-side IPv6 is not rendered at all — open hole. HostPolicy::argv uses
Family::V4.binary() unconditionally, so this branch installs no v6 host rules. The
test 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 ip6tables has no DOCKER-USER chain on a v4-only host; that
premise is measurably wrong here (ip6tables -S DOCKER-USER returns -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.sh is a
reproducibility 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 — has
no aarch64 artifact, so the exact reported build was never run.

4. The same-bridge result is host-specific. br_netfilter is absent here. Where it is
enabled the INPUT vs DOCKER-USER split may read differently and a same-bridge peer may
become 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 joining
it sees lo only), so the design assumes the daemon default is runc and nothing checks
it. Gate 5k measured the unsupported case: with default runc the job is HEALTHY and
reaches a serving listener; with default runsc the 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 host
fails 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 with docker exec into
a 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 the
root cause: the sandbox image ships no nc and no wget, so every probe was doomed
before it ran. v3 rebuilt the harness on --entrypoint node inline JS and is the only run
that measures anything. This also forced a re-check of the earlier gates: 5f/5g/5h/5i never
used those tools, so their SERVING/REACHED readings stand.

8. One privileged surface is added, as an explicit exception. The --network host --cap-add NET_ADMIN host-rule applier is an exception to the brief's no-host-networking
constraint 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=false here and its checkout is configured
fork-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 is
b45f8651dc9cab5b71c962eadd7b84840f579106; main has since advanced to
3faf8950896470d2ffe8d51852a4c0e837ea5196. Every gate was measured against the merge
base, so no result here speaks to code merged into main after it. A reviewer should
treat the evidence as pinned to the merge base, and a rebase should be expected before
any merge consideration.


⚠️ EXPLICIT EXCEPTION TO THE BRIEF — read before reviewing

The brief for this work forbids host networking. This branch adds a container that uses
it.
Per maxie's ruling of 9 Sep 2026 this is an explicit exception requiring review,
not something implicitly approved by "minimum repair" scope. It is stated here, at the
top, because a reviewer must not have to find it in a diff.

No readiness, acceptance, or production-ready claim is made. Advisor has issued no
security verdict; the privileged applier and the runtime boundaries are assigned to
Advisor's first full review. All measurement was confined to an isolated, disposable Lima
VM (gvisor-repro). Nothing was installed on any shared or production host.

The exception, in the seven terms review asks for

1. Exact privilege, capabilities and mounts. One short-lived container per install and per
teardown:

docker run --rm -i --network host --cap-drop ALL --cap-add NET_ADMIN \
  --security-opt no-new-privileges ghcr.io/makeprisms/maxplayer-netfilter:v0.5.8

--network host puts it in the root network namespace; CAP_NET_ADMIN is the one
capability 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 on
the test host:

sha256:913985269261c7169b4f7b14a5db69843b9a3421aa483145a26d6ac38db3da3a

This is the image the product already ships and already runs for the namespace plan. The
exception is the --network host invocation of it, not a new image.

3. Plan and input validation. apply-policy reads one rule per line and refuses any
binary 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 from docker 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_address enforces this on every build, and the gate
scripts 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 --rm and holds no state. There is no long-lived privileged
daemon
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 runsc
job 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: HostRules carries a complete
flag 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 got ENETUNREACH once
its 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 daemon
default
, which the product never verifies. Measured: with the default set to runsc the job
gets lo only 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 /32 and removed by exact inverse; no chain is flushed and no policy is changed. Every
gate 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 artifact
exists for 20260831.0). IPv6 is not covered — no v6 rules are rendered at all; the test
host 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_netfilter absent). Gate 4's write leg is
unauthenticated 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 runsc cannot resolve anything: EAI_AGAIN, every time, while the same container under runc on the same network resolves fine.

Root cause. Docker's embedded resolver at 127.0.0.11 is 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 to 127.0.0.11:53 under runsc times out, under runc it answers. --dns does not help, because docker still rewrites resolv.conf to point at the embedded resolver.

The fix, in three parts

  1. A generated read-only /etc/resolv.conf naming real upstream resolvers, mounted into the job, with port 53 opened to exactly those addresses as /32//128 pinholes. Resolver order: [sandbox] dns_servers → host resolv.conf non-loopback → resolvectl → hard error. No guessed 8.8.8.8; a loopback resolver is refused rather than mounted to fail later.
  2. A network per job (<configured>-job-<job_id>) instead of one shared bridge. The [sandbox] network setting keeps its naming role and loses its sharing role.
  3. A host-side, source-keyed policy in DOCKER-USER (routed destinations) and INPUT (host-directed ones), keyed to the address docker actually gave the holder. The existing netns OUTPUT plan is kept — it is what binds a runc job.

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 DROP was REACHED. Same namespace, same plan, same probe: runc → timeout, runsc → REACHED. gVisor's netstack writes frames straight to the veth; the host OUTPUT chain in that namespace only ever sees host sockets. This predates this branch.

Two further findings, both measured:

  • A shared bridge cannot be ruled shut (gate5c). Two containers on one bridge are switched, not routed, and with br_netfilter absent those frames enter no chain — DOCKER-USER with the correct source key still read REACHED. Moving the neighbour to its own network changed it to timeout. That is why part 2 exists.
  • A job namespace is single-use for gVisor (gate5d). After a runsc container exits, the namespace holds lo only and both runtimes get EAI_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 in RUNLOG.md rather than tidied away, and the failing evidence files are kept beside the passing ones.

Gates

gate what it proves evidence verdict
1 the repro: runsc EAI_AGAIN vs runc OK; raw UDP to 127.0.0.11:53 times out evidence/gate1-*.txt PASS
2 DNS + cert-verified TLS from inside the namespace, fresh and recreated evidence/gate2-*.txt PASS (metadata line withdrawn)
3 doctor/readiness preflight: a failing route cannot report ready crates/maxplayer/src/doctor.rs PASS, 55 tests
4 real git delivery: clone + commit + push from inside the sandbox evidence/gate4-*.txt PASS
5 denial holds, three concurrent jobs still deliver evidence/gate5-denial-and-concurrent-success-PASS-20260910T0311Z.txt PASS, 0 failing
6 bounded runner + limitations evidence/run-all-gates-proof-*.txt PASS

Gate 5's denial legs, before → after the policy: host 192.168.5.15:49254 (live) REACHED → timeout; the same listener by name REACHED → timeout; metadata ECONNREFUSEDtimeout (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 --runtime and 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 --runtime anywhere in holder_argv or sidecar_argv.
  • origin/main:crates/maxplayer-core/src/seller_exec.rs run_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 sets default-runtime=runsc gets a runsc holder; measured, that fails closed (the job sees lo only). 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 host rule applier. It must be --network host because 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

  1. x86_64 is OUTSTANDING. Everything here is aarch64 / runsc release-20260817.0 (host is arm64; release-20260831.0 ships no aarch64 artifact).
  2. br_netfilter absent 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.
  3. Host-side IPv6 is not rendered and v6 denial for a runsc job is UNPROVEN. DOCKER-USER is 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.
  4. Gate 4's write leg is unauthenticated by design — a credentialed push would put a secret inside a stranger's sandbox. It proves the network path for a write, not an authenticated push.
  5. The runner proof covers two gates end-to-end; the other gates have their own direct-run evidence. No single five-gate run through the runner was executed in one sitting.

Verified first-hand

cargo check -p maxplayer-core --features acp clean · cargo test -p maxplayer-core --lib sandbox_ 60 passed · cargo test -p maxplayer --bins doctor 55 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 under docs/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-netfilter image with
--network host --cap-add NET_ADMIN. That is a container in the root network namespace
holding CAP_NET_ADMIN, and it is the single new privileged surface here.

What bounds it:

  • It executes no arbitrary command. apply-policy reads one rule per line and refuses any
    binary other than iptables/ip6tables with exit 5, precisely because it is the one thing in
    the design holding CAP_NET_ADMIN.
  • Every rule is keyed to the job's own /32, and that address is read from
    docker inspect, never computed
    . A guessed key would deny some other container and leave
    the job wide open. every_host_rule_is_keyed_to_the_job_address holds this on every build.
  • It is short-lived — one invocation to install, one (or one per rule) to remove.
  • Failure is closed. establish() refuses the job on an unreadable address, an empty
    address, an applier failure, or a count mismatch. Gate 5i measured the last of these: a
    truncated plan exits 0 having installed 9 of 17 rules, and with those 9 rules a runsc job
    still 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-policy
aborts 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 HostRules carry a complete flag set only after the count cross-check; an
incomplete 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 own
24 rules (rendered by an example compiled against baseline's three-field NetPolicy) and
baseline's own arrangement — one shared network, and no HostPolicy, because origin/main
has none
. With runc as the positive control on every leg:

leg runc runsc
cross-job → live victim timeout REACHED
private egress → live host timeout REACHED

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

  • x86_64 is entirely unvalidated. Everything here is aarch64, runsc release-20260817.0.
    There is no aarch64 artifact for 20260831.0, so that version is untested too.
  • IPv6 is not covered. HostPolicy renders no v6 rules at all. On the test host jobs get
    no global v6 address (only ::1), so v6 is not exercised — a fact about the host, not a safety
    property. 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 ip6tables has a DOCKER-USER chain only when the daemon has
    IPv6 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.
  • The same-bridge result is host-specific. br_netfilter is absent on the test host, so
    frames 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_netfilter loaded.
  • Gate 4's write leg is unauthenticated by design — it proves delivery reaches the remote and
    is rejected for credentials, not that a push succeeds.
  • The gate runner covers a subset, and each gate's verdict line is what it greps, not an exit
    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_ADMIN applier, the teardown paths, and the IPv6 gap above.

w-gvisor-dns-delivery-r2 added 30 commits September 9, 2026 17:42
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.
w-ec2-host-setup-r2 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.
@vercel

vercel Bot commented Sep 10, 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.

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.

1 participant