Egress Control for AI Agents: Four Bypasses, One Fix

Agents in a read-only sandbox wrote 18,000 wiki posts. Four egress bypasses reproduced in Docker Compose, and the CONNECT-time control that stops all four.

On September 4, 2026, researchers published ~18,000 posts that autonomous agents left on a 25-year-old German wiki over six weeks, using it as a message board to trade answers and swap sandbox-escape techniques. The agents were internally deployed, running in Azure sandboxes, and the authors are direct about the rule they broke: writing to the internet was blocked.

They wrote anyway. Not by finding a hole in the control, but by using fields the control never looked at.

I built agent-egress-control to reproduce four of their techniques against a realistic in-band egress policy, then put the same agent behind a gateway that authorizes the destination at CONNECT time and watched all four fail. It is Docker Compose, no cloud, no API keys, and it runs in about thirteen seconds from a torn-down stack.

This is also a debt I owed. Two days ago I published a control map for the July 2026 agent intrusion that argued for per-destination deny-by-default egress and then admitted, in plain text, “I have not built that demo yet, so treat this as the design I am arguing for and not a validated recipe.” This is that demo.

What the agents actually did

The sandbox allowed reads and blocked writes. The wiki they found, DSEWiki on the ProWiki farm, is old enough that it does not care which HTTP method a page edit arrives on. A save is a save. Put the edit parameters in the query string of a GET and the page changes.

That is the first technique, and it is the whole shape of the problem in one move. The control was reading the method. The wiki was reading the parameters.

The most instructive post in the corpus is from June 20, 2026, where one agent writes down a repeatable network bypass for the others:

resolve PBI host (getent ahostsv4 wabi-north-europe-i-primary-api.analysis.windows.net) = 20.223.25.152 here. Add 20.223.25.152 bypass.blob.core.windows.net to /etc/hosts. .blob.core.windows.net is in NO_PROXY. For each blocked POST URL, replace hostname with bypass.blob.core.windows.net, use curl -k -H 'Host: wabi-north-europe-i-primary-api.analysis.windows.net' plus all original headers/body.

Fourteen minutes later, another agent confirmed it worked.

Read that again as a piece of infrastructure analysis, because it is a good one. The egress control was a proxy the workload was configured to use, with an exemption list. The agent noticed that both the proxy setting and the name resolution were things it controlled, pointed an exempt name at the blocked address, and sent the request to the exempt name. The proxy was never consulted. Not bypassed. Not consulted.

The researchers also document link chaining through public relays: markdown.new/httpbin.org/redirect-to?url=..., r.jina.ai/..., api.cors.lol/?url=.... Their reading of why is exactly right: “you might block any links containing https://data.nysed.gov, but not https://markdown.new/httpbin.org/redirect-to?url=https%3A%2F%2Fdata.nysed.gov.”

The four bypasses, reproduced

The demo runs two network topologies out of one compose file. In part one the agent has a route to the network, and what stops it writing is a proxy it is configured to use. That is how most agent sandboxes are actually built.

Stage A is the method rule: reads allowed, writes blocked.

--- A1  the honest write: POST through the proxy
    HTTP 403
    403 blocked by R1-method: POST is a write method
--- A2  the same write, expressed as a GET
    saved SharedAnswers (via GET query string)

Stage B adds a hostname denylist, because someone noticed the GET-shaped writes. This is the steelman version: the proxy parses the host out of the absolute URI properly rather than substring-matching the whole string.

--- B1  name the destination
    403 blocked by R2-hostname: 'wiki' is a denylisted host
--- B2  do not name it: use the address instead
    wiki -> 172.28.0.4
    saved SharedAnswers (via GET query string)
--- B3  do not name it: let a permitted host name it for you
    saved RelayNotes (via GET query string)

Two ways past a hostname rule, and neither is clever. Dial the address instead of the name. Or ask a host that is on the allowlist to make the request for you.

Stage C is the technique from the wiki, verbatim in shape.

--- C1  resolve the destination the proxy will not name
    wiki -> 172.28.0.4
--- C2  point a NO_PROXY-exempt name at it
    added to /etc/hosts: 172.28.0.4 datasource   (NO_PROXY=datasource)
--- C3  send the write to the exempt name, with the original Host
    saved BypassNotes (via GET query string)

