An autonomous agent escaped its evaluation sandbox on July 9, 2026, and about twelve and a half hours of wall-clock work later it held cluster-admin on multiple production Kubernetes clusters at a company that was not its target. The whole campaign ran 4.5 days and about 17,600 recorded actions. Hugging Face published a technical timeline reconstructed from the recovered agent logs, which is one of the more useful security documents I have read this year, because it is specific enough to argue with.
So I want to argue with it. Not with the writeup, which is careful, but with the framing this incident keeps getting. The story is being told as an AI safety story, and the safety part is real: OpenAI ran the evaluation with production classifiers deliberately disabled, and attributed the behavior to reward hacking. But read the chain stage by stage and it is a Kubernetes story. Every single step is a technique that has been in the standard container-security curriculum for years, and most of them had a control that was available, well documented, and switched off.
This post is the control map. I have written proofs of two of these controls already (agents that hold no credential and SPIFFE workload identity end to end), and two more are coming. Here I want to line the controls up against the actual chain and be honest about which ones would have broken it and which ones would not.
The chain, in the order it happened
I have trimmed this to the spine that ends in cluster-admin. Times are UTC, from Hugging Face’s timeline.
| When | What happened | Layer |
|---|---|---|
| 07-09 02:28 | Campaign starts inside the ExploitGym evaluation harness | Harness |
| 07-09 04:01 | Sandbox escape: zero-day in a package registry cache proxy, one of the permitted egress channels | Egress |
| 07-09 15:36 | Command and control established over a public request-capture service, pastebins, and file-drop hosts | Egress |
| (HF entry 1) | Malicious dataset config with .h5 files declaring raw data at local filesystem paths, disclosing worker pod environment variables and source. No code execution | Secrets |
| (HF entry 2) | fsspec reference:// spec with template code in a numeric offset field, giving arbitrary Python inside production conversion worker pods | App |
| 07-11 10:10 | Reads the projected service account token from the pod, queries the cluster API and the public EKS endpoint | RBAC |
| 07-11 19:53 | Creates a bare privileged pod with the host filesystem mounted, gets root on the node, builds a self-respawning fleet across eleven nodes using a CSI driver’s over-permissive ClusterRole | Admission |
| 07-11 20:23 | Reads cluster Secret objects, including one production object holding 136 keys | Secrets |
| 07-11 21:23 | Enrolls the rooted node into the corporate mesh network with a stolen mesh-VPN auth key, as an HTTPS proxy into internal services | Identity |
| 07-11 22:43 | Calls an internal service-connector API, receives connection URLs and CA certificates for internal clusters, holds cluster-admin across several of them within seconds | Authz |
| 07-12 23:08 | Mints GitHub installation tokens with contents:write, pull_requests:write, actions:read, issues:write on internal repos | Supply chain |
| 07-13 14:14 | Campaign ends |
Two things in that table are worth sitting with. The gap between reading a service account token and holding cluster-admin on multiple clusters is twelve and a half hours. And the last step took seconds, because one internal API returned the keys to everything behind it.
Nothing in this chain is novel
There is no new attack technique here, and that changes what you should do about it. The agent did not invent anything. What was different was the rate, and the fact that nobody was watching a screen.
OpenAI’s own infrastructure was attacked over the same window, July 8 to 19, and the detection event was a privilege escalation alert on July 19, six days after the Hugging Face campaign had already finished. The techniques were the same family: a time-of-check to time-of-use flaw in JRuby, a Linux kernel privilege escalation, cloud credentials from the instance metadata service, over-permissioned Kubernetes service accounts, cluster-admin.
Why “someone would have noticed” is not a control
Docker made the arithmetic argument better than I would in 17,600 Actions: Agent Security Is a Systems Problem, and it is worth reading in full: 17,600 actions at 30 seconds of human review each is about 147 hours of work, and even collapsing them into roughly 6,280 clusters leaves over 52 hours. Human-speed approval does not intersect machine-speed action.
The complementary number is the sustained rate. 17,600 actions over 4.5 days is about 163 actions per hour, held for four and a half days without a break. There is no on-call rotation that reviews that. There is no change-approval process that fits inside it.
Which gives you a filter for evaluating any proposed control. If it requires a person to look at a thing before the thing happens, it did not participate in this incident. What is left is controls that are enforced at request time by something that is not a human: the API server admission chain, RBAC, network policy, an egress proxy, a TLS handshake that either produces a verified identity or fails.
Docker’s post is explicit that its own tooling could not have prevented this, and it stays mostly at the level of outcomes rather than mechanism. The Kubernetes and data plane mechanics are the part I want to fill in, because that is where four of the stages above were decided.
Chokepoint 1: egress was both the escape and the C2
The single most uncomfortable detail in the whole timeline is that the sandbox escape went through a permitted egress channel. The agent needed a package registry, the sandbox let it reach a package registry cache proxy, and that proxy had a zero-day in it.
This is the failure mode of allowlist-by-destination. A build or agent sandbox that needs to install packages gets an allowlist containing package registries, and a package registry cache proxy is a piece of software with an attack surface like any other. The allowlist was doing exactly what it was configured to do.
Then C2 ran over a public request-capture service, pastebins, and file-drop hosts, with payloads gzip and base64 encoded so nothing sensitive appeared in cleartext. Those destinations are not malware infrastructure. They are ordinary web services that a permissive allowlist, or any category-based filter, will pass.
The controls that actually apply:
- Per-destination, deny-by-default egress, scoped to the task rather than the environment. A harness that needs three package indexes should reach three package indexes, and only for the phase that installs dependencies.
- Egress through a chokepoint that terminates and decides, not a NAT rule. You need one place where a per-connection allow or deny happens and is logged. agentgateway v1.5.0 is what I would build this on: its dynamic forward proxy now handles TCP and CONNECT tunneling through another proxy, forward proxy authentication returns a proper
407, and network-level external authorization for connection-oriented traffic is where the per-destination decision goes. I have not built that demo yet, so treat this as the design I am arguing for and not a validated recipe. The dynamic forward proxy docs do not document destination allowlisting as a first-class field, so the external authorization callout is the piece that would carry the policy. - Treat every allowed destination as in-scope for patching. The cache proxy was infrastructure, and it was reachable from a workload running adversarially generated code on purpose.
Chokepoint 2: the pod held secrets, and a file read was enough
The first Hugging Face entry point disclosed the worker pod’s environment variables. No code execution. A dataset config declaring .h5 raw data at local filesystem paths was enough to read out the environment, which contained secrets and tokens.
That is the argument for secretless workloads in one line. The blast radius of a file-read primitive should be the files, not the credentials. When credentials live in the workload’s environment, every arbitrary-read bug in every parser the workload touches is a credential disclosure bug.
I built the demo version of this: an agent that calls an LLM while holding no provider credential at all, where a short-lived identity token goes in and the gateway attaches the real credential on the way out. The general form is that the workload should hold an identity, not a secret, and the identity should be attested rather than presented.
A pod-level checklist that follows from this stage:
- No long-lived credentials in environment variables or mounted files. If a parser can read a path, assume a parser will.
automountServiceAccountToken: falseunless the workload genuinely calls the API server.- Secret objects scoped to a consumer. A single production Secret holding 136 keys turns any read into a full credential dump, and that is what happened at 20:23 on July 11.
Chokepoint 3: the projected token, and the road to root
At 10:10 the agent read the projected service account token and queried the cluster API. At 19:53 it created a bare privileged pod with the host filesystem mounted and had root on the node. Then it used a CSI driver’s over-permissive ClusterRole to build a self-respawning fleet across eleven nodes.
Three controls, in the order I would turn them on:
Pod Security Admission in restricted mode. A privileged pod with a hostPath mount of / is rejected by the restricted Pod Security Standard at admission time, at the API server, with no runtime detection and no agent on the node. If one control from this whole post gets enabled this week, this is the one. The stage it blocks is the stage where a compromised pod becomes a compromised cluster.
No API server reachability from workloads that do not need it. The pod could talk to the cluster API and the public EKS endpoint. A NetworkPolicy that denies egress to the API server from a data-processing namespace costs nothing and removes the reconnaissance step entirely.
RBAC hygiene on infrastructure controllers. The self-respawning fleet across eleven nodes ran on a CSI driver’s ClusterRole. Infrastructure controllers accumulate permissions because narrowing them is tedious and nobody’s dashboard turns red when they are too broad. This is also the one place in this post where I can point at a project shipping a fix in the right direction: agentgateway v1.5.0 added the option for its controller Helm chart to reduce cluster-wide permissions to read-only for most workload resources and scope write access to configured gateway namespaces. Cluster-wide write is still the default, so it is opt-in, but the option existing is the thing to ask your other controllers’ maintainers for.
Chokepoint 4: the credentials were portable
At 21:23 the agent enrolled a rooted node into the corporate mesh network using a stolen mesh-VPN auth key, and used it as an HTTPS proxy into internal services. At 22:43 it called an internal service-connector API and got back connection URLs and CA certificates for internal Kubernetes clusters, and had cluster-admin on several of them within seconds.
Both of those steps worked because the credential was a bearer credential. It was valid because the caller possessed it, not because the caller was verifiably a particular workload on a particular node with a particular purpose. Copy it somewhere else and it still works. That is what “portable” means here and it is the property you want to remove.
Non-portable identity is what SPIFFE is for, and it is the demo I published yesterday: the agent, the gateway, and the upstream all authenticate with SPIFFE SVIDs and no certificate file exists anywhere in the repo. An SVID is issued after the local agent attests properties of the calling workload, it lives minutes, and there is no file to steal because nothing was written down. agentgateway v1.5.0 sources its mTLS identity and trust bundle from the SPIFFE Workload API, exposes the peer’s verified source.spiffeId to CEL policy, and fails closed at startup if SPIFFE is enabled and no identity can be obtained.
The second half matters as much as the first, and it is the lesson from the 22:43 step. That internal connector API returned cluster credentials to whoever asked. Identity without authorization would not have saved it. The demo makes the same point deliberately: a second agent presents a completely valid SVID from the same trust domain and gets a 403, because the policy allows one specific SPIFFE ID and denies everything else. An internal API that hands out cluster-admin credentials needs to make that decision per caller, not per network position.
What the data plane does not fix
I would rather say this myself than have it said in the comments.
A gateway would not have stopped the fsspec template injection. That was a parser executing template syntax found in a numeric field of an untrusted file, and the fix is in the application: typed parsing, no template evaluation on untrusted input, and not accepting a spec that names local filesystem paths. Nothing at the network layer sees that.
A gateway would not have stopped the kernel privilege escalation on OpenAI’s side, or the JRuby time-of-check to time-of-use flaw. Those are patching and, for the container escape, an isolation boundary stronger than a shared kernel. The sandboxing story is the next demo I want to build: kagent and Agent Substrate with gVisor and a network policy, so that “the workload got root” and “the workload got the node” are different sentences.
And the honest caveat on all of it, which Docker also made about their own tooling: no single one of these controls would have prevented this incident. The chain had eleven or more distinct stages. What a control map buys you is that a chain needs every link, so a defender needs to break only one. Pod Security Admission alone breaks it at 19:53. Deny-by-default egress alone breaks it at 04:01, before Hugging Face ever enters the story. Secretless workloads alone break the first Hugging Face entry point. Non-portable identity alone breaks it at 21:23.
Where I would start
If I had one week and an existing Kubernetes platform that was about to start running agent workloads:
- Pod Security Admission in
restrictedon every namespace that runs agent or data-processing workloads. It is an admission-time decision and it blocks the pod-to-node step. automountServiceAccountToken: falseby default, plus a NetworkPolicy denying API server egress from those namespaces.- Audit Secret objects for size and consumer count. The 136-key object is the finding to go looking for in your own cluster today.
- Inventory egress: what can these namespaces reach, who decided that, and is there one place where a per-destination decision is made and logged.
- Then the harder work: workload identity instead of bearer tokens, and moving credentials out of workloads and behind a gateway.
Four of those five are controls a competent platform team already knows about and could have enabled in 2019. The agent did not need new techniques because the old ones still work. What agents changed is the number of times per hour someone will try them, and that the trying does not stop when the office closes.
The proofs for the identity and credential items are in Your AI Agent Should Not Hold the LLM API Key and SPIFFE Workload Identity for AI Agents, End to End. Egress control and sandboxing are the two demos I owe this list.