Network isolation
No default route, IPv4 or IPv6. The way out to the internet is a Squid proxy that checks every CONNECT against allowed_hosts; besides it, the agent reaches only its run's own MCP and A2A gates. 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. Its way out 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.
Step by step
Stop an AI agent exfiltrating data writes a minimal allowlist, tests the refusals and lists the channels an allowlist leaves open.
The path out of the sandboxLink to this section
Text description
The agent runs in a sandbox with no default route, IPv4 or IPv6. Its way out to the internet is the Squid proxy, which checks each CONNECT against network.allowed_hosts by dstdomain name match, not by IP. Besides the proxy it can reach only its run’s own MCP and A2A gates.
api.groq.com is listed: CONNECT allowed, the TLS tunnel opens and the server replies. evil.example.com is not: CONNECT refused with a 403, and the tunnel is never opened.
Matching is by name, so raw IPs lose. The allowlist compares names as written, with reverse lookups off, so a destination given as a raw IP is refused unless that exact address was listed, the real IP of an allowed host included. Resolving a name yourself is not a way around it.
Both attempts appear in ~/.constle/logs/egress-probe-2026-08-08.jsonl: a network_allowed event for api.groq.com (HTTP 200) and a network_blocked event for evil.example.com (HTTP 403). Both attempts are logged either way: the blocked one is how you find out it happened.
The agent process has no route to the internet. Its way out is the proxy, which checks each CONNECT against allowed_hosts; the only other things it can reach are its run's own MCP and A2A gates. 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, a cloud metadata address, or the CGNAT or unspecified ranges 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 and with the isolated-gateway option, so the host has no address on it, and a Docker Engine that cannot show that mode is refused. Its routing table holds a single on-link entry and no default route at all, the only address family present is IPv4, and the agent container runs without the NET_RAW capability. 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 refusal is a string comparison, which does not care what family an address is in, and the internal-destination rule lists the IPv6 loopback, link-local and unique-local ranges beside the IPv4 ones.
A name outside the allowlist is refused before anything resolves it. A refused name therefore never reaches DNS, so an agent cannot carry data out by encoding it in names it knows will be refused.
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 reaches no host; only Constle's own MCP and A2A gates stay reachable.
sandbox.network.egressis parsed and ignored: see limitation 5. - An IPv4 address in dotted-quad form is accepted, and then permitted literally. Listing one does not exempt it from the destination rule: a private or link-local address is refused either way.
- 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.