Sandbox battery assembly
The other sandbox pages describe components. This page is where they become a deployment, and where the security decisions actually get made — none of the components can make them on your behalf.
Three are decided here and nowhere else, and each has a failure mode that looks like working software:
- The gate. A sandbox object without a decider is not a safe default; it is a suspension waiting to hang a turn, or a rubber stamp waiting to approve everything.
- The CSP, in a browser. No header means no outbound boundary at all, and the code looks identical either way.
- One policy, one handle.
SandboxManageris process-global, wearing a per-handle API. Two policies in one process is not a configuration, it is a conflict.
One policy per process
The safe deployment is one policy for the whole process. Multi-tenant agents with different policies want separate processes — and adopting a foreign sandbox is a degraded mode, not a second policy.
Node assembly
The Node path composes srtEnforcer({ policy }) with createSandbox({ policy, enforcer, ... }). SRT is beta and its internal behaviour is deliberately tracked by this battery; an SRT version bump requires a source re-read. Native Windows is not the supported path; use WSL2.
import { createSandbox, createRunShellCommandTool, createSandboxTools } from '@nhtio/adk/batteries/sandbox'
import { srtEnforcer } from '@nhtio/adk/batteries/sandbox/node'
import type { SandboxPolicy } from '@nhtio/adk/batteries/sandbox'
const policy: SandboxPolicy = {
filesystem: {
allowRead: ['/workspace'],
allowWrite: ['/workspace/out'],
denyRead: ['/workspace/.env'],
denyWrite: ['/workspace/.git/hooks'],
gitSafeDirectories: ['/workspace'],
},
network: { allowedDomains: [] },
}
const enforcer = await srtEnforcer({ policy })
const sandbox = await createSandbox({ policy, enforcer, strictMode: true })
const gate = async (_ctx: unknown, call: { tool: string; args: unknown }) => {
if (!await approvalService.ask(call.tool, call.args))
return { approved: false, note: 'operator declined' }
return { approved: true }
}
const shell = createRunShellCommandTool({
sandbox: enforcer,
policy,
translator: workspaceTranslator,
gate,
})
const workspace = await createSandboxTools({
handle: sandbox,
fileSystem: workspaceFileSystem,
pathTranslator: workspaceTranslator,
writeRoot: '/workspace/out',
trustTier: 'untrusted',
gate,
search: searchBackend,
})
registry.register([shell, ...workspace])The shell receives the enforcer because it owns streaming process execution; workspace tools receive the handle because they also need epoch lifetime, evaluator policy checks, and save semantics. Do not omit gitSafeDirectories: the resulting child can otherwise report dubious ownership.
Gates are mandatory
Every tool gates — open_file, list_directory and search_files included. Reads are in because reading .env is unrecoverable: a bad write can be reverted, but a disclosed secret is in the turn, the transcript and the provider's logs for good. And searching is worse than reading, because a model-supplied regex finds credentials without knowing where they live.
No reference gate ships. A default would be adopted unread as "the safe config", carrying an assumption about which subtrees are boring into a deployment it knows nothing about.
A blanket approval is a decision
Returning { approved: true } unconditionally is legal and supported. It is also this:
You have just handed the model an unsandboxed shell. That was a choice. Own it.
A gate is a real suspension — the turn stops until a decider answers, so a headless harness without one hangs indefinitely. Supply a real approval service, or reject before awaiting a decision, but do not leave the branch unwired.
Denials throw a narrated E_SANDBOX_REFUSED; pre-execution failures throw a narrated E_SANDBOX_*.
Browser deployment — CSP REQUIRED
CORS controls response readability, not request emission. mode: 'no-cors' fetches and image beacons still exfiltrate. The embedder MUST send a restrictive Content-Security-Policy header with connect-src, plus img-src and default-src constraints:
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.test; img-src 'self' data:;The battery cannot set this header. Treat missing CSP as deployment failure, not degraded feature. There is no browser no-op shim for Part A; SES is Part B, not a pretend OS boundary.
Persistence and handles
Every adapter renders spooled results with inline: false, so an artifact handle where text was expected is normal. Query open_file with artifact_grep. stage_file is in-memory until save_media; encoding the staged Media first throws E_READER_NOT_DESCRIBABLE.
Two different boundaries are in play, and knowing which one you are behind decides how much you trust it. The shell and search tools spawn binaries this library did not write, so the kernel enforces policy on them. The workspace tools run in-process library code, so the evaluator is the boundary — the same rules, read from SRT's own derived config. A denyRead therefore holds for open_file because the evaluator checks it, and for run_shell_command because the OS does. The first is defeated by an evaluator bug; the second is not. TOCTOU is residual on both.
There are deliberately no byte caps on results. The streaming shell returns complete output, and open_file streams a large file into the spool store rather than the prompt. If you want a ceiling for your deployment, put it in your own middleware, gate or inner executor — where it is a local decision rather than a library-wide default.
Two absences are deliberate: no read_file/edit_file, because staging makes mutation impossible to mistake for observation; and no git tool, because run_shell_command is the git tool, with the real binary and no wrapper to drift out of sync.
Sandboxed children inherit PATH and nothing else. This tool runs commands the model chose, and env is one of them — so anything inherited is readable back into the model's context, where no policy reaches it. Name what a command genuinely needs in envAllowList (it replaces the default, so include PATH if you still want binaries to resolve), and treat inheritHostEnv: true as what it is: handing the model every secret in the process. Full details in the shell tool's environment section.
If the deployment itself runs in an unprivileged container — a containerised CI job is the common case — the sandbox cannot start at all until you set enableWeakerNestedSandbox: true, and the failure gives you nothing to go on: exit 1, empty stdout, no diagnostics. See running inside a container before concluding the policy is at fault.
Live verification and failure map
A green CI run is NOT evidence the OS boundary works
The suites that prove real enforcement are gated on TEST_SANDBOX_LIVE, and when it is unset they skipIf themselves into a passing report while executing nothing. A skipped suite is indistinguishable from a green one in the summary.
They need a real profile — and on Linux, real bubblewrap/socat/ripgrep — so they cannot run in a default CI image. Run them yourself:
TEST_SANDBOX_LIVE=1 pnpm test:nodeVerify on both macOS and Linux; the platforms derive their rules differently, so one does not substitute for the other. If you cannot show an observed non-skipped run, you have not verified the OS boundary at all.
| Symptom | Meaning |
|---|---|
dubious ownership | gitSafeDirectories was not threaded into policy. |
E_READER_NOT_DESCRIBABLE | A staged Media was never saved. |
"[object Object]" | An adapter could not wrap the handler's arbitrary object result. |
| Hung turn | A gate had no decider. |
| Artifact handle | Uniform inline: false; query it, do not expect inline text. |