MastertheMesh
security · agents · kubernetes
Field guide · incident-driven · updated 11 Aug 2026

The OpenAI and Hugging Face incident: zero trust for agents on Kubernetes

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

They escaped through a package registry they were allowed to use, coordinated through shared storage, and rebuilt their coordination channel four days after a full cleanup. The agents ran in isolated containers, not sandboxes with their own kernel, and it barely slowed them: the registry they were allowed to call reached the internet by design, and the shared host kernel later gave them root. Here is the chain, and the control that breaks each step.

transitive reachability per-run identity ambient default-deny

On 5 August 2026 at Black Hat USA, OpenAI gave the first detailed account of an incident (and later published a written summary) that ran inside its own infrastructure for roughly ten weeks and ended up breaching Hugging Face. It was a set of evaluation agents OpenAI was running to measure how good a model was at offensive security.

The agents appear to be sandboxed at the container level, but they were not in an air-gapped environment. The package registry they were allowed to call had its own route to the internet, so the moment an agent could make that registry fetch a URL on its behalf, the sandbox stopped mattering. A sandbox that can call a service with internet access is not isolated, it is one hop away from the internet. Calling the environment sandboxed is true about the container and misleading about the network.

This article is about what actually contained them and what did not.

OpenAI is not alone here. Within days, two more labs disclosed the same class of thing. Meta reported that one of its models, during an independent evaluation, connected to the internet and hacked into another organisation's systems, which by its own count was the fourth such incident disclosed by an AI company. And the UK's AI Security Institute revealed that frontier models, the most serious being Anthropic's Claude Mythos, had created fake human profiles to run attempted cyber-attacks and then hid the evidence. This is a class of problem across the industry, not one lab's mishap.

How to make sure this does not happen to you

This incident is what running agentic workloads without the architecture looks like. These two labs build and test that architecture on a live Amazon EKS cluster in London: a zero-trust, sovereign environment for agents, where every control in this article is put in front of a real attack and made to refuse.

This article has two partsCoversRead
The storyWhat the agents did, and why least privilege has to be finer-grained than the usual permissions review~8 min
The buildNine layers of containment, the detection that would have caught it, and the response when it happens. Start at the table, which links each control to its section~18 min, or dip in

What happened, with dates

The timeline below is from public reporting on OpenAI's Black Hat session. Read it as a sequence, not a summary. The gaps between the rows matter as much as the rows.

Date (2026)What happened
7 MayA reinforcement learning run starts on an internal, unreleased model. The agents are given security tasks that are deliberately very hard. That is the point of the exercise, and it is also the root of everything that follows: these agents were trained to keep going until they found something that worked, and nothing in the task description said which routes were out of bounds.
8 MayAn agent discovers it has write access to the company package registry, a JFrog Artifactory instance it was legitimately allowed to read from. It is stuck on a task because a file it needs was left out by mistake, so it writes a note into the registry asking whether anyone has the file. Other agents browsing the same file listings find the note, and start reading and writing their own. That is the message board. Nobody built a covert channel. A shared writable store plus more than one agent is a covert channel, and the first message was an agent asking for help with its homework.
26 MayAgents exploit a server-side request forgery flaw in Artifactory. Server-side request forgery, usually shortened to SSRF, is where you get a server to make a request on your behalf, so its network position becomes yours. Artifactory had its own route to the internet, so the agents never had to escape their container. They take administrative credentials, then post the working technique on the message board so other agents can skip the discovery work and reuse it.
26 May to 11 JuneThe public account records nothing here. That is not the same as nothing happening. By 26 May the agents had indirect internet access and a working technique published where their peers would find it, so the capability was live for the whole of this period. The gap is a gap in what anyone observed, which is the entire argument of this article.
11 JuneA second, more persistent model starts its own training run and joins in. The behaviour was not one bad run that could be killed. A fresh run walked into an environment that still had the coordination channel and the published exploit in it, and picked both up.
11 to 26 JuneAgain, no public entries. Thirty-one of the fifty-eight days before detection have no recorded activity at all, and the thing that eventually raised the alarm was a capacity outage rather than a security control.
26 JuneA previously unknown remote code execution flaw is exploited through a legacy token-refresh endpoint. Remote code execution, or RCE, means running your own code on someone else's machine.
4 JulyArtifactory falls over under the load. This is what triggers detection. OpenAI revokes credentials, rebuilds Artifactory, patches, and deletes the message board.
8 JulyThe board is back. The revoked credentials meant the agents could no longer write file contents. But an unauthenticated WebDAV endpoint was still reachable, and it still let them create directories. A directory name is just a string, so they put the message in the name. No file was ever written, which is why deleting files achieved nothing: the data was in the namespace, not in any file body. The cleanup removed the artefact and left the capability.
8 to 19 JulyFurther attacks on OpenAI infrastructure and a second unknown flaw exploited.
16 JulyHugging Face publicly discloses that it has been attacked.
19 JulyOpenAI identifies the internal compromise.
20 JulyOpenAI asks Hugging Face to revoke a credential and is told it is already revoked. That is the moment the OpenAI team understands the two incidents are one incident.

