Mrinal Wadhwa · February 2026

Agent Sandboxes

how to create millions of them

In Autonomy, our customers can spin up swarms of thousands of long-horizon agents in milliseconds. These swarms collaborate across machines and work autonomously for hours.

Mid-last year, it was clear to me that agents need their own workspace: a filesystem and a shell. With one, they can work autonomously over a much longer horizon. Recent models are capable enough to reason and self-correct. A workspace gives them somewhere to do it. The longer an agent can work, the more it can achieve.

Claude Code was an early glimpse. It wrote progress files and structured notes that bridged its thinking across context windows.

It ran shell commands like grep and awk to find what it needed rather than relying on vector search. It created and ran ad hoc scripts to get work done without needing a predefined, exhaustive set of tools.

My own experiments confirmed this. A single agent with a workspace could take thousands of autonomous steps toward complex goals. In Autonomy, it can delegate to swarms of sub-agents that each have their own workspace. Together, they can autonomously tackle far more difficult problems. The conclusion was obvious: Autonomy should be able to give every agent its own workspace.

In the last couple of months, the larger community has caught up.

Gokul Rajaram: Turns out you are facing an existential challenge: long-horizon agents like Claude Code. Agents that are not trained on a specific domain, but can reliably work for hours or days on end in pursuit of a goal, self-correct, and actually do stuff. Ethan Mollick: All those products where building an AI agent meant defining a series of basic prompts linked together deterministically through a flowchart with separate RAG inputs are looking pretty dated right about now. They certainly aren’t AI agents as we understand them in a post-Claude-Code world.

But giving an agent a shell is dangerous. Agents are gullible. They deal in untrusted input and can’t reliably tell the difference between a legitimate request and a malicious one. If an agent can run shell commands, an attacker can talk it into running a fork bomb.

That risk is amplified in Autonomy. We’re a multi-tenant platform, and our customers are building multi-tenant products on top of us. Agents from different end users all run side by side. One compromised agent can’t be allowed to harm another.

Every workspace must be locked down. Without filesystem isolation, one agent can read another’s secrets or corrupt its work.

Without process isolation, it can spy on a neighbor’s memory or kill its processes. Without network controls, it can exfiltrate sensitive data or open a reverse shell. Without resource limits, a single runaway agent can consume all the memory and starve every other agent on that machine.

Swarms of long-horizon agents are incredibly powerful. But to enable them, we needed millions of locked-down workspaces, created in milliseconds, with minimal overhead, and without compromising security.

Seatbelt: the macOS Sandbox

I develop on macOS. So the first sandbox technique I explored was Seatbelt: the kernel-enforced mechanism that Chrome, Safari, and every App Store app use to restrict what a process can do.

The interface to it is the command: sandbox-exec. We write a profile in SBPL (Sandbox Profile Language), a Scheme-like syntax that compiles to kernel-enforced rules. /System/Library/Sandbox/Profiles/ has dozens of built-in profiles worth studying.

I deny everything by default, then allow only what’s needed: reads from system paths and the workspace, writes to only the workspace.

Run the following script with ./sandbox.sh /bin/bash and we get a shell where reads outside /usr, /bin, and /tmp/workspace fail, writes outside /tmp/workspace fail, and network access is limited to approved endpoints. The kernel enforces the rules.

#!/bin/bash
# sandbox.sh - Run a command in a sandboxed environment

mkdir -p /tmp/workspace

# Create the sandbox profile
cat > /tmp/sandbox-profile.sb <<'EOF'
(version 1)
(deny default)                                                ; deny everything by default
(allow file-read* (subpath "/usr"))                           ; allow reading system binaries and libraries
(allow file-read* (subpath "/bin"))
(allow file-read* (subpath "/tmp/workspace"))                 ; allow reading the workspace
(allow file-write* (subpath "/tmp/workspace"))                ; allow writing to the workspace
(allow file-write* (literal "/dev/null"))
(allow network-outbound (remote tcp "api.example.com:443"))   ; allow connecting to a specific api endpoint
(allow process-exec)                                          ; allow executing programs
(allow process-fork)
(allow sysctl-read)
EOF

# Run the command inside the sandbox
sandbox-exec -f /tmp/sandbox-profile.sb "$@"

Seatbelt has no process isolation; a sandboxed process can still see, signal, and kill other processes on the machine, and trace them to read their memory. There are no resource limits either; a single runaway process can consume all available memory.

