Seal a network-off run to its model broker - #2035
Merged
Merged
Conversation
ppXD
force-pushed
the
fix/stand-codex-sandbox-down-under-ours
branch
from
September 26, 2026 23:35
2cb0cfc to
0e96f1f
Compare
ppXD
force-pushed
the
fix/seal-a-network-off-run-to-its-broker
branch
from
September 26, 2026 23:54
eb9be4b to
66df2db
Compare
6 of 7 tasks
ppXD
force-pushed
the
fix/stand-codex-sandbox-down-under-ours
branch
from
September 27, 2026 00:32
0e96f1f to
6a6a9b4
Compare
ppXD
force-pushed
the
fix/seal-a-network-off-run-to-its-broker
branch
4 times, most recently
from
September 27, 2026 02:54
f71bbf6 to
3aaa4bb
Compare
ppXD
force-pushed
the
fix/stand-codex-sandbox-down-under-ours
branch
from
September 27, 2026 08:15
6a6a9b4 to
f8bcc43
Compare
ppXD
force-pushed
the
fix/seal-a-network-off-run-to-its-broker
branch
from
September 27, 2026 08:16
3aaa4bb to
0baf9a0
Compare
ppXD
changed the base branch from
fix/stand-codex-sandbox-down-under-ours
to
main
September 27, 2026 08:16
Under bubblewrap a network-off run got --unshare-net: an empty namespace, which severed it from its model broker along with everything else. Standard, the tier every recipe recommends, is network-off, so on a confining host no default-tier brokered run reached a model. The executor now stamps a network-off run's broker lease port onto its spec. Where bubblewrap confines and the host has proved it can build a namespace, the runner runs such a run in a sealed one instead: no route, no NAT, no DNS, and an inet input filter on the host veth that admits only that port on the gateway. The allowlist plan with no IPs would not do: it accepts DNS anywhere, routes out, and has no input filter, so the worker's own listeners and every other run's broker were reachable through the gateway. A host that cannot seal keeps severing, as before. The launch records the seal, and the journal says the run was sealed to its broker. Sealing makes the per-run /30 the default for network-off brokered runs, so the allocator now also skips any /30 the host already routes (ip -j -4 route show table all). Its lock sees neither the worker's own network nor a run whose namespace outlived the worker that reserved it; handing either out shadowed real peers or split two runs' broker replies between two veths. A routed range is passed over whole without counting against the 4096 the walk may try, so a broad route over the first range moves the walk on through 10.0.0.0/8 rather than refusing; null routes broader than a /30 are ignored, and running out of /30s is a failed setup like any other. A null route in a table that a policy rule consults before main wins by rule order, not prefix length, which no route listing shows, so every setup now asks the kernel how it reaches the namespace (ip route get <ns> from <gateway>, read as text: route get learned -j only in iproute2 5.0) and fails unless the answer is the run's own veth. The sealed ruleset replaces a leftover table of the same name instead of appending to it, since every revise round reuses the name. A launch that provably released nothing (a rejected receipt, or a committed one whose broker died) now tears down the namespace and cgroup it was given. The tools and seal probes retry a failure after a minute instead of caching it for the process, keep the reason, and the seal probe runs before the bootstrap's admission window opens. The Room ranks and names a sealed agent as its own posture, between confined and severed. A sealed launch drops the proxy variables it inherits, since its one destination is the broker on its gateway, which a proxy cannot reach. Every brokered launch that was handed a proxy (by its task, or by a worker variable the env scrub keeps) also exempts its broker's address from NO_PROXY. Beside a "*" the address stays in NO_PROXY, because reqwest (Codex) matches no IP address against "*", while no_proxy stays a lone "*", which curl, Python and undici honour only as the whole value.
ppXD
force-pushed
the
fix/seal-a-network-off-run-to-its-broker
branch
from
September 27, 2026 09:10
0baf9a0 to
d3c6729
Compare
4 of 5 tasks
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.
Summary
--unshare-net. That severed it from its model broker along with everything else. Standard (network-off) is the tier every recipe recommends, and research mode forces the network off as well, so on such a host no default-tier brokered run reached a model. Pin whether a read-only reviewer can read its diff with git #2032 pinned this: the reviewer's model saw zero requests.SandboxSpec.ModelBrokerPort.AgentRunExecutor.ApplySealedEgressdoes this insideHardenSpec, which takes an input record so it stays under the parameter cap.FilteredEgressNetns.CanSealhas proved this process can build a namespace by creating and deleting a throwaway one.FilteredEgressPlan.BuildSealed) instead of severing: no route, no NAT, no DNS, and aninetinput filter on the host veth that admits onlytcp dport <lease port>on the gateway.CanSealis a real probe and notIsSupported.SandboxConfinement.EgressSealedToBroker, which impliesNetworkSevered. The journal then reads "Network: off (Standard) — confined: egress sealed to the run's model broker". That wording is pinned in the shared posture fixture and checked by both stacks.inettable. Every reaper is keyed on the run id alone, so none of them needed changes.HostRoutedPrefixes, read fromip -j -4 route show table all). The lock alone sees neither the worker's own network, where a /30 would shadow real peers, nor a run whose namespace outlived its worker. Re-issuing that run's /30 split two runs' broker replies between two veths.rejectedreceipt now tears down the namespace and cgroup it was given. That receipt proves nothing was released, and before this no reaper could find those resources.SealUnavailableReason;ConfinedEgressSealedToBroker("confined · egress sealed to its model broker") on both stacks.0.0.0.0/1pair) are treated as defaults.HTTP(S)_PROXY/ALL_PROXYvariables it inherits. Its one destination is the broker on its gateway; a proxy is unreachable from the namespace and would turn every model call into a failure that looks like the provider's.committed, so a rejection that lands a moment later is still torn down.IndexAtOrAboveis pinned as the inverse of the candidate order at every octet boundary.blackhole/unreachable/prohibit/throw) are ignored.CapabilityProbe).committedreceipt whose broker is dead also proves nothing was released (thereadyreceipt precedes release), and is torn down.NO_PROXY. This covers the allowlist netns gateway and a loopback that aNO_PROXYomits.*in either NO_PROXY spelling had collapsed both to a lone*. Codex's reqwest matches no IP address against*, so its brokered calls still went to the proxy. NowNO_PROXYkeeps the broker's address beside the*(for Go and reqwest), andno_proxystays a lone*(for curl, Python and undici, which honour it only as the whole value).main. Such a route wins by rule order, not prefix length. Every setup now runsip route get <ns> from <gateway>after its setup commands (FilteredEgressPlan.RouteCheckArgv) and fails closed unless the answer is the run's own veth. The check also catches rule-levelblackhole/prohibitactions, which no route listing shows. It runs for the allowlist plan too, but it is an output lookup: an allowlist run's NAT'd replies are routed on input, so a rule keyed on the uplink (iif) still escapes it. That gap is not new; the allowlist plan had no check before.ip route get's text answer (the word after its onedev).route getlearned-jonly in iproute2 5.0, three releases after the route listing, so on Debian 10's 4.20 every setup was refused. Reviewers checked it on a real kernel against Ubuntu 20.04/22.04/24.04, Debian 12, Rocky 8/9, Amazon Linux 2/2023 and Alpine. They also checked common policy routing: wg-quick, Tailscale with and without an exit node, Calico, Cilium, AWS VPC CNI, a VRF,rp_filter=1and linkdown. Every verdict matched a real TCP exchange.ALL_PROXYnever reaches the child, so it no longer writes NO_PROXY into a child with no proxy..claude/settings.jsonenvproxy still reaches an unpinned Claude run. That is the existing "repo settings load when--setting-sourcesis not pinned" exposure. Pinning every Claude run first needs a real-CLI check that projectCLAUDE.mdstill loads.Test plan
SandboxEgressPolicyTests: Sealed only for network-off plus a sealable port.FilteredEgressPlanTests:inet;inettable.BubblewrapSandboxTests: the sealed record is severed and sealed; an unconfined record claims neither.AgentRunExecutorEgressTests: the port is stamped only for network-off brokered runs.NativeLaunchRegistryTests: the port binds the hash and is absent from an unbrokered spec's JSON.NetworkPostureWordingDriftTestsandlaunchInput.test.ts(84/84): pass on the new fixture case.agent_run.sandbox_confinementjsonb column and reads back as sealed.ApplySealedEgressfromHardenSpecturns the Standard row red.SealedEgressE2ETests, durable and non-durable, running the real runner and real broker, with a python3 probe inside the chain:1.1.1.1:80and8.8.8.8:53(TCP and UDP) stay shut;A_network_off_reviewer_reaches_its_model_through_the_sealed_namespace: real Claude and Codex, a durable launch, a sealed record, and the diff reaching the model.EgressSubnetAllocatorTests: a host-routed /30 is skipped, including a survivor's /30 and a /30 holding a host address. A host that routes every candidate is refused with a message naming its routes, not "4096 live runs". Plus the overlap theory.rejectedtears down.tsc --noEmit -p tsconfig.app.json, eslint.inettable is gone.A_30_still_held_by_a_run_that_outlived_its_worker_is_not_handed_to_the_next_run.ModelCredentialBrokerNetnsE2ETestscovers both the sealed and the allowlist namespace.EgressSubnetAllocatorTests: a 10.1.0.0/16 route moves the walk to 10.2.1.0/30; /1 routes are defaults; the remount-ro refusal names the mount with routes present.SealProbeCacheTests: retry interval, kept reason, a proof kept for good, and a non-blocking re-probe.committedis waited for.HTTP_PROXY, and it reports the proxy variables its child actually has; the lane requires none, on both durable and non-durable launches.NO_PROXYexemption, and theReserveretype.*placements.ready/indeterminate, orcommittedby another broker, proves nothing.ALL_PROXY/all_proxyalone writes nothing, while a workerhttps_proxyis exempted.SealedEgressE2ETests,FilteredEgressNetnsE2ETests,ModelCredentialBrokerNetnsE2ETests,DurableLaunchEgressE2ETests.A_host_whose_policy_rule_discards_the_run_s_replies_fails_the_setup_and_leaks_nothing. It uses a TEST-NET-1 /30 that the allocator never hands out. The same plan first sets up cleanly as a control. It then fails underunreachable 192.0.2.0/24in a table consulted before main, names the lookup that found it, and leaves no namespace or veth behind. The kernel answers were checked by hand in a privileged container: the veth on a clean host, and exit 2Host is unreachableunder the rule.