Architecture
ADR 0009: Hardening an agent's container
Status: accepted, 26 Sep 2026 · Workstream: computers · Spec: section 7.9
The problem
An agent's computer is a container, made and driven by core/computers/driver/docker.js through
box's restricted Docker API proxy (tecnativa/docker-socket-proxy, team/archive/work-journals/computers.md's
computers.docker). The proxy's job is to keep vyred off the raw Docker socket, which is
root-equivalent on the box: it allow-lists which Engine endpoints are reachable (containers,
images, exec, a POST, info) and denies others outright (networks, volumes).
What it does not do is read request bodies. A POST /containers/create the proxy lets through
can still ask for Privileged: true, a bind mount of /var/run/docker.sock, NetworkMode: host,
PidMode: host, extra Linux capabilities, or host devices, and the proxy has no opinion on any
of them, because none of that is which endpoint was called. Anything that can reach vyred's
computers.checkout (an agent, a bug, a bad label) can therefore ask docker.js to build a body
like that, and if docker.js ever did, the proxy would carry it straight to the Engine. A
container created that way is not sandboxed at all: it is root on the host.
So the boundary has to be in the one place that builds every create body: DockerDriver.create().
Decision
create() never reads a dangerous flag from anywhere (not CreateSpec, not config, not a
caller), because CreateSpec (core/computers/driver/index.js) has no field for any of them and
never will. Every one of the following is hard-coded, not merely defaulted:
Privileged: false, always. There is no parameter that can set it true.- No bind mounts. The only entry in
Mountsis the agent's own named volume at/home/agent; nothing else is ever mounted, so/var/run/docker.sockcan never appear in a create body. A test asserts this by scanning the whole body for the string, not just the one field, since a mistake could as easily land it somewhere else. - Never the host network or PID namespace.
create()throws before building a body if the resolved network is"host", from either the spec or the driver's own config default: defense in depth, since nothing in this codebase passes that today.PidMode: "container"is set explicitly rather than left to the Engine's default, so an agent's computer never sees the box's own process table. CapDrop: ["ALL"], unconditionally. Xvnc, Chrome (already--no-sandbox, since the container has no setuid sandbox helper), AT-SPI and xdotool all run as the unprivilegedagentuser and need no Linux capability.computers.capAddstill exists for a box that finds otherwise, so this file never grows a capability list of its own.Devices: [], always. An agent's computer has no reason to see anything under the box's/dev.ReadonlyRootfs: true, with a smallTmpfsfor/tmp,/runand/var/run, where Xvnc's X11 socket and lock live, and wheredbus-launchputs the session bus AT-SPI needs. Everything that has to persist (the Chrome profile,.vnc,.fluxbox, the entrypoint's own log files) already lives under/home/agent, which is the one volume mount and is writable regardless of the root filesystem's mode.- No custom seccomp profile.
SecurityOptcarriesno-new-privilegesand nothing that namesseccomp=, so the Engine applies its own default profile, which already denies the syscalls that matter here (mount,ptraceoutside the container, the kernel keyring, and more). A hand-written profile was considered and set aside: without a real container to test it against, a mistake in a custom profile is more likely to silently break Xvnc, xdotool or AT-SPI than to buy meaningfully more safety over the default. - Labels. Every container and its volume carry a fixed
run.vyre: "1", independent of whatevercomputers.labelPrefixis configured to, plus the existing<prefix>.managed=true/<prefix>.computer=<agent>pair this file already used to decide which containers are its own (seedocker.js's header comment). Box's compose stack expectscomputers.labelPrefixset torun.vyre.computersat deploy time, which this ADR does not change the code default of ("vyre", used by every existing test): that is a config value on the box, not a hard-coded one here.
Consequences
docker.test.jsproves each of the above against a fake Engine: noPrivileged, no bind mount, nodocker.sockstring anywhere in a create body,NetworkMode/PidModenever"host"(andcreate()throwing if asked),CapDrop: ["ALL"]andDevices: []always present,ReadonlyRootfs: truewith a non-emptyTmpfs, and both label sets on every container and volume.- A read-only root is untested against a real container (there is no Linux Docker host in this
worktree yet). The
Tmpfsset is a read ofentrypoint.sh's own writes, not a run of it; the box's first real container is also the first real check of whether that list is complete. computers.capAddremains the one escape hatch, and stays config, not code: a box that finds a capability genuinely needed adds exactly that one, never a default list.- Known gap (security, 26 Sep), being closed: none of the above held against a caller that
reached the restricted proxy directly instead of through
DockerDriver.create(). A Claude session's own Bash shares vyred's container and network namespace, so a rawcurltodocker-api:2375could ask forPrivileged: trueand a host bind mount, and the proxy (which filters endpoints, not bodies) would forward it: root on the host. A text filter in the harness floor denies the obvious strings (docker-api,:2375,:2376), which helps but is not the real fix. The real fix, per security and box: box replaces the endpoint-filtering proxy with a small one it owns, holdingdocker.sockitself, that importsdriver/policy.js'sallowCreate(body, config)andallowExec(labels, cmd)and calls them on every request: the exact shape this file builds, checked again at the one point that matters regardless of who is asking, withconfig(network, image, capAdd) always the box's own, never the request's.policy.jsandpolicy.test.jsare built from the same fixture asdocker.test.js, so the two can never quietly drift apart. With box's separate sessions container for Claude's own work, Claude will not reach the proxy at all;policy.jsholds even so.- Security's review of the first
policy.js(26 Sep) found five more holes a direct caller could still use, all closed in the same file:Sourcecould name an existing volume, whose labels Docker ignores once it already exists (vyred's own home or another agent's, mounted just by naming it; closed by requiring the exact derived name, though the proxy must still inspect an existing volume of that name itself before reusing it, which no pure function can do);NetworkModeaccepted anything but"host", includingcontainer:<vyred>;CapAddaccepted any list;Imageaccepted any string. One was left residual: an exec with an unrestrictedcmdstill reaches whichever agent's computer the caller names, and labels cannot tell one agent's computer apart from another's, only from everything else on the box. - A second review (26 Sep) found
computerLabelslet the caller choose its own label prefix (and with it, the volumeSourcederived from that prefix), and that two*.managed/*.computerpairs in one body resolved ambiguously via.find().allowCreatenow requirescomputers.labelPrefixin its config and refuses any label pair that isn't exactly that one, body-wide. This surfaced the same class of residual on the create side that exec already had: a caller naming a different agent still gets that agent's real, already-existing computer and home volume back, since the check only asks "is this a computer" and "does the claim match what is really there," never "is the caller allowed to act as this agent." Closing both residuals needs the same thing: the caller is vyred and nothing else, true once Claude's own sessions have their own container, off this network entirely.
- Security's review of the first