Even with these gaps, Seatbelt is far better than a loose coding agent that can read every secret on your disk. For local development, it’s a meaningful layer of protection. But for production, I needed to run on Linux with full process isolation and resource limits.

Landlock

The classic approach to filesystem isolation on Linux is chroot. It changes the root directory for a process so it can only see files under the new root. But chroot was never designed as a security boundary. A root process can escape it trivially, and it does nothing to restrict network, processes, or resources. Landlock is the modern replacement.

It’s a Linux Security Module, available since kernel 5.13, that lets an unprivileged process restrict its own filesystem access. No root required. No containers. Once applied, the restrictions are permanent for that process and all its children. There’s no undo. Landlock uses three syscalls. There’s no shell command for it. Here’s the minimal C code:

// sandbox.c - minimal Landlock example
#include <linux/landlock.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>

// Step 1: Create a ruleset that handles filesystem access
struct landlock_ruleset_attr ruleset_attr = {
    .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR |
                         LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_EXECUTE,
};

int ruleset_fd = syscall(SYS_landlock_create_ruleset, &ruleset_attr, sizeof(ruleset_attr), 0);

// Step 2: Add rules - allow read access to /usr
struct landlock_path_beneath_attr path_attr = {
    .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
    .parent_fd = open("/usr", O_PATH),
};
syscall(SYS_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_attr, 0);
close(path_attr.parent_fd);

// Add write access to workspace
path_attr.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE;
path_attr.parent_fd = open("/tmp/workspace", O_PATH);
syscall(SYS_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_attr, 0);
close(path_attr.parent_fd);

// Step 3: Enforce - no going back after this
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
syscall(SYS_landlock_restrict_self, ruleset_fd, 0);
close(ruleset_fd);

// Now sandboxed. Exec the target command.
execvp(argv[1], &argv[1]);

Landlock restricts the filesystem. But that’s all it does. A Landlock-sandboxed process can still open TCP connections, exfiltrate data, or establish reverse shells.

It can see and signal every other process on the machine. It has no resource limits.

Compile and run the C code as shown below.

$ gcc -o sandbox sandbox.c
$ mkdir -p /tmp/workspace
$ ./sandbox /bin/bash

# Inside the sandboxed shell:
$ echo "test" > /tmp/workspace/allowed.txt   # works
$ echo "test" > /tmp/not-allowed.txt        # Permission denied
$ cat /etc/passwd                           # Permission denied (not in rules)

seccomp

For network restrictions, we need a different tool. seccomp is a kernel mechanism that filters syscalls. We write a BPF program that runs on every syscall and decides whether to allow or reject it. Docker, Chrome, and systemd all use it.

seccomp closes the network gap that Landlock leaves open. But it can only inspect syscall numbers and their immediate argument values, not pointers to user memory.

It can’t filter by destination address. Unlike Seatbelt’s per-endpoint rules, seccomp is all or nothing: block network syscalls entirely, or allow them.

Even with Landlock and seccomp, a process can still signal other processes, consume unlimited memory, and fork without limit.

The filter below blocks syscalls: socket, connect, and bind.

// block_network.c - minimal seccomp example
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/prctl.h>
#include <sys/syscall.h>

// BPF program: if syscall is socket/connect/bind, return EPERM
struct sock_filter filter[] = {
    // Load syscall number
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),

    // If socket(), return EPERM
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_socket, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    // If connect(), return EPERM
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_connect, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    // If bind(), return EPERM
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_bind, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    // Allow everything else
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

struct sock_fprog prog = {
    .len = sizeof(filter) / sizeof(filter[0]),
    .filter = filter,
};

// Apply the filter
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);

cgroups

Landlock and seccomp together cover filesystem and network. But neither limits resources. A process can still spin up thousands of threads, balloon its memory, or saturate disk I/O.

Control groups (cgroups) solve this. They’re a kernel mechanism for limiting CPU, memory, and I/O per process group. The interface to create cgroups is the filesystem: create a directory under /sys/fs/cgroup/, write limits into it, and move processes in.

If the process exceeds 256MB, the kernel kills it. If it tries to fork more than 50 processes, the fork fails. If it tries to use more than one core, the scheduler throttles it. The limits are enforced by the kernel and inherited by all child processes.

Resources are now capped. But without namespace isolation, the process still shares the host’s process table and mount tree.

#!/bin/bash
# cgroup.sh - Apply resource limits

