Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,603 changes: 663 additions & 940 deletions CHANGELOG.md

Large diffs are not rendered by default.

82 changes: 47 additions & 35 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ code do what the contract says" was circular. This is the part that is not.
## What it is for, from Felix

**Where it started.** Setting up a new Windows laptop and wanting nvm. nvm does
not run on Windows. nvm-windows is a different project and is no longer actively
maintained. The want was a better, more modern nvm that is *truly* cross-platform.
not run on Windows. nvm-windows is a different project, and Felix recorded it on
2026-09-01 as no longer actively maintained. That stopped being true the next day.
nvm-windows 2.0.0 was released on 2026-09-02 and has had releases since.
The want was a better, more modern nvm that is *truly* cross-platform.

Security came second. Thinking about supply-chain attacks and how much exposure
developers still carry led to sandboxing and to checks like known-vulnerability
Expand Down Expand Up @@ -48,6 +50,15 @@ and security is quickly becoming the second main thing. So the ordering below is
his, not the contract's, and the Purpose section now follows it instead of the
other way round.

**Settled again 2026-10-07, by Felix: sandbox first.** The owner chose to lead
with the sandbox. nvx's pitch, on the README, the site, the npm package and in
the docs, is one sentence: "nvx runs npm install and npx inside an OS sandbox on
Windows, macOS and Linux, so a package cannot read your credentials or reach a
host you did not allow. You and your agent type the same commands." The version
manager is the second sentence. This reverses the 2026-09-02 ordering. Where the
paragraphs below still say the version manager is the main thing, this decision
wins. The consequences listed after them hold under either order.

It stood unreconciled for two weeks. Which of them is true changes what this
project should do when the two conflict, so reconciling them is a decision to
take deliberately. Quietly editing one to match the other would skip that
Expand All @@ -72,25 +83,24 @@ And the extensible interface described as an ambition is real and shipped:
Make the default JavaScript developer workflow safer against supply-chain
attacks, without asking the developer to change how they work.

nvx manages Node.js and Bun versions like nvm or fnm. Because it is
already on `PATH` intercepting `npm`, `npx`, `yarn`, `pnpm`, `bun` and `bunx`,
nvx uses that position to audit what gets installed. It also contains the
commands that execute untrusted code.

**The version manager is the main thing. The security layer is quickly becoming
the second main thing.** Decided 2026-09-02. See "What it is for, from Felix"
above, which this sentence used to contradict. It read "the security layer is the
reason to switch; the version manager is how it earns a place on `PATH` in the
first place". The same context that built the containment work wrote it, and tended
to rate that work first.

The difference is which way a conflict resolves. Version management being primary
means that being slower or more annoying than fnm is a defect in the main job.
It is not a tax on an optional one. It also means that a platform where nvx
manages runtimes badly is a worse failure than one where it contains them
narrowly. Security being a close and rising second means it is not a bolt-on
either. A containment guarantee is not traded away for a few milliseconds without
that being argued for in writing.
nvx runs `npm install` and `npx` inside an OS sandbox on Windows, macOS and
Linux, so a package cannot read your credentials or reach a host you did not
allow. You and your agent type the same commands. It does that from its position
on `PATH`, intercepting `npm`, `npx`, `yarn`, `pnpm`, `bun` and `bunx`, where it
also audits what gets installed. It also manages Node.js and Bun versions like nvm
or fnm.

**The sandbox comes first and the version manager second.** Decided 2026-10-07,
reversing the 2026-09-02 decision that put the version manager first. See "What it
is for, from Felix" above. The version manager is still required, because the
shims that intercept the toolchain are also what run each project's pinned
version.

The difference is which way a conflict resolves. With the sandbox first, a
containment guarantee is not traded away for convenience in the version manager.
Being slower or more annoying than fnm is still a defect, and a platform where nvx
manages runtimes badly is still a failure. Neither is argued away without saying
so in writing.

## Users

Expand All @@ -108,7 +118,8 @@ that being argued for in writing.
A developer can complete this, on any of the three platforms, without reading
documentation:

