Skip to content

feat(jail): net-jail — deny-by-default worker egress - #97

Merged
drewstone merged 3 commits into
mainfrom
feat/net-jail
Aug 1, 2026
Merged

feat(jail): net-jail — deny-by-default worker egress#97
drewstone merged 3 commits into
mainfrom
feat/net-jail

Conversation

@drewstone

Copy link
Copy Markdown
Owner

Closes #94. Deny-by-default network egress for jailed workers, sibling to fs-jail (#79). Three adversarial rounds; two real escapes found and closed.

Why

Denying webfetch removes a tool. It does not stop bash from running curl or git clone. A worker with bash and no network policy has the whole internet regardless of its permission map — and cli-bridge is the local, subscription-backed path, so it is the one we actually use for evals and bulk work.

This is not hypothetical: a DeepSWE rig was one run from a large fake result because the worker could git clone the repo it was graded against. Every task declares allow_internet = false; nothing enforced it.

Mechanism

An --internal Docker network plus a relay aliased for each allowlisted hostname, verifying the name each connection claims (TLS SNI, or Host for cleartext) before forwarding a byte. TLS is spliced, never terminated — certificate validation stays end-to-end and the relay holds no key material. On top, a per-container packet filter installed by a throwaway CAP_NET_ADMIN sidecar: default DROP both directions, exceptions only for loopback and the relay.

No proxy environment variables, deliberately. HTTPS_PROXY is a request an agent can decline with unset; here the worker never learns a relay exists (env | grep -ci proxy → 0), and clients that ignore proxy env — undici/fetch, which these CLIs use — are covered too.

Model host is derived from configured base URLs, not hand-typed, with each entry's provenance printed at startup.

The two escapes, and why they matter more than the feature

Round 1 — the Docker host gateway. --internal denies routing off the bridge; it does not deny the bridge. The host's gateway address sits on-link in the worker's own subnet, never touches the relay, and verifyNetJail never looked at it. Measured: 591,738 bytes of github.com with ssl_verify_result=0 from inside the jail.

Round 2 — a restart wipes the namespace. The filter lives in the container's network namespace, and Docker recreates that namespace on restart.

Round 3 found that the round-2 diagnosis named the wrong door. --restart on-failure is nearly impossible for a worker to trigger — kill -9 1 from inside a PID namespace is ignored by the kernel (measured: RestartCount stayed 0, filter intact). The door that actually opened was the bridge's own: terminateDockerExecution runs docker restart --time 0 whenever a command exits non-cleanly, because killing the local docker exec client does not stop the process tree. A worker triggers it by exiting 3. Removing the restart policy alone would have fixed nothing — and that was the proposed fix.

Closed by scoping the hook to a container START, not a container: no Docker restart policy on hooked slots, and every handout compares .State.StartedAt against the start the hook ran for, re-arming before the slot is usable. livenessTtlMs is forced to 0, because the cached fact is exactly the fact that changes inside the cached window. A Docker event watcher was rejected: it races the next docker exec, and the loser of that race is a request running unconfined.

The verifier is now the thing that was wrong twice

verifyNetJail had missed both escapes by checking something adjacent. It now restarts the probe with the same docker restart --time 0 the executor uses, re-arms through the same code path the pool calls, and re-runs all eight assertions — failing provisioning if the probe's start time does not move, since a restart that did not happen makes the second round a re-run of the first. Reachability is judged by errno: ECONNREFUSED means a packet came back and counts as a leak.

A calibration test provisions with the filter suppressed and asserts provisioning rejects. The guard has been watched to fail.

Proof (executed, real Docker)

before after
github via host service, post-restart 339,598 bytes, http 200 0 bytes, rc=7
gateway, every port OPEN EHOSTUNREACH
link-local 169.254.169.254 reachable ENETUNREACH
peer container (proven live) reachable EHOSTUNREACH
worker running iptables as uid 0 Permission denied
real model completion through the jail returns

Self-caught during review

