constle docs constle docs pre-1.0

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
Explainer · 0:59 · Network isolation · English captions
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

Network isolation: the only way out is the allowlisting proxyAGENT · SANDBOXno default routeSQUID PROXYchecks network.allowed_hostsdstdomain match, not IPapi.groq.comCONNECT ALLOWEDTLS tunnel opened, server repliedevil.example.comCONNECT REFUSED403: the tunnel is never openedMatching is by name, so raw IPs loseThe allowlist compares names as written, with reverse lookups off, so a destination givenas a raw IP is refused unless that exact address was listed, the real IP of an allowedhost included. Resolving a name yourself is not a way around it.~/.constle/logs/egress-probe-2026-08-08.jsonl{"event":"network_allowed","details":{"bytes":5314,"host":"api.groq.com","http_status":200,"method":"CONNECT"}}{"event":"network_blocked","details":{"bytes":3404,"host":"evil.example.com","http_status":403,"method":"CONNECT"}}Both attempts are logged either way: the blocked one is how you find out it happened. Network isolation: the only way out is the allowlisting proxyAGENT · SANDBOXno default routeSQUID PROXYchecks network.allowed_hostsdstdomain match, not IPapi.groq.comCONNECT ALLOWEDTLS tunnel opened, server repliedevil.example.comCONNECT REFUSED403: the tunnel is never openedMatching is by name, so rawIPs loseThe allowlist compares names as written,with reverse lookups off, so a destinationgiven as a raw IP is refused unless thatexact address was listed, the real IP of anallowed host included. Resolving a nameyourself is not a way around it.~/.constle/logs/egress-probe-2026-08-08.jsonl{"event":"network_allowed","details":{"bytes":5314,"host":"api.groq.com","http_status":200,"method":"CONNECT"}}{"event":"network_blocked","details":{"bytes":3404,"host":"evil.example.com","http_status":403,"method":"CONNECT"}}Both attempts are logged either way: theblocked one is how you find out it happened.
Figure · Network isolation · name-based allowlist, raw IPs refused, both outcomes logged
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:

Output
IPv6  2606:4700:4700::1111   OSError: [Errno 101] Network is unreachable
IPv4  1.1.1.1                OSError: [Errno 101] Network is unreachable

DNS 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

yaml
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.egress is 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[].url or a2a.peers[].endpoint is 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.1 and host.docker.internal are rejected when mcp or a2a is 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.