Skip to content
13 min read · 2,581 words

Sandbox Battery

This library shipped twenty-three tool categories — vector databases, browser automation, media pipelines — and could not run ls. That gap was deliberate, not an oversight: an ungated shell is the most dangerous thing you can hand a language model. Give an agent raw shell access and one prompt injection turns your host into an exfiltration pipeline. Give it soft path-sanitisation inside your own Node process instead, and a symlink or a ../ spelling you did not think of walks straight through the guardrail. Closing that gap properly means answering two questions that only look like one: what can this process reach? and what can this code reach? This battery answers both, separately, and is honest about where each answer stops.

The thesis

1. Two boundaries, deliberately unbundled. What can this process reach? is answered by OS policy — Anthropic's @anthropic-ai/sandbox-runtime, which compiles your rules into a Seatbelt profile on macOS, bubblewrap+seccomp on Linux, WFP on Windows, and no container anywhere. What can this code reach? is answered by a hardened SES Compartment with capabilities you enumerate by hand. A container appears to solve both at once and solves neither cleanly: it brings a daemon, an image, and seconds of startup, while doing nothing about a snippet reaching process.env inside the sandbox it just built for you. Keeping the two apart means the kernel restricts syscalls, SES restricts evaluation, and you can reason about which one failed. Neither substitutes for the other, and a deployment that conflates them has one boundary where it believes it has two.

2. A refusal the model can read, not an errno it can only guess at. When the OS denies something, the child sees Permission denied or a connection reset — indistinguishable from a typo. So the model concludes its command was wrong and retries variations that can never work, flailing against a wall it cannot see, burning your tokens on syntax. The sandbox separately records what actually happened: tried to connect to api.example.com, which is not on the allowlist. Surfacing that is the whole value-add — the difference between a boundary and a mystery. Read the shell tool's page before you rely on it: on macOS today that record comes back empty.

3. Every tool is gated, reads included — because a read is the unrecoverable one. Gating writes and trusting reads is the intuitive split and the wrong one. A bad write can be reverted; a read of .env cannot, because by the time you notice, the secret is in the turn, the transcript, and your provider's logs. search_files is worse than a read: it is a secret-discovery primitive that finds credentials without knowing where they live. So the gate is mandatory on all nine tools and no reference implementation ships — a default gate would be copied unread as "the safe config", carrying an arbitrary assumption about which subtrees are boring.

Assemble one policy and one handle

The policy is intentionally boring: reads are deny-then-allow, writes are allow-only, and network is an explicit allowlist unless disabled. gitSafeDirectories must be threaded into the policy. Omitting it is the cause of Git's dubious ownership failure inside an otherwise working shell.

ts
import { createSandbox } 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', '/workspace/.git/hooks'],
    denyWrite: ['/workspace/.git/hooks'],
    gitSafeDirectories: ['/workspace'],
  },
  network: { allowedDomains: ['api.example.test'] },
}
const enforcer = await srtEnforcer({ policy })
const sandbox = await createSandbox({ policy, enforcer, strictMode: true })
// Pass `sandbox` to the tools below. Dispose it when the owning turn/session ends.
await sandbox.narrow({ ...policy, network: { allowedDomains: [] } })

createSandbox is process-global. A second handle must be no wider than the admitted baseline; subsequent SRT drift is detected, not magically prevented. Set allowUnsandboxedFallback only as a loud, audited product choice. Native Windows is not the supported SRT path; use WSL2.

Gates are the security control

Every tool has a gate, reads included. A read of .env is unrecoverable exfiltration, and search_files is a secret-discovery primitive. Gate the operation before path translation or I/O. The gate is a real suspension: a headless harness with no decider HANGS THE TURN — indefinitely, with no timeout of its own and nothing in the logs to say why.

There is deliberately no reference gate implementation. A shipped default gets adopted unread as “the safe config.” Here is a real decider: deny by default, ask a human/policy service, and return an explicit verdict.

ts
import type { DispatchContext } from '@nhtio/adk'

type Verdict = { approved: true } | { approved: false; note: string }
const gate = async (
  _ctx: DispatchContext,
  call: { tool: string; args: unknown },
): Promise<Verdict> => {
  const approved = await approvalService.ask(call.tool, call.args) // your decider
  return approved ? { approved: true } : { approved: false, note: 'operator declined' }
}

// The blanket example is intentionally frightening and never a default:
const blanketApprove = async () => {
  // You have just handed the model an unsandboxed shell. That was a choice. Own it.
}

A gate denial is narrated as E_SANDBOX_REFUSED; operational failures throw narrated E_SANDBOX_* errors so the model receives actionable text. A command that did run returns its artifact even for non-zero exit, timeout, violation, or post-spawn I/O failure: its output is the signal. Returning an error string would turn it into a handle on most adapters.

What the model actually reads: the prefix is not ours