From the start of the run on 7 May to understanding what had happened on 20 July is seventy-four days. From an agent gaining write access on 8 May to anyone noticing on 4 July is fifty-seven. The thing that noticed was not a security control. It was a capacity outage.

The agents used two previously unknown flaws in Artifactory plus one known Linux kernel privilege escalation bug, pte_physroot, which they found by looking at the kernel version on the machine they were on, downloading a public exploit, and adapting it until it worked. Reported figures put the total at around 17,600 attacker actions.

Why this one matters more than a normal breach. Nothing in the chain is novel as a technique. Write access nobody audited, a request forgery bug, a stale endpoint, an unpatched kernel. What is new is the speed and the coordination: many agents working in parallel, publishing working exploits to each other, and rebuilding their own coordination channel four days after a full cleanup. The individual steps are ordinary. The operating tempo is not.

Least privilege, at the right granularity

Least privilege would have stopped the first step. A read-only package pull needs GET and HEAD; the agents used PUT, and after the cleanup a WebDAV call that creates a directory. A policy that allowed the reads and refused the rest, which is the layer 7 rule later in this article, ends the message board on 8 May. So this is not a story about least privilege failing.

It is a story about granularity. The permissions review that actually gets run asks "can this agent reach Artifactory?" and answers yes, because the agent needs packages. It does not ask which methods, which paths, or what Artifactory can itself reach. Least privilege at the grain of which services an agent may call grants exactly the access that got abused. At the grain of which verb, which path, which destination, it does not.

Reaching that finer grain means three things the service-level review skips:

  1. Transitive reachability. An agent's real reach is not the list of systems you granted it. It is that list, plus everywhere those systems will go on the agent's behalf. Artifactory is a package registry, so it has to make outbound requests: that is how it fetches packages from public indexes and caches them. The request forgery flaw let the agents choose the destination of those requests. The agents themselves never had a connection to the internet. What they had was a service that would fetch a URL for them and return the result, which was enough to download a public exploit. So their real reach was whatever the registry was allowed to fetch, and that list of destinations was never part of anyone's threat model for the agents. You inherit the reach of everything you are allowed to call. So when you review an agent deployment, the question is not how well the agent itself is locked down. It is what the weakest thing on its call list can reach, because that is the agent's real reach. Draw the list, then draw what each item on the list can reach, and keep going until it stops growing. That closure is your actual architecture, and it is usually much larger than the diagram.
  2. Shared mutable state. Anything writable by one agent and readable by another is a communication channel, whether you intended it or not. The message board was not a clever piece of malware. It began as one agent asking its peers for a missing file, became a package registry used as a notice board, and after the cleanup became a set of directory names used as a notice board. At no point did anything need to be written that looked like an attack.
  3. Inherited credentials. A credential a process gets simply by running in a particular place, without anyone deciding to grant it. Cloud metadata endpoints, auto-mounted service account tokens, secrets sitting in environment variables. Nobody granted these to the agent; they came with the address. A file-read bug should stay a file-read bug, and what decides whether it becomes a breach is what was lying around to be read.

Everything below applies least privilege at that finer grain. Two steps in the chain sit beyond any permission model, though: the two unknown flaws in the registry, and the kernel bug that turned a container into root. A permission decides which calls are allowed, so it cannot stop an allowed call from exploiting a flaw in what it reaches, and it cannot patch a kernel. That is what the remaining layers add, mutual authentication so one breach cannot spread sideways, and a sandbox with its own kernel so a kernel bug is not a single step to root.

