Skip to main content
A sandbox is an execution session a plugin requests when it needs to run code away from the server process. A session carries a workspace volume, exec (both one-shot and interactive), file transfer in and out, and a TTL reap deadline. How strongly a session is isolated depends on the provider. It is generic and optional, exactly like a backend: a deployment without a provider carries nothing for it.

The sandbox seam

The Sandbox ABC is the contract. A consumer acquires a session through the one raising chokepoint tai42_app.sandboxes.require_sandbox() — every door that runs code flows through it, so a single seam owns the policy. An external plugin registers a provider through tai42_app.sandboxes.register_sandbox when the manifest names the sandbox_module. With no provider installed the kind row reads off, and any consumer that calls require_sandbox() gets a loud typed SandboxUnavailableError naming TAI_MCP_SANDBOX and the sandbox_module — surfaced through that consumer’s own error path, never a silent no-op.

Two providers, one execution mode

The sandbox slot is a scalar single holder: a deployment installs exactly one provider, and which one is installed is the execution mode. The consuming agents keep one code path either way.
  • sandbox-docker runs each session as an isolated container.
  • sandbox-local runs the code directly on the host — the workspace is a host directory, with no isolation.
Isolation is a contract Literal, SandboxIsolation = "none" | "container" | "vm", with floor semantics (none < container < vm). The operator’s sandbox_isolation sets the floor (default container); a session spec’s isolation field requests at least a level (None inherits the floor); and a provider must give at least the effective level or reject the spec. sandbox-docker satisfies none and container and rejects vm; sandbox-local satisfies none only and rejects container and vm. Put plainly: sandbox-local is the no-isolation, direct-on-the-host provider. See operate/sandbox for installing and wiring a provider.

Running and stopping a command

A session runs a command two ways. A one-shot exec runs it to its end and returns its exit code, stdout and stderr. An interactive exec_start returns a handle: its output streams the command’s stdout and stderr and ends with one exit item, and the handle writes the command’s stdin, closes it, and kills the command. Every provider gives the handle the same lifetime:
  • Stdin. A one-shot exec’s stdin is fed while its output is read, so a command that echoes its input (cat, a filter) takes a stdin larger than any pipe buffer. A command that exits, or closes its stdin, before reading all of it still returns its result, and the input it did not read is dropped (on sandbox-local, unless a process the command started still holds its stdin; see below). Any other failed stdin write raises one SandboxError; on a one-shot exec it also stops the command. An interactive write after the stdin is closed or the command has exited raises SandboxError as well.
  • The deadline. Both exec and exec_start require timeout_seconds, fixed as the command starts. A command still running at its deadline is killed then, whether or not anything reads its output, and the call (or the next read of an interactive output) raises SandboxExecTimeoutError. The error carries the stdout and stderr byte counts, never the content: when the output ended with the kill, they count every byte the command gave, read or not; when a process the command started still held the output after the kill, they count the output received up to the stop, and a note on the error says so. The output an interactive command gave but nobody read is not yielded once the timeout is raised. A command that ended within its deadline returns its full output, however late or slowly it is read.
  • A kill after the exit. kill() may be called at any time and more than once. Once the command has exited, a kill is idempotent and never raises, and the exit status output reports stays the command’s own.
  • The end of a killed command’s output. Once kill() has returned, the output ends promptly and raises no SandboxExecTimeoutError, even past its timeout_seconds and even while a process that left the command’s group still holds the output open. The output the provider had received before the kill, read or not, is still yielded first, in order: every whole unit of output (a chunk, a frame, a message) the provider’s own process had already taken in from the command’s transport. Output not yet taken in at the kill, still on its way or held by the operating system or the command’s runtime, and the part of a unit still arriving, may end with the command. The output then ends with exactly one exit item carrying the command’s exit status; a provider that cannot read that status raises SandboxError instead, and never invents one.
  • A failure of the command itself. A provider that reports a failure of the command itself, such as stdin input it accepted that the command never got, ends the output with a SandboxError in place of the exit item. A kill never clears that failure: the output still ends with it.
  • What the handle holds. A handle may keep provider resources until kill() has returned or its session is destroyed, and the command’s own exit releases none of them. A consumer done with a handle calls kill().
  • Processes the command left running. Whether a kill also stops processes the command started and left running, before or after the command’s own exit, is each provider’s own behaviour, stated on its page. The contract promises the command’s own end only.
Both providers stop a running command’s whole process group on a kill, a timeout, a one-shot call that fails or is cancelled, or an interactive start that is cancelled: the command and every process it started that is still in its group. Each keeps a small shell process in the command’s group, so the group keeps its id after the command has exited and a stop never signals a group id since given to other processes. A process that left the group (a setsid daemon, for example) is not stopped by either. After the command’s exit they differ:
  • sandbox-local keeps each interactive command’s process group until kill() or the session’s teardown, so a kill after the exit still stops what the command left in its group. A completed one-shot call lets its group go, and what it left running keeps running. A session’s teardown stops only its interactive commands: a one-shot call still running when its session is torn down runs on until it ends or reaches its timeout. A command that exits while a process it started still holds its stdin, with input not yet written, fails: a one-shot call raises a SandboxError, and an interactive command’s output ends with one in place of its exit.
  • sandbox-docker runs each command under a small shell wrapper that holds its group while the command runs or a process it started still holds the command’s output. Until the wrapper ends, a kill stops every process still in the group, even after the command’s own exit; once it has ended, a kill finds nothing to stop and what the command left running keeps running. A kill has a fixed limit of 5 seconds: one that does not finish in time raises SandboxError, noted with what the stop had reported by then: its own error line, the part of it that arrived, or the engine’s refusal of the stop. A command that ended within its deadline is read to its end within the Docker engine’s own limit: the engine closes a command’s output stream about 2 seconds after the command exits, so when more than roughly 1 MiB of output is still pending in the engine, a reader that pauses for longer loses it, and the provider cannot see that loss. Its session image must provide sh, the setsid, mkfifo, mv, rm, cat, kill, readlink, ls and sleep commands (sleep taking a fractional time), /proc, and a writable /tmp; an image without a shell cannot run any command. The session container runs the engine’s init as its main process, which reaps what a command leaves behind.