1. Install nvx, open a new shell, `nvx install 22` and `nvx use 22`.
1. Install nvx, open a new shell and run `nvx install lts`. The first version
installed becomes the default.
2. `npm install <package>` in a project. It completes normally, at a speed they
would not think to complain about.
3. A malicious `postinstall` in that package **cannot** read `~/.ssh`, `~/.aws`
Expand Down Expand Up @@ -231,8 +242,10 @@ Deferred with intent, not built:
`TestOneSandboxSessionCannotReadAnother`, which passes with it in place. The
trade the paragraph above declined was never the trade on offer.
- **Overhead must stay invisible.** nvx sits in front of every npm invocation.
Measured dispatch overhead is **about 75 ms on Windows**, and is not currently
established on Linux or macOS.
Measured on Windows, `node --version` through the shim takes a median of
0.101 s against 0.052 s for node alone (21 interleaved runs, 2026-10-08, after
the housekeeping sweep moved off the end of the command). On Linux the shim
call measured 0.018 s. macOS has not been measured.

**Every figure this constraint carried before 2026-09-03 was withdrawn, and the
reason is worse than the numbers being wrong.** `scripts/bench.py` timed the
Expand All @@ -256,13 +269,11 @@ Deferred with intent, not built:
design produced 140.0, 147.8, 92.9 and 42.5 ms on four consecutive runs of one
idle laptop. In those runs the raw baseline alone swung 65→142 ms.

The Windows figure is three runs on one machine. The medians were 73.8, 74.2 and
77.1 ms, and p10 to p90 ran roughly 63 to 93. The script's own spread check refused a fourth
run on a busy machine instead of writing it down. A Linux container reported
4 to 10 ms with the spread swamping it, and the script declined to give a figure.
macOS has never been measured with a working script. This constraint now states
one number, for the one platform where a working script has produced a stable
one.
The figure this constraint stated until 2026-10-08 was about 75 ms on Windows,
three runs on one machine with medians of 73.8, 74.2 and 77.1 ms. The shim has
changed since, and the figure above replaces it. The script's own spread check
refuses a run on a busy machine instead of writing it down. macOS has never been
measured with a working script.
- **Pre-1.0.** Breaking changes are acceptable between minor versions. Silently
weakening a documented security guarantee is not.
- **Three platforms are not equal, and the differences are published.** Replaying
Expand Down Expand Up @@ -290,11 +301,12 @@ Deferred with intent, not built:

## Where this sits next to an agent sandbox

Docker Sandboxes (announced 2026-09) runs a coding agent inside a microVM.
Docker Sandboxes (launched 2026-01-30) runs a coding agent inside a microVM.
It mounts only the project workspace, the agent cannot reach the host Docker
daemon, and allow and deny lists govern network access. macOS and Windows today,
Linux listed as future work, driven through an `sbx` CLI. Other agent harnesses
are converging on the same shape.
daemon, and allow and deny lists govern network access. It launched for macOS and
Windows, and Ubuntu 24.04 and later is supported now. It is driven through an
`sbx` CLI. Other agent harnesses are converging on the same shape, and several
now sandbox their own agent's commands, on Windows too.

This is a neighbouring layer, not a competitor, and the distinction is
worth being precise about because it decides what nvx is still for.
Expand Down
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<img src="site/public/assets/wordmark-light.png" width="360" alt="nvx">
</picture>

*A Node.js and Bun version manager that runs what your agent installs inside an OS sandbox.*
*nvx runs `npm install` and `npx` inside an OS sandbox on Windows, macOS and Linux, so a package cannot read your credentials or reach a host you did not allow. You and your agent type the same commands.*

