ADR 051 — Coder containment is a declared property, not a deployment detail¶
Status¶
Accepted. Does not alter ADR 014, which remains the record of snodo's threat model.
Context¶
snodo once carried a subsystem called "sandbox": a package in snodo-core, a
snodo sandbox sub-app, a snodo run --sandbox flag and a worker image. It
put a container around snodo itself and ran the whole task inside. It was
removed, because ADR 014 places sandboxing and containerisation out of scope
under the trusted-repository threat model and rejects naming that implies a
posture the tool does not hold, and because the implementation did not do
what its name claimed: it mounted the workspace read-write, so the process it
contained wrote straight into the operator's checkout; it fell back to local
execution with a warning when Docker was unavailable; and it disabled the
network while injecting provider credentials, a combination under which the
default coder could not reach any provider at all. No protocol in any project
asked for it.
Separately, the opencode coder grew the ability to run its server in a
container and was driven end to end against a remote Docker daemon. That work
made a distinction visible that the old subsystem never drew. Where the
container runs is not interesting: an operator who wants the work to happen on
another machine can run snodo on that machine, which snodo already supports
locally and remotely. What is interesting is what the coder can touch. A
container that receives a copy of the task worktree, is written to only by the
coder, and is read back as an archive has a property the host path does not:
the coder's writes are confined to that copy, and the only thing that crosses
back is the difference it made. A bind mount has no such property — it is the
operator's own directory under another name.
Observed on a real project: the read-back path compared archive entries
against worktree entries without accounting for the root directory Docker
names the archive after. Nothing matched, so every file in the task worktree
was judged absent from the container and deleted, including the .git that
made it a repository, and the archive was unpacked one level down. The coder's
work was correct and invisible; the task halted no_file_operations, a
verdict about the task that was really a fault in the transfer. The same
function is reached with the operator's own working tree when isolation is
off. Containment is therefore not only a property worth having — it is a
property that has to be stated, so that the code implementing it can be held
to it.
Decision¶
Containment is declared on the coder definition, as sandboxed, defaulting to
false. A protocol that wants its coder contained says so; a protocol that says
nothing gets the behaviour it has today.
sandboxed: true means exactly this: the coder is given a copy of the task
workspace, it runs against that copy and not against the operator's tree, the
copy is discarded when the task ends, and the only thing taken from it is the
changed content read back into the task worktree. It does not mean that snodo
defends against a hostile protocol, a hostile repository or a hostile coder,
and it must not be described as though it did. ADR 014 stands: protocol files
and project tooling are trusted input, and nothing here changes that.
How a contained coder is reached is the adapter's business, not the
operator's. The opencode coder talks HTTP to a server in the container
because the image serves one; a coder built on a host CLI would run that CLI
inside the container instead. Neither belongs in the model string, in a
protocol, or in a setting. Likewise the daemon's location: DOCKER_HOST
already says where it is, and a remote daemon is the same declaration as a
local one.
Containment is meaningful only for a coder that executes against a
filesystem. A coder that is an API client writes nothing except the artifact
snodo itself applies, so there is nothing to contain. An adapter that cannot
honour sandboxed says so through the path that already reports a setting a
coder does not read, at the point the run is configured — not by silently
ignoring it, and not by quietly running uncontained.
A coder asked to run contained that cannot be — no daemon, no image, a
workspace that cannot be transferred — halts environment_error. That is an
existing halt and the correct one: it is a statement about the run, not a
verdict about the task. No new state, severity, halt type or task status is
introduced by this decision.
Consequences¶
Whether a task's coder ran contained becomes a fact about that task, declared before the run and therefore recordable with it, rather than an inference from which coder happened to be configured. A protocol that declares it gets a refusal when the environment cannot provide it, instead of an uncontained run that looks identical in the log.
The transfer becomes load-bearing rather than incidental. Copying in and
reading back is the mechanism that makes the claim true, so a bind mount is
not an optimisation of it — it is a different thing that does not satisfy the
declaration, and an adapter may not substitute one for the other while
reporting sandboxed. Code that deletes from the worktree during read-back
carries the weight of that claim: what cannot be established about the
contained side is a reason to refuse, never a reason to delete.
Coders that cannot be contained stay visibly uncontained. The operator learns this from the same warning that already tells them which settings a coder reads, rather than from a task that quietly ran somewhere they did not expect.
Alternatives¶
Spelling containment into the model address — @docker/opencode/... — was
rejected. It puts a deployment fact into an identifier for a model, gives the
same coder and model two addresses that differ only in how they are run, and
does not generalise: there is no @docker/litellm worth having. It also
cannot express a remote daemon, so it would read as "local container" while
being neither.
Treating the axis as an execution host — host, local daemon, remote daemon — was rejected. It describes placement, and placement is not the property being bought; an operator who wants the work elsewhere runs snodo elsewhere. It also cannot express ephemerality, which is the part that matters.
Reviving the removed sandbox subsystem was rejected. Its boundary was drawn around snodo rather than around the coder, which is the wrong object: the engine, the validators and the audit log are snodo's own and are not what needs confining.
A global setting rather than a coder property was rejected. Containment is not uniformly meaningful — it is real for a coder that edits a filesystem and vacuous for one that returns an artifact over an API — and a setting that means nothing for half its subjects teaches operators to ignore it.