Files
clawmates/docs/MISSION-EGRESS.md
T
Omar SobhandClaude Opus 5 a0f6cd7175
deploy / test (push) Successful in 5m14s
deploy / build (push) Successful in 1m4s
docs(egress): the remediation was applied, failed twice, and what holds
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
2026-09-18 11:50:54 -05:00

239 lines
11 KiB
Markdown

# 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](#applied--what-actually-worked-2026-09-18) 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_cli` runs the claude binary as a **subprocess**
`Command::new(&self.binary_path)``.spawn()` in
`crates/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 are `link_enricher.rs`,
`helpers/domain_guard.rs` and `plugins/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 `postgres` and `clawmates`: `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.254` is 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:
```sh
# 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:
```rust
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.
- `architect` and the fleet nodes were offline, so they were not probed.
- The probe container ran `alpine`, not `clawmates-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 `curl` any public host.
- The microVM tier has its own egress model (per-backend rootfs, vsock) and is
not covered by any of this.