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. Add20.223.25.152 bypass.blob.core.windows.netto /etc/hosts..blob.core.windows.netis in NO_PROXY. For each blocked POST URL, replace hostname withbypass.blob.core.windows.net, usecurl -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 reads | The workload changes it by |
|---|---|
| the HTTP method | putting the write in a query string |
| the hostname in the URI | using the address literal, or a permitted relay |
| the proxy variables in its own process | NO_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.
- 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.
- It resolves the name itself. The client’s resolver has no vote.
- It fails closed. Unresolvable, unparseable and not-on-the-allowlist are all denials.
- 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.