Sandboxing Agents

In Engineering with Agent Teams I describe a workflow in which independent coding agents work from the same specification document to create separate implementation code and automated tests, with any disagreement between them as a signal. ateam is the tool I built to run that workflow. This note covers one part of its design: how it sandboxes the agents it dispatches.

For the workflow to function, agent independence must be a property of the environment, not a constraint you only ask an agent to respect. If two agents share a filesystem, one can read the other’s output before producing its own. Your instruction says they must not, but nothing in that instruction actually prevents it. Structural environmental enforcement is the only kind that holds.

What each agent gets Link to heading

ateam dispatches role agents as subprocesses: either Claude Code (for Anthropic models) or OpenCode (for OpenAI models). When you run ateam dispatch implement or ateam dispatch harness, the tool compiles a work order from the specification, spawns the agent process with that work order as its context, and waits for it to finish. The agent runs, produces output, and exits. ateam reads the result.

Each dispatch runs inside a sandbox constructed with bubblewrap. The sandbox is not shared between agents: implementation and harness each get their own, constructed independently, and torn down when the agent exits.

Each agent gets:

  • A throwaway home directory, created on a tmpfs. Both Claude Code and OpenCode store session state and configuration under the home directory (~/.claude and ~/.local/share/opencode). A fresh tmpfs home means state written during a dispatch does not carry over to later dispatches or become visible to a parallel agent.
  • Two persistent writable directories: the workspace, a fresh clone of the repository the agent will modify, and the output directory where it deposits its report. The original repository is not mounted inside the sandbox. The agent’s only view of the codebase is through the workspace clone. Both are created per dispatch and mounted only into that dispatch.

Laid out as a tree:

/
├── usr/  bin/  lib/  etc/     (read-only, host system runtime)
├── /usr/local/bin/claude      (read-only, for Claude Code dispatches)
├── /usr/local/bin/opencode    (read-only, for OpenCode dispatches)
├── /home/ateam-sandbox/       (tmpfs, throwaway home)
│   ├── .claude/               (Claude Code session state)
│   │   └── .credentials.json  (read-only bind, API credentials)
│   └── .local/share/opencode/ (OpenCode session state)
├── /tmp/                      (tmpfs)
├── /run/ateam-tunnel/         (tmpfs, relay sockets for tunnel mode)
├── /path/to/workspace/        (read-write, fresh repository clone)
└── /path/to/out/              (read-write, report output)

The sandbox starts with the host environment stripped. Only the variables the agent needs are passed in explicitly.

Network tunnels Link to heading

Agents need to reach their model provider. ateam gives each agent a controlled network path to exactly the endpoints it needs, and nothing else.

The mechanism has two parts: a trusted supervisor that runs inside the sandbox before the agent starts, and host-side relay workers that listen on Unix domain sockets.

Before launch, ateam starts a relay worker thread for each allowed endpoint. Each worker listens on a Unix domain socket in a private directory on the host. Those socket files are bind-mounted read-only into the sandbox at /run/ateam-tunnel/. A generated /etc/hosts mapping each API hostname to a dedicated loopback address is also mounted in.

The bwrap invocation does not run the agent directly, it runs the supervisor, a small executable mounted into /run/ateam-tunnel/. The supervisor runs with the capabilities needed to call unshare(CLONE_NEWNET) and create a fresh private network namespace, verifies that the new namespace is owned by the current user namespace, brings loopback up, and binds a TCP listener on each allocated loopback address (127.77.0.x:443). It probes each Unix domain socket to confirm the host relay is ready, then drops all capabilities and sets no_new_privs before forking the agent.

The agent starts in a network namespace it did not create, with only loopback as its interface. When it connects to api.anthropic.com, the name resolves to 127.77.0.2 via the sandbox’s /etc/hosts, the TCP connection is accepted by the supervisor’s listener in the same namespace, and the supervisor relays it through the Unix domain socket to the host worker. The host worker connects to the real upstream endpoint from outside the sandbox’s network namespace entirely.

flowchart LR subgraph sandbox["bwrap sandbox (private network namespace)"] agent["agent"] sup["supervisor\n127.77.0.2:443"] end relay["host relay\n(Unix domain socket)"] api["model API endpoint"] agent -- "TCP to 127.77.0.2\n(api.anthropic.com → 127.77.0.2\nvia /etc/hosts)" --> sup sup -- "/run/ateam-tunnel/\nroute-0.sock" --> relay relay -- "TLS end-to-end\n(opaque bytes)" --> api

The byte stream is end-to-end TLS. The agent sends the real hostname as the SNI and verifies the certificate against it. Neither the supervisor relay nor the host relay terminates TLS: both copy opaque bytes. The real connection to the upstream endpoint is made from the host.

Each endpoint gets its own loopback address rather than sharing 127.0.0.1, so each relay selects the right upstream from which socket it accepted on, without reading any bytes.

The enforcement Link to heading

The sandbox enforces what the workflow promises: that independence is structural, not instructed. A fresh tmpfs home, a per-dispatch workspace clone, and a network path scoped to one endpoint each take one class of contamination off the table. The design does not rely on agent cooperation for filesystem separation or network routing. Bubblewrap and the Linux kernel enforce both boundaries.

Views expressed here are my own.