AI Agent Sandboxing: Securing Tool Execution
Once a workflow can run generated code or shell commands, a bad command can read host files, reach an internal service, use all the memory or leave state that changes the next run. Prompt rules do not stop that, because the model is already inside the boundary it was told not to cross. The fix is a disposable execution cell with an enforceable per-run contract: only the files, network destinations, credentials, resources and time the task needs, then destroy the cell and test the denied actions as hard as the allowed ones.
Start with what you are protecting
Do not begin by picking a container product. List the assets that must stay outside the agent's authority: the host filesystem, cloud metadata endpoints, control-plane APIs, internal databases, unrelated repositories and the workflow service's own credentials. Then list every path to them. Mounts and outbound connections are the obvious ones. Inherited environment variables, writable container sockets, cached package credentials, persistent work directories and shared namespaces are the ones that get missed.
Write a threat statement before building. For example: code produced or selected by the model is untrusted, and may try to read data outside the task, change persistent state, contact an unapproved destination, exhaust resources or exploit the runtime. The workflow must contain that without the model's cooperation. The gVisor security model is a useful reminder that a sandbox reduces exposure to the host interface and is not a substitute for a secure architecture.
A shared kernel is a real boundary with real holes
A default container shares the host kernel, and container runtimes have shipped escapes. runc's CVE-2024-21626, described in its security advisory, came from an internal file descriptor leak and allowed a container escape to the host filesystem, in versions from 1.0.0-rc93 through 1.1.11 (fixed in 1.1.12). Some of its variants could be launched from inside an existing container through runc exec. If the code you run is hostile by assumption, a plain container is the weakest option worth considering. Add a userspace kernel or a lightweight VM for those workloads, and weigh startup time and compatibility against the threat statement instead of treating every isolation layer as equal.
Docker's default seccomp profile is a syscall allowlist built for least-privilege containers. Keep it unless compatibility testing shows a narrower custom profile is needed, and never switch filtering off to make one job pass.
Sandboxing is what lets you remove approval prompts
Teams often add a sandbox and keep a human confirmation on every command, which gets the benefit of neither. Anthropic's write-up on Claude Code sandboxing reports that sandboxing reduced permission prompts by 84% in internal usage, because actions inside the boundary no longer need a decision. It also argues that filesystem and network isolation are both required. Without network isolation a compromised agent can leak files such as SSH keys, and without filesystem isolation it can escape the sandbox and gain network access. Decide which actions the boundary makes safe enough to run unattended, and spend human approvals only on what crosses it.
Treat model output as untrusted control input
A dangerous command can come from an ambiguous request, a hallucinated path, poisoned retrieved content or direct prompt injection. The source does not change the control. First ask whether the task needs code execution at all. Then check that the requested operation is allowed for that workload class. Then run it in a cell that survives hostile behavior. A command denylist cannot replace the third step, since shells, interpreters and package hooks give too many equivalent paths. OWASP lists excessive functionality, permissions and autonomy as the root causes of Excessive Agency, and a sandbox is no reason to hand the workflow broad business permissions.
Write a per-run execution contract
The workflow service, not the model, creates this before starting a runtime. A document conversion job and a repository test job need different files, binaries, network access and limits, so avoid one shared "agent sandbox" profile.
execution_contract:
image: document-tools@sha256:pinned-image-digest
identity:
uid: 10001
gid: 10001
filesystem:
readonly_root: true
readonly_inputs:
- input/report.docx
writable_paths:
- output/
- tmp/
output_allowlist:
- output/report.pdf
network:
mode: deny
allowed_domains: []
resources:
cpu_millis: 1000
memory_mb: 768
process_limit: 64
wall_time_seconds: 90
output_bytes: 5242880
credentials: []
cleanup:
destroy_runtime: true
retain_outputs_only: true
Pin the image by digest, keep it small, and create a new cell for each trust boundary: usually one per tool call, or one per job when steps genuinely share temporary state. Never reuse a cell across users. The baseline is an unprivileged user, a read-only root, explicit read-only input mounts, a small writable temp area, no host namespaces, no runtime socket, dropped capabilities, a syscall filter, resource ceilings, and destruction once outputs are collected.
Keep credentials and network out by default
Start with an empty credential set. Use a broker for business actions: the sandbox emits a structured request such as "store this PDF for case 4821", and the trusted service validates it and writes with a short-lived credential. If code must call a service directly, issue a token scoped to one audience and expire it with the task timeout.
Start with no network. OpenAI's shell tool guide gives a concrete model: hosted containers have no outbound access by default, and enabling it takes an admin allowlist plus an explicit request policy. If you allow domains, resolve through a controlled resolver, reject private, loopback, link-local and metadata addresses, and recheck after redirects. An allowed domain that proxies arbitrary URLs makes the list meaningless. Treat package installation as its own workload class, with prebuilt images preferred over runtime installs.
Inbound is a boundary too
Egress gets all the attention, but a cell can also be reachable from outside. A sandbox that can run a server can serve whatever it just wrote, such as a page, a token or a CSV, to anyone who fetches its public URL, so turn inbound public traffic off along with egress. E2B's documentation shows two traps in that configuration. Network rules updated on a running sandbox are replaced rather than merged, so an update that omits rules clears every rule. And options such as allow_public_traffic are create-only and cannot be changed after the sandbox is created (E2B internet access). Set the inbound and egress posture at creation, and treat any later rules update as a full rewrite.
Enforce ceilings from outside
Recursive processes, decompression bombs, huge logs and infinite loops can take down shared capacity without escaping. Set CPU, memory, process, disk, wall-clock and output limits. The outer orchestrator owns the deadline, because a timeout implemented inside the sandbox can be broken by the process being timed. Treat truncation as a result state: record which limit fired and keep a bounded tail or summary instead of feeding a huge partial stream to the model.
Capture results, then destroy the cell
Whatever leaves the cell goes back to the model and to your storage, so file names, sizes and content need the same untrusted-input treatment as anything else. Cap output with an explicit truncated flag, because a silently cut result is how a confident wrong answer gets written. Limit exported files by size, checked from the sandbox's metadata before any bytes enter your own process.
Return only declared outputs. Record exit status, timeout state, resource usage, policy denials and truncation, validate names, types, sizes and path containment, copy allowlisted artifacts to trusted storage, destroy the runtime and its volumes, and revoke task credentials. Attach a fresh run ID to the trace. Do not upload the whole writable filesystem, which may hold caches, copied inputs and attacker-made paths.
A failed job must not earn more authority. A missing package is not a reason to rerun with host access, and a blocked syscall is not a reason to disable seccomp. Classify the failure as a task error, a policy denial or a capacity limit, retry only runtime faults that are safe to repeat, and route policy denials through an explicit capability review.
Verify it as a security boundary
A command that succeeds shows the sandbox is useful. It says nothing about containment. Run negative tests in CI for every image or policy change. Confirm that code cannot read an unmounted host file, write outside the declared paths, reach an unapproved domain or a private or metadata address, see host processes, touch a runtime socket, exceed its limits, leave a file for a later run, or export an undeclared artifact. Also test the permitted path, since a sandbox that blocks the real job collects exceptions until it protects nothing.
Pick one workload that runs code today. Disable its network, mount one input and one output directory, set hard limits, and try to read a harmless marker file outside the mount. If the marker is reachable, the boundary is not ready for production.
References
- OWASP LLM06:2025 Excessive Agency for excessive functionality, permissions and autonomy as root causes.
- OpenAI shell tool guide for shell risks, audit logging and default-denied hosted networking with allowlists.
- Docker seccomp security profiles for the default syscall allowlist.
- gVisor security model for attack-surface reduction and the limits of sandboxing.
- runc advisory GHSA-xr7r-f8xq-vfvv (CVE-2024-21626) for the container escape through a leaked file descriptor.
- Anthropic, Making Claude Code more secure and autonomous with sandboxing for the 84% prompt reduction and the filesystem plus network isolation argument.
- E2B: Internet access: network rule updates replace rather than merge, and allow_public_traffic is create-only.