Security model
What vx trusts, what it checks, what the sandbox stops and what it does not, and how to report a problem. Report a vulnerability privately through GitHub’s private vulnerability reporting on vznjs/vx (Security → Report a vulnerability), never in a public issue.
What vx trusts
Section titled “What vx trusts”- Your configs.
vx.config.tsand.vx/workspace.tsare programs vx runs in its own process. A config can do anything you can: vx validates what it returns, not what it does. The workspace root is the nearest.vx/workspace.*, and vx never looks for it above the git top-level, so a file planted in a shared directory such as/tmpcannot claim your checkout and run as you. Outside git, one another user owns is refused, root aside, judged by the file itself (a link, not what it names) and its.vx(tests/workspace-root-file.test.ts). No task output reaches the root’s.vxor a config’svx.lock.json: a clean never deletes them and a hit never restores them (tests/output-never-dotvx.test.ts). What a config imports must already be installed: vx runs Bun with--no-install, so an import nonode_modulesprovides is refused, never fetched from the registry (L-22). - Your projects lock. A run executes
.vx/projects.lock.jsonas written and never re-evaluates or re-validates it, so the lock is code too. It is minified and marked generated, which collapses it in a pull request’s diff: a change to it alone changes what runs while every config andvx.lock.jsonreads the same. Runvx lock --checkin CI before anything else; it fails on any entry that differs from what its config evaluates to and on one with no config (tests/lock.test.ts). - Your project’s own vx. A global vx hands every call to the
workspace root’s
node_modules/.bin/vx(cli.md § Which vx runs), so evenvx --versionin a repo you do not trust runs that repo’s code. One another user owns is never run. - Your plugins. A plugin is code you imported. It runs in vx’s process with a config’s reach; vx checks the shape of what it hands back and isolates a telemetry plugin’s failures, nothing more.
- Your lockfile and your repo. vx parses a lockfile (and
turbo.jsonor an Nx graph when migrating) into keys and refuses one it cannot read, naming the file. A lockfile that names another install is a different key, never a code path. A file vx generates (vx init,vx lock’s review copies,@vzn/vx-migrate,vx dte init) is never written through a symlink the repo carries: vx refuses it, naming where it points. vx runsgit status,git ls-filesand their kin in the checkout, so a repo-local git config that runs code (core.fsmonitor, a filter) runs as it would for a plaingit statusthere: vx adds no trigger git lacks. - Whoever can write your remote cache. A cache key names the
inputs, not the bytes, so a store that answers a key with bytes of its
choosing is trusted as a writer, as it is in every action cache. Use a
store only your builds write to, or
turboCache()’s signature key, which refuses an artifact a keyholder did not sign. A job you do not trust (a fork’s pull request) reads at most:--cache-policy=local:rw,remote:rorcache.policy. Core’s policy alone decides; a cache plugin reads no tool’s own switch (TURBO_CACHE,NX_SKIP_REMOTE_CACHE) and cannot narrow it.
Cache poisoning
Section titled “Cache poisoning”A pull request that can write the cache the default branch reads can plant bytes main later replays (CREEP, CVE-2025-36852). The boundary is the credential, enforced by the cache server: the default branch’s job holds a token that writes the default keys, and a pull request’s job holds one that cannot (read-only, or confined by the server to its own namespace), or none.
Task keys carry no branch (2026-10-20): the server decides both halves.
A write lands where the CI OIDC token, checked against the server’s
trust policy, allows it: the token’s own branch, or nowhere.
No header, key or client option changes that. turboCache() only
steers reads: it names the run’s refs (x-artifact-read-scopes), or
keys each entry on its ref (scope: 'key') for a server that ignores
the header. Whether a run writes at all is cache.policy’s call: off
CI the default reads the remote and writes nothing, since a key holds
only declared inputs and a laptop’s undeclared tool or file would
replay on CI. A laptop that should never write gets a read-only token
too. The local cache never knows a scope (2026-10-17): one disk
is one pool under the task keys. So a persistent runner that runs
untrusted pull requests and main on one disk shares their entries; give
such a runner its own cache.dir per trust level, or an ephemeral
disk.
A boundary the server holds, with bazel-remote and @vzn/vx-reapi: reads
are open, writes need a password, and only main’s push job has it.
htpasswd -cB /etc/bazel-remote/htpasswd ci # the write credentialbazel-remote --dir /data --max_size 50 --grpc_address 0.0.0.0:9092 \ --htpasswd_file /etc/bazel-remote/htpasswd --allow_unauthenticated_reads// .vx/workspace.ts: the header only where the secret is setreapi({ endpoint: 'grpcs://cache.example.com:9092', headers: process.env.CACHE_AUTH ? { authorization: `Basic ${process.env.CACHE_AUTH}` } : {},})# .github/workflows/ci.yml: CACHE_AUTH (base64 of ci:<password>) is a# secret of the `cache-write` environment, whose only deployment branch# is main, so no other branch's workflow can read itjobs: build: if: github.event_name != 'push' || github.ref != 'refs/heads/main' steps: - run: bun vx run build --all build-main: if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: cache-write env: CACHE_AUTH: ${{ secrets.CACHE_AUTH }} steps: - run: bun vx run build --allA pull request’s job has no CACHE_AUTH, so the server refuses its
writes whatever its config says. Never give the write secret to a
pull_request_target, issue_comment or workflow_run job that checks
out a pull request’s code: it runs with main’s ref and secrets, and
github() scopes it to the PR only on the client side.
A boundary that holds whoever can write the store is a signing cache
plugin: main’s CI signs each upload, and a reader refuses, as it streams,
a download no trusted key signed, so the hit is a miss. Core needs no
change for it; the plugins guide (§ Your own cache › Signed artifacts) has a
complete one, run by tests/signed-cache-guide.unsafe.test.ts.
What vx checks on bytes it did not write
Section titled “What vx checks on bytes it did not write”Every artifact that arrives from a remote, and every one read back from the local store:
- ends in a CRC-32 of its entries: a byte changed in transit or at rest makes the hit a miss, never a replay of the damaged bytes;
- on arrival, records the cache key it was packed under, and is refused under another (a local read-back is not re-checked);
- on arrival, carries only the files the task declares as outputs;
- lands each file under the task’s project or declared workspace path:
traversal (
..,., absolute names) is refused before anything lands, a refused archive leaves nothing behind, and links and devices are never written; a seeded fuzz of hostile archives holds this; - is bounded: a decompression bomb, an oversized header or body, and a remote answer larger than asked for are refused as they are read.
Remote execution (@vzn/vx-reapi) holds each blob to its digest, and
writes only the server’s outputs a declared glob names, inside the
workspace, through no link that leads out of it, and through no link
into a place no declared glob names.
What the sandbox stops
Section titled “What the sandbox stops”A task with exec.sandbox runs where only what it declares exists:
- reads of the workspace outside the task’s project and grants fail;
- writes outside its
allow.writegrants fail (declared outputs grant none); on Linux a grant naming a file binds its whole directory, so a write to a sibling there lands and is reported as a violation instead, as an open or hard link of one is (astatof one is not seen); - the network is closed except to the domains granted, one union per
run: a task granted any domain reaches every domain the run grants;
with none, it reaches no listener on the host’s loopback and no unix
socket outside the workspace (L-46);
localhost:<port>reaches that one port of the host’s loopback (a dependency’s server), relayed into the task’s own network on Linux; - its temp directory, port-bridge socket and trace log are its own (mode 0700), unreachable from another task and another local user;
- the host’s credential stores (
~/.ssh,~/.gnupg,~/.aws,~/.npmrc,~/.netrc, the token files of deploy and agent CLIs such as Vercel, Wrangler, Fly or Claude, and the like; the list is in the schema’s sandbox section) are unreadable unlessallow.readnames one (L-41), cannot be hard-linked into an output for vx to pack, and neither a denied read’sfix:hint nor the note naming the paths the wall hid ever suggests granting one, or vx’s own store (tests/sandbox-credential-stores.unsafe.test.ts,tests/sandbox-fix.test.ts,tests/sandbox-hidden-reads.unsafe.test.ts); - vx’s own cache directory, wherever
cache.dirputs it in the workspace, is a wall like.vx/cache: a broad grant stops at it, and a write grant that would bind it, or that lies inside it or.git, is refused (L-24);~/.vx, which holds every repository’s shared store, and a cache directory outside the workspace are unreadable to a task; - a symlinked output is cached as the link, never read through: vx packs
outputs outside the sandbox, and packing a link as its target’s bytes
carried a file the task could not read into the cache (L-23). A link
must be relative and stay inside the workspace, on save and again on
restore, where a link out of it fails the whole restore. Nor may it
reach its target through another link of the same outputs: each
target is checked as text, and
up -> ..withesc -> up/../../xeach read as inside whileesclands outside. Another link as the target’s LAST hop is no such step (a shared library’s soname chain,libu.so -> libu.so.1): the kernel lands where that link lands, and that link is checked on its own, so the chain is kept unless it loops or runs past 8 hops. An output directory linked out of the project refuses the task by name (X-88). Linux judges making a link by the link, never its target (tests/sandbox-output-link.unsafe.test.ts). - vx’s own environment, which holds the variables a task is not given
(a token, a key), is unreadable: the task cannot see vx’s
/proc/<pid>/environon Linux or read it throughps ewwon macOS (tests/sandbox-parent-env.unsafe.test.ts).
An undeclared touch of the task’s own files fails the task, and a failed task is never cached. See Sandboxing tasks.
The sandbox costs each task it runs; a cache hit runs nothing and costs
nothing. tests/sandbox-parent-env.unsafe.test.ts prints the cost of one
true task, min of 20, on every CI run. On GitHub-hosted runners on
2026-10-13 it printed +269 ms on Linux and +97 ms on macOS.
What it does not stop
Section titled “What it does not stop”- A task with no
exec.sandbox. It runs with your permissions: it reads anything you can. vx hides its own environment from it on every command: on Linux vx makes itself non-dumpable (about 0.8 ms), on macOS it zeroes the valuesps ewwreads, every variable but the essentials every task gets (about 0.7 ms). It starts its helpers (the macOSpsthat samples a running task,stty) withPATHalone and eachgitwith only what git reads. The sandbox’s host network bridge, which lives the whole run, and vx’s sandbox probes get the essentials only (tests/hide-env.unsafe.test.ts,tests/git-env.test.ts,tests/proc-sample-env.test.ts,tests/sandbox-helper-env.unsafe.test.ts). That hides the environment only, not files. - Reads outside the workspace root.
~/.cache,/etcand the like stay readable (tools need them) and fold into no key. The credential stores under home (~/.ssh,~/.aws,~/.npmrcand the like) are not: a task reads one only when itsallow.readnames it (L-41). - Resource use. CPU, memory and disk are bounded by timeouts and the artifact ceiling, not by the sandbox.
- A weaker sandbox where the host cannot nest one:
weakerWhenNestedin a container, a task that itself sandboxes on macOS.vx infosays what the host supports.
Secrets
Section titled “Secrets”The value, of 6 characters or more, of a variable whose name holds
TOKEN, SECRET, KEY, PASSWORD, PASSWD or CREDENTIAL (not one
ending _FILE, _PATH or _DIR, nor GIT_CONFIG_KEY_<n>), or that a
task lists in exec.env.secret, the password, also of 6 characters or more, of a URL
(scheme://user:<password>@, split at the last @ before the path as a
URL parser splits it) in any variable’s value, and the sandbox proxy’s
session token a networked task’s env carries, is printed as *** wherever vx shows, stores or
exports it: task output, the stdout a hit replays, commands, an
executor’s error, telemetry,
vx show (L-26). Values in the run history are digests under a per-store
salt, and what follows -- on the command line reaches telemetry as a
count, not a quote, a secret before it masked (L-44). The origin
remote’s URL reaches telemetry, a branch’s cache scope and the files
vx dte init writes with no userinfo, query or fragment. A remote executor (@vzn/vx-reapi) receives a
task’s exec.env.define values and the inputs.env values its
local child gets, never passThrough; those values sit unmasked in the
action’s Command, which the remote stores in its CAS, as Bazel’s
--action_env does, so a secret a remote task needs is trusted to that
remote. vx lock refuses to write a lock holding a secret value, or
any piece of one the output masks (a URL’s password, a key’s line),
including a secret-named define read from a variable of any name
(API_TOKEN: process.env.MY_CREDS): the lock is committed (L-42), and
vx lock --check fails such a lock. A value the config’s own text
spells, in the config or a local module it imports (not one under
node_modules), is committed already and locks: a literal define, or
docker compose up postgres while DATABASE_URL’s password is
postgres.
Masking covers the text vx shows and stores, not a task’s outputs: they
are cached and uploaded as the task wrote them, so a build that bakes a
secret into a file (a server bundle reading process.env.API_KEY) hands
it to every reader of the remote cache. Keep secrets out of cached
outputs, or give that task no cache. vx does not scan outputs for
secret values: a name-based match would refuse public *_KEY values too.
Releases
Section titled “Releases”Release binaries carry a build-provenance attestation
(gh attestation verify vx-<target> --repo vznjs/vx); vx upgrade
installs a binary only after npm’s registry signature over the platform
package’s name@version:integrity verifies against npm’s key, pinned
in vx, and the tarball matches that integrity: the host that serves the
bytes cannot make the signature, so a swapped tarball is refused before
anything is written, and a key id vx does not pin is refused with a hint
that the installer needs updating (2026-10-13; pnpm’s installer
checks the same). The signed version must be the one asked for, and
latest below the running vx is refused, however vx was installed, so
a mirror cannot downgrade vx to an older signed release; the tarball
URL must be https. The new binary is staged in a fresh directory, never a
name another user could plant a link at. A vx a package manager
installed is upgraded by that manager, which checks its own integrity.
npm packages
publish with provenance; every third-party action in CI runs from a
full commit SHA; and a workflow hands an event’s or a dispatcher’s value
to a script through env, never pasted in (a release tag is checked as a
version first); every workflow names its token’s permissions, and CI’s
is read-only. A test holds each rule.
The publishing workflows build a release tag only: release.yml checks
out refs/tags/<tag>, and npm.yml a dispatch’s ref as a tag, so a
dispatch naming a branch or a commit builds nothing. npm’s trusted
publisher takes a token only from a job of the npm environment, whose
branch rule admits main and the release tags
(tests/auto-release.unsafe.test.ts).