Skip to content

v0.5.6

Choose a tag to compare

@github-actions github-actions released this 24 Aug 17:30
· 1648 commits to main since this release

Upgrade if you use nvx at all. A family of commands that download and run untrusted package code was not being sandboxed, and this fixes it. Everything 0.5.5 was going to ship is here too — 0.5.5 was blocked by an independent review before it was published, and never released.

Commands that run untrusted code are now contained

npx was sandboxed. The same operation spelled any other way was not:

Command Before Now
npm exec, pnpm dlx, yarn dlx, bun x not contained contained
npm create, npm init <initializer> not contained contained
npm update, npm rebuild, npm dedupe, npm audit fix not contained contained
yarn upgrade, pnpm update, bun update not contained contained

These ran with no sandbox, no vulnerability scan and no typosquat check. npm exec cowsay fetched a package from the registry and ran it uncontained, while npx cowsay — the identical thing — was contained. npm rebuild re-runs every dependency's install scripts. npm audit fix is the command you run because of a security advisory. npm create vite is how a project begins.

README said the opposite in four places. No limitations section mentioned it, and no test covered it either way — the classification tests listed npx, bunx, uvx, pyx and stopped, so the gap was invisible from both directions at once. It was worst under --agent-mode, which suppresses the "Running directly (not sandboxed)" line that was the only signal you'd have had.

The reverse is tested too: npm run build, npm test, a bare npm init and npm audit without fix still run uncontained. A security tool that contains everything is one people switch off.

--strict is no longer read from a command's own arguments

All three containment flags — --no-sandbox, --standard, --strict — now work only before the command. Written after it they belong to the command, and nvx passes them through and tells you they did not apply.

--strict used to be honoured anywhere, on the reasoning that it only ever adds containment so smuggling it gains nothing. True of an attacker, wrong for everyone else: --strict is TypeScript's most-used flag and ESLint's. nvx tsc --strict meant "typecheck strictly" and nvx read it as "sandbox this", moving the command somewhere its writes outside the project go to a throwaway home — and on Windows such a write reports success, so a build could appear to work and produce nothing.

A server inside the sandbox can be reached from the host

A contained npx vite used to bind its port, print that it was listening, and serve nobody. Windows refuses connections into an AppContainer and no setting changed that, so the only way to use a dev server was to turn the sandbox off.

nvx --expose 5173:8080 npx vite

Give the port your server uses inside, then the port you want to visit. Also settable per project as isolation.network.expose_ports: ["5173:8080"]. Leave the second number out and nvx picks a free port and prints the URL.

The two numbers cannot be the same, and that is a property of Windows rather than a design choice. An AppContainer shares the host's network stack instead of getting its own, so a port bound inside is occupied outside too — with both set to the same number the contained server loses the race and dies with EADDRINUSE.

Nothing is relaxed to make this work. The contained side dials outward and the connection is reused in reverse; no network permission is granted, and what the sandbox can reach is unchanged. The test asserts both halves in the same run: the host reads the page, and the same contained process still cannot reach the internet.

Publishing a port from a checked-in project file asks for your approval, the way an egress allowlist entry does. It puts whatever the sandbox is serving onto localhost, where a browser treats it as trusted.

Contained commands are faster

Measured on Windows 11, a warm contained command: ~650ms before, ~390ms after. Bare node -e 0 on the same machine measures ~210ms, so most of what is left is Node starting rather than nvx.

Nothing about the sandbox changed. nvx was re-reading every file permission on every launch: 17 icacls processes per command, each measured at ~20ms. Sandbox setup measured ~410ms in total, of which the permission phase alone measured ~250ms. The permission work is trivial; starting a process to ask for it was the cost. nvx now remembers the answers it has already verified.

Only positive answers are remembered, which is what makes that safe: a stale entry can make a command fail, and can never make the sandbox more permissive. They expire after a week, and any failed launch clears them, so a permission removed behind nvx's back repairs itself on the next run.

More of the security claims are now checked by a machine

This is the part with no visible behaviour change and the most substance.

Windows containment can be re-checked by running one script. Hosted CI runners cannot start an AppContainer at all, so Windows had no automated containment gate — its claims rested on someone having tested them by hand at some point. scripts/sandbox-enforcement-windows.ps1 now asserts the five outcomes that matter, and CONTRIBUTING.md records it as a step before a release.

macOS proves three things it previously only described: that an allowlisted host is actually reachable through the proxy, that UDP is blocked, and that nvx refuses to run at all if sandbox-exec is missing. The first matters most — every earlier macOS check ran with an empty allowlist, so all of them would have passed against a sandbox that had failed to start entirely.

Fixed

  • A hung sandbox launch no longer takes the whole Windows test suite down with it, reporting a runner's limitation as a product failure.
  • Two CI checks were skipping for the wrong stated reason, which is worse than skipping loudly: the logs claimed one cause while a different one applied.

Also fixed, from the review that blocked 0.5.5

These were found by an acceptance pass over the tagged build, before it was published.

nvx was taking your program's arguments away from it. It read its own flags out of a wrapped command's arguments and removed them, anywhere in the line, past --, silently:

nvx npx tsc --strict            -> tsc ran WITHOUT --strict
nvx npx electron --no-sandbox   -> electron never saw it
nvx node app.js -- --strict     -> stripped past the end-of-options separator

Those names are not nvx's to take. A non-strict typecheck was being reported as a strict one, with no error, and it happened to uncontained commands too. nvx now notices these flags without confiscating them, and stops reading at --. Nothing about the anti-bypass rule changed: a weakening flag smuggled through a package manager is still refused, you are just told, and your program still receives what you typed.

Three checks were weaker than they looked. The loopback-exemption warning — the only mitigation for a hole the documentation calls serious — was covered by a test that only ran on a machine already carrying an exemption, so it never ran anywhere. The Windows containment gate skipped on any launch failure, so a regression breaking every launch would have looked like an environment limit. And the release workflow built from a commit without waiting for its cross-platform CI. All three are fixed, and the first now has four tests covering the branch that could not previously be reached.

Verified on both platforms

The containment split was checked command by command against the real binary on Windows and on Linux, and matches: npm install, npx, npm exec, npm create, npm update, npm rebuild, npm audit fix, npm dedupe, pnpm dlx and bun x all take the sandbox path; npm run build, npm audit, a bare npm init and npm test do not.

It could never have differed by sandbox backend: nvx decides whether to contain before it picks Docker, WSL or the native provider, so all of them got the same answer.

Honesty notes

macOS still does not contain filesystem reads. A contained install can read ~/.ssh, ~/.aws and ~/.npmrc. This is deliberate and explained in docs/enforcement-matrix.md, and it is now asserted on purpose — the probe requires that read to succeed, so tightening the profile fails the build and forces the documentation to be updated in the same change.

One macOS claim is still not made. The probe watches an outbound connection be refused, but nothing distinguishes a failure at DNS from one at connect. On macOS that difference is real, so it is left open rather than rounded up.

--expose is Windows-only, because Linux and macOS never had the problem: a contained server is already reachable there.

docs/enforcement-matrix.md states, cell by cell, what is measured, what is checked automatically, and what still rests only on the generated policy.