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.