Skip to content
GitHubRSS

The sandbox: turning a declaration into a boundary

The previous post argued that inputs must be declared, not inferred. Declared inputs have a weakness of their own: they can be wrong, and a task that reads a file its inputs never named produces a cache entry that silently depends on that file. Green check, stale hit, nothing downstream can tell.

vx’s answer is to let you enforce the declaration. A task with a sandbox block runs inside an OS-level sandbox where the paths you grant are the only ones it can read, write or reach.

lint: {
exec: {
command: 'eslint .',
sandbox: { allow: { read: ['.'] } },
},
cache: { inputs: { files: ['src/**', '.eslintrc'] }, outputs: { files: [] } },
}

sandbox: {} is the baseline: reads nothing in the workspace, writes nothing, no network of its own (a run’s domain lists are one union every sandboxed task reaches; schema.md § exec.sandbox). Not even the project’s own directory, which is why read: ['.'] is the first line of nearly every real block. The read wall stands at the workspace root: ~/.cache and /etc stay readable, and fold into no key. On top of the baseline you grant exactly what the tool needs:

  • read and write paths or globs, project-relative, absolute or ~-expanded. A write grant is readable too, so tsc --incremental can re-read its own .tsbuildinfo.
  • network: true, or a list of domains with wildcards. Domain lists are enforced by one filtering proxy per run; a task that declares no network is never given the proxy’s port and reaches nothing.
  • localBinding for a test that boots its own server, unixSockets, systemInfo, gitConfig for the rare tool that must write .git/config, and the macOS-specific machLookup and pty.

Two lists sit beside allow. deny takes a capability back — it is evaluated first, so a domain in both is denied — and ignore keeps a denial out of the report without granting it.

There is no workspace-wide default and no inheritance between tasks. Those three lists are the whole permission surface of that one task.

The obvious shortcut would be to grant the cache.inputs globs as reads. vx did that once and removed it. cache.inputs says what invalidates a task; sandbox.allow says what it may touch. When one was derived from the other, a path added for caching silently widened the sandbox, and a path the task genuinely needed had to be laundered through the cache key to become readable. Two declarations, and the place they meet is the violation: a sandboxed task that reads a file its inputs never named fails on the denied read, which is exactly the under-declaration you wanted to find.

An undeclared path inside the project is a finding: the run fails and the report names the path. An undeclared path outside the project is the wall, denied silently, because a project reading its neighbour is not a mis-declaration to fix but a boundary being held.

A failed task is never cached, so a violation cannot poison the cache. When a tool is legitimately noisy (a probe for a file that may not exist), ignore silences the specific pattern without granting it.

  • Linux needs three binaries on PATH: bwrap for the namespaces, socat for the network bridge, and ripgrep (rg) to expand the runtime’s mandatory deny globs. The child lives in its own mount and network namespaces; an undeclared path structurally does not exist, so the tool sees ENOENT. With strace present, that becomes the same structured report macOS produces. Each localBinding port is bridged to the host’s loopback over a unix socket so a downstream task or your browser can reach the server.
  • macOS uses the system sandbox (seatbelt) plus a log monitor for the report. Enforcement is the OS’s; the unified log feeding the report is lossy under load, so a violation can go unreported while still having been denied.
  • Windows is WSL, where the Linux sandbox applies.

The two limits worth knowing: root inside a container usually cannot create the nested user namespace the Linux runtime needs (run as an unprivileged user, or accept weakerWhenNested), and seatbelt cannot nest, so a task that itself sandboxes cannot be sandboxed on macOS. In vx’s own repository exactly two tasks have no sandbox block: the part of the core suite a sandbox cannot host, and the one plugin suite that dials service containers on the host’s loopback. Everything else, lint, format, docs build, every other package’s tests, runs inside one.

The sandbox is opt-in per task. Use it on the tasks whose inputs you are least sure of, in CI where hermeticity is worth the setup, and before marking a task eligible for remote execution, where a worker will see exactly the declared inputs and nothing else. The guide is Sandboxing tasks.