Workspace tools
There is no read_file here, and no edit_file. Reading and editing want opposite things: reading wants the exact bytes, lazily, addressable by line so the model can grep a 50 MB log without pulling it into the prompt, while editing wants a mutable copy, because producing new bytes is the operation. One verb serving both cannot be lazy and mutable at once.
So open_file observes and stage_file authors, and the return type tells you which you are holding: a queryable artifact, or an in-memory Media that is not on disk until you save it.
There is no dedicated git tool either, and that is not an omission: run_shell_command is the git tool, with the real binary and its real output. See run_shell_command.
Register the tools
createSandboxTools requires the real filesystem, path translator, handle, write root, trust tier, and gate. The backend types are deliberately explicit: do not replace them with a permissive any.
import { createSandboxTools } from '@nhtio/adk/batteries/sandbox'
import type { SandboxToolsOptions } from '@nhtio/adk/batteries/sandbox/tools'
const options: SandboxToolsOptions = {
handle: sandbox,
fileSystem: workspaceFileSystem,
pathTranslator,
writeRoot: '/workspace/out',
trustTier: 'untrusted',
gate: async (_ctx, call) => {
const approved = await approvalService.ask(call.tool, call.args)
return approved ? { approved: true } : { approved: false, note: 'operator declined' }
},
search: searchBackend,
}
const tools = await createSandboxTools(options)
registry.register(tools)The factory includes open_file, open_json_file, and open_markdown_file, plus stage_file, save_media, list_directory, search_files, and find_files. registeredTools can be supplied when descriptions need to reflect a subset, but it does not make an unregistered capability appear.
Gates are mandatory, including reads
The obvious objection is that gating a read is paranoid — a read changes nothing, so why suspend a turn for one? The objection has the threat model backwards.
A bad write can be reverted. A read cannot. The moment .env or id_rsa is read, the secret is in the turn, the transcript, and the provider's logs, and no amount of care afterwards puts it back. That is the asymmetry: writes are recoverable, reads are terminal. And search_files is worse than a read — it is a secret-discovery primitive, because a model-supplied regex finds credentials without knowing where they live. A gate on writes and not reads protects the recoverable half.
So every tool here gates before it translates or opens a path, and no reference gate ships — a default gets adopted unread as "the safe one", carrying an arbitrary assumption about which subtrees are boring.
A blanket gate is a decision
gate: async () => ({ approved: true }) on the read tools is legal and supported. It is also this:
You have just handed the model an unsandboxed shell. That was a choice. Own it.
Every file the policy permits is now one tool call from the transcript.
A gate is a real suspension — the turn stops until a decider answers, so a headless harness without one hangs indefinitely. Wire a decider, or wire an explicit auto-verdict knowing that is what it is.
Denials throw a narrated E_SANDBOX_REFUSED; operational failures throw a narrated E_SANDBOX_*. Both arrive inline on every backend, which is the entire reason they throw rather than return — the mechanics are on the index page.
The query path: open_file → artifact_grep
open_file streams the file into the turn as a spooled artifact and hands back a handle, not text. That is uniform inline: false behaviour across all six adapters, and the handle is the point rather than a limitation: a 5 GB file opens with bounded memory, and the model pages through it with the artifact_* tools instead of spending its context on bytes it will not read.
// Model/tool calls, in order:
const opened = await dispatch('open_file', { path: 'src/config.ts' })
// `opened` is an artifact handle, not source text.
const matches = await dispatch('artifact_grep', {
callId: opened.callId,
pattern: 'timeout',
maxResults: 20,
})The actual artifact tool is forged by core from the live result; the workspace battery does not mint a private read_file or artifact_grep. If an adapter prints "[object Object]", a handler returned something its adapter could not wrap; return a supported string/bytes/Media/artifact result instead.
The mutation path: stage → patch → save
stage_file returns a reader-backed Media and writes nothing. A media verb produces another media result, still in memory. save_media is the step that touches the disk: it resolves the media_id, checks the explicit write root, refuses symlinked components, and writes.
The unsaved-mutation trap
A model that stages a file, patches it, and stops has changed nothing on disk. git diff shows clean. This is the single most likely authoring mistake in the battery, which is why both tool descriptions lead with it — stage_file says the edit is in memory, and save_media says it is the step that makes it real.
const staged = await dispatch('stage_file', { path: 'src/config.ts' })
const patched = await dispatch('apply_patch', {
media_id: staged.id,
patch: { find: 'timeout: 30', replace: 'timeout: 60' },
})
await dispatch('save_media', {
media_id: patched.id,
path: 'out/config.ts',
})apply_patch is a media verb supplied by the surrounding media/tool assembly, not one of the eight workspace tools returned by createSandboxTools; use the verb your media registry actually registers. The important invariant is that save_media is the commit. A staged Media has no serialisable reader descriptor. Encoding it before saving throws E_READER_NOT_DESCRIBABLE.
Policy and residuals
These tools are not OS-enforced. run_shell_command and the search tools spawn binaries this library did not write, so they get the OS boundary. These tools run library code, so there is no untrusted binary between the policy check and the open(), and the in-process evaluator is the boundary — the same rules, read from SRT's own derived config.
That decides how much to trust each path. A denyRead on a credential file stops open_file because the evaluator checks it, and stops run_shell_command because the kernel does. The first is defeated by a bug in the evaluator; the second is not.
What that leaves unmitigated:
- TOCTOU. A symlink swapped between the check and the open wins. There is no OS backstop on this path, and spawning a helper would not help — the helper races identically.
- Evaluator bugs. See above. The compensating controls are the mandatory gate and a narrow
writeRoot, not a claim of correctness. - Clobbering. A permitted write can overwrite a build output or a script the agent later runs, and nothing can distinguish that from intended work.
list_directory's existence-hiding covers the result channel only. A denied entry is omitted silently and changes neither the paths nor their order — but it still costs areaddirand astat, so at scale it shifts operator-visible latency, and a racing descendant can still turn a clean listing into anio-failure.
Which is why a deployment holding secrets keeps them outside the sandbox root, rather than trusting denyRead to hide them.
Troubleshooting
| Symptom | Meaning and fix |
|---|---|
E_READER_NOT_DESCRIBABLE on encode() | Save the staged media first; a staged reader is intentionally not a durable descriptor. |
| Artifact handle where text was expected | Expected inline: false; call artifact_grep or another artifact query tool. |
| Hung turn | A mandatory gate has no decider. |
"[object Object]" | The adapter could not wrap the handler's returned object; use the declared return shapes. |
| Search finds a secret | The read/search gate was too broad. Gates are required precisely because discovery is exfiltration. |