Failures throw, where the usual ADK convention is to return an Error: … string. The reason is mechanical: a returned string is spooled, and a spooled inline: false result renders as an artifact HANDLE, so the model would have to spend a whole call querying an artifact to read one line of refusal. A throw becomes inline text on every adapter instead.

The consequence is that the model does not read your narration alone. Tool.executor() wraps a handler throw in E_TOOL_DOWNSTREAM_ERROR, whose message is a fixed sentence, and the adapter concatenates that with the immediate cause. So the model reads exactly:

text
The tool handler threw an error during execution. <narration>

That prefix is core behaviour and not configurable. Two rules follow, and both are easy to get wrong:

  • Throw E_SANDBOX_* as the DIRECT cause. Only the immediate cause's message is appended, so one extra wrapping layer drops your narration out of what the model sees entirely.
  • Never prefix the narrator template. 'Sandbox refused: %s' would read as a second prefix on top of the core's. The exception templates are bare '%s' for this reason.

A deployer who reads "the model sees the narration" and then meets that sentence in a transcript would reasonably conclude something is broken. Nothing is: that is the delivered form.

Platform and path-class rules

Do not say merely “the sandbox.” These are distinct matching rules:

PlatformPath classRule and consequence
macOSmandatory sensitive filesMatching is case-sensitive; .BASHRC is not the mandatory deny. A case-insensitive volume can still resolve a name the profile did not cover.
Linuxmandatory sensitive filesDangerous file matching is case-insensitive, so .BASHRC is denied.
Linux.git directory suffixesMatching is mixed: a .GIT directory matches, but the literal suffix test means sub/repo/.GIT/hooks/pre-commit is PERMITTED. Do not infer safety from the directory match.
macOS and Linuxordinary read/write policy listsLists are case-sensitive. Read deny/allow and write allow/deny therefore need the exact path spelling.
Windowsordinary read/write policy lists and path classesMatching folds case. Native SRT is unsupported here; use WSL2 so the Linux rules above are the rules you test.

Linux mandatory scanning also stops at mandatoryDenySearchDepth (default 3); macOS globs match at any depth. Linux root .git/hooks and .git/config mandatory entries exist only for a real .git directory, not a worktree .git file.

Adopting a foreign sandbox: network.disabled does not mean what it looks like

SandboxManager is a process-global singleton, and its second initialize() is a no-op — verified directly: initialise with denyRead: ['/tmp/AAA'], then again with ['/tmp/BBB'], and the derived rules still report /tmp/AAA. So an enforcer that always initialises would, on a process where something else enabled SRT first, report the policy you requested while a different one is in force.

srtEnforcer() therefore detects before it initialises. If sandboxing is already enabled it adopts that sandbox read-only: it does not call initialize(), it derives its snapshot from the LIVE manager, and it never reset()s a manager it did not create — a reset would tear down ACEs the host application depends on, so dispose() is a reported no-op there.

Adoption is still a degraded mode, and one field in effectivePolicy() will mislead an operator who reads it as authoritative:

  • network.disabled is this battery's field, not upstream's. SRT has no such flag. It records "this handle was constructed in disabled mode" — and an adopted sandbox was never constructed here at all.
  • Therefore disabled: true can only ever appear on a handle this battery created. On an adopted handle it is always false, and that false does not mean "the foreign sandbox restricts domains". It means only "its domain lists are being compared literally". Reading it the other way inverts the conclusion.

Two consequences follow. Both are benign, and both look alarming:

  • The domain-skip branch never fires under adoption. With no baseline disabled: true there is nothing to skip, so both domain axes are always compared — strictly MORE checking, not less.
  • A foreign mode flip is not reported as a MODE change — there is no upstream mode bit to read, so the only thing a comparison could ever see is an EFFECT on the lists.

A foreign mid-session widening IS detected, by a mechanism that differs per mode:

  • AdoptedeffectivePolicy() re-derives from the live manager on every call. A foreign updateConfig() that widens the domain lists between two invocations is caught by the ordinary per-invocation drift check — verified end to end: a foreign ['*'] baseline widened to ['*','bar.com'] is reported as drift.
  • Owned ⇒ the snapshot is cached. This battery's updateConfig() is the only writer, so re-deriving would buy nothing but a getFsReadConfig() call per tool invocation (and on Linux a ripgrep scan).

What remains genuinely undetectable is why a foreign policy changed — there is no upstream mode bit, so a comparison only ever sees the effect on the lists. And detection is not prevention: SRT's proxies consult policy per request, so a widening affects an already-spawned child for its whole lifetime. You learn about it on the next invocation, not before the current one finishes.

