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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ jobs:
# The smoke script checks a sandboxed process can write its own working
# directory, which would pass against a sandbox that blocks nothing. The
# enforcement script is the one that can fail: it asserts what must be
# DENIED, what must still be ALLOWED, and the documented read weakness, so
# DENIED and what must still be ALLOWED, so
# macOS stops being the platform whose claims rest on reading the generator.
- name: macOS sandbox smoke
if: matrix.os == 'macos-latest'
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
never sent to your proxy. Other schemes, such as `socks4://`, are still
ignored with a warning.

### Fixed

* **On macOS, a contained install can no longer read the rest of your home
directory.** The sandbox allowed every read outside the credential stores, so
a package could read other projects in the home directory, nvx's own settings
and the tool credentials nvx saves. Reads under the home directory and under nvx's home are now
refused, apart from the project, the sandbox's own home, nvx's runtimes and
directories listed in `isolation.filesystem.allow_read_exec`. Windows and
Linux already refused reads of the home directory. A Node.js installed in the
home by another tool, such as nvm, now needs its directory in
`allow_read_exec` to run contained, as on Linux. Files outside the home stay
readable on macOS.

## [0.7.0] - 2026-10-06

### Added
Expand Down
16 changes: 8 additions & 8 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ documentation:
It **cannot** open a network connection to a host outside the policy allowlist,
including by ignoring `HTTP_PROXY`.

On macOS the read half covers the credential stores only. The profile denies
`~/.ssh`, `~/.aws`, `~/.npmrc` and the other stores it names, and allows every
other read, so another project on the machine stays readable. That is a
narrower product, and this document says so instead of leaving a reader to
discover it in a footnote.
On macOS the read half covers the home directory. The profile denies reads
there apart from the project, nvx's runtimes and the directories a policy
names, and allows reads elsewhere on the disk, so a project kept outside the
home stays readable. That is a narrower product, and this document says so
instead of leaving a reader to discover it in a footnote.

Step 3 is the product. Steps 1 and 2 are the price of admission. If either is
slow or fails on a normal machine, step 3 never happens because nvx is not
Expand Down Expand Up @@ -268,9 +268,9 @@ Deferred with intent, not built:
sandbox must deny and what it must still allow. A sandbox that refuses
everything fails them, which is the failure mode a denial-only check cannot see.

**macOS does not contain reads**, and the probe asserts that instead of merely
admitting it. So tightening the profile fails CI and forces the documents to
move with it.
**macOS contains reads only under the home directory.** Its probe requires a
read in the home outside the project to be refused, and the project and the
runtime to still read.