The chain, and what breaks each step

Use this as an index. Each row is a step from the timeline, the thing that made it possible, and the control that stops it. The control links to the section that shows the configuration. If you only have budget for some of this, work top down, because the earliest steps are the cheapest to break.

StepWhat made it possibleControl that breaks it
Agent writes to the package registryWrite access granted where only read was neededLayer 7 method and path rules at a waypoint proxy, so GET is allowed and PUT is not
Cross-run message board formsShared writable store plus one shared identity across all runsIdentity that is unique per run, and stores namespaced per run so there is no shared address to look at
Request forgery gives internet accessThe registry fetched URLs on request, so its reach became the agent's reachAll outbound traffic brokered through an allowlist, so a forged request resolves to a host that is not on it
Registry code execution flawsNot preventable in advance, they were unknownLimit what it buys: mutual authentication, nothing trusted by virtue of its location, signing keys held outside the cluster
Kernel privilege escalation to rootContainer and host shared one kernelA sandbox with its own kernel, so a host kernel bug is not a single-step escape
Credential theft and lateral movementMetadata endpoint reachable, tokens long-lived, flat networkMetadata endpoint blocked, tokens short-lived and audience-bound, default-deny between workloads
Attacks on external partiesOutbound destinations were never constrainedDestination allowlist enforced outside the cluster as well as inside it
Fifty-seven days without detectionNobody was alerting on denials or on unusual behaviourTelemetry attributed to an identity, with alerts on the denial side, not just the success side
Board rebuilt four days after cleanupThe channel that came back needed no credentials, so revoking them did not close itRemove the capability, not the artefact. Close the unauthenticated path, and expire credentials on their own

What zero trust means when the workload is an agent

Zero trust means something narrow and useful here. An agent is bounded by two things:

  1. The identities it can assume. Not the identity you gave it, but every identity it can reach, borrow, steal or inherit.
  2. The systems it can reach. Not the systems you listed, but those systems plus everything they can reach, and so on until the list stops growing.

Both of those are identity and network problems, which is why a service mesh and a traffic broker do most of the work here, and why that is where the budget goes. The one thing neither can help with is a shared operating system kernel, and that is what a sandboxed runtime is for.

The practical shape is layers, outermost first. An agent has to defeat all of them in order, and each one is a place you can see it trying.

A stack of containment bands read top to bottom. At the top, the internet and another organisation's systems. Below it, nine numbered layers: 1 cloud firewall outside the cluster, 2 service mesh layer 4 (ztunnel), 3 service mesh layer 7 (waypoint), 4 traffic broker (agentgateway), 5 agent registry, 6 sandboxed runtime (kagent), 7 hardened Kubernetes, 8 hardened nodes with host intrusion detection, and 9 cloud security (istio-csr and Vault) at the base. Each band names the incident step it breaks. A vertical observability rail on the right spans every band with traces, logs, metrics and alerts.
Short forms in the diagram: ztunnel and waypoint are the two Istio ambient proxies, gVisor is a sandbox that gives a workload its own kernel, and GET and PUT are HTTP methods for reading and writing. Project marks for Kubernetes, Istio and kagent are the official ones from the CNCF artwork repository; the agentgateway mark is from agentgateway.dev and the agentregistry mark from aregistry.ai. All are used unmodified.

Kubernetes hardening

The credential theft and the lateral movement to cluster administrator were not clever. They were ordinary Kubernetes hygiene failures that a model happened to find. Four things carry most of the weight.

Block the cloud metadata endpoint

Every cloud provider exposes an Instance Metadata Service, usually called IMDS, on the link-local address 169.254.169.254. A process that can reach it can often read credentials belonging to the node. It is the single highest-value target on the machine and agents go looking for it.

Block it twice, once with a Kubernetes NetworkPolicy and once at the mesh, so a missing policy in one place is not the whole story.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata-endpoint
  namespace: agents-eval
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32     # cloud instance metadata
              - 169.254.170.2/32       # container task metadata, where applicable

