Skip to content

Sandboxed Tools

@agent-dev-lab/tools is an optional package of sandboxed file, bash, search (grep / glob), and fetchUrl tools for @agent-dev-lab/core agents. Core does not depend on it. A project can omit the dependency entirely.

Providers take a sandbox policy (allowWrite, …) and share a process-scoped executor pool, or an escape-hatch executor. There is no unsandboxed default. Missing host packages fail with a clear error instead of running without a sandbox.

These tools raise the bar against a misbehaving model. They are not a multi-tenant security boundary.

The factories are ToolProviders (or plain ToolSets) so cwd / root can be set per agent.run via toolProviderContext.

Terminal window
bun add @agent-dev-lab/tools
# or: npm install @agent-dev-lab/tools

createWorkspaceToolProvider is the usual surface: file tools, grep / glob, bash sharing one working directory, and fetchUrl. Pass policy (not a constructed executor). Prefer loadAdlProject so projectRoot is on the tool-provider envelope for pooling (or pass projectRoot to createAdlRuntime in tests).

import { mkdirSync } from "node:fs";
import { createAdlRuntime } from "@agent-dev-lab/core";
import { createWorkspaceToolProvider, resolveDefaultSandboxRoot } from "@agent-dev-lab/tools";
import { openai } from "@ai-sdk/openai";
const cwd = resolveDefaultSandboxRoot();
mkdirSync(cwd, { recursive: true });
const adl = createAdlRuntime({
defaults: { model: openai("gpt-4o-mini") },
projectRoot: process.cwd(),
});
const coder = adl.createAgent({
id: "coder",
systemPrompt: "You edit files and run commands only inside the sandbox.",
tools: createWorkspaceToolProvider({
effectGate: adl.services.effectGate,
allowWrite: [cwd],
cwd,
}),
});

Every factory and provider requires an effectGate; there is no default. adl.services.effectGate is the runtime’s gate (see Gating Tool Calls). Tests that do not need a policy pass allowAllGate from @agent-dev-lab/core explicitly.

resolveDefaultSandboxRoot() is .data/sandbox under the current working directory, or ADL_SANDBOX_ROOT when set. It only resolves a path — create the directory yourself.

File writes and reads are allow/deny lists (not a privileged root). root / cwd is the relative-path base; omitted allowRead / allowWrite default to [cwd]. Changing cwd relocates relative paths and omit-defaults; it does not replace an explicit allow list:

await coder.run({
user: "List the files here.",
toolProviderContext: { cwd: "/path/to/another/folder" },
}).result;

Call dispose() on the tool provider (project reload does this for outgoing providers) to release pool refs; unused supervisors exit when the refcount hits zero.

Before a tool runs, it records an effect_intent event on the run log (kind: "tool", payload: { toolName, input }) and asks its effectGate for a decision:

  • allow runs the tool with the original input.
  • deny fails the tool call with the reason.
  • rewrite runs the tool with payload.input from the decision. The tool name cannot change.
  • suspend parks the run until someone resolves it, for example with Allow / Deny in the inspection UI. Only the runtime gate (adl.services.effectGate) can park; any other gate that returns suspend fails the call.

An effect_settled event records the outcome. Put the policy on the runtime, then hand adl.services.effectGate to the tools. This example asks for approval before every tool call:

import { createAdlRuntime, createSuspendHandle } from "@agent-dev-lab/core";
const adl = createAdlRuntime({
effectGate: {
async handle(intent) {
if (intent.kind === "tool") {
return { action: "suspend", handle: createSuspendHandle({ intent, reason: "approval" }) };
}
return { action: "allow" };
},
},
});

The runtime puts the inspection UI’s debugger in front of this policy, so Pause also stops tool calls.

FactoryToolsWhen to Use It
createWorkspaceToolProviderreadFile, writeFile, editFile, grep, glob, bash, fetchUrl, describeWorkspaceEnvWork on one folder
createFileTools / createFileToolProviderreadFile, writeFile, editFileFile access only
createSearchToolsgrep, globFilesystem search (rg via the executor)
createBashTool / createBashToolProviderbashShell only
createFetchUrlTool / createWebToolProviderfetchUrlRead one http(s) URL as text or markdown

Providers also expose a describe*Env tool so the model can see the resolved root, byte caps, bash permissions, and fetchUrl allowlist.

Pass a provider as tools when cwd / root / timeouts should come from toolProviderContext. Plain tool objects (createFileTools, createBashTool, createFetchUrlTool, …) also need an effectScope: the run identity and a recordEffect hook for intent events. Providers get both from the runtime on each turn, so prefer them inside agents; use plain tools in tests (testToolEffectScope()).