mkdir -p /sys/fs/cgroup/sandbox
echo "256M"          > /sys/fs/cgroup/sandbox/memory.max   # max 256MB memory
echo "100000 100000" > /sys/fs/cgroup/sandbox/cpu.max      # max 1 CPU core
echo "50"            > /sys/fs/cgroup/sandbox/pids.max     # max 50 processes (stops fork bombs)
echo $$              > /sys/fs/cgroup/sandbox/cgroup.procs # move this process in

# Run the command
exec "$@"

Namespaces and Bubblewrap

Together, Landlock restricts the filesystem, seccomp restricts syscalls and network, and cgroups restrict resources. Add namespace isolation to these three primitives and we have a Linux container. That’s what Docker does under the hood with runc.

But containers carry a large overhead: a daemon, image layers, storage drivers, per-container networking. Container startup takes several seconds. We need millions of sandboxes in milliseconds. Containers can’t get us there. We need namespace isolation without the rest.

Namespaces work differently from everything we’ve seen so far. Landlock, seccomp, and cgroups deny access to things that exist. Namespaces make the things not exist at all.

A mount namespace hides the host filesystem. A PID namespace hides other processes. A network namespace hides the host network. What isn’t in the namespace doesn’t exist.

Bubblewrap is a single binary that does exactly this. It wraps a process in its own set of Linux namespaces: mount, PID, network, IPC. No daemon, no container image, no storage driver.

The entire setup takes milliseconds. It starts with an empty mount namespace, then selectively bind-mounts only the paths we explicitly allow. The process can’t see paths that weren’t mounted. It’s an allowlist, not a denylist.

#!/bin/bash
# bubblewrap.sh - Run a command in isolated namespaces

mkdir -p /workspace/sandbox_a

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /lib /lib \
  --ro-bind /lib64 /lib64 \
  --ro-bind /bin /bin \
  --bind /workspace/sandbox_a /workspace \
  --tmpfs /tmp \
  --proc /proc \
  --dev /dev \
  --unshare-pid \
  --unshare-net \
  --unshare-ipc \
  --new-session \
  --die-with-parent \
  "$@"

Run it with ./bubblewrap.sh /bin/bash. Inside, the process sees only /usr, /lib, /bin, and its /workspace. Another workspace at /workspace/sandbox_b? Doesn’t exist. The host’s /etc/passwd? Doesn’t exist. --unshare-pid gives it its own PID namespace, so it can’t see or signal other processes. --unshare-net gives it its own network namespace with only a loopback interface. No network access unless we proxy it through a Unix socket.

This is the key difference from Landlock. With Landlock, a path like /etc/shadow exists but access is denied. With Bubblewrap, we never mount that path. There’s nothing to block because there’s nothing there.

Bubblewrap gives us strong agent-to-agent isolation. But isolating agents from each other isn’t enough. We also need to isolate customers from each other and from our infrastructure.

Autonomy runs on Amazon EKS. Customers define zones with pods and containers. A container runs many agents, and each agent’s workspace can be wrapped in Bubblewrap. But Bubblewrap needs CLONE_NEWUSER to create user namespaces without root. Inside a container, this requires CAP_SYS_ADMIN, the capability behind most container escape exploits. Granting it would strengthen the agent boundary but weaken the container boundary.

Customers bring their own code to run in these containers. The container boundary is what stands between that code and our infrastructure. It shares the host kernel with every other container on that Kubernetes node. We need to strengthen it, not weaken it.

Virtual Hardware: Firecracker

Next, I considered running each customer’s pod inside a virtual machine. A dedicated EC2 instance per customer would start too slowly and add too much overhead. Firecracker is a lightweight virtual machine built for exactly this. It’s what AWS Lambda and Fargate use.

Instead of sharing the host kernel, each Firecracker VM gets its own Linux kernel. The isolation is hardware-enforced via Intel VT-x/AMD-V. To escape, an attacker must exploit the guest kernel, then the Firecracker process or KVM, then gain access to the host. Three independent bugs, in different codebases.

Firecracker requires Linux with KVM. The script below downloads it and boots a minimal VM:

#!/bin/bash
set -e

# Check KVM
if [ ! -e /dev/kvm ]; then
    echo "KVM not available. Firecracker requires hardware virtualization."
    exit 1
fi

# Download Firecracker
ARCH=$(uname -m)
VERSION="v1.6.0"
curl -fsSL -o firecracker \
  "https://github.com/firecracker-microvm/firecracker/releases/download/${VERSION}/firecracker-${VERSION}-${ARCH}"
chmod +x firecracker

