Skip to content
vxvx
GitHubBlueskydev.toRSS

exec.sandbox

Opt this command into an OS-level sandbox. A persistent task runs inside it too — enforced, but with no violation report, since the report reads the trace after the child exits and a server exits only at teardown. Opt-in per task — omit it and the command runs unsandboxed. Full walkthrough in the sandboxing guide.

interface SandboxConfig {
allow?: SandboxGrants // what the command may do
deny?: { network?: string[] } // domains to refuse, run-wide (below)
ignore?: SandboxIgnore // violations to leave out of the report
weakerWhenNested?: boolean // Linux: let a sandboxed task sandbox (default false)
weakerNetworkIsolation?: boolean // macOS: host-proxy net, lower isolation (default false)
}
interface SandboxGrants {
read?: readonly string[] // paths or globs, project-relative or absolute
write?: readonly string[] // paths or globs; a write grant is readable too
network?: true | readonly string[] // an allowlist of domains; `true` adds none (below)
systemInfo?: readonly string[] // sysctl names, e.g. 'vfs.disk-space'; each grants system-info and sysctl-read (macOS)
unixSockets?: true | readonly string[] // AF_UNIX bind/connect, all or by path (Linux: any path)
localBinding?: boolean | readonly number[] // bind and reach localhost ports (macOS; Linux needs no grant); a list also exposes them to the host (a port the host or another running task already holds fails the task)
machLookup?: readonly string[] // mach global-names (macOS)
pty?: boolean // acquire a TTY
gitConfig?: boolean // write the repository's .git/config (this task only)
}
interface SandboxIgnore {
read?: readonly string[] // denied reads to leave out of the report
write?: readonly string[]
systemInfo?: readonly string[]
network?: readonly string[] // '<host>:<port>'
}

One shape describes what a task may do; vx translates it into a seatbelt profile on macOS and bwrap mounts plus seccomp on Linux. ignore takes patterns per class a denial is reported in — read, write, systemInfo, network — so a noisy probe is silenced with the grant that would have permitted it; any other name is refused. Every grant is the task’s own: unixSockets (or a localBinding port list, whose bridge is a unix socket) lifts the socket(AF_UNIX) block for the task that declares it, never for the run’s other sandboxed tasks. On Linux the block is the kernel’s answer to the call itself, so it is reported nowhere: the task reads only its tool’s own socket(1, 1, 0): Operation not permitted (a Docker, ssh-agent or database socket), and the grant is unixSockets:

exec: {
command: 'bun build --compile --target=bun-linux-arm64 src/bin.ts --outfile dist/vx',
sandbox: {
allow: {
read: ['.'],
// A cross-compile target the Bun cache lacks is fetched from npm,
// extracted into `<cwd>/.<hash>-00000000.tmp/` and moved into the cache.
write: ['dist/vx', '~/.bun/install/cache/', '.*.tmp/**'],
network: ['registry.npmjs.org'],
systemInfo: ['vfs.disk-space'],
},
ignore: { write: ['*.bun-build'] },
},
}