fetchUrl is included in the workspace provider when allowNetwork is true. It is omitted when allowNetwork is false (the default) or when constructed with fetchUrl: false. Use createWebToolProvider (or createFetchUrlTool) alone when an agent only needs to retrieve URLs. Timeouts are bashTimeoutMs and fetchTimeoutMs so the two knobs cannot collide. Empty allowedUrls still allows public http(s) — it does not remove the tool. Workspace allowedDomains / deniedDomains apply to both bash and fetchUrl.

This package does not search the web. Use your model provider’s native search tool (for example openai.tools.webSearch()) for discovery; use fetchUrl when you already have an address.

Every path is checked against allow/deny lists after symlink resolution. root is the relative-path base and the omit-default for allow lists (allowRead / allowWrite omitted → [root]; pass UNBOUNDED_ALLOW_READ ("**") for host-wide reads). denyRead / denyWrite win over allow. The jail is a userland check, not a kernel boundary. Bash writes outside allowWrite are a different footgun (a discarded overlay — see Bash).

writeFile requires the parent directory to already exist. editFile replaces exactly one occurrence of find and fails if the string is missing or not unique.

Prefer policy on createBashToolProvider / createWorkspaceToolProvider (pooled). For a one-off tool without a provider, pass an executor explicitly. The tool never picks an unsandboxed fallback.

Backend / FactoryPlatformHost PackagesNotes
pooled backend: "asrt"Linux, macOSLinux: bubblewrap, socat, ripgrep. macOS: ripgrepPreferred default. Per-domain network allowlist. Shared supervisor per policy.
pooled backend: "native"Linux onlybubblewrapNo extra npm sandbox runtime. Network is all-or-nothing. Throws on macOS.
createAsrtBashExecutor(same)(same)Escape hatch when you must own the executor instance.
createNativeBashExecutor(same)(same)Escape hatch for a dedicated native executor.

On providers, omitted allowWrite / allowRead default to [cwd]. Pass allowWrite: [] for a sandbox that can run commands but write nowhere; allowRead: [] or null allows no reads. Pass UNBOUNDED_ALLOW_READ ("**") for host-wide reads. Escape-hatch executors still require allowWrite at construction and have no cwd — omitted allowRead there defaults to allowWrite. Sandboxed commands inherit no host environment by default — pass allowEnv: true or a name/glob/RegExp allowlist. Providers default to backend: "asrt"; pass backend: "native" or an executor (mutually exclusive with policy / backend).

Pooled supervisors are keyed by project root + policy. Provider dispose() (and project reload of outgoing providers) releases pool refs. Escape-hatch executors: call executor.dispose() yourself. Supervisors also exit if the host process dies (stdin keepalive). ASRT creates an owned TMPDIR inside the sandbox (or pass tmpDir); auto-created dirs are removed on dispose, while an explicit tmpDir is left for the caller. That directory is not the host’s /tmp/claude.

A bash write outside allowWrite often looks successful inside the sandbox (exit 0, the command can read its own file back) while nothing reaches the host — ASRT uses a discarded overlay for paths that are simply absent from allowWrite. Genuinely read-only mounts fail with Read-only file system. Verify edits by reading them back from the host (or via readFile). Do not treat a zero exit code as proof the write persisted.

A non-zero command exit code is data (stdout / stderr / exitCode), not a thrown tool error.

bash.execute is a streaming tool: it returns an AsyncGenerator. The AI SDK consumes that correctly. Awaiting it directly (in a test or harness) yields the generator object, which prints as {}:

// wrong — looks like a tool that ran and returned nothing
const r = await tools.bash.execute({ command: "pwd" }, toolCallOptions);
// drain progress snapshots; the last value is the final result
let last;
for await (const update of tools.bash.execute({ command: "pwd" }, toolCallOptions)) {
last = update;
}

grep and glob run rg through the same executor. The model supplies a pattern and optional in-root path / glob — not flags.

Retrieves one http(s) URL and returns readable text (HTML becomes markdown). Private, loopback, and link-local addresses are refused — including after redirects — unless you set a concrete-host allowedUrls entry or allowPrivateNetwork: true. Host-wildcard patterns like ** alone do not bypass the address check. On the workspace provider, allowedDomains / deniedDomains also restrict which hosts fetchUrl may reach. A non-2xx status is returned as data. Treat the body as untrusted third-party content.

import { createWebToolProvider } from "@agent-dev-lab/tools";
const web = createWebToolProvider({
effectGate: adl.services.effectGate,
allowedUrls: ["https://docs.example.com/**"],
});
PlatformFile toolsBash / searchfetchUrl
LinuxSupportedASRT or native bwrapSupported
macOSSupportedASRT onlySupported
WindowsUnsupportedUnsupportedUntested