The E2B SDK accepts an allow_internet_access boolean on
sandbox create. Setting it to False means the sandbox
must run without the open internet — useful for evaluating an
untrusted prompt’s code, or for compliance postures that require
egress logging/allowlisting. Coppice implements the flag by
extending the per-sandbox pf anchor that Wave A already used for
live network policy (eBPF → pf).
No new machinery — just a different fragment shape.
The fragment
Every sandbox gets its own pf anchor at
cube/sandbox-<short>, where <short>
is the first twelve hex chars of the sandbox id. The root ruleset has
a single anchor “cube/*” directive installed by
coppice-pool-ctl.sh init, so whatever we load into
cube/sandbox-<short> takes effect immediately for
traffic matching that anchor’s path.
For a broad-allow sandbox (default), the fragment is a no-op:
table <sandbox_allow> persist {}
table <sandbox_deny> persist {}
block quick from any to <sandbox_deny>
pass quick from any to <sandbox_allow>
For allow_internet_access=false, the fragment installs a
default-deny with loopback and an optional DNS allowlist punched
through:
table <sandbox_allow> persist {}
table <sandbox_deny> persist {}
table <sandbox_dns> persist { 1.1.1.1, 8.8.8.8 }
pass quick on lo0
pass quick proto { tcp, udp } from any to <sandbox_dns> port 53
pass quick from any to <sandbox_allow>
block quick from any to <sandbox_deny>
block quick all
The <sandbox_dns> table is populated from
COPPICE_DNS_ALLOWLIST, a comma-separated env var read by
the gateway at startup. Leave it unset and the air-gapped sandbox
has no DNS path at all — which is usually what you want when the
point is to keep a suspicious prompt from phoning home.
The pf evaluation order matters: quick short-circuits, so
the first matching rule wins. Loopback always passes, then DNS to
the allowlisted resolvers, then the caller’s allow_out
list (via <sandbox_allow>), then the caller’s
deny_out list, then the default-deny catches everything
else.
The live toggle
PUT /sandboxes/:id/network accepts an optional
air_gapped field alongside the existing
allow_out / deny_out. Behaviors:
air_gapped=true: reload the anchor with the default-deny fragment.allow_outentries become pinholes — effectively an allowlist for an otherwise-sealed sandbox.air_gapped=false: reload with the broad-allow shape.- Field omitted: the sandbox’s mode is preserved — a sandbox created
with
allowInternetAccess=falsestays sealed even if the caller PUTs a new allow/deny list without mentioning air-gap status. This keeps a forgetful caller from accidentally unsealing a sandbox that was deliberately air-gapped at create time.
The handler persists the new mode on the in-memory
Sandbox record, so
GET /sandboxes/:id’s allowInternetAccess
field reflects the live state, not the create-time value.
What it does and doesn’t prevent
Prevents outbound TCP/UDP to any destination not
explicitly allowed. That’s the whole story pf can tell when the jail
uses ip4=inherit and shares the host’s IP: everything
the sandbox tries to send to the external interface goes through the
cube anchor and gets dropped. Ping / ICMP, DNS, HTTP, HTTPS, arbitrary
SOCKS — all blocked unless allowlisted.
Does not prevent three things worth naming:
- Unix-domain sockets. pf is an IP-level filter. Nothing a
sandbox does on a
/var/run/*.sockpath traverses pf at all. If you mount a host socket into the jail, the sandbox can talk to it regardless of air-gap state. We don’t mount host sockets by default. - Loopback.
pass quick on lo0means the sandbox can talk to any loopback service the host exposes — including the e2b-compat gateway itself on port 3000 and envd on 49999. This is intentional: an air-gapped sandbox still needs its own ipykernel and file server, both of which live on 127.0.0.1. The side effect is that any host service bound to0.0.0.0or127.0.0.1is reachable. Bind sensitive services to a non-loopback address the jail can’t reach, or don’t run them on the bench host at all. - Same-host sandbox-to-sandbox. When two sandboxes run on
the same host with
ip4=inherit, they share the host’s IP — so they reach each other through loopback, which is open. If you need strict isolation between sandboxes on one host, switch tovnetjails with per-sandbox addresses oncubenet0(the default for pooled bhyve sandboxes) and replace thepass quick on lo0rule with a narrowerpass quick on lo0 from 127.0.0.1 to 127.0.0.1. Out of scope for the current jail-backend wave.
The receipt
benchmarks/rigs/air-gapped-smoke.sh creates two
sandboxes, one air-gapped and one default, and asserts four things:
- External reachability is blocked on the air-gapped sandbox within a 5-second fetch timeout.
- Loopback still reaches the gateway’s own
/health. PUT /networkwithair_gapped=falserestores outbound to the external target.- A sandbox created with the default (air-gap off) reaches external destinations immediately with no PUT.
Runs green on honor against a gateway built from master with the B3
commit. The rig dumps pfctl -a cube/sandbox-<short> -sr
mid-run so the transcript shows exactly which fragment was live.