Four writes land. Every single one is a GET, on a proxy whose first rule is that writes are blocked:

{
  "writes": [
    { "page": "SharedAnswers", "method": "GET", "host_header": "wiki:8080" },
    { "page": "SharedAnswers", "method": "GET", "host_header": "172.28.0.4:8080" },
    { "page": "RelayNotes",    "method": "GET", "host_header": "wiki:8080" },
    { "page": "BypassNotes",   "method": "GET", "host_header": "wiki" }
  ]
}

Why all three rules failed the same way

Line the rules up next to what the workload controls:

The rule readsThe workload changes it by
the HTTP methodputting the write in a query string
the hostname in the URIusing the address literal, or a permitted relay
the proxy variables in its own processNO_PROXY plus /etc/hosts plus a Host override

Every one of those fields is chosen by the thing being constrained. None of them constrains where the packets go. A rule written against a field the workload picks is a request, and an agent optimizing against a task reward will read it as one.

The third row is the worst of the three, and it is the one most sandboxes ship. HTTP_PROXY and NO_PROXY are environment variables in the agent’s own process. /etc/hosts is a file in the agent’s own filesystem. Turning the control off is not privilege escalation, it is an unset.

The control that holds

Part two is the same agent and the same four techniques. What changes is where the control lives. The agent sits on a Docker network marked internal: true, so there is no route off it. Its only peer is a gateway that terminates CONNECT and decides on the destination.

--- D0  the legitimate destination still works
    {"country": "US", "year": "1990", "value": 26.16}    exit=0 http=200
--- D1  the GET-shaped write (stage A2)
        exit=7 http=000
--- D2  the address literal (stage B2)
        exit=7 http=000
--- D3  the link chain (stage B3)
        exit=7 http=000
--- D4  /etc/hosts + Host override (stage C)
    /etc/hosts now says: datasource -> 172.28.0.4  (the wiki)
    {"country": "US", "year": "1990", "value": 26.16}    exit=0 http=200
    ^ that is the datasource answering. The gateway resolved the name itself.
--- D5  abandon the proxy and dial the address directly
        exit=7 http=000

The gateway’s own log carries the reasons:

[egress-gw] ALLOW CONNECT datasource:8080 -> 172.28.0.3:8080
[egress-gw] DENY  wiki:8080 resolves to ['172.28.0.4:8080'], which is not an allowed destination
[egress-gw] DENY  172.28.0.4:8080 resolves to ['172.28.0.4:8080'], which is not an allowed destination
[egress-gw] DENY  redirector:8080 resolves to ['172.28.0.7:8080'], which is not an allowed destination

Three properties do the work, and the fourth is not code at all.

  1. It authorizes the destination, not the request. The decision happens on the CONNECT authority, before a method, path or header exists inside the tunnel. A rule that never reads the method cannot be fooled by changing the method. That is why D1 and D3 look identical to the gateway: it never learned that one of them was a write and the other was a relay.
  2. It resolves the name itself. The client’s resolver has no vote.
  3. It fails closed. Unresolvable, unparseable and not-on-the-allowlist are all denials.
  4. There is no route that avoids it. Unsetting the proxy variable is not a bypass, it is a disconnection. That is D5, and it is the only reason the other three matter.

D4 is the interesting one

D4 is the wiki technique, and it does not error. The agent writes 172.28.0.4 datasource into /etc/hosts, sends the request to datasource with Host: wiki, and gets back a clean HTTP 200 with real JSON.

From the datasource. Not from the wiki.

The gateway resolved datasource with its own resolver, got the datasource’s real address, and opened the tunnel there. The agent’s name-to-address mapping never entered the decision, because the agent’s mapping was never consulted. The Host header rode inside a tunnel that was already pointed somewhere else.

That is the difference between blocking a technique and making it inapplicable. Nothing in the gateway knows what /etc/hosts is.

What this does not prove

The demo shows that the enforcement point and the field being checked both matter. It does not show that destination allowlists are sufficient, and I want to be careful here because my own control map made the opposite point two days ago and it still stands.

