---
url: 'https://adk.nht.io/assembly/batteries-sandbox.md'
description: >-
  Assemble the gated shell, workspace tools, SRT policy boundary, media
  executor, and SES evaluator.
---

# Sandbox battery assembly

## LLM summary — assembly

* Construct one [`SandboxPolicy`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/interfaces/SandboxPolicy), one SRT [`SandboxPolicyEnforcer`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/interfaces/SandboxPolicyEnforcer), and [`createSandbox`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createSandbox); pass the handle to gated tools.
* Gates are mandatory for every tool, including reads. No default gate ships.
* Add [`createRunShellCommandTool`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createRunShellCommandTool), [`createSandboxTools`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/tools/functions/createSandboxTools), and optionally [`createEvaluateJavascriptTool`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createEvaluateJavascriptTool).
* `TEST_SANDBOX_LIVE=1` enables live OS suites; unset suites skip silently and pass without executing.

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.** `SandboxManager` is **process-global**, wearing a per-handle API. Two
  policies in one process is not a configuration, it is a conflict.

::: tip 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](../batteries/sandbox/#adopting-a-foreign-sandbox-network-disabled-does-not-mean-what-it-looks-like),
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.

```ts
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.

::: danger 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:

```http
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](../batteries/sandbox/run-command.md#the-child-s-environment).

**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](../batteries/sandbox/run-command.md#running-inside-a-container) before
concluding the policy is at fault.

## Live verification and failure map

::: warning 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:

```bash
TEST_SANDBOX_LIVE=1 pnpm test:node
```

Verify 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. |

## Components

* [Sandbox overview](../batteries/sandbox/)
* [Shell tool](../batteries/sandbox/run-command)
* [Workspace tools](../batteries/sandbox/workspace-tools)
* [Media executor](../batteries/sandbox/media-executor)
* [JavaScript evaluator](../batteries/sandbox/javascript)
