Skip to content
vxvx
GitHubBlueskydev.toRSS

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.

packages/app/vx.config.ts
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.


  1. Turn it on

    Add sandbox to the task’s exec. sandbox: {} allows nothing in the workspace, not even the package.

  2. Grant the package

    allow: { read: ['.'] }. Its node_modules is readable already, and so is a linked sibling the task depends on through a command task.

  3. Grant outputs and hosts

    Each output directory in write, each host in network. The sandbox does not read cache: declare both.

  4. 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 (sandboxFix in vx last --format json).

  5. Declare the path

    A read no inputs.files glob 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 with ignore.


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/**'] } },
},
Terminal window
$ 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)

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

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.


bubblewrap (bwrap), socat and ripgrep (rg); strace to fail and name an undeclared read. vx info says if your host can.

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.


  • write /proc/self/uid_map: Operation not permitted. You are root in a container. Run as a normal user, or set weakerWhenNested: true on every sandboxed task.
  • File exists or Not a directory from the task’s own mkdir. A write grant with no trailing slash is a file (mkdir -p coverage reads the first, mkdir -p coverage/lcov the 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. Grant network: ['registry.npmjs.org'] and write: ['~/.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.

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