# Download a minimal kernel and rootfs
curl -fsSL -o vmlinux.bin "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/kernels/vmlinux.bin"
curl -fsSL -o rootfs.ext4 "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/rootfs/bionic.rootfs.ext4"

# Create a config file
cat > vmconfig.json <<EOF
{
  "boot-source": {
    "kernel_image_path": "vmlinux.bin",
    "boot_args": "console=ttyS0 reboot=k panic=1 pci=off"
  },
  "drives": [
    {
      "drive_id": "rootfs",
      "path_on_host": "rootfs.ext4",
      "is_root_device": true,
      "is_read_only": false
    }
  ],
  "machine-config": {
    "vcpu_count": 1,
    "mem_size_mib": 128
  }
}
EOF

echo "Starting Firecracker VM..."
echo "You'll get a login prompt. Default: root (no password)"
echo "Type 'poweroff' to exit."
echo ""

# Run Firecracker
./firecracker --no-api --config-file vmconfig.json

Save as run-firecracker.sh and run with chmod +x run-firecracker.sh && sudo ./run-firecracker.sh. We see a Linux boot sequence and get a login prompt. This is a complete, isolated Linux system. It has its own kernel, its own init, its own view of the world.

Fargate would be a natural choice. It runs on Firecracker under the hood: ~125ms boot time, ~5 MiB memory overhead, an attack surface much smaller than a shared kernel.

The ideal combination would be Fargate for hardware isolation between customers, Bubblewrap for namespace isolation between agents. But Fargate’s seccomp profile blocks CLONE_NEWUSER, and Fargate doesn’t allow CAP_SYS_ADMIN.

Firecracker and Bubblewrap can’t work together on Fargate.

We could run Firecracker ourselves. Kata Containers integrates it with Kubernetes as a container runtime. But Kata needs KVM. On AWS, that means bare metal instances or nested virtualization, and neither works with EKS auto mode.

We use EKS auto mode. AWS manages node infrastructure: scaling, health checks, draining, AMI updates. Choosing Kata would mean giving all of that up. We would have to provision instances with KVM access, self-manage node lifecycle, install and update Kata on every node, and maintain guest kernel images. We’d end up rebuilding Fargate ourselves, just without the restrictions.

Virtual Kernel: gVisor

Firecracker virtualizes the hardware. gVisor virtualizes the kernel.

gVisor intercepts every syscall and reimplements Linux kernel behavior in user space. The sandboxed process talks to gVisor’s Sentry, not the real kernel. Even if the process exploits a kernel vulnerability, it’s exploiting gVisor’s Go implementation, not the host. The attack surface shifts from ~260 Linux syscalls in C to ~40 host syscalls in memory-safe Go.

gVisor is an OCI-compatible container runtime, a drop-in replacement for runc. In Autonomy, we run it on EKS auto mode nodes. No Fargate restrictions. No blocked capabilities.

# Install gVisor
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/gvisor.gpg] \
  https://storage.googleapis.com/gvisor/releases release main" \
  | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install -y runsc

# Configure Docker to use gVisor
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<EOF
{
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc"
    }
  }
}
EOF
sudo systemctl restart docker

The only change is --runtime=runsc. The Sentry intercepts every syscall: file reads, process forks, network calls. The entire syscall surface changes. The host kernel is unreachable.

When Bubblewrap requests CAP_SYS_ADMIN inside gVisor, the Sentry grants it virtually. The capability never reaches the host.

gVisor isolates customers from each other and from infrastructure. Bubblewrap isolates agents from each other. This is the combination that works.

Run the same container with and without --runtime=runsc:

# Standard container runtime (runc)
docker run --rm ubuntu uname -r
# 6.1.109-118.189.amzn2023.x86_64

# gVisor container runtime (runsc)
docker run --rm --runtime=runsc ubuntu uname -r
# 4.4.0

Customer Isolation

The techniques above protect two boundaries: an outer boundary between customers and our infrastructure, and an inner boundary between agents.

A customer’s container shares the host kernel with every other container on that node. We considered three approaches to strengthening it.

Shared Kernel (runc) Virtual Hardware (Firecracker) Virtual Kernel (gVisor)
Kernel Shared host kernel Own kernel per VM Reimplemented in Go
Host syscall surface ~260 ~25 ~40
Escape requires 1 kernel bug KVM + Firecracker chain Sentry bug (Go, not kernel)
After escape ❌ Host node, all containers exposed ✅ Sandboxed Firecracker process ✅ Sandboxed Sentry process
Bubblewrap inside ⚠️ Needs CAP_SYS_ADMIN CLONE_NEWUSER blocked ✅ Virtual CAP_SYS_ADMIN

