MISSION-EGRESS.md said the fix was deliberately not applied and prescribed DOCKER-USER. Both would now mislead. Appended what happened on 2026-09-18: missions shared the server's subnet, DOCKER-USER loses to ts-forward on every tailscaled restart, raw PREROUTING dropped the server's replies — and the design that holds, verified from inside a real mission container. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
11 KiB
What a mission container can reach — 2026-08-27
Applied 2026-09-18. The lateral path this document measured is closed. The remediation below was applied, failed twice in ways worth reading, and was replaced by a different design. See Applied — what actually worked at the end before acting on anything above it.
Measured, not modelled. Nothing in this document changes production; it exists because plan item 4 named a fix that cannot reach the problem, and the problem turned out to be larger than the one it was written for.
Item 4 was mis-scoped
The plan said to pull upstream's 0db7d999a feat(plugins): add shared egress policy foundation (#9137) — "defence for the egress problem we have not
solved". It is good work (DNS pinning, IPv4-mapped metadata blocking,
proxy-conflict surfacing, ~2,400 lines across three crates) and it cannot
observe a single mission tool call.
Two facts settle it:
claude_cliruns the claude binary as a subprocess —Command::new(&self.binary_path)….spawn()incrates/zeroclaw-providers/src/claude_cli.rs:345. Every tool the mission agent runs happens inside that child process.net_guard's only call sites upstream arelink_enricher.rs,helpers/domain_guard.rsandplugins/egress.rs— ZeroClaw's own Rust HTTP paths.
A mission agent's curl is spawned by claude, not by ZeroClaw, so it never
touches the guarded stack. Pulling the commit would harden the chat tier's
link previews and native web tools. It would leave mission egress exactly as it
is. Worth doing on its own merits; not worth doing under the belief that it
closes this.
The topology
Mission containers are attached to two docker networks
(mission_runtime.rs:327):
| network | internal |
subnet | what it is for |
|---|---|---|---|
clawmates_core |
true | 172.20.0.0/16 | server, database, skills door |
clawmates_edge |
false | 172.23.0.0/16 | outbound provider egress |
core has no default route at all — confirmed from a container on it, where
every external address is unreachable and even the positive control fails.
edge supplies the default route, and with it everything below.
The measurement
A throwaway alpine:3.20 container attached to both networks, exactly as a
mission container is. Controls in both directions, because a probe whose
positive control fails proves nothing — the first run of this probe was
core-only, reported "no internet", and was discarded for that reason.
--- routes ---
default via 172.23.0.1 dev eth1
172.20.0.0/16 dev eth0 scope link src 172.20.0.6
172.23.0.0/16 dev eth1 scope link src 172.23.0.5
positive control 1.1.1.1:443 raw IP REACHABLE (expected)
positive control arxiv.org:443 DNS+connect REACHABLE (expected)
negative control 192.0.2.1:80 TEST-NET blocked (expected)
tailnet gw-02 100.84.218.70:22 REACHABLE <--
host SSH docker gateway 172.23.0.1:22 REACHABLE <--
link-local 169.254.169.254:80 REACHABLE <--
database clawmates_postgres_1:5432 REACHABLE (authenticated)
internet unrestricted REACHABLE
LAN 192.168.1.1:80 blocked
architect:8090 read blocked only because that host has been offline 15 days;
it is not evidence of a control.
What this means
A mission agent reaches the entire tailnet and SSH on its own host. The container's default route is the docker gateway, the host runs tailscale, and NAT forwards the rest. Nothing between the agent and 100.64.0.0/10.
That matters more here than it would for ordinary software, because a mission
agent runs model-generated shell commands over content it fetched from the
open web. Two production missions made 158 Bash calls, 151 of them
curl/wget against 12+ hosts, and delegated 12 more fetches to subagents. The
instruction stream and the data stream are the same stream.
Related: gw-01/02/04 are key-only with fail2ban, but the hosts have no firewall, and gw-02's databases are bound to localhost only. Reachability is not compromise. It is the precondition for it.
What is not exposed
Stated because a report that lists only the bad half is not a measurement:
- Postgres requires a password over TCP. Attempted from the mission network
as both
postgresandclawmates:fe_sendauth: no password supplied. - No database credentials are forwarded into mission containers. The env is
ZEROCLAW_GATEWAY_PORT,CM_MISSION_ID,GIT_CONFIG_*and provider keys — nothing else (mission_runtime.rs:735). - The LAN is not reachable, only the tailnet.
169.254.169.254is link-local on bare metal, not a cloud metadata service. Reachable, but there is no credential endpoint behind it on gw-04.
Remediation — described, deliberately NOT applied (superseded — see the end)
The operator's call was to measure and report. The smallest change that closes the lateral path, for whenever that decision is made:
# exempt first: missions legitimately need the core network (skills door, API)
iptables -I DOCKER-USER -s 172.23.0.0/16 -d 172.20.0.0/16 -j ACCEPT
# then deny the private world
iptables -I DOCKER-USER -s 172.23.0.0/16 -d 100.64.0.0/10 -j DROP # tailnet
iptables -I DOCKER-USER -s 172.23.0.0/16 -d 169.254.0.0/16 -j DROP # link-local
iptables -I DOCKER-USER -s 172.23.0.0/16 -d 10.0.0.0/8 -j DROP
iptables -I DOCKER-USER -s 172.23.0.0/16 -d 192.168.0.0/16 -j DROP
Public egress is untouched, so research missions keep working — which is the
constraint that rules out a host allow-list as the first move. JEPA Research
alone fetched arxiv, api.github.com, raw.githubusercontent.com, arrow.apache.org,
pytables, clawpack, h5py, lancedb, netcdf4, paperswithcode and more. No
pre-approved list would have contained them, and a mission that cannot read
cannot do research.
Order matters (-I prepends, so the ACCEPT must be inserted last to sit first),
the rules are not persistent across reboot as written, and they should be
verified with the same positive/negative control pair used above rather than
assumed.
One fix that was applied
mission_runtime.rs discarded the result of the edge-network attach:
let _ = self.docker.connect_network(EDGE_NETWORK, …).await;
core is internal, so a failed attach leaves a mission with no egress at
all — no provider call, no fetch — while the launch reports success and the
phase can still complete. The green-with-nothing shape this codebase keeps
meeting.
Now: on error, the container's own network list decides. Already attached is benign and logged; genuinely not attached fails the launch with a message that says what it means. The container's networks are the fact, not the return code.
Limits of this measurement
- One host (gw-04), one moment. Other deployments may differ.
- Reachability of a port, not exploitability of a service.
architectand the fleet nodes were offline, so they were not probed.- The probe container ran
alpine, notclawmates-runtime. Same two networks and the same route table; a different image cannot have more access.
Applied — what actually worked (2026-09-18)
Everything measured above was re-measured first, from a container on the mission egress network, and was still true: tank's SSH, web-01's registry, this host's SSH, link-local — all open. Then the rules above were applied, and the positive/negative control pair this document asked for found three things the rules could not have known.
1. Missions shared a subnet with the server
Missions egressed from clawmates_edge (172.23/16). So does
clawmates_server_1, and the server needs the tailnet: the Beszel hub on
architect (:8090), Ollama for the local-model backend (:11434), and the
node daemons (:8088) for exec-test and node-placed terminals. The tailnet
drop scoped to 172.23/16 cut the server off from architect:8090 within the
minute it was applied. No rule on that subnet could ever be right for both.
Fix: missions egress from a network of their own, clawmates_missions,
pinned at 172.25.0.0/16 (mission_runtime.rs EDGE_NETWORK, commit
869c3ad; declared in both compose files). core is unchanged — the skills
door and the API are still reached over 172.20. Compose v1 will not create a
network no service uses, so on gw-04 it was created by hand with compose's own
labels (com.docker.compose.network=missions, …project=clawmates), which
compose then accepts as its own.
2. DOCKER-USER loses to Tailscale
tailscaled inserts -j ts-forward at the head of FORWARD on every restart,
above -j DOCKER-USER, and ts-forward ends in -o tailscale0 -j ACCEPT. A
tailnet drop in DOCKER-USER worked until systemctl restart tailscaled, and
then the tailnet was open again — verified by doing exactly that. Re-asserting
the rule from an ExecStartPost on tailscaled ran before Tailscale had
installed its chains and lost the same race. Link-local, which does not go
through tailscale0, stayed blocked throughout; that is what made the cause
legible.
3. raw PREROUTING cannot see connection state
Moving the drops to the raw table put them ahead of every chain Tailscale
touches — and ahead of conntrack. A drop on -d 100.64.0.0/10 then matched
the server's replies to tailnet clients (tank's node daemon, the operator's
curl) and took the API off 100.102.112.85:8088 while every container
reported healthy. The public site kept serving, because Traefik reaches the
server over the Docker network.
What holds
/usr/local/sbin/clawmates-egress.sh on gw-04, run by
clawmates-egress.service at boot and by ExecStartPost drop-ins on
docker.service and tailscaled.service. Idempotent (-C before -I). The
rules live in mangle PREROUTING with -m conntrack --ctstate NEW: after
conntrack, so replies pass; before FORWARD and INPUT, so ordering against
ts-forward is moot; and neither Docker nor Tailscale writes to that table.
missions (172.25/16): DROP NEW → 100.64/10, 10/8, 192.168/16, 169.254/16, tcp/22 anywhere
edge (172.23/16): DROP NEW → 10/8, 192.168/16, 169.254/16, tcp/22 anywhere (tailnet allowed)
Verified three ways, then verified again after restarting both daemons:
| from | tailnet | host ssh | link-local | public | core door |
|---|---|---|---|---|---|
| mission subnet (throwaway container) | blocked | blocked | blocked | open | 200 |
inside a real mission container (01a0b550) |
blocked | blocked | blocked | open | 200 |
| edge (the server) | open | blocked | blocked | open | — |
Inbound to the API on the tailnet port: 200 from the host and from tank. The
mission itself ran normally behind the policy — 19 curl fetches, 2 skill
reads through the door, judge met=true, independent.
Limits
- gw-04 only. The laptop has the network (
clawmates_missions, same subnet) and no firewall; a local mission can still reach the LAN and tailnet. - Reachability of a port, as before. A mission can still
curlany public host. - The microVM tier has its own egress model (per-backend rootfs, vsock) and is not covered by any of this.