A foreign allowedDomains: ['*'] is recorded as an ordinary allow-everything list, not as evidence of disabled mode, because that is what the config actually says. Note the honest cost: '*' is a glob, and two globs compare only when lexically identical, so ['*'] → ['*','bar.com'] is reported as drift even though it widens nothing semantically. That is the conservative bias the subset rules declare — a false "not a subset" costs a reconfiguration, a false "is a subset" costs containment.

Deployment consequence: this is a process-global capability wearing a per-handle API. One policy per process is the safe shape; multi-tenant agents with different policies want separate processes.

What this is not

Not a guarantee that arbitrary code is safe

This is a policy boundary, and a policy boundary is only as good as the policy plus the code enforcing it. It does not make the model trustworthy, the filesystem race-free, or JavaScript magically native. The file tools run in-process library code, so a denial there holds because the evaluator checks it — TOCTOU between the check and the open() is a real residual, and spawning a helper process would not fix it, because the helper races identically. Only the shell and search paths get kernel enforcement.

Not verified by a green pipeline

The suites that prove the OS boundary actually works are gated behind TEST_SANDBOX_LIVE, and they skipIf themselves into a passing report when it is unset. So a green CI run is not weak evidence about enforcement — it is no evidence. Run them yourself, on both macOS and Linux, and record the SRT version you ran against:

sh
TEST_SANDBOX_LIVE=1 pnpm vitest run --project node tests/live/

There is now one CI job that does set the flag, in docker-in-docker. It is non-blocking and proves less than it sounds: that runner's daemon forbids unprivileged user namespaces, so bubblewrap cannot start there at all. It exercises the path and reports what it found; it does not certify enforcement. Your own run, on your own kernel, is still the evidence.

Not a stable dependency contract

Node's enforcement rides on @anthropic-ai/sandbox-runtime, a Beta Research Preview whose own notes say the APIs "may evolve". Every enumerated list in this battery is a snapshot of one version's source — not its README, which is wrong about ripgrep on macOS (it documents rg as a dependency there; the code gates that check inside if (platform === 'linux')). Treat an SRT version bump as a code change requiring a source re-read, not a routine dependency bump.

Opt-in, like every other battery here

Nothing in the ADK requires the sandbox. It exists for the case where you want to hand a model real execution and keep a boundary you can reason about — reach for it then, ignore it otherwise.

And what was deliberately not built

  • No reference gate. A safe-looking default gets adopted unread as "the safe config", carrying an arbitrary assumption about which subtrees are boring. The signature and worked examples ship; the decider is yours.
  • No browser no-op shim for the OS layer. A shim that degrades to nothing produces code that reads as sandboxed and enforces nothing — worse than a build error. In a browser, SES is the boundary.
  • No read_file/edit_file. open_file queries, stage_file + save_media mutate explicitly. The split is the point: mutation should be impossible to do by accident.
  • No dedicated git tool. run_shell_command is the git tool — the real binary, its real output, no wrapper to keep in sync with a fast-moving CLI.
  • No byte caps on results. Truncating a shell's output can cut the one line that explained the failure, and killing a child mid-write can leave the real system half-changed. Output is spooled whole into a queryable artifact; bound it with timeout_seconds and policy, not silent truncation.

Troubleshooting: what this failure means

SymptomMeaning and fix
dubious ownershipgitSafeDirectories was not threaded into the policy/enforcer. Add the workspace root.
E_READER_NOT_DESCRIBABLE on encode()A staged Media was never saved. Call save_media before persisting history.
"[object Object]"A handler returned a value the adapter could not wrap. Return the supported handler shape, not an arbitrary object.
Hung turnA gate suspended and there was no headless gate decider. Supply one or fail closed.
Artifact handle where text was expectedUniform inline: false behaviour: query the artifact with artifact_grep/other artifact tools.

Browser deployment

CORS gates response readability, not request emission. no-cors fetches and image beacons can still send secrets. The embedder MUST provide CSP, especially connect-src, plus img-src and default-src:

http
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.test; img-src 'self' data:;

Keep real secrets outside the sandbox root. File tools have evaluator-level checks and retain TOCTOU and implementation-bug residuals; shell and search paths receive OS enforcement.

Where to go next

  • run_shell_command — the streaming shell and the git tool: how stdout and stderr are merged, why a non-zero exit is a final line rather than an error, and the measured state of violation reporting on macOS.
  • Workspace tools — the read/mutate split (open_file queries, stage_file + save_media change), per-entry authorisation in list_directory, and the search pair.
  • Media executor — wrapping an existing BinaryExecutor for media pipelines, the audit-only bypass, and the retention semantics it inherits.
  • evaluate_javascript — the SES boundary: guest-side lockdown(), enumerated capabilities, and which availability guarantees hold in which environment.
  • Assembly → Sandbox batteries — the full option and exception surface, side by side with the other battery domains.
  • Isolation battery — the related-but-different substrate: it isolates trusted code for stability, where this battery confines untrusted code for authority. Compose them for the full stack.