Repository navigation
Releases: fstubner/nvx
Release list
v0.7.0
nvx 0.7.0 is the first signed release. nvx.exe carries an Authenticode signature from a Certum certificate issued to "Open Source Developer Felix Stubner". SmartScreen may still warn on the first downloads while the certificate builds a reputation.
Install
irm https://raw.githubusercontent.com/fstubner/nvx/v0.7.0/install.ps1 | iexcurl -fsSL https://raw.githubusercontent.com/fstubner/nvx/v0.7.0/install.sh | shnpm install -g @fstubner/nvxThe npm package installs the binary for your platform and runs no install script.
Highlights
Installs
- pnpm installs inside the Windows sandbox, the first time and every time after.
- bun's package manager cannot run in the Windows sandbox outside the drive Windows is installed on. That is a bun limitation, and nvx now names the cause after the failure.
- The pre-install checks run on every package an npm install brings in, and a lockfile entry must match the registry's record of it.
- Packages from a private registry are checked on that registry, and contained installs work behind a corporate proxy.
- The typosquat check runs only on names you chose, and a command that already turns install scripts off is not asked about them.
Containment
- On macOS a contained install can no longer read your credential files.
- On macOS a contained install can write only to the project, its own home and a few device files such as
/dev/null. It can no longer write the system temp folders or other apps' caches. - A contained install can no longer write the project's
.git. - On Linux a contained process can no longer reach the host's UNIX sockets, and killing nvx stops the contained process.
Setup and shells
nvx setuptakes seconds instead of minutes on a large drive, and covers every fixed drive in one run.- fish and cmd.exe are supported shells.
NVX_NODE_MIRRORfetches Node.js from a mirror.- The shims run the version a project pins, with or without the shell hook.
Policy and audit
- A global policy can be a baseline that a project cannot weaken.
- New commands:
nvx policy explain,nvx policy checkandnvx audit export.
Releases carry SHA-256 checksums and a build-provenance attestation as before. gh attestation verify nvx.exe --repo fstubner/nvx checks a download (GitHub CLI 2.49 or newer).
The full list, over a hundred changes, is in the changelog.
v0.6.0
The containment work accumulated since 0.5.6, and the first features that let a contained tool reach named things outside its box. It also carries everything from 0.5.7, which never shipped.
Before you upgrade (Windows)
If you ever ran nvx setup, run it again. Each project now gets its own sandbox identity, so the grants an earlier setup made name an identity nothing launches under. The symptom is npx failing with EPERM: operation not permitted, lstat 'C:\Users'. npm install, npm run and node are unaffected. nvx doctor reports a stranded setup and exits non-zero.
Removed
The wsl, wslc and systemd-nspawn providers, and NVX_EXPERIMENTAL with them. All three silently ignored network.mode: offline, and nspawn needed root and left root-owned files in your project. native and docker remain, along with sandbox-exec on macOS. Naming an unsupported provider stops the run, and the refusal now lists the providers that exist.
Reaching things outside the sandbox
--connect— reach one service already running on your machine: a browser with remote debugging on, a local database, an emulator.nvx --connect 9222:19222 npx some-tool. The contained side chooses when to connect, and nvx chooses where. Works on Windows, macOS and Linux; the docker provider says it cannot carry it rather than accepting the flag and doing nothing.network.mode: loopback— every service on your 127.0.0.1, without naming any. On macOS and Linux a raw connection arrives at the address it dialled, so a local database works; on Windows it covers what a proxy-aware client sends. Selecting it in a project policy needs approval, which it did not before: it was ranked as stricter than the default, so one line in a pull request could hand a contained install every local service.isolation.filesystem.allow_read_exec— let a contained tool read and run a program kept outside the project, such as Playwright's browsers. Read and execute only. The grant is recorded, and withdrawn when the policy stops asking for it.isolation.environment.allow— keep a named environment variable inside the sandbox. A name matchingAWS_,GITHUB_orSECRET_is refused: a policy file lives in your repository, so one line in one would otherwise hand a cloud credential to whatever an install script runs.
Applying the install checks selectively
Every check applies to every package until a policy names an exception, and each list waives only its own check.
{
"typosquatting": { "trusted_packages": ["my-internal-helper"] },
"release_age": { "trusted_packages": ["chrome-devtools-mcp", "@upstash/*"] },
"install_scripts": { "trusted_packages": ["esbuild", "sharp"] },
"vulnerabilities": { "allowed_advisories": ["GHSA-xxxx-yyyy-zzzz"], "min_severity": "high" }
}release_age.trusted_packages is the answer to an MCP server that will not start because its package was published this morning — a prompt is a denial when nothing can answer it. install_scripts.trusted_packages also waives enforce_ignore_scripts, which is how "block install scripts except for these" is written; every run that uses one names the package it let through. Advisories are accepted by ID rather than by package, so a finding published after your assessment still stops the install, and an advisory nvx could not rate stops it at every severity floor.
Adding any of these counts as loosening, so a project file naming a package needs approval.
Fixed
Security and containment:
- One nvx sandbox could reach another's loopback services, defeating the egress allowlist.
npm -- install evilran uncontained and skipped every pre-install check.- A runtime archive could write outside its destination through a chain of links.
- A project policy could switch on
isolated_toolswith no approval. - macOS: the Seatbelt profile was written where a contained process could reach it.
- Linux: a contained process could remove directories from the nvx runtime store.
Things that did not work:
- Bun could not run inside the Linux sandbox at all, and the Docker provider could not write to your project there.
- A contained process could not find the other runtime, so a postinstall calling
bunfrom Node, or the reverse, failed. - Inside the sandbox on macOS and Linux, a nested
nodelookup did not get the runtime you pinned. macOS ran the wrong version silently — measured as a pinned v22.23.2 alongside a nested v24.20.0 — and Linux failed the install outright. - Windows: the ninth piped child in a contained process hung. Linux: the sandbox refused to start on kernels between 5.13 and 6.9.
- Upgrading nvx while anything was running from it kept the old binary and reported success, so the version simply did not change.
Windows PATH, which had two separate defects:
- The installer flattened
%VAR%entries in your PATH, so anything written as%USERPROFILE%\binstopped resolving. The installer's PATH write is now covered by a test in CI. nvx doctor --fixchanged the type of your User PATH fromREG_EXPAND_SZtoREG_SZ, with the same effect.
Being told what happened:
- Containment stripped most of your environment without saying so, so a tool reading
CIstarted prompting and a build readingNODE_ENVquietly emitted a development bundle. It now names what it drops. nvx install tscwas refused as a typosquat ofms.- Three refusals reached an MCP client as a silently closed pipe.
nvx importdownloaded runtimes without asking;nvx import <unknown-source>exited 0.
That is a selection. See CHANGELOG.md for the rest, including the corrections made to documents that overstated what nvx contains.
Known issue
Bun needs 1.4.x inside the Windows sandbox. Older Bun keeps a working-directory descriptor captured at startup that an AppContainer will not honour, so 1.3.1 fails every relative-path operation with EBADFD. Bun added AppContainer support in 1.4.0. Node holds no such descriptor and is unaffected.
Install
Download the binary for your platform, or on Windows use install.ps1. Checksums are in SHASUMS256.txt.
v0.5.6
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.
v0.5.4
Upgrade if you use nvx on Linux. The sandbox there could not run anything at all, and three checks that should have caught it were reporting success without testing anything. Windows and macOS are unaffected by the fix; what changes for everyone is how much of the containment story is now backed by a machine rather than by a design document.
The Linux sandbox could not start the program it was supposed to contain
Every contained command on Linux failed with no such file or directory, naming a runtime that was present and executable. Not an edge case — nothing ran.
The sandbox was putting your program into a second, nested user namespace of its own. Setting one of those up means writing a couple of files under /proc, and by that point the sandbox has already locked the filesystem down, and /proc is not on its allowed list. So the kernel refused, and the program never started.
It reported a missing file rather than a refusal because of where the supervisor sits: it has its own process-ID namespace while still seeing the host's /proc, so the path it builds names nothing there and fails to open before permissions are ever consulted. That one detail is why this read as a missing runtime for as long as it did.
The nested namespace is gone. Your program keeps its own filesystem namespace and takes the user namespace from the supervisor, which is where it belongs. Nothing is less contained — the second namespace only handed the program a fresh one to be root in.
Three Linux checks were green while testing nothing
This is why the above survived. Each check asked "can I create a network namespace?" in a way only the root user can answer yes to, so all three skipped themselves on every ordinary machine, including the one in CI. The containment check went further and explained away its own silence: finding no result, it blamed the Linux distribution for restricting namespaces, without ever checking whether it had. It hadn't.
Once they actually ran, both smoke checks turned out never to switch the sandbox on. The one asserting that a contained program cannot write outside its project could not have passed, and the network one was measuring whether the machine had working DNS rather than whether the allowlist worked.
All of them now test what they claim to, and the containment check fails — rather than shrugging — when the sandbox had everything it needed and still produced nothing.
Containment is now checked on real Linux and macOS machines
Every build runs a probe on a hosted runner of each operating system, and each probe asserts both what must be blocked and what must still be allowed. That second half matters: a sandbox that refuses everything is a broken launch, not a secure one, and only the positive checks tell them apart.
| Linux | macOS | |
|---|---|---|
| Write outside the project | blocked | blocked |
| Write inside the project | allowed | allowed |
| Read outside the project | blocked | allowed |
| Reaching a host that is not allowlisted | blocked | blocked |
| Reaching a host that is allowlisted | works | not tested |
Windows containment continues to be verified by replaying the original attacks locally, because hosted Windows runners cannot start the sandbox at all.
Honesty note
macOS still does not contain filesystem reads. A contained install can read ~/.ssh, ~/.aws and ~/.npmrc. This is deliberate — the dynamic linker needs system libraries whose locations vary by macOS version, and a strict read allowlist stops programs launching — and it is now asserted on purpose: the probe requires that read to succeed, so if anyone ever tightens the profile, the build fails and the documentation has to be updated in the same change.
What v0.5.3's notes said about macOS being unverified is no longer true, and was already stale when written. A macOS runner now confirms write containment and egress denial. Four things there are still unchecked and are not claimed: that an allowlisted host gets through the proxy, that UDP specifically is refused, that nvx fails safely if sandbox-exec is missing, and which layer refuses the outbound connection it does refuse.
docs/enforcement-matrix.md states, cell by cell, what is measured, what is checked in CI, and what rests only on the generated policy.
v0.5.3
Upgrade if you run test suites or MCP servers through nvx. Two long-standing problems on Windows made contained commands leave processes behind forever.
Test runners and tool runners no longer hang
A contained command could not read a subprocess's output as it was produced. It blocked before the child process even existed, with no error — so every contained npx vitest or npx playwright run left a process wedged until someone found and killed it. On the development machine 17 had accumulated, some stuck for 13 hours.
Windows builds piped output out of named pipes, and a sandboxed process is not allowed to create one. nvx now creates them outside the sandbox and the contained side only opens them, which Windows does permit. Output streams as it is produced, stdout and stderr stay separate, and exit codes come through.
Two limits, both stated rather than discovered: writing to a contained child's input is not supported, and beyond 8 children capturing output at once the output arrives when each stream ends rather than as it is produced. nvx prints a warning the first time that happens.
nvx stops when the program that started it stops
An MCP server that ignores end-of-input kept running after its client went away, and nvx kept waiting on it, holding a sandbox open. They accumulated at about one a minute until Windows ran out of memory. nvx now leaves when its input has hung up and the program that started it has exited — both, because a finished shell pipeline looks like the first on its own, and a deliberately detached command looks like the second.
Sandbox leftovers clean themselves up
Abandoned sandbox profiles used to wait for nvx cleanup, which nobody ran; 91 had built up. Each run now reclaims a few afterwards, skipping any still in use. nvx cleanup still exists for reclaiming everything at once.
nvx audit
A local record of the security decisions nvx made, and — with NVX_TRACE=1 — of what each command did: whether it was contained, why not when it was not, exit code, duration, warnings. Nothing is sent anywhere. Command arguments are not recorded, only a subcommand nvx recognises, because argv is where tokens and private paths live.
Read it as what nvx recorded about its own runs. Anything running as you can append to that file, including your own uncontained code, so it is not evidence against someone who already runs code on your machine.
Honesty note: macOS does not contain filesystem reads
The Seatbelt profile allows reads, so a contained install on macOS can read ~/.ssh, ~/.aws and ~/.npmrc. This is deliberate — the dynamic linker needs to read system libraries whose locations vary by macOS version, and a strict read allowlist stops processes launching — but README, SECURITY.md and PRODUCT.md all stated the Windows behaviour as the product's behaviour. That is corrected on main (after this tag): on macOS nvx contains writes and egress, not credential reads.
macOS also remains unverified at runtime. The profile's text is asserted by tests; no macOS hardware has been observed enforcing it.
Everything above is Windows-only unless stated. Upgrading is a binary replacement; no state migration.
Full Changelog: v0.5.2...v0.5.3
v0.5.2
Upgrade if you use pkg@latest. On 0.5.1 that silently skipped two security checks.
Two checks were doing nothing
nvx resolved a version only when you gave it none. Name a dist-tag — npm install npm@latest, pkg@next, pkg@beta — and the literal string was carried forward as if it were a version number. Every lookup keyed on it missed:
- the install-script prompt never appeared, so a package that runs code at install time was installed without asking;
- the release-age check never fired, so the supply-chain cooling-off window was not applied;
- the vulnerability scan was run against a version that does not exist, which is why it reported advisories with no descriptions.
The noisy output was the symptom people noticed. The missing prompts were the actual problem. Every dist-tag now resolves, and an exact version wins over a same-named tag.
A consequence worth knowing: a semver range (lodash@^4.17.0) now asks "could not verify registry metadata — proceed?" rather than passing silently. nvx cannot check a version it cannot name, and quietly running no checks is what this replaces. Resolving ranges properly would remove the prompt and is not done yet.
One sandbox could borrow another's egress allowlist
Every nvx sandbox shares one Windows package identity, and Windows scopes its loopback restriction to that identity — so two projects running at once sat in the same namespace. A contained process could scan loopback, find another session's proxy, and reach a host only that project's policy allowed. Each session now mints its own credential, over HTTP and SOCKS both, checked before the allowlist so the accept/reject difference cannot be used to probe what another session may reach.
A platform with no sandbox ran your command anyway
On any Unix that is not Linux or macOS, nvx set a process group, logged "using environment isolation only", and ran the command — while printing "Running in native sandbox". It now refuses, and names --no-sandbox as the deliberate opt-out. No release binaries are built for those platforms, so this is reachable only from source — which is exactly the person who would trust the word "sandbox" without checking.
Other fixes
- A hung contained process blocked every later contained launch. The supervisor was staged under one fixed name, and Windows will not replace a running executable — so a supervisor left alive held that file and every subsequent launch failed with a bare "Access is denied". Staging is now per-build.
- A corrupted supervisor bricked launches permanently, because the reuse check compared only file size. An image error now discards the copy and retries once.
- macOS granted all of loopback in every mode, so
network.mode: offlinewas not offline and contained code could reach any local service. Loopback is now scoped per mode. Unverified at runtime — see below. - A typo in
isolation.network.modesilently gave you more network than you asked for."offlin"fell through to proxy with no warning. Unrecognised modes now warn and normalise. - A failed install pointed at a debug log that had already been deleted. Logs are now copied to
~/.nvx/logs/<session>before the sandbox home is removed, and only on failure. - Shims pointed at whichever binary generated them, so generating them from a source build left every shim depending on a file that could be rebuilt or deleted. They now name the installed nvx.
yarn global addwas not recognised as a global install and failed inside the sandbox with a permission error instead of a clear refusal.- A refused global install still ran a vulnerability scan and asked you to approve it first, for a command that could never have run.
nvx doctornow reports pre-0.5.0 permissions that leave a project writable by any sandbox, and removes them with--fix.
Honesty notes
macOS is still unverified at runtime. The Seatbelt profile's text is asserted by tests, so nvx generates the policy it intends to; no macOS hardware has been observed enforcing it. The loopback fix above is a fix to the generated profile. docs/enforcement-matrix.md marks every macOS row "profile only".
Windows: a contained server is unreachable from the host. nvx npx vite, npx serve and anything else serving a port will bind, report themselves listening, and serve nobody. Windows refuses connections into an AppContainer.
Asynchronous piped output still hangs. Synchronous capture works (that is what made npm install esbuild work); a tool that streams a child's output as it is produced does not. Run it with --no-sandbox.
Upgrading from 0.5.1 is a binary replacement; no state migration.
v0.5.1
A patch release for two Windows bugs that 0.5.0 shipped with. One of them affects most contained tools rather than an edge case, which is why this follows 0.5.0 within a day.
The sandbox had no working temp directory
Windows ignores the TEMP nvx sets for a contained process and redirects it to <LOCALAPPDATA>\Packages\<package>\AC\Temp. That path resolved inside the sandbox, but nothing ever created it — so os.tmpdir() pointed at a directory that did not exist and every scratch file written by contained code failed with ENOENT. That is most tools, not a corner case. It was found while diagnosing the next item.
Installs that capture a subprocess no longer hang
npm install esbuild hung forever inside the sandbox, with no error. 0.5.0 documented this as an OS limitation nvx could not fix. That was half right.
The restriction is real and is now measured rather than asserted: CreateNamedPipeW inside a real AppContainer returns ERROR_ACCESS_DENIED for every name shape tried, so it is the named-pipe device refusing and no choice of name avoids it. Granting it would mean loosening \Device\NamedPipe for every AppContainer on the machine.
But file descriptors were never restricted, and synchronous capture does not need a stream — its whole contract is "run it, give me the output at the end", which a file satisfies exactly. A preload in every contained node process now routes spawnSync, execSync and execFileSync through temp files in the guest home. esbuild installs in seconds and the resulting binary works. The preload falls back to the original function on any error, because it loads into every contained node process and must never be the reason one fails.
Still broken, deliberately not folded into the above: asynchronous spawn(..., { stdio: "pipe" }) is a real stream that a file cannot stand in for, and it still hangs. Install such a package with --no-sandbox. nvx warns after two minutes naming this as the likely cause.
Also
The lifecycle smoke test could not fail on the bug it was written for — its fixture's postinstall only wrote a file and never captured a subprocess, so it passed while esbuild hung. It now captures a child and asserts the captured text, verified by disabling the preload and watching it hang rather than pass.
Nothing here changes containment. It changes how a contained process talks to its own children, not what it is able to reach.
Upgrading from 0.5.0 is a binary replacement; no state migration.
v0.5.0
First published build since v0.2.0-beta. (v0.4.0 was tagged but never published; everything in it is included here.)
Most of this release is Windows containment: making it enforce what it claimed, cover the shell people actually use, and stop hanging on the packages it exists to contain.
Egress is enforced, not requested
A package that ignores HTTP_PROXY now reaches nothing. The sandbox holds no network capability on Windows, so the OS itself refuses direct connections and name lookups fail — the only route out is nvx's allowlist proxy, reached over a UNIX socket and relayed back inside the sandbox by a supervisor process. No administrator rights required.
Services on your own machine are allowlisted like any other destination rather than permitted automatically. To reach a dev server from inside the sandbox, add allow_hosts: ["127.0.0.1:3000"].
nvx setup no longer registers a loopback exemption, and removes an existing one. It is now only about drive-root access and is not needed for egress.
If you ran nvx setup before 0.5.0, read this. That elevated setup registered a Windows loopback exemption, because the proxy then ran on the host's loopback and an AppContainer cannot reach it otherwise. While that exemption is registered, contained code reaches every service on 127.0.0.1 — local databases, daemon ports, other dev servers — with no allow_hosts entry.
Treat the egress allowlist as unenforced until you remove it. Only direct connections to other hosts stay blocked; any reachable loopback service that forwards traffic — a debugging proxy, ssh -D, a dev server's proxy route — turns this into arbitrary egress.
0.5.0 removes it, but only during an elevated nvx setup — the command this release otherwise tells you that you no longer need. Removing it requires administrator rights, so nvx cannot do it on a normal run. Instead it warns on every affected launch and nvx doctor reports it and exits non-zero, both printing the one-line removal command. Fresh installs are unaffected.
One sandbox can no longer borrow another's egress allowlist
Every nvx sandbox on a machine shares one AppContainer package identity, and Windows scopes its loopback restriction to the package — so two projects running at once sat in the same loopback namespace. A contained process could scan loopback, find another session's relay, and tunnel to a host only that project's policy allowed. The host-side proxy listeners were reachable by any local process for the same reason. The allowlist was per-project; the thing enforcing it was shared and unauthenticated.
Each session now mints a random credential and its proxy requires it, over HTTP and SOCKS both. It travels as ordinary proxy credentials inside HTTP_PROXY, so npm, node and curl send it without knowing anything about nvx, and a sibling that found the port by scanning gets 407. Authentication is checked before the allowlist, so the 403-vs-200 difference cannot be used to probe what another session is permitted to reach.
Each project gets its own sandbox identity
An install in one project can no longer read or write another project, another running session's home, or the credentials a trusted tool has persisted. Previously every sandbox on the machine shared one identity and the permissions nvx granted were never revoked.
If you used nvx before 0.5.0, this is not retroactive. Old permissions are removed the first time nvx runs in an affected project, but nvx keeps no record of where it has run, so projects you do not revisit keep theirs. README and SECURITY.md give the manual command.
The shim directory is no longer a way to shadow a system command
nvx use puts a directory of project-local shims near the front of your PATH, ahead of System32. That directory used to live inside the project, so a contained install could write a file called git into it and wait for you to type git — which then ran uncontained, as you, with every credential you have. The sandbox held; a directory nvx itself put on your PATH went around it.
The shims moved to ~/.nvx/project-bin/<project hash>, which a contained process cannot write, and a name that already resolves elsewhere on your PATH is now never shimmed — node_modules/.bin is itself writable by an install, so relocating alone would only move the plant one directory back.
The cost, stated plainly: if you have a global tool of the same name, the project-local one no longer wins through nvx. npx <tool> still runs the local one, contained.
What this does not fix, said plainly too. A project-local CLI whose name isn't taken by a global one — eslint, tsc, vitest, prettier — still gets a shim, and at the default standard level that shim runs it uncontained, as you. node_modules/.bin is writable by design, so a contained install can rewrite what one of those tools does and wait for you to run it. That is the documented stance that your project's own dependencies are your code rather than a sandbox bug, but the two halves had never been stated next to each other. If you don't want it, isolation.level: strict contains project-local CLIs too.
Requests to weaken the sandbox no longer take a blanket yes
Trusting a project's own .nvx-policy.json when it loosens settings, and adding a host to the egress allowlist, both decide the security model rather than a step inside it. They were covered by -y/NVX_YES, and --agent-mode sets that yes — so an agent cloning a repository nobody had read would auto-approve that repository's request to disable containment.
Those two now need an interactive answer, or NVX_TRUST_YES — deliberately a different variable, because nothing sets that one by habit. Ordinary prompts still honour -y, so non-interactive installs do not stall.
Fixes
- Installing a package with a lifecycle script hung forever. A contained process cannot create a named pipe, and Windows builds piped child stdio out of them, so npm's default of piping script output blocked before the child even existed. Lifecycle scripts now inherit stdio. Partly, and the limits are now written down: this fixes npm's own piping, not a postinstall that captures a subprocess itself.
npm install esbuildstill hangs inside the sandbox — install that one with--no-sandbox. nvx prints a hint naming this cause after two minutes instead of sitting silent. - Git Bash got no protection at all, while
nvx doctorreported that it did. The shims were.cmd/.ps1only, which bash never selects. Extensionless shims are now written for both shim directories. nvx usesilently did nothing in Git Bash, and still printed success. Shell detection always answered PowerShell on Windows, so nvx emitted assignments bash cannot evaluate. Auto-switch oncdnever fired there either.nvx doctorwrote files while claiming to diagnose, which also made its own missing-shim check unreachable — it regenerated what it was about to look for. It now reports first and repairs only under--fix.- Security prompts hung instead of denying when stdin was not a terminal — CI steps and agent harnesses stopped rather than failing closed.
- The sandbox refused to start against an nvm-installed node, which is how most Windows developers install it.
nvx node --strict app.jsran uncontained — the flag was accepted and discarded.nvx cleanupdeleted sandboxes that were still running.- An interrupted install blocked that version permanently.
- A permission grant that could never succeed was retried on every launch, costing several seconds each time.
--agent-modeis documented as-y -qand only did the-yhalf. It now sets quiet too, which gates success and info lines only — warnings and errors still print.nvx setup --undocould not remove the profile-root grant the docs said it removed.- Four documented policy keys did nothing.
prompts.interactive,prompts.non_interactive,prompts.network_unknownandisolation.filesystem.modewere parsed, merged and scaffolded bynvx policy init, and read nowhere — tightening one was silently ineffective. They are no longer written into new policies and the README marks them unimplemented. Existing policies still parse.
Speed, stated honestly
A contained command costs roughly 1–2 seconds depending on the machine, against ~0.4s uncontained. The first contained run after nvx stages a runtime copies the whole distribution and has been measured between 45 seconds and 3 minutes. The ~38ms figure in the README measures shim dispatch — the uncontained path — not the sandbox. Measure it yourself before depending on it.
Known limitations
The README has a Known limitations section covering what the sandbox does not do — a .env inside the project is readable by a contained install, your own code and project-local CLIs are not contained by default, directory names outside the project are visible on Windows, pre-0.5.0 permissions and a pre-0.5.0 loopback exemption persist until dealt with, npm install -g is refused, and a contained process cannot capture a child's output. Each is pinned by a test. Per-OS detail, and how every guarantee was measured, is in docs/enforcement-matrix.md.
macOS and Linux enforcement is documented but was not re-verified for this release on that hardware; the matrix says which rows that affects.
Verifying downloads
Each asset ships a .sha256 sibling, with a combined SHASUMS256.txt. Binaries are built by GitHub Actions with a build-provenance attestation.
v0.2.0-beta
Isolation v1 (beta)
Added
- Isolation v1 policy schema:
isolation.filesystem.providerandisolation.network.modereplace the flatisolation.provider; top-levelruntimeandpromptsblocks. - Shim-only sandbox path:
npm,node,npx,yarn,pnpm, andbunxrun sandboxed by default whenisolation.enabledis true; use--no-sandboxto bypass per invocation. - Embedded egress proxy:
network.mode: proxystarts an in-process HTTP CONNECT + SOCKS5 proxy on loopback with policy allowlist and interactive approval for unknown hosts (persisted to.nvx-policy.jsonon approve). - RuntimeProvider execution hooks: binary resolution and default network allowlists go through
RuntimeProviderso sandbox code is not Node-specific. - Cross-platform smoke tests: filesystem, egress block, and macOS runtime smokes in CI.
nvx policy init: scaffold global and project policy files.- Project bin shims: sandbox
node_modules/.bintools via.nvx/project-bin/.
Changed
- Default isolation:
isolation.enableddefaults totrue;network.modedefaults toproxy. - Removed legacy CLI:
nvx sandbox,nvx s,nvx exec, and thenvxsshim target are removed; shims are the sole sandbox entry point. - Fail-closed Windows native path: AppContainer setup failure no longer falls back to Low IL alone.
- Linux network isolation: loopback-only network namespace with in-child egress proxy; seccomp blocks UDP and offline TCP.
Removed
--providerflag: use--filesystem-provider=on shim invocations instead.
v0.1.0
nvx v0.1.0 — Initial Stable Release
nvx (Node Version X-platform) is a zero-dependency, ultra-fast, and security-conscious runtime version manager and package manager wrapper. It is designed to secure local developer environments, AI coding agents, and CI/CD runners against supply-chain attacks while maintaining native-speed execution.
Backstory: Why nvx?
This project started while setting up a clean development machine on Windows. Facing the usual version manager headaches (aliases failing in IDEs, slow startup times, environment leaks across concurrent terminals), I decided to build a modern, native runtime version manager from scratch to solve these issues.
Why Go?
I chose Go as the implementation language for several key reasons:
- Zero-Dependency Native Binaries: Go compiles to a single, zero-dependency binary with sub-millisecond execution overhead. It requires no interpreter (like Python) or runtime environment (like Node.js itself) to execute.
- First-Class Platform Interoperability: Go allowed me to easily interface with platform-specific security primitives—such as Windows Low Integrity Level tokens and native Linux Namespaces—while keeping the codebase clean and maintainable.
- Cross-Platform by Design: A single codebase compiles cleanly to target Windows, macOS, and Linux (amd64/arm64) natively.
Core Security Drivers
Along the way, I designed nvx to solve two major security concerns in modern development:
- Supply Chain Safety: Typosquatting and malicious pre/postinstall hooks are active threats.
nvxintercepts installs on the fly to scan, check, and control package executions. - AI Coding Agent Safety: AI agents (like Gemini, Claude, or Copilot) executing shell commands in your workspace present a new risk. By automatically wrapping package managers and runners,
nvxguarantees that any package an AI agent attempts to install or run is audited and sandboxed automatically, reducing workspace compromise risks.
Key Features in v0.1.0
- Multi-Platform Runtime Version Swapping: Swaps Node.js versions in under a millisecond by modifying only session-level shell environment variables (
PATH,NPM_CONFIG_PREFIX), supporting PowerShell, Zsh, and Bash. - Auto-Configuration Swapping: Instantly switches Node.js version when navigating into directories containing configuration files (
.nvmrc,.node-version,package.json, or Volta configurations). - Dynamic PATH Shim Architecture: Uses dynamic shims in
~/.nvx/binto intercept execution reliably in subshells, IDEs, and scripts, resolving early shell alias vulnerabilities. - Registry Checksum Integrity: Enforces cryptographic integrity for Node.js downloads using SHA-256 hashes from nodejs.org, mitigating MITM or server compromise attacks.
- Interactive Security Interceptor: Intercepts
npm,yarn, andpnpminstall commands to perform:- Vulnerability scans against the OSV database.
- Typosquatting audits based on Levenshtein distance and registry download comparison.
- Release-age warning for packages published less than 24 hours ago.
- Install script blocking/warning to prevent arbitrary code execution during dependencies installation.
- Process Sandboxing: Runs executions inside isolated environments: either using OS-native isolation primitives (Windows Low Integrity Levels and Linux Namespaces with home directory virtualization and environment scrubbing) or containerized via Docker.
Upcoming Features in v0.2.0 (Roadmap)
I am actively working on the next set of features to make nvx even more secure and robust:
- Secret Interception: In-memory resolution of credentials (e.g.
op://1Password andaws://secrets) before sandboxed execution to keep credentials entirely out of workspace configuration files. - Workspace Write Protection: Copy-on-Write (CoW) sandbox environments to emulate a read-only local workspace, symlinking only safe assets and preventing unauthorized writes.
- Native Multi-OS Sandbox Engines: Expanding native sandboxing options to support:
- macOS native sandboxing via
sandbox-execusing Scheme profiles. - Windows WSL Containers (
wslc) natively without requiring Docker Desktop. - Linux container runtimes natively via
systemd-nspawn.
- macOS native sandboxing via
Quick Start Installation
Windows (PowerShell)
irm https://raw.githubusercontent.com/fstubner/nvx/master/install.ps1 | iexmacOS / Linux (Shell)
curl -fsSL https://raw.githubusercontent.com/fstubner/nvx/master/install.sh | sh