Sandboxing
Prove a task reads only what it declares. A sandboxed task sees only the workspace files you grant, and a read or write outside them fails the task and names the path.
import { defineProject } from '@vzn/vx/config'
export default defineProject({ tasks: { build: { exec: { command: 'vite build', sandbox: { allow: { read: ['.', '~/.cache/ms-playwright'], write: ['dist/**'], network: ['registry.npmjs.org'], }, deny: { network: ['telemetry.example.com'] }, ignore: { write: ['*.bun-build'] }, }, }, inputs: { files: ['src/**', 'index.html'] }, cache: { outputs: { files: ['dist/**'] } }, }, },})Reads outside the workspace (~/.cache, /etc) are open and fold into
no key. Credential stores under home (~/.ssh, ~/.aws, ~/.npmrc) are
denied unless allow.read names one.
Sandbox a task
Section titled “Sandbox a task”-
Turn it on
Add
sandboxto the task’sexec.sandbox: {}allows nothing in the workspace, not even the package. -
Grant the package
allow: { read: ['.'] }. Itsnode_modulesis readable already, and so is a linked sibling the task depends on through a command task. -
Grant outputs and hosts
Each output directory in
write, each host innetwork. The sandbox does not readcache: declare both. -
Run it
An undeclared read or write fails it, names the path, and prints a
fix:line with the grants that would have allowed it (sandboxFixinvx last --format json). -
Declare the path
A read no
inputs.filesglob matches is named again as an input: a grant lets the task read it, but only an input re-keys the task when it changes. Silence a noisy tool’s path withignore.
See it catch a missing input
Section titled “See it catch a missing input”A build that reads tsconfig.json and writes dist/, sandboxed with only
src/ granted:
build: { exec: { command: 'mkdir -p dist && cat tsconfig.json src/a.ts > dist/out.js', sandbox: { allow: { read: ['src/'] } }, }, inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },},$ vx run build --all┌─ @demo/app#build > failed (exit 1, 1 sandbox violation)
$ mkdir -p dist && cat tsconfig.json src/a.ts > dist/out.js
├─ OUTPUT ──────────────────────────────────────────────────
mkdir: cannot create directory 'dist': Read-only file system
├─ SANDBOX VIOLATIONS (1) ──────────────────────────────────
mkdir(/repo/packages/app/dist) = a write no grant covers [/repo/packages/app/dist]
fix: exec.sandbox.allow: { write: ['dist/'] }
└─ @demo/app#build ── (12ms) failed (exit 1, 1 sandbox violation)With write: ['dist/'] added, the next run fails on the read it never
declared, and says the file belongs in the key too:
$ vx run build --all┌─ @demo/app#build > failed (exit 1, 1 sandbox violation)
$ mkdir -p dist && cat tsconfig.json src/a.ts > dist/out.js
├─ OUTPUT ──────────────────────────────────────────────────
cat: tsconfig.json: No such file or directory
├─ SANDBOX VIOLATIONS (1) ──────────────────────────────────
openat(tsconfig.json) = -1 ENOENT [/repo/packages/app/tsconfig.json]
fix: exec.sandbox.allow: { read: ['tsconfig.json'] }and, if the output depends on them, inputs.files: ['tsconfig.json']
└─ @demo/app#build ── (12ms) failed (exit 1, 1 sandbox violation)Grant it and add it to inputs.files, and the task passes. Without the
sandbox, this build would have cached with tsconfig.json outside its
key, and an edit to that file would have replayed the old dist/.
What you can grant
Section titled “What you can grant”allow takes every key below; deny takes only network; ignore
takes read, write, systemInfo and network. Domain lists are one
union per run.
| Key | Grants |
|---|---|
read |
paths or globs: package-relative, absolute or ~/ |
write |
paths or globs; a directory ends in / or is a glob (dist/**) |
network |
true, or a list of domains (*.sentry.io) |
localBinding |
bind localhost ports (macOS; Linux needs no grant); a list ([3000]) makes them reachable from outside |
unixSockets |
true, or socket paths (Linux: any path) |
systemInfo |
sysctl names a tool probes (vfs.disk-space) |
machLookup |
macOS services (com.apple.FSEvents) |
pty |
a terminal |
gitConfig |
write the repository’s .git/config, for this task only |
The boundary is the project
Section titled “The boundary is the project”A task never reaches another package or a root file you did not grant;
that wall is silent. An undeclared touch of its own files fails the task,
and a failed task is never cached. A write refused past the wall is named
with the directory to grant; under the host’s temp directory it names
$TMPDIR instead, the task’s own empty temp directory.
Requirements & platform support
Section titled “Requirements & platform support”bubblewrap (bwrap), socat and ripgrep (rg); strace to fail
and name an undeclared read. vx info says if your host can.
The system sandbox. Its report can miss a record under load; the denial never does.
Under WSL2.
What can’t be sandboxed: a group task (it has no command), and on macOS a task that itself sandboxes (a sandbox cannot nest).
A persistent task (a dev server) runs inside the same walls, but nothing
traces it: a refused read is ENOENT, named nowhere. Run its command as
a one-shot sandboxed task to see what it was refused.
Common problems
Section titled “Common problems”write /proc/self/uid_map: Operation not permitted. You are root in a container. Run as a normal user, or setweakerWhenNested: trueon every sandboxed task.File existsorNot a directoryfrom the task’s ownmkdir. A write grant with no trailing slash is a file (mkdir -p coveragereads the first,mkdir -p coverage/lcovthe second). Write'coverage/'.- A file the task creates later is refused on Linux (“Read-only file system”). A glob with a file part (
*.log,gen/**/*.ts) is matched when the task starts. Grant its directory ('gen/';'dist/**'grants the directory too). A temp directory the tool makes and removes in the package (.*.tmp/**) needs only the glob: nothing written there is kept. bun build --compile --target=…fails on a fresh machine (“Network error downloading executable”, then “Failed to extract executable”). Bun fetches the target’s runtime from npm into its cache. Grantnetwork: ['registry.npmjs.org']andwrite: ['~/.bun/install/cache/', '.*.tmp/**'].read packages/ui through packages/app/node_modules/@x/ui, and its key folds no task of @x/ui. The task imports a sibling its key never sees. Depend on a command task of it (dependsOn: ['^source'], or^build), or grant and key the files yourself.