constle docs constle docs v0.5.0pre-1.0

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
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. 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

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, but blocking isn’tA separate rule denies destinations given as raw IPs, including the real IP of an allowedhost. Resolving a hostname yourself and connecting straight to the address is not a wayaround the allowlist.~/.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, butblocking isn’tA separate rule denies destinations given asraw IPs, including the real IP of an allowedhost. Resolving a hostname yourself andconnecting straight to the address is not away around the allowlist.~/.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 only reachable next hop is the Squid proxy, which checks each CONNECT against network.allowed_hosts by dstdomain name match, not by IP.

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, but blocking isn’t. A separate rule denies destinations given as raw IPs, including the real IP of an allowed host. Resolving a hostname yourself and connecting straight to the address is not a way around the allowlist.

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. 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:

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 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

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 denies all egress. sandbox.network.egress is parsed and ignored: see limitation 5.
  • 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.