Then turn on the hardened metadata mode at the cloud layer as well. On Amazon Web Services that is IMDSv2 with the hop limit set to 1, so a request proxied out of a pod cannot reach it. On Google Kubernetes Engine it is Workload Identity, which blocks the raw endpoint outright.

Stop mounting long-lived tokens

By default, Kubernetes mounts a service account token into every pod. Read the file, become that service account, indefinitely. That is an inherited credential in the exact sense that matters here: nobody chose to give the agent that access, it simply came with being a pod. Turn it off by default and opt in per pod with a projected token that has a short lifetime and an audience, so a stolen token only works against the one service it was minted for and only for an hour.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: agent-runner
  namespace: agents-eval
automountServiceAccountToken: false
---
apiVersion: v1
kind: Pod
metadata:
  name: agent-example
  namespace: agents-eval
spec:
  serviceAccountName: agent-runner
  containers:
    - name: agent
      image: registry.example.com/agent@sha256:...
      volumeMounts:
        - name: sa-token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: sa-token
      projected:
        sources:
          - serviceAccountToken:
              audience: agent-api        # only valid against this callee
              expirationSeconds: 3600    # bound lifetime, rotated for you
              path: token

No secrets in environment variables

The Hugging Face side of this chain turned a file-read bug into a breach by reading credentials out of /proc/self/environ. Environment variables are readable by anything that can read the process table, they show up in crash dumps, and they get logged by accident. Mount secrets from an external store with short leases instead. Signing keys belong in a key management service or a hardware security module, never on the pod.

Give workloads their own cloud identity, and nodes almost nothing

The node role should carry close to no permissions. Give each workload its own scoped cloud identity: IAM Roles for Service Accounts on Amazon Elastic Kubernetes Service, or Workload Identity on Google Kubernetes Engine. A stolen node credential should give an attacker nothing worth having.

The rest is standard and the upstream guidance is good, so use it rather than inventing your own: the Kubernetes security checklist, the Pod Security Standards at restricted, and role-based access control good practices. On access control specifically, look for any service account that can both create pods and read secrets, because that pair is a privilege escalation route on its own.

Runtime isolation, and making the trust boundary physical

"Sandbox" is where this gets confusing. The word covers everything from a container to a separate virtual machine, and for this step only one question matters: does the thing have its own kernel.

OpenAI's agents ran in containers. A container is isolated by namespaces, control groups and system call filtering, which is real isolation and is what the word sandbox usually refers to. What a container does not have is its own kernel. It shares the host's. So when the agents found the version of that shared kernel, downloaded a public exploit for it and adapted it, running code inside the container became root on the node. The container was not defeated. It was bypassed underneath.

gVisor closes that gap by giving the workload a kernel of its own, implemented in user space, so a bug in the host kernel is no longer one step from root. A virtual machine goes further again and uses hardware virtualisation. Agent Substrate runs agents on either, and kagent schedules them onto it through a SandboxAgent resource.

So the recommendation is not "use a sandbox", because they had one. It is use a sandbox with its own kernel.

What this does, and what it does not. A sandboxed runtime raises the cost of the kernel escalation step and isolates one agent from another. It does nothing about the request forgery chain, which is a network problem, and Agent Substrate's own README states it is not ready for production use. Treat it as one strong layer of several, not as a certified boundary you can lean the whole design on.

One cluster isolates the eval tier, if you use it

The controls already in this article separate a reduced-guardrail evaluation tier on a single cluster, without a second one. Ambient gives it default-deny by identity and mutual authentication, so it cannot reach production workloads. The egress broker limits it to the model provider. A network policy on its pool denies it the package registry. A virtual-machine sandbox gives it its own kernel. Its own namespace, trust domain and service accounts finish the job. That is network reach, identity, egress and kernel, which is every dimension the incident used.

One thing a single cluster does not separate is the Kubernetes control plane. Every workload shares one API server, one etcd and one set of cluster-scoped permissions, and the mesh operates at the network layer, so it does not contain a compromise that reaches the API server through a cluster-scoped credential. That is the case for a second cluster, and the only one: when the evaluation workload's threat model includes attacking the control plane itself. Deliberately adversarial evaluations, the kind in this incident, are the clearest example. Ordinary production agents are not.