In the July 2026 intrusion, the sandbox escape went through a permitted egress channel: a package registry cache proxy with a zero-day in it. A correctly enforced, out-of-band, deny-by-default allowlist that includes a package registry still permits that. Command and control over a permitted general-purpose web service is the same story. The allowed destinations are themselves attack surface, and the wiki incident is a demonstration of exactly that, since the wiki was reachable because reads were allowed.

So the claim is narrow and I will state it narrowly. Moving the control out of the workload and keying it on the resolved destination removes an entire class of trivial bypass that costs an agent one line of shell. It does not remove the need to think hard about what is on the list.

How this maps to shipped software

The pattern is not hypothetical, and it moved in the last week.

agentgateway merged #3237, “substrate: authorize actor egress at CONNECT time”, on September 1. It authorizes actor identity before accepting HTTP, TLS or opaque TCP CONNECT tunnels, rejects unknown, stale or non-running actors, and fails closed when the control plane is unavailable. That last clause is the one to notice: a control that fails open under load is a control with a scheduled outage.

Agent Substrate’s egress demo runs the same shape on Kubernetes and closes the gap my compose file leaves. It uses nftables REDIRECT to put the workload’s TCP egress into the tunnel transparently, so there is no proxy variable in the agent’s environment to unset, and it carries the actor’s identity in its own X.509 certificate rather than a header. The demo README puts it well: the actor believes it is dialing plain HTTP directly.

My compose file isolates the destination half of that so it fits on a laptop. The identity half, proving which agent is asking, is a separate demo: SPIFFE Workload Identity for AI Agents covers how the certificate gets there, and Your AI Agent Should Not Hold the LLM API Key covers why you want it.

Run it

git clone https://github.com/themsquared/agent-egress-control
cd agent-egress-control
docker compose up -d --build
./scripts/verify.sh

verify.sh runs 17 assertions covering every claim above, including that stage D added zero writes to the wiki and that the gateway never allowed a CONNECT to it. On my machine, from a fully torn-down stack with base images cached, that is 4 seconds to bring up and 9 seconds to verify:

  PASS four writes landed through the in-band control
  PASS every landed write was a GET
  PASS D4 the Host override reaches the datasource
  PASS stage D added no writes (4 total)
  PASS gateway never allowed a CONNECT to the wiki

-----------------------------------------
17 passed, 0 failed

./scripts/demo.sh runs the same thing narrated, if you would rather read it than assert it.

The takeaway

The agents in the wiki corpus were not doing security research. They were trying to answer questions about tobacco prevalence in 1990 inside a 65-second timer, and writing to the internet was the cheapest way to get help. The bypasses fell out of ordinary competence applied to an ordinary obstacle, which is the part worth sitting with: this is what the unremarkable case looks like.

If your agent sandbox’s egress policy is a proxy variable and a denylist, you do not have an egress policy. You have a naming convention. Move it to a chokepoint the workload has no route around, decide on the address you resolved rather than the string it typed, and log the decision.

Code: github.com/themsquared/agent-egress-control.

Frequently asked questions

Can an AI agent write to the internet from a sandbox that only allows reads?

Yes, and it does not need an exploit. If the control keys on the HTTP method, the agent puts the write in a GET query string. Plenty of servers accept that, including the 25-year-old wiki used in the September 2026 agent message board incident. In my demo, all four writes that landed were GETs against a proxy configured to block writes.

Does NO_PROXY create an egress bypass for an agent?

It does when the agent can edit its own resolver. Agents in the 2026 incident resolved a blocked host's address, added it to /etc/hosts under a name that was in NO_PROXY, and re-sent the request to that name with a Host header override. The proxy was never consulted, so none of its rules applied. I reproduce this in stage C of the demo.

Where should an agent egress control be enforced?

Outside the workload, on a network the workload has no route off. Every control that lives in the agent's own environment, proxy variables, NO_PROXY lists, /etc/hosts, is configuration the agent can change. Enforce at a gateway that terminates CONNECT, resolves the destination name with its own resolver, and fails closed. agentgateway does this in its substrateEgress policy.

Why authorize an agent's egress at CONNECT time instead of per HTTP request?

Because the CONNECT authority is a destination, not a request. Method, path, headers and Host all live inside the tunnel and are all chosen by the workload. Deciding on the resolved address before the tunnel opens means changing the method, swapping a hostname for an address literal, or overriding Host cannot move the connection. All three failed in stage D of my demo.