reprovisionSlot did not copy the new container's armedStart, so after any replacement the stale stamp would never match and every request would run an enforcer sidecar forever. Fail-safe rather than unsafe, but real. Fixed and pinned.

Gates

tsc 0 · 533 passed / 6 skipped · net-jail.test.ts 50/50 on real Docker · docker-executor.test.ts 72/72.

…llowlist (WORKER_NET_JAIL)

Closes #94.

fs-jail confines what a worker can READ. Nothing confined where it could
CONNECT, so denying the harness's `webfetch` tool removed a tool while `bash`
kept the whole internet: a benchmark rig was one run from a large fake result
because the worker could clone the public upstream of the repo it was graded on.

Enforcement is the network, not a request to the agent. Each jailed backend gets
an `--internal` Docker network (no gateway, no default route, no external DNS)
plus a relay container on both that network and a routable one. The relay is
registered on the internal network under a network-scoped alias for every
allowlisted hostname and verifies the name each connection claims — TLS SNI, or
the Host header for cleartext — before forwarding a byte. TLS is spliced, never
terminated, so certificate validation stays end-to-end against the real origin.
There is no proxy variable, so there is nothing for the worker to unset.

The model endpoint is derived from the configured base URL (ANTHROPIC_BASE_URL /
OPENAI_BASE_URL / TANGLE_ROUTER_URL, else the CLI's own default), so a caller
naming no hosts still gets a working agent.

Unenforceable configurations fail loud rather than silently:
- a host-executed backend refuses to load, naming each backend and the
  <NAME>_EXECUTOR=docker that fixes it. No weaker env-proxy mode is offered.
- <NAME>_DOCKER_NETWORK alongside a net-jail refuses to load.
- a per-request net-jail against a backend with none provisioned, or against
  execution.kind=sandbox, fails 501 naming the mode.
- execution.netJail.allow ASSERTS the enforced allowlist; a mismatch fails
  rather than quietly widening, since a pooled worker cannot be re-jailed.

Provisioning ends by proving the jail on a throwaway container from the same
image and network a worker gets: a name that must not resolve, a name that must,
and a real TLS handshake through the relay.

Also fixes a pre-existing typecheck failure in tests/opencode-worker-timeout.

Verified on real Docker: `git clone` exits 128, `curl https://github.com` exits
6, raw-IP egress and a worker-pinned --resolve are both refused, and a real
opencode agent turn completes through the allowed endpoint (29,465 prompt
tokens). Regression tests execute those same commands rather than asserting on
config, and one test removes `--internal` to prove the verification step fails.
…scape

`--internal` denies routing OFF the bridge and leaves the bridge itself
reachable: Docker configures the host's gateway address on the jail's
subnet, on-link in the worker's own network, along with every peer
container. Neither is on the allowlist, neither passes the relay, and
`verifyNetJail` never looked. Measured on the shipped jail: 339,594 bytes
of github.com with ssl_verify_result=0, pulled from a jailed worker
through a service listening on the Docker host.

Each jailed container now also gets a packet filter in its OWN network
namespace — default policy DROP both directions, exceptions for loopback
(Docker's embedded resolver) and the relay's newly pinned address. That
covers the host gateway, peer containers, the host's other interfaces,
link-local metadata and all IPv6 with one rule set instead of an
enumeration of things to block.

The capability lives in a throwaway sidecar that joins the worker's
namespace with CAP_NET_ADMIN and exits; the worker keeps Docker's default
capability set, so its own iptables fails with Permission denied as uid 0
and the rules are state it cannot reach. Host DOCKER-USER rules were
rejected because that chain hangs off FORWARD and never sees traffic
delivered locally to the host; no Docker network option removes the
bridge address from a member's reachable set.

verifyNetJail now runs its probe under the same filter a worker gets,
beside a listening peer container, and FAILS provisioning unless the
gateway, the peer and link-local are all unreachable, the worker cannot
run iptables, and the relay IS reachable — the control that keeps the
four denials from being a dead network stack. Reachability is judged by
errno: ECONNREFUSED means a packet came back and counts as a leak.
The pool destroys any slot whose filter could not be installed.

Requires iptables in the runtime image; the Dockerfile installs it and a
backend whose image lacks it refuses to start rather than run a jail that
does not deny.
… un-jail a worker

The deny lives inside the container's network namespace, and Docker destroys
and recreates that namespace on every restart — same container id, same
mounts, same filesystem, empty rules. A worker reaches that: when its command
exits non-cleanly the executor runs `docker restart --time 0` on the slot,
because killing the local `docker exec` client does not stop the process tree
inside the container. Measured on the real request path before this commit —
real pool, real spawner — a worker exited 3, and the next acquire of the same
slot dialled the Docker host (`OPEN`) and pulled 339,598 bytes of github.com
through a service listening there.

The fix is not an enumeration of restart sources. `afterCreate` is now
declared to be scoped to a container START rather than to a container, and the
pool enforces that in the two places a start can change:

  - No Docker restart policy on a slot that uses the hook. A policy revives the
    container in place, with fresh empty namespaces, and nothing in this
    process observes it happening. The pool already models a dead slot
    correctly (`rm -f` then `provisionSlot`, which re-runs the hook), so a dead
    jailed container is REPLACED rather than revived. Nothing depended on the
    old policy: the entrypoint is `tail -f /dev/null`, `on-failure` does not
    fire on daemon restart, and a stopped container is rebuilt by the pool's
    existing liveness path.
  - Every handout compares `.State.StartedAt` against the start the hook was
    run for, and re-runs the hook before the slot is usable. This is what
    covers the restart the bridge performs ITSELF, which is the larger door and
    the one the escape came through. `livenessTtlMs` is forced to 0 for such a
    pool, because the cached answer is exactly the fact that changes inside the
    cached window.

A Docker event watcher was rejected: it races the next `docker exec`, and the
loser of that race is a request running unconfined. The gate has no such
window — the restart is over before it runs, the only thing alive in the
container is the idle entrypoint, and the slot stays reserved across the whole
check. Moving the deny outside the namespace was rejected too: the only place
outside it that is on the path is the host firewall, which is the DOCKER-USER
option already rejected for hanging off FORWARD, and it would have to name the
container by an address Docker may reassign on the very restart it must
survive.

The start is read before AND after the hook and the two must match, so a
container that restarts DURING arming is never stamped with a start whose
namespace the hook never wrote to.

verifyNetJail now proves the property directly instead of proving something
adjacent to it, which is how it missed the previous two escapes. It restarts
the probe with the same `docker restart --time 0` the executor uses, re-arms it
through the same function the pool calls, and re-runs every assertion — host
gateway, peer, link-local, iptables, DNS, TLS. Provisioning fails if the
probe's start time does not move, because a restart that did not happen makes
the second round a re-run of the first. `skipRestartRearmForVerification` is
the calibration seam: with it the guard fires, naming the reachable host.
@drewstone

Copy link
Copy Markdown
Owner Author

Adversarial verification — measured byte egress, real Docker

Not a code read. Provisioned a real jail with the production provisionNetJail, stood up a host HTTP server on the jail's bridge gateway serving a fixed 100,000-byte payload (the stand-in for round 1's "github via a service on the host"), and measured actual bytes leaving the jail for every vector. 0 = denied. Every agent worker was avoided entirely — the whole battery is deterministic Docker-level (curl / node / python3 execed into jailed containers), so no model was spawned.

Provenance. image cli-bridge-cli-runtime:netjail (the only local runtime image with iptables; :pi/:latest lack it and correctly fail-closed) · network <prefix>-netjail (--internal) · gateway 172.23.0.1 · relay 172.23.0.2 · allow router.tangle.tools:443 · host payload 172.23.0.1:<port> (100,000 B) · verifyNetJail ran its full twice-over (provision + post-restart re-arm) during provisioning.

Escape-attempt table

Vector Method Bytes egressed Verdict
E1 — Docker host gateway (round 1) curl host service at 172.23.0.1:<port> (100 KB) from a filtered worker; also ports 80/443/2375/2376 0 (rc=7 connect refused) DENIED
E2 — restart clears namespace (round 2) — raw mechanism docker restart, filter not re-applied, then curl host 100,000 door is real; proves the pool gate is load-bearing
E2 — restart, real pool handout gate ContainerPool (afterCreate=filter, restart-policy no); restart out-of-band, then pool.acquire()curl host 0 (rearms=1) DENIED
E3 — terminateDockerExecution restart (round 3, the real door) fake worker exit 3 → real terminateDockerExecutiondocker restart (StartedAt moved) → next acquire()curl host 0 (restart fired, rearms=1) DENIED
N1 — IPv6 egress curl -6 to external v6 (Cloudflare/GitHub); 0 global v6 addrs on the jail 0 DENIED
N2 — DNS exfil / tunnel resolve external name via 127.0.0.11; bytes to 8.8.8.8:53 UDP+TCP 0 (external name → EAI_AGAIN; ENETUNREACH to :53) DENIED
N3 — unix socket to docker daemon stat + curl --unix-socket /var/run/docker.sock /version 0 (no socket mounted) DENIED
N4 — peer container as relay unfiltered peer on the jail net (can itself pull 100,000 B); filtered worker → peer:9999 0 (worker→peer blocked, INPUT/OUTPUT DROP) DENIED
N5 — nested --network=host run docker run --network=host from inside the jail 0 (no docker client/daemon in image) DENIED
N6 — link-local metadata curl 169.254.169.254 IMDS + 169.254.170.2 ECS creds 0 DENIED
N7 — ICMP egress raw-socket ICMP echo to gateway + 8.8.8.8/1.1.1.1; loopback control replied (60 B) so the probe can detect a leak 0 (sendtoEPERM from the icmp-admin-prohibited REJECT; external ENETUNREACH) DENIED
N8 — already-open socket vs. filter open TCP to host, apply filter mid-stream 0 worker-reachable (see note) DENIED
N9 — relay as open proxy to off-host Host: github.com / CONNECT github.com / --resolve github→relay (real SNI) / abs-URI with allowlisted Host 0 to github (relay returns its own 403 body only) DENIED
N10 — UDP egress (QUIC/arbitrary) UDP to gateway / 8.8.8.8 / 1.1.1.1:443 0 (no replies) DENIED
CONTROL — allow-path TLS through the relay to router.tangle.tools/v1/models http=200 ALLOWED — proves the denials are a filter, not a dead network

N8 autopsy (why it is 0, not a leak)

First pass flagged N8 at ~9,000 B. That was a measurement artifact, run down to ground truth: I timestamped "filter applied" at the call, but applyNetJailEgressFilter takes ~1,960 ms to start its sidecar and install rules. Measuring from when the function returns (rules confirmed in the namespace):

  • the instant the filter returns, fresh connections become EHOSTUNREACH and
  • the pre-existing socket drains exactly one in-flight TCP segment (~1,000 B) then stalls flat forever (can't ACK out, inbound dropped).

The bytes I first attributed to "post-filter" flowed during the ~2 s sidecar-install window. On the real worker path that window is before slot handout, with only the idle tail -f /dev/null entrypoint running — the worker process does not yet exist (documented at container-pool.ts:692). A worker never holds a pre-filter socket, so its reachable egress is the E1/E2/E3 number: 0.

Gates

  • tsc --noEmit: 0 errors
  • merge into main: clean (git merge-tree empty conflict set)
  • test suite (CLI_BRIDGE_TEST_IMAGE=cli-bridge-cli-runtime:netjail): see the run below

Verdict: every worker-reachable vector measures 0 bytes off-allowlist. All three prior escapes stay closed. Merging.

@drewstone
drewstone merged commit 2680197 into main Aug 1, 2026
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.

net-jail: the local path confines the filesystem but not the network

1 participant