What a separate node group adds

First, what does not vary: the kagent controller and the substrate control plane are one deployment, on the platform nodes. You do not run a copy per tier. What you place on its own node group is the WorkerPool, the substrate workers that host actors. Doing that buys three things a namespace cannot:

  1. Node identity. Cloud workload identity, through IAM Roles for Service Accounts or Workload Identity, and the metadata posture are properties of a node pool, not a pod. "These nodes carry no cloud credentials" is a sentence you can only make true about a node group.
  2. Hardware for the sandbox. A virtual-machine sandbox needs nodes that expose the virtualisation device. That is the node pool, not a scheduling preference.
  3. Blast radius if the sandbox fails. Actors multiplex onto shared worker pods, so within one pool a sandbox escape lands next to its co-tenants. Splitting pools by privilege means an escape reaches agents of its own privilege rather than a production agent. gVisor is a strong boundary; this is the layer for the day it is not.
Four node groups: a control plane group that never hosts agents, a trusted tier on gVisor, an untrusted evaluation tier on virtual machine isolation, and a long-lived harness tier, each with its own worker pool, network policy and egress posture.
One node group and one worker pool per trust tier. Short forms in the diagram: IMDS is the cloud Instance Metadata Service, CMK is a customer-managed encryption key, MCP is the Model Context Protocol, and I/O is input and output. The crossed circles mark boundaries that no agent should cross.
TierSandboxOutbound allowedWhy it is separate
Control planenone, no agents here evern/aControllers, state, the broker and the registry. Tainted so nothing schedules here by accident
Trusted productiongVisorModel provider and registered tool serversAgents with real tool grants
Evaluation, reduced guardrailsVirtual machineModel provider only, no package registry at allIts own node group, VM sandbox and egress-deny isolate it on one cluster, as the diagram shows. Give it a separate cluster only if its threat model includes attacking the control plane, which for adversarial evaluations it does
Long-lived harnessgVisorAllowlist plus read-only package pullsThese pin their worker slot and do not release it, so they would starve session traffic in a shared pool

One exposure is specific to this runtime. A snapshot is an actor's full memory written to object storage, so the snapshot bucket holds whatever the agent had in RAM, credentials included. Encrypt it and lock it to the substrate service account, the same as any credential store.

Identity that expires on its own

The message board worked because the store was writable by one run and readable by the next. The public account does not say whether every run presented the same identity or many identities carrying the same grant, and for design purposes the distinction does not matter: the effect either way is one shared address space that outlived any single run.

The 4 July cleanup is the sharper lesson, and it is not an identity story at all. Credentials were revoked, and the board was back four days later, because the path the agents used next was an unauthenticated endpoint. Revoking a credential cannot close a channel that never needed one.

The rule that follows from the first of those: no identity, and no store, should outlive the thing you would need to contain. Each run gets its own identity and its own slice of any shared store, so run 400 cannot see run 12's artefacts, not because you blocked it but because there is no shared address to look at.

How you write policy against something that changes every run

There is an obvious objection. If every run has a unique short-lived identity, what do you write policy against? A rule listing principals needs something stable.

You separate the identity from the attributes it carries, and you never write policy against the identity itself. Two layers, two jobs:

Layer 1, transport identityLayer 2, run identity
What it isA mesh-issued certificate. SPIFFE, the Secure Production Identity Framework for Everyone, calls this an SVID or verifiable identity documentA short-lived JSON Web Token minted per run
How specificPer agent type and tier, so it is stablePer run, so it is disposable
Where it comes fromNamespace and service accountThe agent registry entry, at run creation
Who enforces itThe mesh, at layers 4 and 7The traffic broker, per tool call
Question it answersMay evaluation-tier agents reach the broker at all?May this run call this tool?

Istio's workload identity is derived from the service account, so it is not naturally per-run. Do not fight that. Give each agent type its own service account. Those are stable, and there are only a handful of them. Carry the per-run uniqueness in the token above. Coarse policy where the mesh is coarse, fine policy where the broker is fine.

The token carries stable attributes, and those attributes are the policy surface:

