Docs How it works
Network isolation
No default route, IPv4 or IPv6. The only next hop is a Squid proxy that checks every CONNECT against allowed_hosts. Every allow and every block is an audit event.
On this page
Transcript
0:00 Here's what happens when a hijacked agent tries to phone home. The sandbox has no default route — not on IPv4, not on IPv6. The only next hop is Constle's allowlisting proxy. A CONNECT to a declared host goes through. Anything else gets a 403, and never leaves the sandbox. Raw IPs are refused too — even the real address of an allowed host. Matching is by name, with reverse lookups off. Skip the proxy and dial out directly? Network is unreachable. DNS doesn't even resolve in there. Every allow and every block lands in the audit log — which is how you find out it happened. One honest limit: the proxy trusts DNS for public addresses. It's documented, like every gap. No route out. One proxy. Every allow and every block on record.
The path out of the sandboxLink to this section
[Agent process]
│ no default route, IPv4 or IPv6 — there is nowhere else to send a packet
▼
[Squid allowlist proxy] ──▶ hosts in network.allowed_hosts → network_allowed
──✗ everything else, including raw IPs → network_blocked (403)The agent process has no route to the internet. The only reachable next hop is the proxy, which checks each CONNECT against allowed_hosts. Both backends render this policy from the same function (buildSquidConfig, internal/sandbox/docker.go), so Docker and Firecracker enforce the same ruleset.
Names, not addressesLink to this section
Resolving a hostname inside the sandbox and connecting to the resulting address does not get around the proxy. The raw IP of an allowed host is denied along with every other IP literal, because the allowlist is a dstdomain ACL built with reverse lookups disabled: an address is admitted only if that same address was itself listed, never by being resolved back to a name. Without that, an address whose PTR record named an allowed host was admitted too, and a PTR record is written by whoever owns the address, not by whoever wrote the allowlist.
| Destination | Request | Result |
|---|---|---|
| declared hostname | https://api.groq.com/ |
CONNECT allowed |
| raw IP of the declared host | https://172.64.149.20/ |
Tunnel connection failed: 403 Forbidden |
| raw IP, undeclared | https://1.1.1.1/ |
Tunnel connection failed: 403 Forbidden |
| undeclared hostname | https://evil.example.com/ |
Tunnel connection failed: 403 Forbidden |
An allowed name is not a blank chequeLink to this section
An allowed name does not decide where it points, or what travels over it. A name resolving into the sandbox host, the host's own network or a cloud metadata address is refused at the proxy whatever DNS answered; that check is made on the resolved address, which is the only place it can be made. And a tunnel to an allowed host is a tunnel to its HTTPS port: a request may name port 80 or 443, and a CONNECT tunnel may name 443 alone, so an allowed hostname cannot become a raw TCP path to an SSH or database port, whose contents the proxy could not see in any case.
| Case | Request | Squid | Audit |
|---|---|---|---|
| allowed host, HTTPS | CONNECT api.groq.com:443 |
TCP_TUNNEL/200 |
network_allowed |
| allowed host, port 22 | CONNECT api.groq.com:22 |
TCP_DENIED/403 |
network_blocked |
| allowed name → 127.0.0.1 | CONNECT localtest.me:443 |
TCP_DENIED/403 |
network_blocked |
| address whose PTR is one | CONNECT 1.1.1.1:443 |
TCP_DENIED/403 |
network_blocked |
Ignoring the proxy isn't an option eitherLink to this section
Everything above assumes the agent goes through the proxy. It can't do otherwise: the sandbox has no route that reaches anything else. On Docker the agent's network is created --internal, its routing table holds a single on-link entry and no default route at all, and the only address family present is IPv4. Attempting to dial out directly, with the proxy environment cleared:
IPv6 2606:4700:4700::1111 OSError: [Errno 101] Network is unreachable
IPv4 1.1.1.1 OSError: [Errno 101] Network is unreachableDNS doesn't resolve in there either: the sandbox cannot look up an address, let alone route to one.
IPv6 in particular is closed on both backends, and closed deterministically rather than environment-dependently: the internal network is created with an explicit --ipv6=false instead of inheriting whatever the operator's Docker daemon defaults to, so this guarantee is a property of the code and not of the host it runs on. The Firecracker guest gets only a kernel-generated link-local fe80:: address, never a global one or a ::/0 route, and its per-run nftables table drops the tap interface in the dual-family inet table, so the same rule covers both families.
The proxy's own rules cover both families rather than relying on the sandbox only ever speaking one: the raw-IP ACL is spelled dst all, and the internal-destination rule lists the IPv6 loopback, link-local and unique-local ranges beside the IPv4 ones. It used to be an IPv4-only 0.0.0.0/0, which Squid quietly rewrote to all while announcing the substitution as a security notice. An ACL that means something other than what it says is the kind of thing that rots quietly, so the generated config is now parsed by a real Squid in the test suite and any complaint at all fails the build.
Writing allowed_hostsLink to this section
sandbox:
network:
allowed_hosts:
- api.groq.com
- .example.com # example.com and all of its subdomains- Entries are plain hostnames: lowercase ASCII letters, digits, hyphens and dots, with one optional leading
.to include subdomains. A scheme, port, path, wildcard, uppercase or non-ASCII name is rejected at validate time (use the punycode form). - An empty or absent list denies all egress.
sandbox.network.egressis parsed and ignored: see limitation 5. - A host that also appears under
mcp.servers[].urlora2a.peers[].endpointis rejected, because allowlisting it would let the agent skip the gate. MCP and A2A traffic is routed through the gate automatically. localhost,127.0.0.1andhost.docker.internalare rejected whenmcpora2ais declared: they name the host where the gate listens.
Every rule, with its reasons: Field reference §7.2.
What this does not coverLink to this section
The proxy trusts DNS for public addresses. If a declared hostname resolves to a public address an attacker controls, the allowlist will let it through: name-based allowlisting is only as good as the name resolution behind it. What DNS cannot do is aim a declared name back inside: loopback, link-local, metadata and private destinations are refused on the resolved address, whatever the record says.
If a document the agent reads contains a hidden instruction to exfiltrate data to an undeclared host, that instruction has no path to succeed. The block happens at the network layer, below the model, whether or not the agent "knows" it is compromised, and the attempt lands in the audit log as a network_blocked event, which is how you find out it happened.
Something wrong or unclear? Open an issue. The specifications on these pages are copies of spec/ in constle/constle.