The “after escape” row matters most. With a shared kernel, escaping one container puts the attacker on the host node. Every other container on that node is exposed. The kubelet has credentials to the Kubernetes API server. The node’s IAM role may grant access to cloud resources. One kernel exploit can cascade from container to node to cluster to cloud account.

With Firecracker or gVisor, escaping the boundary lands the attacker in a sandboxed process with minimal privileges. They need a second, independent exploit just to reach the host. The first gets them nowhere.

gVisor is the strongest option where both layers work together. It provides a virtual kernel boundary comparable to Fargate’s hardware boundary, without blocking Bubblewrap inside.

If Fargate lifts its restrictions, hardware-enforced isolation becomes available without changing the agent layer. If we later choose to manage Kata Containers ourselves, we can add Firecracker underneath. gVisor is the right choice today. The others remain open.

Millions of Sandboxes

The inner boundary is between agents. One container per agent would provide filesystem, process, network, and resource isolation out of the box. But containers are slow to start, complex to maintain, and they impose an upper bound. Past a certain scale, creating more is impossible.

Kubernetes supports a maximum of 150,000 pods per cluster, across 5,000 nodes. That ceiling requires the best DevOps teams in the world. Practically, clusters become hard to maintain in the tens of thousands of pods. Every pod is an object in etcd. Every pod needs an IP address, a containerd-shim process, kubelet health monitoring. The orchestration overhead per pod compounds until the cluster hits a wall.

Bubblewrap and cgroups have no such constraint. They are kernel primitives. A single container can spawn millions of Bubblewrap sandboxes without the Kubernetes control plane knowing they exist. No etcd entries, no IP addresses, no API server watches.

Landlock seccomp cgroups Container Bubblewrap + cgroups
Filesystem ✅ Kernel blocks ✅ Isolated ✅ Isolated
Processes ✅ Own PID ns ✅ Own PID ns
Network ✅ Blocks syscalls ✅ Own net ns ✅ Own net ns
Resources ✅ CPU, memory, I/O ✅ cgroups ✅ cgroups
Startup Negligible Negligible Negligible ❌ ~5 seconds ✅ ~5 milliseconds
Memory per sandbox None None None ❌ ~10 MiB ✅ Negligible
Max per cluster No limit No limit No limit ❌ 150,000 ✅ No limit

In Autonomy, none of this complexity is visible to a developer. Each agent gets a sandboxed workspace through a tool definition: Workspace.

Each Workspace() spawns a Bubblewrap sandbox in about five milliseconds. The agent gets a mount namespace where only its workspace is visible, a PID namespace where it’s PID 1, and an isolated /tmp. seccomp filters its syscalls. cgroups cap its CPU, memory, and process count. The customer’s gVisor container intercepts every syscall underneath, reimplements Linux in Go, and exposes ~40 host syscalls instead of ~260.

By default, each workspace is isolated to a single agent. But agents that need to coordinate can also share a workspace. The filesystem then becomes a shared surface: one agent writes a plan, another refines it.

from autonomy import Agent, Model, Node, Zone, Workspace

# Discover runner machines and distribute work
runners = await Zone.nodes(node, filter="runner")
batches = split_list_into_n_parts(pull_requests, len(runners))

for runner, batch in zip(runners, batches):
    await runner.start_worker("reviewer", ReviewWorker())
    for pr in batch:
        await node.send("reviewer", pr, node=runner.name)

...

# Each worker spawns an agent with its own sandboxed workspace
class ReviewWorker:
    async def review(self, pr):
        await Agent.start(
            node=self.node,
            instructions="Review ...",
            model=Model("claude-opus-4-5"),
            tools=[Workspace()],
        )

The agent gets a terminal, read_file, write_file, edit_file, and a few other tools to work with its filesystem. It sees what looks like a full Linux environment. It runs shell commands, writes scripts, installs packages. But the boundaries are invisible and absolute.

A prompt injection that tells the agent to read another workspace fails: the path doesn’t exist in its namespace. A fork bomb hits the cgroup process limit and stops. An attempt to signal or kill another agent’s process fails: the process doesn’t exist in its namespace.

Sandboxing is one layer of control. I wrote about the other in Agent Identities. Both create gates, not guardrails: deterministic controls the agent cannot bypass.

This is how we create millions of sandboxes for swarms of agents.