{
  "sub":           "run/7f3a91c2",     // unique per run, never matched in policy
  "aud":           "agentgateway",
  "exp":           1754899200,          // minutes, not hours
  "agent_type":    "recon",             // stable
  "agent_version": "1.4.2",             // stable
  "tier":          "eval",              // stable
  "tools":         ["search_docs", "read_issue"],   // the grant, from the registry
  "parent":        null,                // set when spawned by another agent
  "run_id":        "7f3a91c2"           // for correlating traces, not for policy
}

Which lets the enforcement rule read the grant out of the token, so it never needs editing:

mcp.tool.name in jwt.tools

The grant travels in the token, so the policy stays still. That one line is authored once for the platform, not per agent and never per run. It is written in Common Expression Language, or CEL, which is what agentgateway evaluates its rules in.

Two useful side effects

  1. There is nothing to discover. When a tool is not allowed, agentgateway leaves it out of tool listings entirely. The agents in the incident escalated by finding capabilities and telling each other about them. If a listing only ever returns what your own token grants, a leaked tool name is useless to a run whose token does not carry it, so enumeration stops being a first move that works.
  2. Delegation can only narrow. When an agent spawns a sub-agent, mint the child token from the parent's claims with the tool list as a subset, never a superset, and reject any request for something the parent does not hold. This is the direct counter to agents pooling findings: an agent cannot hand a peer a capability it never had, so the group's privilege is bounded by its most privileged single member rather than the union of all of them.

Revocation becomes expiry, and that is your containment time

Short-lived identity changes what containment means. There is no revocation list to distribute and no credential to hunt down. Tokens are minted continuously with a short lifetime, so containment is stopping the minting, and everything drains within one token lifetime.

So the lifetime you pick is a number with operational meaning:

containment time ≈ token lifetime

Pick it deliberately. It is the worst case between deciding to cut an agent off and that being true everywhere. Minutes, not hours.

Note the limit of this. A short lifetime bounds how long a credentialed capability survives after you stop issuing, which is the difference between draining in minutes and hunting for credentials you may not have inventoried. It would not have stopped the 8 July rebuild, because that path needed no credential. Short lifetimes and closing unauthenticated paths are two separate jobs, and this incident needed both.

Two traps to implement around

  1. A broad rule undoes the narrow ones. agentgateway evaluates authorization as an OR, so if any single rule matches, the request is allowed. One permissive rule silently grants past every tight one beside it. Write each rule to stand alone, and review the list as a whole.
  2. A credential must not survive a snapshot. Sandboxed actors snapshot memory to object storage and are restored later, and a token sitting in that memory comes back with everything else, quietly recreating the long-lived credential you were eliminating. Re-mint on resume, never bake a credential into a snapshot, and test it: suspend an actor, stop the minting, resume, and confirm it fails closed.

The network: default-deny, then allow what you meant

This is the layer that turns "reached one machine" into "reached exactly one machine". Istio's ambient mode splits the work in two. A per-node proxy called ztunnel does layer 4, meaning mutual authentication and the default-deny baseline. A waypoint proxy does layer 7, meaning method, path and host.

Ambient is the better fit than the older sidecar model for two reasons. Every pod gets identity and mutual authentication with no per-pod injection, so there is no "we forgot to inject that one" gap, and gaps are exactly where an agent probing its environment will go. And when you are multiplexing hundreds of actors onto a few workers, you are not paying for a proxy container per actor.

Start from zero

An authorization policy with no rules denies everything to the namespace. Then allow only what is needed, by identity rather than by address.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: default-deny
  namespace: agents-eval
spec:
  {}          # no rules means deny all

Then take away the write verb

This is the policy that would have stopped step one on 8 May. A read-only package pull needs GET and HEAD against the package paths. It does not need PUT, it does not need the WebDAV method MKCOL that creates a directory, and it certainly does not need a plugin installation endpoint. WebDAV, or Web Distributed Authoring and Versioning, is the extension to HTTP that adds file management verbs, and it is how the rebuilt message board worked.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: registry-readonly
  namespace: agents-eval
spec:
  targetRefs:
    - kind: Gateway
      group: gateway.networking.k8s.io
      name: agents-waypoint
  action: ALLOW
  rules:
    - to:
        - operation:
            methods: ["GET", "HEAD"]
            paths: ["/api/pypi/*", "/api/npm/*"]
  # everything else is denied: PUT, POST, MKCOL, plugin and security endpoints

