src/exec/sandbox-runtime.ts — sandbox wrapper for per-task isolation
Purpose
Section titled “Purpose”Thin wrapper around @anthropic-ai/sandbox-runtime (SRT) for running a
single task inside a filesystem + network sandbox with strict isolation.
Used by executeCachedTask when the task’s config declares
exec.sandbox.
Policy: fail on violation, no cache for failed tasks. The sandbox
enforces the declared grants at the kernel level; a task that reads
outside them is denied (bwrap on Linux, seatbelt on macOS), the denial
is reported (an strace pass on Linux, the unified log on macOS), and a
reported violation inside the project forces a non-zero exit.
cache.save only fires when the task succeeded AND no violation was
reported.
sandbox-runtime.ts keeps the lifecycle (probeSandbox, initSandbox,
resetSandbox), the config resolution (resolveSandboxConfig) and the
spawn (runSandboxed, wrapSandboxedCommand, the macOS profile rules).
Three companions hold the rest, split 2026-09-09 as pure code motion:
sandbox-violations.ts— the Linux strace pass (deniedCalls,parseStraceViolations), the seatbelt record description, and the report filters (reportableViolations: inside the project or a withheld linked package, minus loopback noise, minus the task’signore).sandbox-binds.ts— write grants as bwrap can honour them (bindableWrites), read grants punched around the write grants inside them (punchWritePaths), and the SRT custom config.sandbox-paths.ts—toRealPath,absolutize,isUnderAny,unique.
User-facing config
Section titled “User-facing config”The task declares its sandbox policy under exec.sandbox in
vx.config.ts (the SandboxConfig type, exported from src/config.ts).
It is capability-shaped, not a mirror of SRT’s own config: one vocabulary
says what a task may do, and this module translates it per platform.
exec: { command: 'bun test', sandbox: { allow: { read?: string[] // paths or globs write?: string[] // paths or globs; a write grant is readable too network?: true | string[] systemInfo?: string[] // sysctl names, macOS unixSockets?: true | string[] localBinding?: boolean | readonly number[] machLookup?: string[] // macOS pty?: boolean gitConfig?: boolean }, deny?: { network?: string[] }, ignore?: { read?, write?, systemInfo?, network? }, // what to leave out of the report weakerWhenNested?: boolean, // Linux weakerNetworkIsolation?: boolean, // macOS },}Paths resolve relative to the project directory, are used as-is when
absolute, and expand ~ against the user’s home. Globs are accepted:
macOS passes the pattern into the policy (so it matches files created
during the run); Linux expands it at task start, because a grant there is
a mount. <dir>/** and <dir>/**/* collapse to <dir> on both, so
read: ['**/*'] lets a task list its own cwd.
There is no inheritance from vx.workspace.ts, and nothing is
derived from cache. The single grant core makes is dependencies:
node_modules for the project and the workspace root, plus the real path
of every workspace package symlinked into them — a project never names a
sibling to import what its package.json depends on. A link back to the
task’s own project, or to a directory holding it, is dropped (compared
canonically): npm and Yarn link every package at the root, and following
that link re-granted the whole project past its allow.read.
What SRT’s config cannot carry
Section titled “What SRT’s config cannot carry”localBinding, unixSockets, machLookup and systemInfo do not reach
SRT as config, and neither does a network domain list. The first three exist as fields, but sandbox-manager.js
(0.0.75, still 0.0.76) reads them off the config given to initialize() and never off
the per-call one, so a per-task grant is silently dropped; systemInfo
has no field at any level. vx is per-task by definition, so it appends
the corresponding SBPL rules to the END of the seatbelt profile SRT
generated — last-match-wins is the only position where a rule of ours
outranks one of SRT’s. Measured 2026-09-05: the same rules injected after
the (deny default …) header are inert in both directions. Filesystem
grants still go through SRT’s config, where they also work on Linux.
The network case has no such workaround: SRT runs ONE filtering proxy
per run and checks every request against config.network.allowedDomains
from initialize() (sandbox-manager.js:238 in 0.0.76). run() therefore arms it
with the union of every domain any sandboxed task declared. Every
sandboxed task is handed that proxy, so a task that declared no domains
still reaches the union. deniedDomains is the union of every task’s
deny.network (sandboxRunUnion), refused to every task; it was always
empty, and a deny refused nothing, until B-21 (schema.md §
exec.sandbox). Per-task filtering is out of reach: SRT’s filter hears a
host and port, and the command’s name rides in the proxy username, which
the sandboxed process writes, so one task could claim another’s list.
Public surface
Section titled “Public surface”export interface SandboxAvailability { available: boolean reason: string // empty when available}
export function probeSandbox(opts?: { weakerNested?: boolean }): Promise<SandboxAvailability>export function initSandbox(opts?: { allowedDomains?: readonly string[] // the run's union deniedDomains?: readonly string[] // every task's deny.network, refused to all (B-21) allowAllUnixSockets?: boolean // some task asks; the lift is set per task's wrap}): Promise<void>export function resetSandbox(): Promise<void>
export interface ResolvedSandboxConfig { /* same shape as SandboxConfig, paths absolute */}export function resolveSandboxConfig(cfg: SandboxConfig, projectDir: string): ResolvedSandboxConfig
export interface SandboxedRunArgs { command: string cwd: string env: NodeJS.ProcessEnv forwardArgs?: readonly string[] onStdout?: (chunk: string) => void onStderr?: (chunk: string) => void liveChildren?: Set<ReturnType<typeof Bun.spawn>> timeoutMs?: number capture?: CaptureConfig baseAllowRead: readonly string[] // node_modules + resolved workspace links baseDenyRead: readonly string[] // [workspaceRoot] — the task may not leave its project reportWithin: string // projectDir — only denials in here are worth reporting reportLinked: readonly string[] // withheld linked packages (canonical) — reported too config: ResolvedSandboxConfig // its allowWrite is the whole write set: none is derived}
export interface SandboxViolation { line: string timestamp: Date target?: string hint?: true // vx's own note, shown but never counted (B-20) path?: string ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]}export interface SandboxedRunResult extends RunResult { violations: SandboxViolation[]}export function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult>
// The wrap without the run: the sandboxed command line, the tag its// violations are reported under, and the canonical baselines it was// built from. What an executor running the command ITSELF needs.export function wrapSandboxedCommand( args: Pick<SandboxedRunArgs, 'command' | 'cwd' | 'forwardArgs' | 'config' | 'env'> & Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead'> & { server?: boolean },): Promise<{ wrapped: string tag: string taggedCommand: string srtCommand: string baselines: CanonicalBaselines forwardsSignals: boolean // the command reads its polite signals off fd 3 (item 752)}>
// Release the port bridges a tagged run held. Paired with the wrap above:// runSandboxed does it itself, a caller that wrapped must do it.export function releaseBridges(tag: string): void
// A thrown value as a line a user can read — an FS refusal keeps its// errno, anything else its message. The sandbox never reports a stack.export function thrownReason(err: unknown, what?: string): string// The probe's verdicts as one line each: a failed start, missing// dependencies, a temp dir too long for the socket path.export function unavailableReason(exitCode: number | null, stderr: string): stringexport function dependencyReason(errors: readonly string[]): stringexport function socketPathRefusal(tmpdir?: string): string | undefined
// The port bridge (Linux; § Port bridge)export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): number[]export function portBridgeSocket(tag: string, port: number): stringexport function portBridgeInner(ports: readonly number[], tag: string): stringexport function portBridgeHostArgv(tag: string, port: number): string[]
// macOS: the per-task seatbelt rules vx emits, and a path refused, never escapedexport function macProfileRules(c: ResolvedSandboxConfig): string[]export function sbplResolvedPath(value: string, field: string): string
// sandbox-paths.ts: the wildcard alphabet of a GRANT. Smaller than// util/paths.ts's BUN_GLOB_WILDCARDS on purpose — `{}` is a literal to a// grant (a brace grant gets a placeholder and is widened to its// directory; scanned, it matches nothing before the task writes). The// request builder reads it to decide whether an output grant is a glob.export const MOUNT_WILDCARDS: RegExpexport function isMountableLiteral(grant: string): boolean// A path with its existing prefix realpath'd and the rest re-appended.export function toRealPath(p: string): stringexport function absolutize(p: string, cwd?: string): stringexport function isUnderAny(abs: string, allow: Set<string>): booleanexport function unique(arr: readonly string[]): string[]export function localBindingOn(c: { localBinding?: boolean | readonly number[] }): boolean
// sandbox-binds.ts: the binds a write grant becomes on Linux (a file grant// widened to its directory), and a read grant cut around the walls a// project stops at (item 1010). The request builder refuses a write bind// that would hold a wall and punches the read grants.export function bindableWrites(paths: readonly string[]): string[]export function punchWalls(readPath: string, walls: readonly string[]): string[]export function punchWritePaths(readPath: string, writePaths: readonly string[]): string[]// The SRT customConfig: the baselines merged with the resolved blockexport function buildCustomConfig(args, baselines): SrtCustomConfig
// sandbox-violations.ts: what a trace or a seatbelt log reportsexport interface DeniedCall { syscall: string rawPath: string errno: string}export function deniedCalls(text: string): DeniedCall[] // strace lines, split calls pairedexport function parseStraceViolations(logPath, args, baselines): Promise<SandboxViolation[]>export function reportableViolations( violations: readonly SandboxViolation[], opts: { within: string; linked?: readonly string[]; config: ResolvedSandboxConfig },): SandboxViolation[]export function refusedWrites( records: readonly string[], writable: readonly string[],): SandboxViolation[]How it works
Section titled “How it works”probeSandboxasks SRT whether the platform is supported and whether its runtime deps (bwrap, socat and ripgrep on Linux — the runtime expands its mandatory deny globs withrg; sandbox-exec on macOS) are present — a missing one is named with the whole set and the install (dependencyReason) — then on Linux runs ONE sandboxedtruethrough SRT’s own wrapper — bwrap with the runtime’s namespace flags plus its vendoredapply-seccomphelper, which creates a nested user namespace. A barebwrap … /bin/truepassed on hosts where every task then failed (root inside a container: the helper’swrite /proc/self/uid_mapis EPERM under--cap-drop ALL); the wrapper probe refuses up front, naming the fix (a non-root user, orsandbox.weakerWhenNested: trueon every sandboxed task —run()probes the weaker mode only when every sandboxed task opts in). Memoized per mode. A throw from the runtime itself is the same one-line verdict, and one about its own temp files (the observer directory, the bridge sockets, the strace log all live underos.tmpdir()) names the knob: the sandbox runtime needs a writable temp directory and the one it has is not one, point TMPDIR at a writable directory. The probe also refuses up front a temp directory whose socket path is past the OS limit (sun_path, 108 bytes on Linux and 104 on macOS): past it the runtime said ENAMETOOLONG on macOS and “Failed to create bridge sockets after 5 attempts” on Linux, neither naming the directory; the verdict now gives the path, its length, the limit and “point TMPDIR at a shorter path”.initSandboxis called at most once pervx run, lazily: the first sandboxed execution arms the run’sprepareSandboxarmer (sandbox-request.md), which probes and then inits. It callsSandboxManager.initializewith a deny-all baseline (network blocked, no filesystem allows); per-task wrapping overrides those defaults.runSandboxedis called once per sandboxed task:- Prepends a unique
: 'vx-<hash>';shell no-op to the command so SRT’sgetViolationsForCommandcan disambiguate concurrent tasks with identical commands (it keys by base64 of the first 100 chars). - Builds a
customConfigby merging the baseline (dependency dirs and the workspace-root deny anchor) with the user’s resolved sandbox block, then appends the rules SRT’s config cannot carry. - Calls
SandboxManager.wrapWithSandboxto get the wrapped command string, spawns it viash -c wrapped— on Linux with the trace log on fd 5 for the strace inside — withsh,strace, and (through SRT’sbwrapPath/socatPath)bwrapand the network bridge’ssocatresolved on vx’s own PATH (util/which.ts), so neither Bun nor strace walks the task’s PATH, whosenode_modules/.bincomes first, for the shell; and captures stdout/stderr + resource usage likerunner.ts:runCommand, except that on Linux it reports no CPU and no peak: what bwrap’s pid namespace used never reaches vx’s wait (a 500 ms busy loop read 2 ms), and the peak read was vx’s own mark (B-3). - After
proc.exited, reads back any violations from the macOS log monitor (macOS only — see the Linux row below for why the store’s Linux feed is ignored) AND (on Linux) from the strace log the spawn wrote, then callsSandboxManager.cleanupAfterCommand().
- Prepends a unique
- Filtering. Enforcement anchors at the workspace root, but only
denials on a path inside
reportWithin(the project) or one ofreportLinked(the linked packages a cached task was denied because its key does not answer for them) are reported — every process walks from/down to its own cwd, and being stopped at the wall is the sandbox working. A record with no path (asystem-infoprobe) is kept. The task’signorepatterns are applied last: each list silences the operations of its kind, a pattern matching a record’s target exactly or as a glob. A relative pattern anchors at the project, a~one at the home directory (kept as written, it matched no recorded path, B-13), and a pattern’s literal head is canonicalized as the records are, so a project reached through a link (macOS’s/var) is silenced where it lands (B-2). resetSandboxtears down SRT’s proxy servers + (on macOS) the log monitor at the end ofvx run.
Platform behaviour
Section titled “Platform behaviour”| Platform | Behaviour |
|---|---|
| macOS | sandbox-exec + Seatbelt. Structured violations land in SandboxViolationStore via the system log monitor; we force exit 1 when any are recorded. |
| Linux | bwrap mount namespaces. Denied paths are structurally invisible → child sees ENOENT. The command runs under strace -DD -f --seccomp-bpf -e trace=openat INSIDE the sandbox and the trace is parsed for denials against the task’s own baselines (--seccomp-bpf keeps the ptrace stops to openat; without it every syscall stopped and a stat-heavy task ran many times slower — the cache perf baselines failed on the Linux job for that reason until 2026-09-09; strace < 5.3 gets the slow form). Inside, strace follows the command alone: wrapped around bwrap it followed the namespace’s setup too, and a sandboxed true cost 41 ms against 30 (min of 40, A/B interleaved, A/A within 2 ms; B-11). It writes to the host’s log through fd 5, which the command’s shell closes first. It is started from a fresh fork of the shell, with SIGINT and SIGQUIT put back (an async list starts with them ignored, and a task’s trap … INT would never fire), because -DD’s process waits for ANY child to hear the tracer attached: an inherited one that exited first (the watcher, SRT’s network bridges) sent the command on untraced, and its execve failed ENOSYS under the seccomp filter. -DD puts strace off the command’s line, so a sleep 10 & the command leaves is not a tracee strace waits for; the namespace ends with the command and takes strace along, and a tracee stops at each openat until its line is written, so none is lost. SRT ≥ 0.0.75 also feeds its store on Linux from the seccomp helper’s write observer, but judges those reports against the GLOBAL allowWrite from initialize (empty; the per-task list is in customConfig, which the monitor never sees), so every declared-output write arrives as deny openat <output>; vx judges those records against the task’s own binds (refusedWrites, the write grants as bwrap binds them), and what no bind covers is a violation. strace never sees a write: the observer’s USER_NOTIF takes precedence over strace’s TRACE, so a refused write (EROFS under a read-only bind) or one into the anchor’s scratch went unreported and a task that swallowed it exited 0 (B-5). The store is a 100-record ring shared by the run, so vx subscribes once and keeps each record for a command still running (collectRecords): read at exit, a refused write followed by 150 declared ones was already gone (B-7). |
| Windows | Not supported by SRT. probeSandbox reports unavailable; declaring exec.sandbox triggers a UserError before the run starts. |
wrapSandboxedCommand is the enforcement half on its own — the tagged command under SRT’s wrapper, vx’s seatbelt rules appended on macOS — and the persistent path spawns through it (executePersistentTask): a dev server declaring exec.sandbox gets the same walls and no violation report, since the report reads the trace after exit.
On Linux the command runs in a session, and so a process group, of its own inside the sandbox: : 'vx-<tag>'; { read -r s <&3 && kill -s "$s" -- -$$; } & exec setsid sh -c '<command>' 3<&-, both tools resolved on vx’s own PATH. sh, the shell an unsandboxed task runs: it was bash, and brace expansion, [[ … ]] and echo 'a\tb' read one way with the block and another without it where /bin/sh is dash (item 964). bwrap’s --new-session puts the runtime’s shells (the proxy bridges’ script, the seccomp step’s) in one group with the command, and kill 0 reaches a group’s members across the nested pid namespace, so a command that signalled its own group ended the runtime’s shell: bwrap exited 143 and the namespace’s teardown SIGKILLed the rest mid-trap (item 751). The shell execs setsid, which is no group leader there, so it execs without a fork and the command keeps the shell’s pid: an exit status and a signal death (137) are the command’s, as before. The cost is a setsid exec and a second shell, about 3 ms on a 35 ms sandboxed true (min of 15, three interleaved pairs). A cancellation reaches the command the same way (item 752): vx’s group signal would end bwrap’s monitor, and --die-with-parent SIGKILLs the namespace, so a trap … TERM never ran. The watcher forked before the exec reads a signal’s name off fd 3, which vx writes for SIGINT and SIGTERM (signalThrough, kill-tree.md), and signals the command’s group, $$; SIGKILL at the grace’s end still goes to bwrap’s group. The command runs in the foreground because an & command starts with SIGINT ignored, which a shell cannot trap; it does not get fd 3. wrapSandboxedCommand says so in forwardsSignals, and both spawns (runSandboxed, and runPersistent with signalChannel) pass fd 3 when it is set.
On Linux the wrapped command is exec /abs/bwrap …: SRT wrote a bare bwrap into a command the task’s shell runs with the TASK’s environment, so a dependency’s node_modules/.bin/bwrap ran in its place and the task ran unsandboxed, exit 0; initSandbox now hands SRT vx’s own paths for bwrap and socat (B-19). the spawn’s shell execs bwrap, so bwrap is vx’s own child and its --die-with-parent fires when vx dies, a kill -9 included. The pid namespace then takes every descendant, one that called setsid too. Behind a shell that waited on it, bwrap’s parent was that shell, which outlived vx, and a sandboxed server’s whole tree ran on under init (turborepo#9666; item 801, sandbox-runtime.unsafe.test.ts › “a sandboxed server’s backgrounded and setsid children die with vx”). A one-shot task traced for violations is the same exec bwrap …: its strace runs inside (B-11). A persistent task is never traced. strace failing on its own (a stderr line of its own, strace: …) ends only the attempt: the task runs once more, with a line saying why, and the second attempt is its verdict. Its exit is no longer the task’s — a tracer that dies leaves the command running untraced — so the line, not the exit, is the sign: the trace stopped short, and a denial after it would go unreported. Inside, the tracer shares the task’s pid namespace and uid, so a task can end it or reach its log through /proc: the violation REPORT is at the task’s mercy, as it never is for enforcement, which is bwrap’s mounts. The sandbox kept the first attempt’s writes to what it declared, so the second redoes rather than doubles them (STATUS Next 24, sandbox-tracer-retry.unsafe.test.ts). The trace log is the task’s own file under os.tmpdir(), removed once it is read; a second signal’s exit (process.exit) never reaches that read, so the process’s exit event removes every log still listed (item 848, sandbox-runtime.unsafe.test.ts › “a second signal exit leaves no strace log behind”). A first signal lets the run end and read the log itself (item 849).
A write grant under a directory with SYMLINKED entries (Bun’s isolated node_modules layout: every package is a link into .bun/) punches the read grant into that directory’s children, and bwrap mounts a linked child as the directory it points at — inside the sandbox the link is gone and a package resolved through it cannot see the .bun/ siblings its own dependencies live in (Cannot find package 'yargs-parser', the docs build, 2026-09-05 → 09-09). punchWritePaths warns naming the grant; the fix is to keep writable caches out of node_modules (astro’s cacheDir, vite’s cacheDir), since SRT’s config has no --symlink.
The strace pass closes the silent-swallow gap on Linux (tools that read
an undeclared path, catch the ENOENT, and keep running): the denial is
reported as a violation even though the task exited 0. Trace parsing
pairs <unfinished ...> with its <... resumed> line, so a denial in a
forked child is reported too — a single-line match dropped those, which
made the violation list incomplete under concurrency. Without strace
on PATH the sandbox still ENFORCES; only the structured list is lost. The
same holds where strace is present but may not attach (Yama’s
ptrace_scope 2 or 3, a container’s seccomp profile): --version
answers there, so detection also traces true once per run with a
task’s own flags (about 9 ms), and a refusal means no tracing, said once
on stderr. Before, every sandboxed task failed twice on
attach: ptrace(PTRACE_SEIZE…): Operation not permitted (B-18).
A task that failed with nothing to show gets vx’s own notes beside the
failure, each a SandboxViolation marked hint: the cwd it cannot read
(the one denial macOS never logs), a write placeholder it never wrote, a
dependency read through a withheld link. They are shown with the denials
and never counted as one, so the (N sandbox violations) label and
fail-on-violation count denials only; the placeholder’s note had read
“1 sandbox violation” beside a failure of the task’s own. The cwd note
asks whether a read grant covers the cwd, and on Linux also whether one
lies inside it: bwrap builds the path to a bind, so the cwd lists, and a
root’s read: ['.'], bound as its children around the walls, drew the
note on every failure (B-20).
SRT’s in-sandbox network bridge is socat TCP-LISTEN:3128 (and 1080),
which socat 1.8 opens as an IPv6 socket. On a host without IPv6 it
failed (“Address family not supported by protocol”) into /dev/null, and
every networked task met only a refused connection on the proxy. There
the wrapped command sets SOCAT_DEFAULT_LISTEN_IP=4, socat’s own switch
for the listen family (hostHasIpv6: /proc/net/if_inet6, asked once);
a host with IPv6 is untouched (B-22).
initSandbox names the JVM proxy agent SRT ships (javaAgentJarPath,
bundledJavaAgent). SRT’s own search builds its candidate list with
npm root -g in it before trying the bundled path, so every init
spawned npm, about 110 ms of vx info and of a run’s first sandboxed
task. Where the jar is not on disk (a compiled vx), SRT searches as
before (B-25).
A task’s temp directory
Section titled “A task’s temp directory”SRT points every sandboxed task’s TMPDIR at one host directory
(CLAUDE_CODE_TMPDIR, else /tmp/claude), bound read-write and kept
across runs. A file one task wrote there was the next task’s, and the
next run’s, undeclared input: a cached reader replayed the first value it
saw after the writer changed it (item 965). Each task now gets its own,
vx-tasks/vx-task-<pid>-<tag> under it, exported as TMPDIR after the command’s
tag (SRT keys violations by the first 100 characters), created before the
spawn and removed with the task’s bridges at its end, or at exit. A
kill -9 runs no exit hook and leaves it. A sweep of the directories whose
owner’s pid is gone was tried and refused: a nested vx (this repo’s own
test shards) sees another pid namespace, where the outer vx’s pid reads as
dead, and the sweep removed the outer task’s TMPDIR mid-run. The shared
directory itself stays writable (SRT’s policy grants it): a command that
names it outright still reaches it. vx-tasks is walled from every
sandboxed task, each granted its own directory inside (L-10): under the
shared directory a task listed a concurrent task’s TMPDIR, read what it
kept there, and could replace its port bridge’s socket, which lives in the
task’s own directory too.
vx-tasks and each task directory are mode 0700, and vx-tasks must be
this user’s own real directory in a parent no other user may rewrite
(others’ write needs the sticky bit, as /tmp has): the shared directory
is shared across users too, and one who made vx-tasks first renamed a
running task’s directory and planted their own under the name the host
bridge dials (L-13). Anything else refuses the sandboxed task and names
the directory; one of ours left open is closed.
The environment SRT sets
Section titled “The environment SRT sets”With the network restricted, SRT sets its own values over the ones vx
built for the task: SANDBOX_RUNTIME, TMPDIR (above), the proxy
variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, GRPC_PROXY,
NO_PROXY and their lowercase forms), GIT_SSH_COMMAND,
GIT_CONFIG_PARAMETERS, and the CA-bundle variables when it has a CA
(NODE_EXTRA_CA_CERTS, SSL_CERT_FILE and the others in its
CA_TRUST_VARS). A task’s define or passThrough of one of these
names does not reach a sandboxed task: the sandbox’s network goes through
SRT’s proxy and nowhere else.
JAVA_TOOL_OPTIONS is the exception vx repairs. SRT composes its proxy
agent’s flag with the value in VX’S environment, so a host value that no
layer passes reached the task out of its key, and a changed host value
replayed the old output; a task’s own value never arrived (item 995).
The command’s prefix now cuts the host’s value out of what SRT set and
appends the task’s own, so the task sees the agent flag plus exactly what
its layers gave it. Where SRT left the variable alone it already holds
the task’s value and the prefix changes nothing, and where the task’s
value IS the host’s (passed through, or neither has one) SRT’s own
composition is right and no prefix is written (item 1007).
Path canonicalization
Section titled “Path canonicalization”Every path the policy is expressed in is canonicalized (realpath, with
non-existent suffixes re-appended) before it reaches SRT — the user’s
allow.read / allow.write, the orchestrator’s dependency dirs, and the
workspace-root deny anchor. The sandbox matches on
canonical paths (macOS Seatbelt evaluates real vnode paths; bwrap mounts
inside a new root), so a workspace reached through a symlink must not
express half its policy in link paths and half in real ones. Before this
was applied to the orchestrator baselines, such a workspace made every
sandboxed task die with bwrap: Can't mount tmpfs on /newroot/<link>.
Canonicalizing a WRITE grant is also how a link moved it: out.txt -> ../b/src/x, committed or planted by the task’s own previous run, bound
project b’s directory writable, and vx, unsandboxed, created the empty
placeholder at the link’s target first (item 1003). A project-relative
write grant whose path through its links (a dangling last one included)
leaves the project is now refused before anything is created, a
placeholder is made only where nothing is (an exclusive create after an
lstat), and the sweep takes back only a regular file. A read grant may
still resolve out through a link: node_modules links into the store.
A GLOB grant is expanded on Linux to its hits, each bound, and bwrap binds
a link by its target: read: ['*'] over shared -> ../b/src bound
project b readable where read: ['.'] did not, and a cached task
replayed b’s old bytes (item 1006). A hit whose real path leaves the
directory holding the pattern’s first wildcard is now dropped, so a glob
reaches no further through a link than the directory grant would. The
baseline node_modules reads are granted apart and are unaffected.
The walls a project stops at
Section titled “The walls a project stops at”The key of a project excludes the projects nested in it, and the deny
anchor is the workspace root, which is a ROOT project’s own directory: its
read: ['.'] bound every nested project, .git and .vx readable, and a
cached root task replayed a nested file’s old bytes; a file grant there
(write: ['out.txt']) is widened to its directory, which bound the whole
workspace writable, .git included (item 1010). The request now carries
the node’s nested project directories, and with the root’s .git and
.vx they are walls: on Linux a read grant containing one is punched
around it (punchWalls, the write-path punch with the wall dropped), on
macOS each wall is a read deny, which SRT emits after a literal grant
holding it so that the deny wins (B-4), and
a write grant whose bind, widened or not, would hold one is refused,
naming the wall. A grant that names a wall or a path inside it is the
user’s on purpose and stays, as does a bind outside the workspace (/tmp,
~). A glob’s hit that is a wall or lies inside one was matched, not
named, and is dropped before the bind (resolveSandboxConfig takes the
walls): read: ['*'] in a root project binds neither .git nor .vx,
packages/* no nested project, and write: ['.*'] is refused for no wall
(B-1). Seatbelt matches a glob as a path regex, with no hit to drop, and
SRT re-emits a wall’s deny after the allows only under a literal grant:
a root project’s read: ['**/*.txt'] read nested projects and .git on
macOS. Each wall a glob grant reaches (one at or under the glob’s literal
head) is denied at the profile’s tail, reads as file-read-data like
SRT’s own wall denies, writes as file-write*, with a literal grant at
or inside the wall carved out, a baseline’s included (darwinWallRules,
B-12). A custom cacheDir inside a project is not a wall.
A write grant that names a file
Section titled “A write grant that names a file”On Linux a grant is a bind mount, and a grant naming a FILE binds that
file — you cannot rename onto an active mount point. Every tool that
writes its output by staging beside it and renaming (bun build --compile, most compilers, any atomic writer) then dies with EBUSY.
Minimal repro, 2026-09-05: under bwrap --bind /w/dist/out.bin /w/dist/out.bin, mv /w/s /w/dist/out.bin is “Device or resource busy”;
binding /w/dist instead succeeds.
bindableWrites therefore widens a file-shaped write grant to its
directory ON LINUX ONLY. It is a widening — the task may write that
file’s siblings — and it is the narrowest thing the mechanism can
express; the alternative is a declared output the task cannot produce.
macOS matches paths rather than mounting, so the grant stays exact there.
A write grant that mounts nothing
Section titled “A write grant that mounts nothing”A bind covers what exists when the task STARTS, so a Linux write grant whose pattern matches nothing yet binds nothing at all. Measured, one task per spelling, each writing the files it declares:
allow.write | outcome |
|---|---|
g/** | ok — collapses to the directory |
g/a.txt | ok — a file-shaped grant, widened to its directory |
g/* | bash: g/a.txt: Read-only file system |
g/*.txt | same |
g/?.txt | same |
g/[ab].txt | same |
That is the documented contract rather than a defect, but the failure
names neither vx nor the grant, so expandGrants reports the grant
itself — once per grant, before the task runs — and names the directory
to grant instead (grantPrefix, the directory the pattern was in, not
the scan’s anchor one component above it). Read grants are not reported:
a read matching nothing is ordinary.
write: ['g/{a,b}.txt'] is ok, because the classifier here counts only
*?[] and a brace-spelled grant is therefore treated as a file — placed,
then widened to its directory. It deliberately does NOT read
BUN_GLOB_WILDCARDS, Bun.Glob’s own set, which also counts {}
(nor isLiteralPattern, the task-glob predicate item 495 unified, where
a bracket is literal since item 667): doing so would move that spelling into the scan and turn a
working grant into Read-only file system. The two predicates answer
different questions — whether a declaration must be MATCHED against
other declarations, and whether a grant can be MOUNTED.
macOS cannot nest
Section titled “macOS cannot nest”sandbox_apply is refused inside a sandboxed process, at any permission
level — an inner sandbox-exec with a (allow default) profile still
dies with sandbox_apply: Operation not permitted (exit 71, measured
2026-09-05, pinned by tests/sandbox-runtime.unsafe.test.ts). A task that
itself sandboxes something therefore cannot be sandboxed on macOS, which
is why @vzn/vx#test.bun.shard-* is the one task in this repo with no
sandbox block. weakerWhenNested covers the Linux case; SRT offers no
macOS equivalent because there is none to offer.
Loopback
Section titled “Loopback”A runtime that opens a dual-stack socket reaches 127.0.0.1 as
::ffff:127.0.0.1, and seatbelt’s only host tokens are localhost and
* — no rule can name that form. The first loopback connect is therefore
denied, the runtime retries on AF_INET and succeeds, leaving one
addressless deny(1) network-outbound record behind. It happens for a
task’s own server under localBinding, and again for SRT’s proxy
whenever the task declared any network at all, so under either grant the
record is dropped: no config can silence it and it carries no
information. It is not a hole — a connection that actually left the
machine goes through that proxy, which reports it WITH host and port.
Integration points
Section titled “Integration points”src/orchestrator/run.tscallsprepareSandbox(nodes)(sandbox-request.ts): null when no node declaresexec.sandbox, else an armer whosearm()runsprobeSandbox+initSandboxonce, on the first sandboxed execution (execute-task.tsawaits it before the request).resetSandboxruns at the end if it was armed.- Execution goes through the placed
TaskExecutor; the local floor (exec/local-executor.ts) callsrunSandboxedinstead ofrunCommandwhen the request carriessandbox. On violationsexecute-task.tsforces exit 1, appends violation lines to stderr, and surfaces the count onTaskOutcome.sandboxViolations.
Why fail-on-violation?
Section titled “Why fail-on-violation?”The user-facing contract: “if your task can succeed without an
undeclared path, the sandbox is invisible; if it tries to reach one,
you find out immediately.” Without fail-on-violation, a task that
tolerates ENOENT (e.g. probes for an optional ~/.foorc then
proceeds without it) would silently mask a leaked dependency — the
cache would store output as if no undeclared read happened. Failing
the task surfaces the problem early so users can update their
sandbox.allow.read (or accept the leak by adding the path) before
shipping a build that depended on it.
Port bridge (Linux)
Section titled “Port bridge (Linux)”A localBinding port LIST is bridged out of the task’s network namespace
(bwrap --unshare-net sees no host port either way). wrapSandboxedCommand
prefixes the sandboxed command with portBridgeInner: one
socat UNIX-LISTEN:<tmpdir>/vx-port-<tag>-<port>.sock,fork TCP:127.0.0.1:<port>
per port, backgrounded and reaped with the shell (as SRT starts its own
proxy bridges), and spawns the host side, portBridgeHostArgv: one
socat TCP-LISTEN:<port>,bind=127.0.0.1,fork UNIX-CONNECT:<sock>,retry=…
per port. The unix socket lives in the sandbox tmpdir, bound read-write on
both sides. The task’s side has to CREATE a unix socket under SRT’s seccomp
filter, so prepareSandbox passes allowAllUnixSockets when any task
declares a port list (or unixSockets), and wrapSandboxedCommand then
sets SRT’s lift for each task’s own wrap, one wrap at a time: SRT reads it
from its run-wide config, and a run-wide lift let a task that declared no
socket reach the host’s docker or ssh-agent socket (L-6). releaseBridges(tag)
stops the host side: runSandboxed calls it after the child exits (and
when the spawn itself fails), the persistent path on the server’s exit,
resetSandbox for whatever is left. Each host socat is spawned through
spawnGuarded, in a group of its own, and its group is SIGTERMed and
struck from the guard’s list once it has exited (kill-tree.md): a plain
child of vx was in no group the guard lists, and a kill -9 of vx left it
listening under init, where the next run’s bridge could not bind the port
(item 873, sandbox-runtime.unsafe.test.ts › “a kill -9 of vx takes the
host side of a port bridge with it”).
releaseBridges also unlinks each port’s socket. The task’s socat dies
with the namespace and never removes it, so every bridged run left one
socket in the tmpdir (176 on one box, item 877). The sockets are listed
with the exit hook that removes strace logs, so a Ctrl-C mid-task takes
them too (sandbox-bridge-socket.unsafe.test.ts).
A persistent task’s sandbox outlives its run. wrapSandboxedCommand
takes server: true from the persistent path and lists the tag as a
live server. resetSandbox, which every run calls at its end, releases
only the bridges no live server owns, and while one runs it defers SRT’s
reset. That server’s releaseBridges, on its exit, runs the deferred
reset. Before item 882, a foreground vx run dev or a vx watch held a
server past a reset that had already released its port and SRT’s
proxies, and the port went dark ~40 ms after the summary.
That deferred reset runs unawaited from the server’s exit, and a watch
cycle stops its server and starts its next run at once. initSandbox
therefore waits for a reset in flight: an init under it found SRT up,
hot-reloaded it, and had it torn down after (item 884).
Pinned in the unsafe suite on Linux: a sandboxed server on a listed port
answers a downstream task’s fetch and the host’s, and after the run the
port is closed; the control with localBinding: true is refused.
macOS has no network namespace to bridge out of: localBinding, a list
or true, lets the task bind any loopback port and reach any, the
host’s own services included (macProfileRules). Narrowing a list to its
ports would refuse the ephemeral port a task’s own test server binds,
which Linux allows inside the namespace, so the list stays wide there:
on macOS a task under localBinding can read a local service vx does
not key on (B-13).