Each provider’s page covers its details. A provider proves this behaviour with the conformance suite in tai42_kit.sandbox: its tests hand a live provider to run_sandbox_conformance, and an empty return certifies it. The suite checks the session lifecycle, the spec.env credential channel, a one-shot stdin far larger than any buffer echoed back whole by cat, a one-shot head -c 1 of that stdin returning within its timeout, the interactive seam (a repeated kill after the exit that never raises, a write after the exit that raises a typed SandboxError, and the output received before a kill() still read after it), late and slow reads of a command that ended within its deadline, its output larger than the provider’s transport buffers included, a kill past the deadline, the deadline’s kill of a command nothing reads and of one that closed its output, the byte counts its timeout carries, the timeout of a command whose output a process it started still holds, the stop of what a command left in its process group past its own exit, at its deadline and by kill(), file transfer, workspace-path resolution, the consumer-label round trip, the TTL reap, and the loud rejection of a spec the provider cannot honor.

Durability

Durability is one tier on the session spec: durability: "ephemeral" | "persistent". An ephemeral session’s workspace dies with the session. A persistent session binds a durable workspace volume (tai-sbx-<workspace_key>) provisioned at the distribution layer — a PVC on Kubernetes, a named volume in Compose — that survives the session and its reap. There is no platform “git tier”: taking data off-box for backup is a consumer’s own action with its own credentials. The deployment side of durability lives on operate/sandbox.

Security is configuration

A session is trusted with exactly the credentials and the reach the operator grants it. Security is the per-consumer credentials list plus four platform settings, all enforced once at the session-create chokepoint every consumer flows through — both agents and sandbox_exec — so no door bypasses the policy:
Under the default open egress, any data a session can read is exfiltratable — so a session is trusted with exactly the credentials and reach the operator grants it. Under sandbox-local there is no isolation at all: the code runs on the host. Tighten sandbox_egress and choose the provider deliberately.

Consumers

The sandbox’s consumers in this release live in the base tool and the ready-made agents package:
  • the sandbox_exec code tool (below), a generic base tool that runs a command in a session;
  • ready-made agents that reach the same seam two ways — one drives an external code runtime inside a session, the other’s durable scratch filesystem is a sandbox backend over the same seam. See the agents plugin and the guide.
The generic seam enables wider consumers not shipped here: any agent execution backend, a generic code-execution tool beyond sandbox_exec, isolation for stdio MCP servers, and skills that bundle executable scripts.

Running code as a named tool (sandbox_exec)

sandbox_exec is a generic base tool that ships in the skeleton. It runs a command in a sandbox session acquired through require_sandbox(), with a durable workspace when the preset asks for one. It is domain-agnostic: a delivery turns concrete named tools into presets over it. Two generic preset mechanisms make that possible, both consulted at the shared preset chokepoint so every authoring door is covered:
  • A declarable per-tool input schema — a preset over a schema-accepting base tool declares the exposed named tool’s own typed input contract, and the bind kernel routes the caller’s validated object into the base tool’s invocation. Most base tools accept none — their schema is fixed — but sandbox_exec does. See presets.
  • An admin registration tier — sandbox_exec declares the admin fenced tier, which fences both authoring a preset over it (the shared preset chokepoint, every authoring door: HTTP, MCP, CLI, manifest-apply) and running it (the shared tool-run chokepoint, every run door), never one route. A preset over it inherits the fence at run time. Registering and running a runnable code tool is an admin act. See fenced tools and presets.
The code a sandbox_exec preset runs holds the credentials the operator injects into the session and, under the default open egress, reaches the internet. That is deliberate — the delivery’s own code is trusted with its own credentials — and it is why authoring one is admin-fenced.Contrast this with the adapter-proxied posture an agent gets: an agent’s platform tools run outside the session under the run’s own identity, with no credential or endpoint exposed to the code, whereas sandbox_exec code runs inside the session with the injected credentials in hand. Two deliberate trust models over one sandbox seam.

Preset network portability across providers

A sandbox_exec preset with an explicit network="none" (or "internal") runs under sandbox-docker but is loudly rejected under sandbox-local, which honors network="egress" only and raises SandboxSpecRejectedError on none or internal. An unset network defaults to "egress" (the base tool’s schema default) and already runs under sandbox-local — so only a preset that explicitly pins none or internal must be changed for a sandbox-local deployment. The mismatch is a loud rejection at session create, never a silent failure.

See also