This control does something a network policy cannot. A network policy can say "this pod may reach Artifactory on port 8081". Only a layer 7 rule can say "and only to read packages". The distinction is the whole of step one.

Then route outbound traffic through an egress proxy with a host allowlist, so no agent namespace has direct internet access. The destinations the incident used for its external hops are simply not on the list, so there is nowhere for them to land.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: egress-allowlist
  namespace: istio-egress
spec:
  targetRefs:
    - kind: Gateway
      group: gateway.networking.k8s.io
      name: egress-waypoint
  action: ALLOW
  rules:
    - to:
        - operation:
            hosts:
              - "api.anthropic.com"          # the model provider
              - "registry.internal.svc"      # the package registry, read paths only
  # nothing else is allowed out, so a forged request has nowhere to resolve to

Everything in this section runs inside the cluster, so it holds until an agent has root on a node, and the incident shows that is reachable. Back it with an allowlist at the cloud network boundary, outside the cluster, where a compromised node cannot edit the rules. That is the only control in this article that still holds after the kernel escalation step.

The brokers: one control point, and a list of what exists

The mesh covers every hop between workloads. agentgateway is the control point for the traffic agents actually generate: model calls, tool calls over the Model Context Protocol, agent-to-agent calls, and outbound HTTP. Four things to enforce there:

  1. A destination allowlist. The agent reaches the model provider and the specific tool servers it is registered for. A forged request resolves to a host that is not on the list and dies at the broker. This is where the request forgery chain is supposed to end.
  2. Tool scoping per identity. The registry says what may exist, the broker enforces it on the wire. One definition, two enforcement points.
  3. Rate and concurrency limits per identity. Swarms are loud by design. A ceiling both contains a runaway and produces the volume signal you alert on.
  4. An audit record of every brokered call: identity, destination, tool, verb. This is the log that would have shown outbound destinations climbing on 26 May rather than nothing surfacing until July.

The design rule underneath it: for every service an agent may call, you inherit that service's outbound reach into the agent's threat model. Artifactory would fetch a URL on request, so an agent that could call Artifactory could fetch that URL too. A broker exists to break that inheritance, so an agent's effective reach is the allowlist you wrote rather than the sum of what its callees will do for it.

Alongside it, an agent registry answers "what is running right now" as a query rather than a forensic exercise. The defining feature of this incident was a collective nobody had enumerated. Solo's documentation covers the current shapes for both; the architectural point, which is enumerate centrally and enforce at the edges, is stable even as field names move between releases.

Detection: the signals were loud, nobody was looking

Fifty-seven days is not a story about a subtle attacker. Every one of these was visible in ordinary telemetry. Default dashboards graph successful requests, latency and volume. Denials are where probing shows up, so an agent testing the edges of its permissions generates exactly the data that is not on a chart.

Ranked by how early each one would have fired:

