Local Sandbox Workspace
The LocalSandboxWorkspace runs files in a real tmpdir on the host filesystem and runs shell commands as subprocesses — exactly like Local Bash — but wraps every command in a kernel-enforced sandbox: macOS seatbelt (sandbox-exec) or Linux bubblewrap (bwrap). POSIX-only. This is the kernel boundary local-bash does not provide: even if the agent runs an allowlisted command that misbehaves, the OS restricts what it can read, write, and reach over the network.
When to use
- Local isolated execution. When you want real filesystem + shell semantics AND an OS-level boundary around what the agent's commands can touch — writes confined to the workspace, network off by default — without spinning up a container.
- Defense-in-depth on a developer machine. A stronger default than
local-bashfor running agents that handle less-trusted input on macOS/Linux, because the app-layer guards run AND the kernel enforces a sandbox on top.
If your platform has no available isolation backend (Windows, a locked-down container without bwrap, or a host where sandbox-exec is unavailable), this provider fails closed (see below) — use Local Bash instead, the Docker provider if a Docker daemon is available, or run inside a container-based host sandbox. For untrusted-input production on Cloudflare Workers, use Cloudflare Sandbox.
Capabilities supported
| Capability | Supported |
|---|---|
fs | ✅ |
shell | ✅ |
code | ❌ |
snapshot | ❌ |
The provider advertises { fs: true, shell: true } on its WorkspaceRef.capabilities. Declaring a capability marked ❌ above causes WorkspaceFailedError at session start (the framework asserts that config.capabilities ⊆ ref.capabilities and that each declared module is present on the returned workspace). See the error-model table on the workspaces overview.
Install
npm install @helix-agents/workspace-local-sandboxIsolation backends + auto-detect
The provider selects an OS isolation backend at construction time:
| Platform | Backend | Mechanism |
|---|---|---|
| macOS | seatbelt (sandbox-exec) | Deny-default SBPL profile: read broadly, but write only to the workspace (plus any readWritePaths); network allowed only when opted in. |
| Linux | bubblewrap (bwrap) | PID / IPC / UTS namespaces + bind mounts (workspace bound read-write, system paths read-only). Also mounts a fresh --proc /proc, a --tmpfs /tmp, and a minimal --dev /dev (interpreters need /dev). Network-off adds --unshare-net, which removes the network namespace entirely. |
By default (isolation: 'auto') the backend is auto-detected: seatbelt on macOS when sandbox-exec is present, bwrap on Linux when the bwrap binary is on PATH. Detection means the backend is usable, not merely installed — for bwrap the provider runs a trivial probe sandbox, so a host where the bwrap binary is present but unprivileged user namespaces are disabled (common in CI/container runners) is treated as no-backend and the provider fails closed. You can pin a backend explicitly with isolation: 'seatbelt' or isolation: 'bwrap'; pinning a backend the host can't satisfy resolves to no backend and the provider fails closed.
Profile self-protection (seatbelt)
The seatbelt SBPL profile is written to a file at <tmpdir>/.helix-sandbox.sb — inside the writable workspace the agent can touch. Because the profile lives in agent-writable space, a naive policy would let a sandboxed command overwrite or weaken it and have the relaxed policy honored on the next (cached) run. The profile defends itself: buildSeatbeltProfile() appends a trailing (deny file-write* (literal <profilePath>)) as the FINAL line. SBPL is last-match-wins, so that deny overrides every earlier file-write* allow — including the broad workspace-subpath allow and any operator readWritePaths ancestor — and the kernel rejects any attempt by the sandboxed agent to rewrite, truncate, or touch the profile. The profile is regenerated from trusted config on every open() / resolve() (when a seatbelt workspace is constructed), so the on-disk file is never the source of truth the policy is built from — the self-protecting deny only guards the cached file the NEXT sandbox-exec invocation reads.
A narrow defense-in-depth caveat: the deny matches the literal profile path, so it covers symlink aliases (seatbelt resolves symlinks before matching) but NOT a hard link — ln .helix-sandbox.sb alias shares the profile's inode under a different path, which the literal deny doesn't cover, so the alias could be written IF ln/link is in the operator's allowlist (the allowlist is the first line of defense here). Even then the exposure is bounded to a single cached-workspace lifetime, since the profile is regenerated from trusted config on every open() / resolve().
Fail-closed behavior
When no isolation backend is available — Windows, bwrap not installed on Linux, a locked-down container, or sandbox-exec missing on macOS — this provider fails closed: open() (and resolve()) throw WorkspaceFailedError rather than silently running commands without isolation. The error names the platform and the missing binary. "Available" here means usable: a host where bwrap is installed but unprivileged user namespaces are disabled (common in CI/containers) counts as no-backend and likewise fails closed, rather than running commands in a sandbox that cannot actually be created.
This is the deliberate contrast with local-bash, which always runs. The sandbox provider would rather refuse than give you a false sense of isolation. If you land on a host without a backend, the options are:
- Use Local Bash and accept its app-layer-only hardening (trusted input, or inside a host sandbox).
- Use the Docker provider — if a Docker daemon is available, it runs every command inside a container (the container is the isolation boundary) while keeping the same POSIX
fssemantics via a bind mount. A reproducible, cgroup-limited alternative that works on hosts where seatbelt/bwrap don't (including macOS without a read-confining profile). - Run inside a container-based host sandbox (Docker / gVisor / Firecracker / Kubernetes pod) where the container IS the isolation boundary.
- On Windows, run the agent inside WSL (which gives you a Linux host where
bwrapcan be installed).
Network: off by default
Outbound network is off by default (network: 'off'). On seatbelt this means the SBPL profile omits the network* allowance; on bwrap it adds --unshare-net, removing the network namespace entirely. Opt in per-workspace:
workspace: {
provider: { kind: 'local-sandbox', network: 'allow' },
capabilities: { fs: true, shell: true },
}network: 'allow' lets sandboxed commands reach the network. Leave it 'off' unless the agent legitimately needs egress.
Extra paths: readWritePaths / readOnlyPaths
By default the only writable location is the per-session workspace tmpdir. To grant access to additional host paths:
readWritePaths?: string[]— paths the sandbox may also write to (seatbelt:file-write*subpath allow; bwrap: read-write bind mount).readOnlyPaths?: string[]— paths the sandbox may read (seatbelt:file-read*subpath allow; bwrap: read-only bind mount).
Grant the narrowest set the agent needs — every extra path widens the boundary.
Pass canonical (realpath'd) paths. These are matched against the path the kernel resolves, not the string you pass — the provider does not canonicalize them. On macOS
/tmpand/varare symlinks (/tmp→/private/tmp), so a/tmp/...entry will silently fail to match; pass/private/tmp/...(i.e.fs.realpathSync(p)).
Provider config
interface LocalSandboxWorkspaceConfig {
kind: 'local-sandbox';
/** Outbound network policy. Default 'off' (egress blocked). */
network?: 'off' | 'allow';
/** Extra host paths the sandbox may write to (beyond the workspace tmpdir). */
readWritePaths?: string[];
/** Extra host paths the sandbox may read. */
readOnlyPaths?: string[];
}
interface LocalSandboxProviderOptions {
/** Override the tmpdir root. Defaults to os.tmpdir(). */
tmpdirRoot?: string;
/** Per-process cap on concurrent opens across all sessions. Defaults to Infinity. */
maxGlobalConcurrentOpens?: number;
/** Logger for security warnings + lifecycle events. Defaults to silent. */
logger?: Logger;
/** Isolation backend selection. Default 'auto' (seatbelt on macOS, bwrap on Linux). */
isolation?: 'auto' | 'seatbelt' | 'bwrap';
/** Constraints applied to subprocess shell calls (same shape as local-bash). */
shellConstraints?: SubprocessShellConstraints;
}shellConstraints is identical to Local Bash — allowedCommands, maxDurationMs, and the secure-by-default passEnv minimal allowlist all apply unchanged. The app-layer hardening from local-bash is still active here; the kernel sandbox is layered on top of it.
Wiring
import * as os from 'node:os';
import { defineAgent } from '@helix-agents/core';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { LocalSandboxWorkspaceProvider } from '@helix-agents/workspace-local-sandbox';
const agent = defineAgent({
name: 'my-agent',
llmConfig: { model: yourModel },
workspace: {
provider: { kind: 'local-sandbox' }, // network defaults to 'off'
capabilities: { fs: true, shell: true },
},
});
const executor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
yourLLMAdapter,
{
workspaceProviders: new Map([
[
'local-sandbox',
new LocalSandboxWorkspaceProvider({
tmpdirRoot: os.tmpdir(),
isolation: 'auto', // seatbelt on macOS, bwrap on Linux
}),
],
]),
}
);Lifecycle
open()— detects the backend (fail-closed if none), creates a per-session tmpdir, and constructs theLocalSandboxWorkspacewhose shell wraps every command in the chosen sandbox. Returns a serializable ref carrying the network policy and any extra read-write / read-only paths (and the detected backend, recorded for diagnostics only —resolve()re-detects it live rather than trusting it).resolve()— re-attaches to the same tmpdir and rebuilds the sandbox wrapper. The backend is re-detected LIVE on the resolving host —resolve()deliberately IGNORES the ref's persistedisolationBackend(it's kept for diagnostics only). A ref minted on a macOS host must not force seatbelt on a Linux resume; pinning to the stale backend would defeat fail-closed. Only the network policy andreadWritePaths/readOnlyPathscome from the ref. Fails closed if no backend is available on the resolving host. Validates the ref payload via Zod (safeParse) and the ref schema version.close()— removes the tmpdir recursively. Files are gone after close.
Contrast with Local Bash
local-sandbox and local-bash share the same hardened subprocess + filesystem + ref-validation plumbing (extracted into @helix-agents/workspace-posix-core). The difference is where the boundary lives:
| Layer | local-bash | local-sandbox |
|---|---|---|
| App-layer guards | ✅ allowlist, metachar/glob rejection, env-denylist, cwd-escape, symlink-scope | ✅ same guards, unchanged |
| OS-level isolation | ❌ none — commands run as the host user | ✅ kernel sandbox (seatbelt / bwrap) |
| Workspace-only writes (enforced) | App-layer only (a shell escape bypasses it) | Kernel-enforced — writes confined to the workspace |
| Network | Depends on which commands you allowlist | Off by default; kernel-blocked unless network: 'allow' |
| No backend available | Always runs | Fails closed |
The kernel sandbox is defense-in-depth ON TOP of the app-layer guards — it does not replace them. The allowlist, metacharacter rejection, env denylist, and cwd-escape checks all still run before a command reaches the sandbox; the OS boundary is the second line of defense that holds even if an allowlisted command is convinced to misbehave. For the full workspace threat model, see Workspaces Security.
Limitations (v1)
- seatbelt does NOT confine file READS — the key confidentiality boundary. The macOS profile allows
(allow file-read*)BROADLY. A sandboxed command CAN read any host file the agent's uid can access —~/.ssh/id_rsa,~/.aws/credentials,~/.config, arbitrary source trees, etc. The seatbelt boundary confines writes (to the workspace tmpdir plus any opted-inreadWritePaths) and network (off by default), but NOT reads. This is a deliberate v1 trade-off (a read-confining seatbelt profile would have to enumerate every system path interpreters touch). bwrap is different — reads ARE confined: only the--ro-bind'd system paths plus the--bind'd workspace (and anyreadOnlyPaths) are visible inside the namespace; the rest of the host filesystem simply isn't mounted. For full read-confinement on macOS, use a container/VM provider (Cloudflare Sandbox) instead. - No
code,script, orsnapshotcapability. Onlyfs+shell. For code execution or snapshots, use Cloudflare Sandbox. - Windows unsupported — fails closed. There is no seatbelt/bwrap equivalent wired in v1. Windows users should run inside WSL (where
bwrapcan be installed) or use Local Bash for trusted input. - seatbelt cannot restrict process-exec. The macOS profile allows
process-exec— there is no block-all-binaries mode in seatbelt. The sandbox confines filesystem writes and network, but it cannot prevent an allowlisted command from spawning other binaries. The shell allowlist remains the control for which commands run.
Source
@helix-agents/workspace-local-sandbox@helix-agents/workspace-posix-core— shared POSIX plumbing (also used by Local Bash)