Globs. read and write accept patterns. On macOS the pattern reaches the policy and matches files created during the run; on Linux a grant is a mount, so the pattern is expanded when the task starts and a file created later is not covered — grant its directory instead. On both platforms <dir>/** and <dir>/**/* collapse to <dir>, so read: ['**/*'] lets a task list its own cwd; a <dir> that is itself a glob keeps its subtree (.*.tmp/** covers what is inside each match). As in a task glob, [id] is a class, so a Next.js route is granted escaped, read: ['pages/\\[id\\].tsx']. On Linux a WRITE path holding a bracket, * or ? cannot be mounted (the runtime drops it): vx says so once, names the directory above it to grant instead, and a write under it is refused and reported. A READ path whose name holds * or ? (granted escaped, a\\*b.txt) cannot be granted alone either — the runtime would grant its siblings too — so vx leaves it out, says so once, and a read of it is refused and reported. A Linux grant of either kind holding a backslash is left out the same way: Bun’s realpath refuses such a path, and the runtime mounts none it cannot resolve.

A Linux WRITE grant that matches nothing when the task starts therefore mounts nothing. Where a read grant mounts its directory, the task’s first write under it fails with Read-only file system — a message naming neither vx nor the grant — so vx reports that grant itself before the task runs, once, and names the directory to grant instead, spelled as the config spells it (allow: { write: ['gen/'] }). Where no mount holds the directory, it is the sandbox’s own scratch, the one mask left writable: the task may create, write and remove what the glob matches (anything else it writes there too), and nothing it leaves there outlives the task. That is right for a tool’s temp directory and wrong for an output. A read grant matching nothing is ordinary (an optional file, a cache not yet populated) and is not reported. A pattern under a directory that does not exist yet matches nothing the same way, where the scan once failed the task with a bare ENOENT (B-6).

A write grant’s shape. A write path that does not exist yet is created before the task starts (a mount needs something to bind), and a literal names a FILE: write: ['dist/vx'] is an empty dist/vx the build overwrites. A directory the task will create is spelled with a trailing slash — write: ['coverage/'] — or as a glob ('dist/**'); outside the project ('~/.bun/install/cache/') only these two shapes are created, never a file. Spell a directory as a bare literal and the task’s own mkdir meets “File exists” (“Not a directory” for a path inside it, mkdir -p coverage/lcov); the failure then says so, names the dir/ spelling, and vx removes the empty file it made (it takes back any placeholder the task never wrote, so an unwritten one is never archived as an output). A grant that leaves the project through a symlink is refused: the grant binds the path it names, and vx follows no link out of the project. So is one whose bind would make .git, .vx/cache, the cache directory or a nested project writable: a file grant at a single-package workspace’s root binds the root. A directory grant is judged as the directory, made before the judgement when it does not exist yet, so dist/ or dist/** at that root stands.

A write grant is readable, and on Linux it reads WIDER than it looks. A write path is readable too (tsc --incremental re-reads its own .tsbuildinfo). On Linux a grant is a mount and bwrap cannot rename onto an active file mount — every atomic writer stages beside its target and renames — so a FILE-shaped grant is bound as its DIRECTORY. That directory is then readable AND writable in full: with write: ['out.txt'] in the project root, every file in the project root can be read. Such a read succeeds, so there is no denial; vx reports it from the trace instead, as a violation: a read of anything that was in that directory when the task started and that no grant covers, the directory’s listing included. The declared file and what the task made there itself stay readable. Put declared outputs in a subdirectory and the rest stays denied outright. macOS matches paths rather than mounting, so a file grant stays exact there. Pinned in tests/sandbox-grants.unsafe.test.ts (2026-09-20) and tests/sandbox-widened-reads.unsafe.test.ts.

A network entry is a host pattern: example.com, *.example.com, either with a port (example.com:443), or localhost. A scheme or path (https://example.com), a dotless host, a bad port, and * or *.com (too broad) refuse the run with the entry named; deny.network also takes a bare * (deny all, *:22 for one port). Until 2026-10-02 such an entry matched nothing with no word, and an allowed * opened every host to every sandboxed task of the run.

network is per-RUN, not per-task. SRT runs one filtering proxy per vx run and checks every request against the allowlist that proxy was started with: the union of every domain list any sandboxed task in the graph declared. Every sandboxed task is handed that proxy. So a task that declares no network reaches the domains another task of the run listed, and network: true reaches only those (nothing in a run with no list). deny.network is the run’s too: the proxy starts with the union of every task’s denies and refuses those domains to every task, checked before the allowlist (B-21). A refused request is a violation on both platforms, deny network-outbound <host>:<port> (<reason>) from the proxy, and fails the task even when it survived the refusal; ignore: { network: ['<host>:<port>'] } silences one, matched as the proxy matches: case-blind, a trailing dot dropped (until 2026-10-08 the client’s spelling, EXAMPLE.Com or example.com., escaped it). Until 2026-10-02 Linux reported none, and the line could not be ignored on macOS.

Baseline (sandbox: {}): the task reads nothing in the workspace, writes nothing but its own TMPDIR and reaches no domain no task of the run lists — not even its own project directory, which is why allow: { read: ['.'] } is the first line of almost every real block. The task still starts in its own directory, an empty one then: a relative read is refused and reported, never resolved elsewhere. The read wall is the WORKSPACE ROOT: a path outside it (~/.cache, /etc, the toolchain) is readable and folds into no key, so a task whose output depends on one declares it as a key input (inputs.runtime, inputs.env) — the sandbox does not catch it (item 966). The one exception is where tools keep credentials, denied unless a grant names one (read: ['.', '~/.npmrc'] for a publish; a write grant at or inside a store frees that path alone), since a dependency the task ran could copy a key into an output the cache shares (L-41): ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.kube, ~/.config/gcloud, ~/.config/gh, ~/.docker/config.json, ~/.netrc, ~/.git-credentials, ~/.npmrc, ~/.yarnrc.yml, ~/.pypirc, ~/.bunfig.toml, ~/.config/.bunfig.toml, ~/.cargo/credentials, ~/.cargo/credentials.toml, ~/.gem/credentials, ~/.config/hub, ~/.config/containers/auth.json, ~/.terraform.d/credentials.tfrc.json, ~/.vault-token, ~/.pgpass, ~/.config/configstore/firebase-tools.json, ~/.local/share/com.vercel.cli/auth.json, ~/Library/Application Support/com.vercel.cli/auth.json, ~/.netlify/config.json, ~/.config/netlify/config.json, ~/Library/Preferences/netlify/config.json, ~/.wrangler/config, ~/.config/.wrangler/config, ~/Library/Preferences/.wrangler/config, ~/.fly/config.yml, ~/.supabase/access-token, ~/.config/doctl, ~/Library/Application Support/doctl, ~/.config/stripe/config.toml, ~/.config/op, ~/.expo/state.json, ~/.claude/.credentials.json, ~/.codex/auth.json. What it grants from there is the union of the read grants and, on Linux, the DIRECTORY holding each file-shaped write grant (above). Nothing is inherited from cache — inputs says what INVALIDATES a task, sandbox.allow says what it may TOUCH, and deriving one from the other made a declaration added for caching silently widen the sandbox. The one grant vx makes for you is dependencies: node_modules and, through it, the real path of every workspace package linked there, and in turn in each such package’s own node_modules. A project never names a sibling to import what its package.json already depends on. A link back to the task’s own project, or to a directory holding it, is not followed: npm and Yarn link every workspace package at the root, the task’s own included, and following it would grant the whole project past its allow.read.

A task that declares cache gets a linked workspace package only when its key already answers for it: some task of that package is folded into the key through dependsOn (as inputs.tasks selects it, groups passing everything through). Every other link inside the workspace root is withheld, and a read through one fails the task with the denial plus one line naming the package and the edge that would key it. Declaring cache can only narrow this grant, never widen one: a task with no cache keeps it whole, having no key to be stale, and your own allow grants never depend on it. The key answers for the package, not the file: an edge to ui#source (inputs src/**) covers a read of ui/README.md too.

A missing write grant fails the task. On macOS seatbelt refuses the write and reports it. On Linux the write meets a read-only bind or a read-only mask: the empty directory the sandbox lays over what it hides (the workspace root around a project, a single-package workspace’s root, the project root around a write grant punched out of a read grant — read: ['.'] with write: ['dist/'] binds the children one by one). It is Read-only file system on both; until 2026-10-02 the Linux mask was writable, and a write there succeeded and left nothing on disk. The runtime’s write observer saw the attempt, and a write no grant binds is reported and fails the task, even when the command swallowed the error and exited 0 (B-5; before it, both Linux shapes passed with nothing reported, items 444 and 1011). A write outside the project is refused the same way and named on a failed task, never counted. The remedy is to declare it: allow: { write: [...] }.

A write grant cannot be removed on Linux. A directory grant is mounted in place, so rm -rf dist && tsc under write: ['dist/'] empties dist and then fails with Read-only file system; a failed task names the grant. Remove its contents instead: rm -rf dist/*.

The boundary is the workspace root. A task may not leave its own project, so every sibling project and every root file is denied. Being stopped at that wall is the sandbox working, not a finding: only denials INSIDE the project are reported, because those are the reads that make a cache key wrong. A write refused past the wall is named beside a FAILED task, never counted, with the directory to grant, spelled from the project when it is in the workspace (under the host’s temp directory, $TMPDIR, the task’s own, instead): a tool that cannot fill its cache (~/.bun/install/cache) rarely says where it tried. So is a read the wall hid of a path that exists on the host, with the grant spelled from the project ('../../tsconfig.base.json'): the tool said only “not found”. To reach a path outside the project but inside the workspace — a workspace-level fixture — declare it; a path outside the workspace is not walled (above).

git in a sandboxed task reads the repository only if it is granted: read: ['.', '../../.git'] from a project two levels down (.git is a wall, so read: ['.'] in a root project leaves it out too). On Linux a task whose grants name a .git gets GIT_DISCOVERY_ACROSS_FILESYSTEM=1 (a value the task sets wins), since every sandbox mount is a filesystem boundary git’s discovery stops at. git rev-parse and git log then answer as outside; git status reports a file the task may not read as deleted.

Policy: fail on violation. An undeclared read, or a write the sandbox refuses, fails the task, and a failed task is never cached. On Linux a denied read is reported only where strace is on PATH and may attach; elsewhere the sandbox still denies it, the task fails only if the command does, and the run says once that reads go unreported. Activation is lazy (only when some task declares exec.sandbox); on an unsupported platform a sandboxed task fails fast rather than running unsandboxed. Linux needs bubblewrap, socat and ripgrep installed (the runtime expands its mandatory deny globs with rg); a missing one is named with the install. Inside a write grant, a file named like a shell or tool config (.bashrc, .zshrc, .gitconfig, .mcp.json, …), anything under .vscode/ or .idea/, and .git/hooks or .git/config stay read-only to the task, down to three levels below the workspace root, ignored by git or not.

No [, ], * or ? in the project’s path. The runtime reads a path holding one as a pattern: on Linux it mounts no write path that does and a read grant matches its siblings, and macOS’s rules compile it as a pattern too. So a sandboxed task in a project under such a directory (~/[old]/repo, ~/w*s/repo) is refused, naming it: rename the directory or drop exec.sandbox. On Linux so is a project under a directory holding a backslash, which the runtime cannot mount at all (its task saw no project and ran in $HOME).

macOS cannot nest. sandbox_apply is refused inside a sandboxed process, so a task that itself sandboxes something (vx’s own test suite) cannot be sandboxed on macOS. weakerWhenNested covers the Linux case; there is no macOS equivalent to offer.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.