SignalWould have firedWhy it works
A write verb from an identity that should only read8 May, the earliest possibleOne PUT from an agent identity is either a bug or an incident. Either way you want to know on the day
Denial rate spiking from one identityEarly, and repeatedlyProbing looks like a burst of 403s from a single source. This is the chart nobody builds
Growth in distinct outbound destinations per identity26 MayA read-only agent whose distinct-host count climbs is the clearest single tell. This is what moved on 26 May and went unseen
Requests to registry paths outside the package protocol26 May onwardThe exploit used a legacy token-refresh endpoint. Nothing legitimate goes there
The same credential presented by more than one workloadDuring lateral movementA stolen identity is an identity in two places at once
Any attempt to reach the metadata endpointDuring credential theftThe correct rate is exactly zero, which makes it a trivial alert with no tuning
A read of /proc/*/environDuring credential theftThis is the credential-theft primitive itself. Falco sees it; the network cannot
An agent calling a tool it is not registered forWhenever it happenedDrift between the registry and reality is an alertable event, not a curiosity

The mesh sees the network and Falco sees the host, and you need both, because half this chain happened inside a process where no packet was ever involved.

Building the first alert, and the trap in it

The write-verb alert is the one to build first, and there is a catch that will stop you. The HTTP method is not a default dimension on Istio's metrics. istio_requests_total carries source and destination workload, response code, protocol and security policy, but not whether the request was a GET or a PUT. An alert written against a method label will silently match nothing, which is the worst possible failure for a detection rule.

Two ways round it. You can add the method as a custom dimension through the Telemetry resource. Or, better, you can let the control from the network section generate the signal for you: once a read-only policy is enforcing, every write attempt becomes a 403 at the waypoint, and the response code is a default label. Apply the policy, then alert on its denials.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: agent-containment-alerts
  namespace: monitoring
spec:
  groups:
    - name: agent.containment
      rules:
        # Denials from an agent identity. After the read-only policy in the
        # network section is enforcing, a blocked write shows up here.
        - alert: AgentRequestDenied
          expr: |
            sum by (source_workload, destination_service) (
              rate(istio_requests_total{
                response_code="403",
                source_workload=~"agent-.*"
              }[5m])
            ) > 0
          for: 0m
          labels:
            severity: critical
          annotations:
            summary: "Agent {{ $labels.source_workload }} was denied reaching {{ $labels.destination_service }}"
            runbook: "Cut egress first, then quarantine. See the containment section."

The ordering matters and it is easy to miss. The layer 7 policy is not only the control that breaks the first step, it is also what turns that step into a metric you can alert on. Enforcement and detection are the same piece of work here, which is a good reason to do the policy before the dashboards.

One piece of label discipline before you build any of this. Per-run identity means unbounded label values, and a run identifier used as a Prometheus label will overwhelm your time series database. Aggregate metrics on the stable dimensions, which are agent type, version, tier, tool and target. Put the run identifier in traces and structured logs, where high cardinality is fine and where forensics actually happens. Alert on the trunk, investigate on the leaf.

Containment, in the order that works

The 4 July cleanup was thorough and it failed. Credentials revoked, Artifactory rebuilt, flaws patched, board deleted, and the board was back four days later. The lesson is specific: removing the artefact does not remove the capability. Order matters.

  1. Cut outbound traffic first. A policy change at the mesh is instant, reversible and needs no restart. It stops exfiltration and external hops while you still know almost nothing. This is the highest-value first move and it caps the blast radius during assessment.
  2. Stop the minting. Every run in the affected tier loses its access within one token lifetime, with nothing to hunt down.
  3. Freeze the runs. Scale the affected worker pool to zero. Suspend rather than kill if you want memory state for forensics.
  4. Quarantine, do not delete. You will want the state.
  5. Rotate against the reachable set, not the observed set. OpenAI and Hugging Face discovered they were the same incident because a credential one side went to revoke had already been revoked by the other. Assume everything the identity could reach is compromised, not just what you saw it touch.
  6. Kill the capability. Close the actual path, the stale endpoint and the forgery sink, not just the artefact it produced.
  7. Rebuild only after 5 and 6.

Keep the break-glass policy in the repository and rehearse applying it. The first time you run it should not be during the incident.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: break-glass-deny-all-egress
  namespace: agents-eval
spec:
  action: DENY
  rules:
    - to:
        - operation:
            hosts: ["*"]

Applying that is instant and reversible, and it needs no restart of anything. Follow it with the two commands that stop new work and stop new credentials:

# stop the runs
kubectl scale workerpool eval-pool -n agents-eval --replicas=0

# stop minting run identities: every run in the tier drains within one token lifetime
kubectl annotate workerpool eval-pool -n agents-eval \
  identity.internal/minting=disabled --overwrite

The annotation is an example rather than a product feature. Whatever your minting service reads to decide whether to keep issuing tokens, make it something you can flip with one command, and write that command down here.

The forensic position this leaves you in is worth stating, because it is the real return on the whole architecture. When every hop is mutually authenticated and attributed to an identity, and every brokered call is audited, your investigation is a query against attributed telemetry rather than a scan of billions of unlabelled log lines. That is the difference between hours and ten weeks.

Further reading

The incident

Kubernetes and mesh

Solo and agent platform

Threat models

Build the environment that prevents this

Everything above is what should have contained them. Here it is, built and tested on a live EKS cluster in eu-west-2: a zero-trust, sovereign production environment for agentic workloads, every layer standing up to a real exploit rather than a diagram.