[![CI](https://github.com/fstubner/nvx/actions/workflows/ci.yml/badge.svg)](https://github.com/fstubner/nvx/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
Expand All @@ -24,25 +24,33 @@ On macOS files outside your home directory stay readable, and the

**You do not change how you run anything.** nvx installs shims on `PATH`, so
`npm install` is still `npm install`, contained when it runs code you did not
write. Other sandboxes need `theirtool run -- npm install`, and an agent will not
remember to type it.
write. Wrappers such as srt and sfw need a prefix, and agent sandboxes cover only
the commands their own agent runs. nvx contains the install whoever runs it: you,
your agent, your editor or an MCP client.

**A prompt never widens the sandbox.** An agent that drives a terminal can answer
one, so nvx does not ask before trusting a project policy that loosens it or
reaching a host the allowlist does not name. It refuses with exit 77, prints the
`nvx trust` or `nvx allow-host` command for you to run, and tells the agent to
ask you. An agent with a shell of its own could still run that command, as
[SECURITY.md](SECURITY.md#known-limitations) explains.
[SECURITY.md](SECURITY.md#known-limitations) explains. The package checks
(typosquats, fresh releases, install scripts) still ask at a terminal.
`--agent-mode` makes them refuse instead, and `-y` and `NVX_YES` do not override
it. The [agents page](https://nvx.run/docs/agents/) has the details.

It is also a Node.js and Bun version manager, because it has to be. The shims that
intercept the toolchain also run the version each project pins, in a terminal, an
IDE task, a git hook or CI. If you use nvm, fnm or volta today, nvx replaces them.
nvx is also a Node.js and Bun version manager. The shims that intercept the
toolchain also run the version each project pins, in a terminal, an IDE task, a
git hook or CI. If you use nvm, fnm or volta today, nvx replaces them.

nvx is not related to Microsoft's NVX micro-VM project
(github.com/microsoft/nvx). On npm the package is `@fstubner/nvx`. The unscoped
`nvx` is a different project.

## Why this exists

nvx started on a fresh Windows machine and the usual version-manager headaches.
With LLMs making it practical to build exactly the tool you want, I built one
that also takes on the part nobody else handles.
that also contains what it installs.

Coding agents run terminal commands in your workspace, and installs are where they
pick up code nobody has read. No tool can promise a package is safe, so the goal is
Expand All @@ -68,8 +76,11 @@ npm install -g @fstubner/nvx
Prebuilt binaries, building from source and what the installer changes are in the
**[install guide](https://nvx.run/docs/install/)**.

To check a downloaded release asset, run this and compare the `.sha256` file
beside it:
Without the GitHub CLI (`gh`), the macOS and Linux one-liner checks only a
checksum that comes from the same release page as the binary. If you want more,
install `gh` 2.51 or newer, sign in with `gh auth login`, and check a downloaded
release asset against the build attestation. Compare the `.sha256` file beside it
too:

```bash
gh attestation verify <file> --repo fstubner/nvx --signer-workflow fstubner/nvx/.github/workflows/release.yml
Expand Down Expand Up @@ -99,6 +110,8 @@ The full reference is in **[Commands](https://nvx.run/docs/commands/)** and
| [Installation](https://nvx.run/docs/install/) | Every install route, per platform |
| [Containment](https://nvx.run/docs/containment/) | What a contained command can reach, and what backs each claim |
| [Policy](https://nvx.run/docs/policy/) | Global and project policy files, and every setting |
| [Agents and CI](https://nvx.run/docs/agents/) | Exit 77, the approval switches, MCP servers and a snippet for AGENTS.md |
| [When nvx stops something](https://nvx.run/docs/blocked/) | Each refusal, its cause and the command that fixes it |
| [Known limitations](https://nvx.run/docs/limitations/) | What containment does not cover |
| [Commands](https://nvx.run/docs/commands/) | Commands, flags and environment variables |

Expand Down
26 changes: 12 additions & 14 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ These are deliberate trade-offs, and this section documents each one:
`NVX_NODE_MIRROR` (or `NVM_NODEJS_ORG_MIRROR`, `FNM_NODE_DIST_MIRROR`) set,
the archives and checksums both come from that mirror, so the mirror is
trusted exactly as nodejs.org is.
- **Network enforcement is weakest on macOS.** On Linux a loopback-only network
- **Network enforcement differs by platform.** On Linux a loopback-only network
namespace and seccomp genuinely block raw sockets and non-proxied DNS. On
Windows the AppContainer holds no network capability, so the OS refuses direct
connections and DNS does not resolve. On both, the egress proxy runs outside the
Expand Down Expand Up @@ -805,7 +805,7 @@ and the timing behind these claims are in `docs/enforcement-matrix.md`.
This affects any MCP server installed from npm on a floating version
(`npx -y <pkg>` fetches the latest). It is self-inflicted if you publish the
package yourself. Publish in the morning and the server is unavailable until
the next day. Three ways out, narrowest first:
the next day. Two ways out, narrowest first, and one to avoid:

```jsonc
// 1. pin to a version you have already used
Expand All @@ -817,25 +817,23 @@ and the timing behind these claims are in `docs/enforcement-matrix.md`.
{ "release_age": { "trusted_packages": ["your-pkg", "@your-scope/*"] } }
```

```jsonc
// 3. approve every nvx check for this server, not only this one
{ "command": "npx", "args": ["-y", "your-pkg"], "env": { "NVX_YES": "true" } }
```

The first keeps every check, and an agent may do it itself. The second keeps
the cooling-off window for everything else, which `release_age.min_age_hours`
does not. That setting widens the window for every package you install. The
third is the broadest. `NVX_YES` also approves the
typosquat, install-script and known-advisory checks for that server, so it is
the last resort. It never approves a package OSV lists as malicious, and
approves nothing where `NVX_AGENT_MODE` is set too. Each check it approves is printed on stderr and written to
`~/.nvx/audit.log` as a `check_approved` record.
does not. That setting widens the window for every package you install.

Avoid putting `NVX_YES=true` in the server's environment. It approves every
check for that server, the typosquat, install-script and known-advisory checks
included, and every refusal tells an agent not to use it. It never approves a
package OSV lists as malicious, and approves nothing where `NVX_AGENT_MODE` is
set too. Each check it approves is printed on stderr and written to
`~/.nvx/audit.log` as a `check_approved` record. The
[agents page](https://nvx.run/docs/agents/) has the whole recipe.

Until 0.6.0 the only exemption list was `typosquatting.trusted_packages`, which
waived typosquat detection at the same time. It no longer waives the
release-age window. A file still using it that way is told to move the entry.

- **On Windows and macOS, a contained tool needs `--connect` to reach a service
- **On every platform, a contained tool needs `--connect` to reach a service
running on your machine.** This is the other direction, for the same reason.
The sandbox has no route to your loopback, and the egress proxy refuses a host
loopback destination unless `allow_hosts` names it. A contained tool may need
Expand Down
2 changes: 1 addition & 1 deletion docs/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ debugging aid and nvx writes them only when `NVX_TRACE=1`.
| `allow_host_removed` | `host`, `path` | `nvx allow-host --remove` took a host out of `isolation.network.allow_hosts` in that policy file. |
| `install_scripts_exempt` | `package`, `version` | A package's install scripts ran without asking, per `install_scripts.trusted_packages`. |
| `vulnerability_allowed` | `package`, `advisory`, `severity`, `reason` | A known advisory did not stop an install. `reason` is `allowlisted` or `below_min_severity`. |
| `check_approved` | `check`, `by`, and optionally `package`, `version`, `detail` | A pre-install check went ahead. `check` is `typosquat`, `release_age`, `install_scripts`, `vulnerability`, `registry_unreachable`, `osv_unreachable`, `resolution_failed` (npm could not say what an install brings in) or `lockfile_unreadable`. A `release_age` record whose `detail` is `publish time unknown` is for a version the registry gave no publish time for. `by` is `yes_flag` or `nvx_yes` when nobody was asked (stderr also prints a line), or `prompt` when a person answered. Until `--agent-mode` was made to refuse, it could also be `agent_mode`. |
| `check_approved` | `check`, `by`, and optionally `package`, `version`, `detail` | A pre-install check went ahead. `check` is `typosquat`, `release_age`, `install_scripts`, `vulnerability`, `registry_unreachable`, `osv_unreachable`, `resolution_failed` (npm could not say what an install brings in) or `lockfile_unreadable`. A `release_age` record whose `detail` is `publish time unknown` is for a version the registry gave no publish time for. `by` is `yes_flag` or `nvx_yes` when nobody was asked (stderr also prints a line), or `prompt` when a person answered. |
| `check_refused` | `check`, `by`, and optionally `package`, `version`, `detail` | A pre-install check stopped the install. `check` is one of the above, or `blocked_package`, `enforce_ignore_scripts`, `lockfile_mismatch` (a lockfile entry's tarball URL or hash is not the registry's for its name and version) or `malicious_package` (OSV lists the package as malicious, `detail` holds the `MAL-` IDs). `by` is `prompt`, `non_interactive` (nobody was there to answer), `agent_mode` (`--agent-mode` or `NVX_AGENT_MODE` refused it without asking) or `policy` (no prompt exists for it). |
| `check_skipped` | `check`, `by`, `detail` | Some checks did not run for part of an install. `check` is `public_registry_checks`: packages from a registry other than registry.npmjs.org got no typosquat or advisory check, because both send the name to a public service. `by` is `registry`, and `detail` gives the count and the registry hosts. Written once per run. `check` is `install_scripts` when a package with install scripts was not asked about or refused, because the command turns scripts off. `by` is `ignore_scripts_flag`, `ignore_scripts_env`, `ignore_scripts_npmrc`, `yarn_mode_skip_build` or `yarnrc_enable_scripts`, and `detail` names the source. Written once per run. |
| `env_scrubbed` | `count`, `dropped` | Containment dropped environment variables that a build might have wanted. `dropped` is comma-separated. |
Expand Down
Loading
Loading