Earlier versions of this constraint were wrong in opposite directions. Until
2026-08-20 it called macOS egress "cooperative" when the profile is `(deny
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ credentials within reach. nvx puts that command inside an OS sandbox with a
throwaway `HOME` and an allowlist for anything it tries to reach over the
network. It can write only to the project and that home. It cannot read
`~/.ssh` or `~/.npmrc` either.
On macOS other reads are not contained, and the
On macOS files outside your home directory stay readable, and the
[known limitations](https://nvx.run/docs/limitations/) say so plainly.

**You do not change how you run anything.** nvx installs shims on `PATH`, so
Expand Down
16 changes: 9 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ These are deliberate trade-offs, and this section documents each one:
last check is what distinguishes enforcement from a sandbox that has simply
failed to start.

What macOS does not do is contain reads outside the credential stores. See the
entry below. A macOS runner also confirms that an allowlisted host completes
On macOS reads are denied under the home directory and nvx's home, apart from
what a run needs, and allowed elsewhere on the disk. See the entry below. A
macOS runner also confirms that an allowlisted host completes
through the proxy, that UDP is refused, and that nvx fails closed without
`sandbox-exec`. One cell stays unclaimed. Nothing yet shows which layer refuses
the outbound connection the probe observes being refused, DNS or connect.
Expand Down Expand Up @@ -239,11 +240,12 @@ These are deliberate trade-offs, and this section documents each one:
directory has to be readable for an install to work, and `.env` lives in it.
Environment *variables* are scrubbed, and a file is a file. Secrets outside the
project, such as `~/.ssh`, `~/.aws` and `~/.npmrc`, stay unreachable on Windows
and Linux. On macOS the Seatbelt profile allows filesystem reads and denies the
credential stores by path (see `docs/enforcement-matrix.md` note 2). Those
three, the other registry and cloud credential files listed there, and the
keychains cannot be read. **Other files outside the project can**, other
projects included.
and Linux. On macOS the Seatbelt profile denies reads under the home
directory and nvx's home, apart from the project, the guest home, nvx's
runtimes and `allow_read_exec` roots. It denies the credential stores by path
on top of that (see `docs/enforcement-matrix.md` note 2). Other projects in the
home cannot be read. **Files outside the home can**, such as other apps' temp
files under `/private/var/folders`.
- **Your home directory's names are visible on Windows, contents are not.**
A contained process can list your profile directory, which shows which
credential stores exist. The entry that allows it ships with Windows, and nvx
Expand Down
78 changes: 50 additions & 28 deletions docs/enforcement-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ and do not verify whether the kernel honours it.
| Guarantee | Windows (AppContainer) | Linux (Landlock + netns + seccomp) | macOS (Seatbelt) |
|---|---|---|---|
| Host filesystem write blocked (outside workdir + guest home) | Yes⁷ | Yes⁸ | Yes⁵ |
| Host filesystem read restricted | Yes⁴ | Yes⁸ | Partial²: credential stores denied, other reads allowed⁵ |
| Host filesystem read restricted | Yes⁴ | Yes⁸ | Partial²: the home directory denied outside what a run needs, other paths readable⁵ |
| Project `.git` read-only, rest of project writable | Yes¹⁴ | Yes¹⁴ | Yes¹⁴ |
| Environment secrets scrubbed | Yes | Yes | Yes |
| Egress blocked when the allowlist does not cover the host | Yes³ | Yes⁸ | Yes⁵ |
Expand All @@ -60,26 +60,41 @@ and do not verify whether the kernel honours it.
| A contained server reachable from the host | Only via `--expose`⁹ | Yes (shared stack, no inbound block) | Yes |
| Fails closed if a primitive is missing | Yes | Yes (Landlock 5.13+, iproute2 for netns) | Yes⁵ (refuses to run without `/usr/bin/sandbox-exec`) |

² On macOS the Seatbelt profile allows filesystem reads. The dynamic linker must
read system libraries and the dyld shared cache. Their locations vary by macOS
version (e.g. the Cryptexes firmlink on Apple Silicon) and nvx cannot enumerate
them reliably. A strict read allowlist breaks process launch. Write containment and
egress control remain enforced, and nvx scrubs environment secrets and redirects
`$HOME` to a guest profile under `~/.nvx`. That profile is thrown away after
each run, except for pnpm and for tools approved as trusted, which keep one
profile per project.

The user's credential stores are the exception. After the blanket read allow, the
profile denies reads of `~/.npmrc`, `~/.yarnrc`, `~/.yarnrc.yml`,
² On macOS the Seatbelt profile allows filesystem reads outside the home
directory. The dynamic linker must read system libraries and the dyld shared
cache. Their locations vary by macOS version (e.g. the Cryptexes firmlink on
Apple Silicon) and nvx cannot enumerate them reliably. A strict read allowlist
breaks process launch. Write containment and egress control remain enforced, and
nvx scrubs environment secrets and redirects `$HOME` to a guest profile under
`~/.nvx`. That profile is thrown away after each run, except for pnpm and for
tools approved as trusted, which keep one profile per project.

Under the home directory reads are denied. After the blanket read allow, the
profile denies reads of the real home and of nvx's own home (`~/.nvx`, or
wherever `NVX_HOME` points). It then reopens what a contained run reads there,
which is what Linux grants: the project, the guest home, nvx's `versions`, `bin`
and `current`, and every `isolation.filesystem.allow_read_exec` root. File
metadata stays readable, so a contained process can stat a path in the home and
cannot read its contents. A runtime installed under the home outside nvx, such
as one from nvm, runs contained only when its directory is listed in
`allow_read_exec`, as on Linux. Until 2026-10-06 the profile denied only the
credential stores below, and every other file in the home was readable, other
projects included.

The user's credential stores are denied last, after everything the profile
reopens, so a project or an `allow_read_exec` root that holds one does not
expose it. The profile denies reads of `~/.npmrc`, `~/.yarnrc`, `~/.yarnrc.yml`,
`~/.config/pnpm/rc`, `~/Library/Preferences/pnpm/rc`, `~/.bunfig.toml`,
`~/.docker/config.json`, `~/.netrc` and `~/.git-credentials`, and of everything
under `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.kube`,
`~/.config/gcloud`, `~/.azure` and `~/Library/Keychains`. `~` is the real home,
and each path is also named with symbolic links resolved, because Seatbelt
matches the resolved path. None of these is on the dynamic linker's path. Until
2026-10-01 the profile denied none of them. Every other file outside the project
stays readable, other projects included, and so does a credential kept anywhere
the list does not name.
2026-10-01 the profile denied none of them.

Reads outside the home stay allowed. That includes the per-user temp and cache
directories under `/private/var/folders`, which other apps use, and a project
or a credential kept on another volume. Linux denies those too.

Writes are contained to the project and the guest home, where `$TMPDIR` points.
Outside them the profile grants writes only on named device files: `/dev/null`,
Expand All @@ -100,12 +115,13 @@ true of writes and false of reads.
`$HOME` decides where `~` expands to. It does
not stop anything opening `/Users/<you>/.ssh/id_rsa` by absolute path. A
postinstall script looking for credentials does not need `~` to find them. That
is why the profile denies the credential stores above by path.
is why the profile denies the home directory and the credential stores above
by path.

On macOS, reads outside
them are not contained. The write and egress guarantees are real. The read
guarantee covers only the listed stores, which is a narrower product than the
same sentence describes on Windows and Linux.
On macOS the read guarantee covers the home directory and nvx's home. Reads
elsewhere on the disk stay allowed, which is a narrower product than the same
sentence describes on Linux, where a contained process sees only what it is
granted.

¹ On macOS, the loopback proxy and OS network rules gate egress. Linux
also removes all non-loopback interfaces (network namespace), so DNS to
Expand All @@ -119,12 +135,12 @@ build and asserts the denials instead of only that the command ran. A contained
process reports, and CI requires:

```
WRITE_OUTSIDE=DENIED WRITE_INSIDE=ALLOWED READ_OUTSIDE=ALLOWED
WRITE_OUTSIDE=DENIED WRITE_INSIDE=ALLOWED READ_OUTSIDE=DENIED READ_INSIDE=ALLOWED
EGRESS=DENIED UDP_EGRESS=DENIED CONNECT=200 (allowlisted host)
```

Two of those are load-bearing in a way the others are not. `WRITE_INSIDE` and
`CONNECT=200` are the positive controls. Every denial above them would also pass
Three of those are load-bearing in a way the others are not. `WRITE_INSIDE`,
`READ_INSIDE` and `CONNECT=200` are the positive controls. Every denial above them would also pass
for a sandbox that had failed to start. Requiring something to
*succeed* is the only thing that tells enforcement from breakage. `CONNECT=200`
is the one that closed the largest gap here. Until 2026-08-24 the whole script
Expand All @@ -136,11 +152,17 @@ read of each. A project file and node's own binary must still read,
each checked by exit code. Contained `npm config get registry` must succeed with
that `.npmrc` present and must not report the registry planted in it.

`READ_OUTSIDE=ALLOWED` pins the documented weakness in ² deliberately. If the
profile is ever tightened this fails. That forces an update to the docs site's
limitations page (`site/src/content/docs/docs/limitations.md`), SECURITY.md,
PRODUCT.md and this page in the same change. Otherwise they would quietly
go wrong in the flattering direction.
`READ_OUTSIDE` reads a file in the real home outside the project, and the OS
must refuse it with EPERM or EACCES. The project, `NVX_HOME` and an
`allow_read_exec` directory sit under the home for this run, so the controls
`READ_INSIDE`, `READ_RUNTIME` (node's own binary under `NVX_HOME/versions`) and
`READ_EXEC_ROOT` would fail against a profile that denied the whole home.
`NVX_HOME_READ` reads a file in nvx's home outside its runtimes and must be
refused. A fourth phase repeats that with an `NVX_HOME` under `/var/folders`,
outside the home. Before the profile denied the home, all three reads succeeded
(run 37399750782). After it, all three were refused and every control passed,
as did the macOS smoke's contained `npm install` and the launch-escape probe
(run 37400274341).

`UDP_EGRESS=DENIED` comes from Seatbelt refusing at **bind**, not at send. Sending
on an unbound UDP socket makes the runtime bind one implicitly. Seatbelt
Expand Down
2 changes: 1 addition & 1 deletion internal/nvx/nvx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ func TestExtractQuotedStrings(t *testing.T) {

func TestBuildSeatbeltProfile(t *testing.T) {
netCtx := NetworkLaunchContext{Mode: "proxy", HTTPProxyPort: 8080}
profile := buildSeatbeltProfile(netCtx, "/guest/home", "/work/dir")
profile := buildSeatbeltProfile(netCtx, "/guest/home", "/work/dir", "", nil)
for _, expected := range []string{
"(version 1)",
"(deny default)",
Expand Down
2 changes: 1 addition & 1 deletion internal/nvx/remediation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,7 @@ func TestScrubEnvironmentDropsHostProxyCredentials(t *testing.T) {
}

func TestBuildSeatbeltProfileContainsWritesAndEgress(t *testing.T) {
profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "offline"}, "/guest/home", "/work/dir")
profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "offline"}, "/guest/home", "/work/dir", "", nil)
if strings.Contains(profile, "(allow default)") {
t.Fatal("Seatbelt profile must be default-deny, not allow-all")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/nvx/sandbox_git_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestGitMetadataSeatbeltProfileDeniesWrites(t *testing.T) {
if err := os.Mkdir(filepath.Join(work, ".git"), 0o755); err != nil {
t.Fatal(err)
}
profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, tempDir(t), work)
profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, tempDir(t), work, "", nil)

deny := fmt.Sprintf("(deny file-write* (subpath %q))", filepath.Join(work, ".git"))
allow := fmt.Sprintf("(subpath %q)", work)
Expand Down
8 changes: 2 additions & 6 deletions internal/nvx/sandbox_landlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
Expand Down Expand Up @@ -254,11 +253,8 @@ func landlockReadOnlyRules(nvxHome string, privateProc bool) []landlockRule {
// Landlock is allowlist-only -- there is no deny rule -- so narrowing the
// grant is the only way to exclude them. The guest home is granted
// separately with full access, including when it lives under tool_home.
paths = append(paths,
filepath.Join(nvxHome, "versions"), // runtimes: read+exec is the point
filepath.Join(nvxHome, "bin"), // shims: PATH still resolves nested node/npm here
filepath.Join(nvxHome, "current"), // symlink into versions; resolved at rule-add time
)
// The current symlink is resolved at rule-add time.
paths = append(paths, sandboxRuntimeReadRoots(nvxHome)...)
}

var rules []landlockRule
Expand Down
2 changes: 1 addition & 1 deletion internal/nvx/sandbox_native_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func platformLaunchNative(config SandboxConfig, guestHome, workDir, cmdPath stri
// binary itself -- a persistent sandbox defeat on the DEFAULT macOS path. The
// legacy caller in sandbox_seatbelt.go was fixed in July; this one was missed,
// so the comment there described a guarantee the shipped path did not provide.
profile := buildSeatbeltProfile(netCtx, guestHome, workDir)
profile := buildSeatbeltProfile(netCtx, guestHome, workDir, config.NvxHome, config.ReadExecRoots)
// Under ~/.nvx, which the profile does not grant writes to; see
// writeSeatbeltProfile for what $TMPDIR allowed.
profilePath, removeProfile, err := writeSeatbeltProfile(config.NvxHome, profile)
Expand Down
Loading
Loading