---
url: 'https://adk.nht.io/batteries/sandbox/workspace-tools.md'
description: >-
  Gated file, directory, and search tools with explicit staging and save
  semantics.
---

# Workspace tools

## LLM summary — workspace tools

* [`createSandboxTools`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/tools/functions/createSandboxTools) creates gated `open_file`, `stage_file`, `save_media`, `list_directory`, `search_files`, and `find_files`.
* Gates are mandatory on every tool, including reads: `.env` is exfiltration and search is secret discovery.
* `open_file` returns a queryable artifact; `stage_file` returns reader-backed Media in memory; `save_media` is the disk commit.
* `inline: false` is uniform: an artifact handle is expected, not inline text. Use `artifact_grep` for an opened file.

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`](./run-command.md#git-and-network).

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

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

::: danger 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](./index.md#what-the-model-actually-reads-the-prefix-is-not-ours).

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

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

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

```ts
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 a `readdir` and a `stat`,
  so at scale it shifts **operator-visible** latency, and a racing descendant can still turn a clean
  listing into an `io-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. |
