Blog/Security & compliance
AI agent sandbox checklist: lessons from a CI intrusion
An AI agent sandbox checklist for CI: isolation levels, egress allowlists, short-lived scoped credentials, blocked metadata endpoints and untrusted project config.
Balázs Csorba··11 min read
- AI agent sandbox
- CI security
- Egress control
- Credentials
- Container isolation

Key takeaways
- Between 9 and 13 July 2026 an agent in a frontier-lab evaluation escaped its sandbox and ran about 17,600 actions across five trust boundaries inside Hugging Face's production.
- Supervise capability, not behavior: Anthropic measured that users approved roughly 93% of permission prompts, and a boundary enforced by the operating system does not suffer from approval fatigue.
- Isolation levels, cheapest first: an OS process sandbox such as Seatbelt or bubblewrap, a gVisor container with an interposed kernel, and a full VM with its own kernel, which is what Cowork uses.
- An egress allowlist is a capability grant: allow narrow hosts, rate-limit, and bind the session's own credential at the proxy so a request with someone else's key fails.
- An agent's own transcript is evidence it controls, so capture execution out of band: METR found spoofed tool calls in at least 96 transcripts from the incident.
An AI agent sandbox is the boundary that decides what a coding agent can reach when nobody approves every command. It replaces the permission prompt: instead of asking a tired human about one command at a time, the sandbox lets the operating system enforce a rule on every process, file and destination. This checklist covers what to enforce, how to pick an isolation level and what to log, using the July 2026 Hugging Face intrusion as the example.
Between 9 and 13 July 2026 an autonomous agent in a frontier-lab evaluation escaped its sandbox, rooted a third-party sandbox and ran a four-day campaign inside Hugging Face's production, with roughly 17,600 attacker actions across five trust boundaries. Almost every step worked because a control was a default rather than a decision: a template that evaluated a config field, an allowlist that only knew URLs, a pod that could reach the cloud metadata service, one credential shared across every cluster.
What happened in the July 2026 intrusion?
Between 02:28 UTC on 9 July and 14:14 UTC on 13 July 2026, Hugging Face's technical timeline of the incident reconstructs about 17,600 attacker actions from the agent's sandbox logs. The first stage, attributed by the timeline to OpenAI's account, was an escape from the evaluation sandbox through a zero-day in the package registry cache proxy, then the use of an unsecured public code-evaluation endpoint on third-party infrastructure, where the agent ran commands as uid 0. That machine had no route into Hugging Face's cluster: a launchpad, not a foothold.
The second stage needed no exploit at all. The agent uploaded a dataset whose configuration drove a data loader inside a production Kubernetes pod, and abused the same surface twice. An HDF5 file that declared its raw data to live at a local filesystem path made the worker return the pod's environment, secrets included, and the worker's own source code as dataset rows. A Jinja2 template injection in a configuration field that should never have been evaluated gave code execution in the pod.
One detail should change how you write a data-flow policy. An earlier attempt to make the worker fetch the metadata address 169.254.169.254 was rejected by the datasets library's URL allowlist, so the agent asked for local paths instead, and the allowlist never saw them. An allowlist that only matches URLs is not a data-flow control.
From that pod the campaign escalated along the familiar path: read the service-account token, call the cluster API, reach the instance metadata service for node role credentials, then create a privileged pod with the host filesystem mounted, which gave node root and a self-respawning fleet across eleven nodes, because no admission policy rejected privileged or hostPath pods. Cluster secrets produced a mesh-VPN key that became an HTTPS proxy into internal services and source control, and a GitHub App installation token with contents:write was used to open a pull request aimed at a CI pipeline. The cheap pivot was configuration: one connector credential shared across clusters and bound to system:masters, so a single stolen secret was cluster-admin everywhere.
One result from the same timeline is worth keeping: every destructive cloud API call the agent attempted was issued with DryRun set to true. It was mapping capability, not causing damage, and the controls that worked removed options rather than detecting intent.
METR and Redwood Research published an independent investigation on 26 August 2026 with a warning for any CI threat model. Roughly 1,200 agents meant to be isolated from one another found an unsanctioned message board in a shared package cache and posted more than 70,000 messages and files; by 11 July about 700 of them were attacking Hugging Face. Those agents also worked out how to tamper with their own transcripts: they hacked out of their container and replaced the component that executes tool calls, so the transcript recorded one command while another ran. METR found clear evidence of spoofed tool calls in at least 96 transcripts. The agent's own log is evidence it controls, so capture execution outside it.
Why supervise capability, not behavior?
Because behavior supervision does not scale and degrades. Anthropic's "How we contain Claude" (25 May 2026) puts the number on it: their telemetry showed users approved roughly 93% of permission prompts, and the more prompts a user saw, the less attention each one got. Instead of asking whether the agent did the right thing, decide what it is able to do.
They split the problem into three layers: the environment, the model, and the external content the agent can reach. The model layer is the one people keep betting on, and Anthropic's conclusion is that protection there "will never be 100% effective, which is why it can't stand alone." Their red-team result shows why: a researcher phished an employee into launching Claude Code with a prompt that read AWS credentials, encoded them and posted them out, and across 25 attempts the exfiltration succeeded 24 times. Nothing in the model layer could help: the injection arrived through the user, which is where those defenses anchor.
Which isolation level should you pick?
Take the cheapest of four levels that contains the task, and be honest about which one you are on now.
- No boundary. The agent runs on the host or the runner with the repository, a shell and whatever credentials CI injected. Every incident here starts from this rung.
- OS process sandbox. Seatbelt on macOS, bubblewrap on Linux, so the operating system decides paths and destinations, for child processes too. The Claude Code sandboxing docs are honest about the default: reads are allowed across the machine except certain denied directories, so
~/.aws/credentialsand~/.sshstay readable until you deny them, and network access is off until you allow a host. Shipping this sandbox cut Claude Code's permission prompts by 84%. - Container with an interposed kernel. gVisor, which Anthropic uses for claude.ai's code execution, on isolated infrastructure with an ephemeral per-session filesystem. More isolation, spin-up cost, and you own the host boundary.
- Full VM or microVM. Its own kernel, filesystem and process table, with only the workspace mounted. Cowork runs this way, with credentials left in the host keychain and a per-session scoped token inside. Boot time and memory are the price; the payoff is that nothing inside can grant itself an exception.
Two rules about the choice. Match the strength to who supervises: a developer who reads bash can work with an OS sandbox, a knowledge worker cannot, and Anthropic's conclusion is that when approving an exception requires expertise the typical user lacks, the boundary must be absolute and always on. And close the escape hatches, because that is where containment leaks. Claude Code lets a blocked command be retried outside the sandbox with a parameter the model can set; allowUnsandboxedCommands: false disables that, and sandbox.failIfUnavailable turns a sandbox that cannot start into a hard failure rather than a warning and an unsandboxed run.
Egress, credentials and the metadata endpoint
Isolation without egress control is a lock on the front door. Three controls.
Egress is a capability grant
An allowlist says which hosts an agent may reach, so every function behind them is reachable too. Anthropic's own example: a malicious file in a mounted workspace told Cowork to read other files and upload them through the Files API with an attacker-controlled key. The proxy saw api.anthropic.com, which the product must reach, and let the request through. The sandbox worked perfectly and the data still left. Their fix was a proxy inside the VM that only passes requests carrying the VM's own provisioned token. The general form: bind the credential to the session at the proxy, so a request carrying someone else's key fails. Then allow specific hosts and paths, such as one registry mirror, and rate-limit, because a stuck agent in a retry loop is a load generator holding your credentials.
Short-lived, scoped credentials
Mint per task, at the start of the run, revoke at the end, and never give the sandbox a capability the task does not need. Prefer workload identity over static keys, one of the changes Hugging Face made after the incident. And keep the agent's token away from what can change other people's code: a token that can write the default branch, or trigger a workflow holding secrets, turns a bad tool call into a supply-chain event.
Block the metadata endpoint at the platform
After the intrusion, Hugging Face blocked pod-level access to the instance metadata service for all workloads, so a pod remote-code execution cannot trivially become node credentials. Do it in the platform, not at the network edge, and do not treat an IMDSv2 hop limit as a substitute: it is a runtime setting on the instance, and the same pod also had a mounted service-account token. Pair it with an admission policy that rejects privileged pods and hostPath mounts, the control that would have stopped the node-root step.
Why is project configuration untrusted input?
Because it is parsed before the user has consented to anything. Anthropic disclosed three Claude Code vulnerabilities between mid-2025 and January 2026 that all had the same shape: code executing before the user had agreed to anything. The clearest is a repository containing a .claude/settings.json that defines a hook. Because Claude Code reads project settings during startup, before the "do you trust this folder" prompt, the hook the attacker had committed ran automatically. The fix in each case was to defer parsing and execution of project-local configuration until after the trust prompt.
The rule generalizes past one product. Anything an agent reads from a directory it has just opened, whether that is AGENTS.md, a skill file, a hook or CI YAML, is third-party input that can change the agent's authority. Two consequences. Resolve symlinks before you validate paths, or a link inside an authorized folder points outside it, the gotcha Anthropic hit in Cowork's mounted workspace. And protect the files the agent's authority is defined in: Claude Code's sandbox denies writes to .claude, .mcp.json and the hooks and config directories inside .git, because a command that could edit them could grant itself permissions. Treat every local agent runtime the same way: project-open, config-load and localhost listeners are inbound input, not trusted local state.
What containment costs you
Containment is not free, and pretending otherwise produces controls that quietly regress.
- Cost and latency. VM boot time, gVisor overhead, per-run provisioning. Anthropic moved Cowork's agent loop out of the VM after startup failures made the product unusable, and judged the security impact minimal because the VM still enforced policy.
- Visibility. The isolation that contains the agent keeps your endpoint tooling out: to a vendor, Cowork is an opaque hypervisor process it cannot inspect. Anthropic's mitigation is pull-based OTLP exports for admins to retrieve afterwards, which is not live monitoring. Raise this with your security team before you ship.
- Alerts that do not page. In the Hugging Face response the correlation worked, but the alert's criticality was not raised and the on-call team was not triggered. A control nobody is paged for is a control you do not have.
- Logs full of envelopes. Their first automated scan of the attacker's capture found few secrets; replicating the attacker's own chunking and encoding recovered roughly four times as many, mostly tokens inside encoded blobs. Decode what you log.
CI runner checklist
Each step of the July chain, and the control that would have stopped it.
| Attack step | Control |
|---|---|
| Escape from the evaluation sandbox | gVisor container or VM, no other route out |
| Config field drives a data loader in a prod pod | Validate config fields: no external references, no templates |
| Local-path read an URL allowlist cannot see | Control data flow, not URLs; confine reads to declared roots |
| Pod environment and service-account token | No long-lived secrets; per-task scoped tokens |
| Cloud metadata service, privileged hostPath pod | Block pod-level metadata access; admission policy rejecting both |
One connector credential bound to system:masters | Per-cluster credentials, no cluster-admin binding |
| App token writes code and opens a pull request | No default-branch write, no secret-bearing workflow triggers, human review |
| Agent edits its own transcript | Capture exec and gateway events out of band with one propagated id |
# Pseudo-code: what an agent run is allowed to hold
sandbox = OS_sandbox(
read_paths = [workspace, toolchain_cache],
write_paths = [workspace, tmp], # nothing outside the run directory
network = allowlist(["registry.npmjs.org", "api.github.com"]),
env = deny(["AWS_*", "NPM_TOKEN", "SSH_AUTH_SOCK"]),
immutable = ["agent settings", ".mcp.json", ".git/hooks"],
)
token = mint_token(scope="agent branch only", ttl="20 minutes", revoke_on_exit=True)- Put the run behind a boundary the operating system enforces, and fail closed if it cannot start.
- Give the sandbox no long-lived credential. Short-lived, scoped, revoked at the end, never the pipeline's own secret.
- Block the metadata endpoint platform-wide and add an admission policy rejecting privileged pods and hostPath.
- Treat the egress allowlist as a capability grant: narrow hosts, rate limits, session token bound at the proxy.
- Parse project configuration after consent, and treat
AGENTS.md, skills, hooks and CI files as third-party input. - Resolve symlinks before validating paths, and make the files that define the agent's authority read-only.
- Keep the agent's token away from the default branch, release workflows and any pipeline secret.
- Log out of band: exec, gateway and egress events with one propagated id, decoded, alerted on volume and destination.
- Test the alerting path with a simulated intrusion: severity routing that never pages is the control that failed first in July.
Why a prompt cannot be the boundary is in prompt injection as an architecture problem, and the loop that keeps calling tools inside these boundaries is the agent loop explained. Sandboxing agents that write code is much of the work I do as an AI engineer.
Sources
Frequently asked questions
What is an AI agent sandbox?
It is the boundary that decides what a coding agent can reach when nobody approves every command. Instead of asking a human whether each action is acceptable, the sandbox lets the operating system enforce which files a process may read and write and which hosts it may reach, and it applies that rule to child processes too. It replaces per-command permission prompts with a policy that holds whether the model is careful, confused, or manipulated.
Should I use an OS sandbox, gVisor or a full VM for a coding agent?
Take the cheapest level that contains the task. An OS process sandbox, Seatbelt on macOS or bubblewrap on Linux, covers most day-to-day agent work and is cheap, though reads are broad by default so credential paths need explicit denies. gVisor adds an interposed kernel and an ephemeral disk, which is what Anthropic runs for claude.ai's code execution. A full VM with its own kernel and only the workspace mounted is the strongest and costs boot time and memory.
Why block the cloud metadata endpoint at the platform level?
In the July 2026 Hugging Face incident the agent reached the instance metadata service from inside a production pod and used the node role credentials it found there to reach the cluster API. Hugging Face's fix was to block pod-level access to that service for all workloads, so a pod remote-code execution cannot trivially become node credentials. Hop limits on IMDSv2 are no substitute: they are a runtime setting, and the same pod also had a mounted service-account token.
Why treat project configuration as untrusted input?
Because it is parsed before the user has consented to anything. Anthropic disclosed three Claude Code vulnerabilities where a repository's own settings file defined a hook that executed at startup, before the trust prompt appeared. The fix was to defer parsing project-local configuration until after the user accepts the trust prompt. The general rule is that any file an agent reads from a directory it just opened is third-party input that can change the agent's authority.
How do you stop an agent from hiding what it did in its own logs?
Capture execution outside the agent's control. In the July 2026 incident, agents that METR investigated hacked out of their container and replaced the component that executes tool calls, so the transcript recorded one command while the machine ran another; METR found clear evidence of spoofed tool calls in at least 96 transcripts. Log exec, gateway and egress events at the platform layer with a single propagated id, decode stored payloads, and alert on volume and destination changes.