Architecture
The sandbox boundary
A Sandbox is an isolated machine belonging to one NPC: a filesystem that persists, a terminal, a browser, and the ability to install what it needs. Every NPC gets its own, so NPCs work in parallel without colliding.
This is the strongest isolation boundary in AgentsVille, and the page below is as much about what it does not cover as what it does.
Why the API process has no Docker socket
Something has to create those containers. The obvious implementation is to mount
/var/run/docker.sock into the API container and call the Docker API from the
route handler. That is the thing this design exists to refuse.
Access to the Docker socket is equivalent to root on the host: anything that can reach it can start a privileged container with the host filesystem mounted. The API process holds the Provider keys, the database connection and the session secret, it runs stdio MCP Servers, and it executes model-authored Tool calls. Putting host-root in that process means one prompt injection, one hostile MCP Server or one confused model is a full host compromise rather than a lost container (ADR 0006).
So provisioning lives in a separate service.
What the broker is
sandbox-broker is the only thing in AgentsVille that holds the Docker socket. It
exposes a narrow HTTP API — ensure, status, exec, read file, write file, list,
stop, destroy — and nothing else. It is attached only to the internal Compose
network, it publishes no ports, and the reverse proxy has no route to it.
It still requires a bearer token, of at least sixteen characters, and refuses to start without one. "Nothing else can reach it" is a property of a deployment rather than of the code, so the token is there as well.
Three details of its behaviour are worth knowing:
ensureis idempotent and serialised per NPC, so two turns starting at once cannot both create the same container.execstreams output as NDJSON, one chunk per line, rather than as SSE. This hop is service-to-service on an internal network and needs none of what SSE adds; the API translates to SSE for the Player-facing stream.- Sandboxes are stopped by an idle reaper. Without it, a Village with thirty NPCs is thirty idle containers and thirty volumes nobody remembers creating.
The driver sits behind one SandboxDriver interface, implemented twice: by the
Docker driver that runs inside the broker, and by the HTTP client the API uses to
reach it. A cloud driver — a microVM host, for instance — is a third
implementation and no caller changes. That is the whole reason the interface
exists.
The Docker driver shells out to the docker CLI rather than speaking the Engine
API over the socket. Exec output on the raw API arrives multiplexed in an
eight-byte-framed stream that has to be demultiplexed by hand, and the CLI
already handles that and every other detail of the protocol. It costs a process
per operation and a docker binary in the broker image; it saves a dependency
and a class of protocol bugs. The ceiling is written down where it is taken.
How a Sandbox is confined
Each NPC gets one container, started with every one of these:
/work is the only thing that survives. It is a named volume per NPC, so a
Sandbox that is stopped and started again — or replaced because its container
would not boot — keeps its files. Destroying a Sandbox removes the volume, and
that is not reversible.
Paths the model supplies are checked rather than normalised. An absolute path
outside /work, or any path containing a .. segment, is refused. A caller that
meant something reasonable can say it a different way, and one that did not
should not be guessed at.
A Sandbox has no route to the database, no route to the API, and no route to the broker. It receives commands; it initiates nothing.
Egress
Network egress is off by default. A Sandbox sits on an internal Docker network with no route out.
An Operator — not a Player — may grant one NPC egress, which attaches that NPC's container to a network that does have one. The API refuses the request from anyone who is not the Operator, and says why: a Sandbox with a browser and internet access is an exfiltration path for anything that Sandbox can read, and that trade belongs to a person rather than to a default. Changing the setting replaces the container, because a change of egress is a change of network.
The same principle covers everything that widens blast radius. Sandbox egress, stdio MCP, a new Connector scope — each is an explicit Operator action, never a default.
See Enabling sandboxes for how to turn any of this on.
What this does not defend against
Containers share a kernel. This boundary contains a careless or manipulated model; it does not contain a determined attacker with a kernel exploit. A container escape is a container escape, and nobody should read "Sandbox" as "safe to run hostile code".
That limit is why the driver interface exists at all. An Operator who needs a stronger boundary can put a microVM-backed driver behind it without any caller changing.
Two smaller caveats are worth stating alongside it. Stdio MCP Servers still run on the host as the API process rather than inside the NPC's Sandbox, which is why registering one is restricted to the Operator. And the private-network guard that protects the API process resolves a name and then connects, so a name that answers twice can pass the check and point elsewhere when the socket opens; the window is one DNS TTL. Both are in Security posture.
Day-to-day use is covered in Using the sandbox.