From 37913c3442b8f7d5377a97c472bfdacb15836596 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 02:32:57 +0100 Subject: [PATCH 1/3] Assert that a contained macOS process cannot read the home directory The macOS enforcement probe now reads a file in the real home directory outside the project, and a file in nvx's home outside its runtimes, from inside the sandbox. Each read must be refused by the OS with EPERM or EACCES. A fourth phase repeats the nvx home check with an NVX_HOME under /var/folders, outside the home. The controls sit under the home too, so a profile that denied all of it fails them: a read of the project, of the runtime the process runs, and of a directory the policy names in allow_read_exec. The project, NVX_HOME and that directory move under $HOME for this reason. The probe and the macOS smoke install an nvx-managed runtime first, as the Linux probe does. The runner's own node is under /Users/runner/hostedtoolcache, inside the home. The launch-escape probe names that node's install directory in allow_read_exec instead. The Seatbelt profile allows every read outside the credential stores, so this commit fails on macOS. The next one narrows the profile. --- .github/workflows/ci.yml | 2 +- scripts/sandbox-enforcement-macos.sh | 138 ++++++++++++++++++------- scripts/sandbox-launch-escape-macos.sh | 8 +- scripts/sandbox-smoke-macos.sh | 25 ++--- 4 files changed, 122 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cec22ef4..c0a27131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/scripts/sandbox-enforcement-macos.sh b/scripts/sandbox-enforcement-macos.sh index de472233..d82edded 100644 --- a/scripts/sandbox-enforcement-macos.sh +++ b/scripts/sandbox-enforcement-macos.sh @@ -12,15 +12,15 @@ # apart. That distinction is not hypothetical here -- a Windows egress test once # reported success while the sandbox was blocking its own test server. # -# It also asserts a WEAKNESS on purpose. macOS allows filesystem reads outside -# the user's credential stores, so a contained process can read other files by -# absolute path. That is deliberate (the dynamic linker needs system libraries -# whose paths vary by OS version, and a strict read allowlist stops processes -# launching) and it is documented in README, SECURITY.md, PRODUCT.md and -# docs/enforcement-matrix.md. Pinning it here means that if the profile is ever -# tightened, this fails and forces those four documents to be updated together -# -- rather than the docs quietly staying wrong in either direction. The -# credential stores themselves are denied, and phase 3 asserts that. +# Reads are allowed outside the home directory, because the dynamic linker needs +# system libraries whose paths vary by OS version. Under the home directory they +# are denied except for the project, the guest home, nvx's runtimes and any +# allow_read_exec root. Until 2026-10-06 a contained process could read every +# file in the home directory outside the credential stores, other projects +# included, and this script asserted that as a documented weakness. It now +# requires such a read to be refused, with the project, the runtime and an +# allow_read_exec root still readable as the controls. The credential stores +# are denied as well, and phase 3 asserts that. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -48,7 +48,21 @@ if [[ ! -x /usr/bin/sandbox-exec ]]; then exit 1 fi -PROJ="$(mktemp -d)" +# The project, nvx's home and an allow_read_exec root all sit under the real +# home, the layout a developer has, so the reads the profile reopens under the +# home are the ones exercised here. mktemp would put them under +# /private/var/folders, outside the home, where nothing is reopened because +# nothing was denied. sandbox-smoke-macos.sh covers an NVX_HOME outside the +# home. +PROJ="$HOME/nvx-enforcement-project-$$" +export NVX_HOME="$HOME/.nvx-enforcement-home-$$" +READ_EXEC_DIR="$HOME/nvx-enforcement-tool-$$" +rm -rf "$PROJ" "$NVX_HOME" "$READ_EXEC_DIR" +mkdir -p "$PROJ" "$NVX_HOME" "$READ_EXEC_DIR" +printf 'tool-file\n' > "$READ_EXEC_DIR/tool.txt" +# Inside nvx's home but outside its runtimes: the control plane, which holds +# grants, policy and other tools' credentials. +printf 'control-plane\n' > "$NVX_HOME/probe-control-plane" # NOT mktemp for the "outside" fixture. Until 2026-10-06 buildSeatbeltProfile # granted writes on all of /private/var/folders, where macOS mktemp puts its @@ -80,23 +94,45 @@ SHARED_TEMP_TARGETS=( "$(getconf DARWIN_USER_TEMP_DIR)$PROBE_TAG" "$(getconf DARWIN_USER_CACHE_DIR)$PROBE_TAG" ) -trap 'rm -rf "$PROJ" "$OUTSIDE" "$FAKE_HOME" "${SHARED_TEMP_TARGETS[@]}"' EXIT +trap 'rm -rf "$PROJ" "$NVX_HOME" "$READ_EXEC_DIR" "$OUTSIDE" "$FAKE_HOME" "${SHARED_TEMP_TARGETS[@]}"' EXIT +# A file in the home directory that is no credential store, standing in for +# another project's source or secrets. SECRET="$OUTSIDE/credentials" printf 'SECRET-CONTENT-DO-NOT-LEAK\n' > "$SECRET" FORBIDDEN_WRITE="$OUTSIDE/should-not-exist" +# An nvx-managed runtime under NVX_HOME, as the Linux probe uses. The runner's +# own node is under /Users/runner/hostedtoolcache, inside the home, which a +# contained process may not read. Running nvx's runtime is also the realistic +# case, since managing runtimes is what nvx is for. cd "$PROJ" +echo "Installing an nvx-managed runtime..." +if ! "$NVX" -y install 22 >/dev/null 2>&1 || ! "$NVX" -y default 22 >/dev/null 2>&1; then + echo "FAIL: could not install an nvx-managed runtime into $NVX_HOME (network?)." >&2 + echo " Every contained run below needs one, so nothing here can be checked." >&2 + exit 1 +fi + +# The policies below add an allow_read_exec root and an allowlisted host, and +# nvx refuses to honour a widening policy it has not been told to trust. This +# script writes them itself, which is not the case that guard exists for. +export NVX_TRUST_YES=true + +printf 'project-file\n' > "$PROJ/project-file.txt" # Repository metadata. git runs uncontained, so a contained process must not be # able to write it, while reading it still works. mkdir -p .git/hooks GIT_CONFIG_BODY="$(printf '[core]\n\trepositoryformatversion = 0')" printf '%s\n' "$GIT_CONFIG_BODY" > .git/config -cat > .nvx-policy.json <<'POLICY' +cat > .nvx-policy.json < { + try { + const got = fs.readFileSync(p, 'utf8'); + out.push(name + (got.includes(want) ? '=ALLOWED' : '=GARBLED')); + } catch (e) { + out.push(name + (e.code === 'EPERM' || e.code === 'EACCES' ? '=DENIED' : '=ERROR ' + e.code)); + } +}; +readCheck('READ_OUTSIDE', secret, 'SECRET-CONTENT'); +readCheck('NVX_HOME_READ', process.argv[10], 'control-plane'); + +// Must be ALLOWED: the controls for the two denials above. All three sit under +// the home directory too, so a profile that denied the whole home would fail +// here: the project, the runtime this process is running, and the +// allow_read_exec root the policy names. +readCheck('READ_INSIDE', 'project-file.txt', 'project-file'); +try { fs.readFileSync(process.execPath); out.push('READ_RUNTIME=ALLOWED'); } +catch (e) { out.push('READ_RUNTIME=DENIED', why(e)); } +readCheck('READ_EXEC_ROOT', process.argv[9], 'tool-file'); // Must be DENIED: UDP to an external host. Asserted separately from TCP because // the profile's `(deny default)` covers both and nothing checked the second, so @@ -219,7 +271,8 @@ PROBE REPORT="$PROJ/report.txt" echo "Running contained probe..." set +e -"$NVX" -y --strict shim node probe.js "$SECRET" "$FORBIDDEN_WRITE" "$REPORT" "${SHARED_TEMP_TARGETS[@]}" +"$NVX" -y --strict shim node probe.js "$SECRET" "$FORBIDDEN_WRITE" "$REPORT" "${SHARED_TEMP_TARGETS[@]}" \ + "$READ_EXEC_DIR/tool.txt" "$NVX_HOME/probe-control-plane" rc=$? set -e @@ -257,6 +310,11 @@ expect "USER_CACHE_WRITE=DENIED" "a contained process wrote the user's Darwin c expect "OWN_TMP_WRITE=ALLOWED" "a contained process could not write its own temp directory, so the temp denials above prove nothing" expect "DEV_WRITE=ALLOWED" "a contained shell could not write /dev/null, /dev/zero, /dev/stdout, /dev/fd/1 or /dev/stderr" expect "XCRUN_TOOL=ALLOWED" "a contained process could not run /usr/bin/git, which npm uses for git dependencies" +expect "READ_OUTSIDE=DENIED" "a contained process read a file in the home directory outside the project; other projects there are as readable as that file" +expect "NVX_HOME_READ=DENIED" "a contained process read nvx's home outside its runtimes, where grants, policy and other tools' credentials live" +expect "READ_INSIDE=ALLOWED" "a contained process could not read its own project, so the read denials above prove nothing" +expect "READ_RUNTIME=ALLOWED" "a contained process could not read the runtime it runs from NVX_HOME/versions" +expect "READ_EXEC_ROOT=ALLOWED" "a contained process could not read a directory the policy names in allow_read_exec" # On disk, outside the sandbox: nothing reported as denied landed anyway. for p in "${SHARED_TEMP_TARGETS[@]}"; do @@ -274,17 +332,6 @@ if [[ "$(cat .git/config)" != "$GIT_CONFIG_BODY" ]]; then fail=1 fi -# The documented weakness. A change here is not necessarily a regression -- it -# may be an improvement -- but it must not go unnoticed, because four documents -# describe the current behaviour. -if ! grep -qx "READ_OUTSIDE=ALLOWED" "$REPORT"; then - echo "FAIL: reads outside the project are no longer allowed on macOS." >&2 - echo " That may be an improvement, but README, SECURITY.md, PRODUCT.md and" >&2 - echo " docs/enforcement-matrix.md all state that macOS allows reads outside the credential stores." >&2 - echo " Update them in the same change that tightened the profile." >&2 - fail=1 -fi - # Belt and braces: the file must genuinely still be absent, not merely reported # as denied by a probe that lied to itself. if [[ -e "$FORBIDDEN_WRITE" ]]; then @@ -337,10 +384,6 @@ req.setTimeout(20000, () => { req.destroy(); console.log('CONNECT=timeout'); pro req.end(); CONNECT -# The policy above widens what the sandbox may reach, and nvx refuses to honour a -# widening policy it has not been told to trust. This script wrote it a few lines -# up, which is not the case that guard exists for. -export NVX_TRUST_YES=true OUT2="$("$NVX" -y --strict shim node connect.js 2>&1 | grep '^CONNECT=' || true)" echo " proxy said: ${OUT2:-}" case "$OUT2" in @@ -366,11 +409,11 @@ case "$OUT2" in esac # A contained run started in the home directory must not be able to write it, -# nor ~/.nvx below it. The working directory is a writable root, and nothing +# nor nvx's home below it. The working directory is a writable root, and nothing # checked which directory it was: measured on this runner before the guard, all # such writes landed. HOME_WRITE="$OUTSIDE/written-from-home" -NVX_WRITE="$HOME/.nvx/probe-written-from-home" +NVX_WRITE="$NVX_HOME/probe-written-from-home" ( cd "$HOME" && "$NVX" -y --strict shim node -e \ "for(const p of process.argv.slice(1)){try{require('fs').writeFileSync(p,'x')}catch(e){}}" \ "$HOME_WRITE" "$NVX_WRITE" >/dev/null 2>&1 ) || true @@ -436,10 +479,31 @@ elif grep -q 'planted.invalid' <<<"$NPM_OUT"; then fail=1 fi +# Phase 4: an NVX_HOME outside the home directory. +# +# Everything above has nvx's home under the real home, where the deny on the +# home covers it. NVX_HOME may be anywhere, so nvx's home is denied on its own +# as well, with its runtimes reopened. mktemp puts this one under /var/folders. +# A file in it outside the runtimes must be refused by the OS, and the runtime +# itself must still read, or the refusal proves nothing. +echo "Phase 4: nvx's home outside the home directory must be unreadable outside its runtimes..." +OUT_NVX_HOME="$(mktemp -d)" +trap 'rm -rf "$PROJ" "$NVX_HOME" "$READ_EXEC_DIR" "$OUTSIDE" "$FAKE_HOME" "$OUT_NVX_HOME" "${SHARED_TEMP_TARGETS[@]}"' EXIT +if ! NVX_HOME="$OUT_NVX_HOME" "$NVX" -y install 22 >/dev/null 2>&1 || ! NVX_HOME="$OUT_NVX_HOME" "$NVX" -y default 22 >/dev/null 2>&1; then + echo "FAIL: could not install an nvx-managed runtime into $OUT_NVX_HOME (network?)." >&2 + fail=1 +else + printf 'control-plane\n' > "$OUT_NVX_HOME/probe-control-plane" + NVX_HOME_DIR="$OUT_NVX_HOME" + expect_read "$OUT_NVX_HOME/probe-control-plane" 3 "nvx's home outside its runtimes must be unreadable wherever NVX_HOME is" + expect_read "SELF" 0 "The runtime under an NVX_HOME outside the home must stay readable, or the denial above proves nothing" +fi + if [[ $fail -ne 0 ]]; then echo "macOS enforcement probe FAILED." >&2 exit 1 fi echo "macOS enforcement probe passed: writes contained, egress denied for TCP and UDP," -echo "an allowlisted host reachable through the proxy, credential files unreadable, other reads allowed as documented." +echo "an allowlisted host reachable through the proxy, the home directory and credential files unreadable," +echo "the project, the runtime and an allow_read_exec root readable." diff --git a/scripts/sandbox-launch-escape-macos.sh b/scripts/sandbox-launch-escape-macos.sh index e23fa3c3..fb66f5bf 100644 --- a/scripts/sandbox-launch-escape-macos.sh +++ b/scripts/sandbox-launch-escape-macos.sh @@ -37,8 +37,14 @@ rm -rf "$OUTSIDE"; mkdir -p "$OUTSIDE" trap 'rm -rf "$PROJ" "$OUTSIDE"; launchctl remove nvx.probe.control 2>/dev/null; launchctl remove nvx.probe.contained 2>/dev/null' EXIT cd "$PROJ" -cat > .nvx-policy.json <<'POLICY' +# The runner's node is under /Users/runner/hostedtoolcache, inside the home +# directory, which a contained process may not read. Naming its install +# directory in allow_read_exec is how a developer runs a runtime nvx does not +# manage from there, and it saves this script a runtime download. +NODE_PREFIX="$(node -p 'require("path").resolve(process.execPath, "..", "..")')" +cat > .nvx-policy.json </dev/null +# An nvx-managed runtime, so every contained run below uses the runtime nvx +# pins rather than whatever node the machine happens to have. The runner's own +# node is under /Users/runner/hostedtoolcache, inside the home directory, which +# a contained process may not read. It is also what makes the PATH arrangement +# in the install phase meaningful: the fix being covered there puts the pinned +# runtime's own bin directory on the contained PATH, and there is no pinned +# runtime to put there otherwise. +echo "Installing an nvx-managed runtime..." +if ! "$NVX" -y install 22 >/dev/null 2>&1 || ! "$NVX" -y default 22 >/dev/null 2>&1; then + echo "::warning::could not install an nvx-managed runtime (network?); skipping the macOS smoke" >&2 + exit 0 +fi + # --strict, and the containment line checked, for the reason the Linux sibling # spells out: `node -e` is the user's own code, which the default level does not # contain, so this ran outside the sandbox and reported success either way. @@ -130,18 +143,6 @@ trap 'rm -rf "$PROJ" "${NVX_HOME:-}"' EXIT # # No --strict here: an install is contained at the default level, so this is the # path a person actually takes. -# An nvx-managed runtime, so the install exercises the runtime nvx pins rather -# than whatever node the machine happens to have. It is also what makes the PATH -# arrangement below meaningful: the fix being covered puts the pinned runtime's -# own bin directory on the contained PATH, and there is no pinned runtime to put -# there otherwise. -echo "Installing an nvx-managed runtime..." -if ! "$NVX" -y install 22 >/dev/null 2>&1 || ! "$NVX" -y default 22 >/dev/null 2>&1; then - echo "::warning::could not install an nvx-managed runtime (network?); skipping the macOS install phase" >&2 - echo "macOS sandbox smoke passed (install phase skipped)." - exit 0 -fi - echo "Installing a package through the sandbox..." PKG="$PROJ/pkgtest" mkdir -p "$PKG" From c330853ee365a6969068c27ccba07fa229ffaa61 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 02:39:27 +0100 Subject: [PATCH 2/3] Deny contained reads of the home directory on macOS The Seatbelt profile allowed every read and denied only the credential stores. A contained process could read every other file in the home directory, other projects included, and nvx's own home: grants, policy and tool_home credentials. On the macOS runner the probe from the previous commit read a file in the home outside the project, a file in an NVX_HOME under the home, and a file in an NVX_HOME under /var/folders. Windows and Linux already deny reads of the home. After the blanket read allow the profile now denies reads of the real home and of nvx's home, named as given and with links resolved. Metadata stays readable. It then reopens what Linux grants: the project, the guest home, every allow_read_exec root, and nvx's versions, bin and current. The credential-store denies stay last, so a project or an allow_read_exec root that holds a store does not expose it. The runtime trees are now one list shared with the Landlock rules. A runtime under the home outside nvx, such as one installed by nvm, needs its directory in allow_read_exec, as it already does on Linux. --- internal/nvx/nvx_test.go | 2 +- internal/nvx/remediation_test.go | 2 +- internal/nvx/sandbox_git_metadata_test.go | 2 +- internal/nvx/sandbox_landlock_linux.go | 8 +- internal/nvx/sandbox_native_darwin.go | 2 +- internal/nvx/sandbox_seatbelt.go | 80 ++++++++++++++--- internal/nvx/sandbox_seatbelt_connect_test.go | 6 +- .../nvx/sandbox_seatbelt_credentials_test.go | 30 +++++-- .../nvx/sandbox_seatbelt_home_read_test.go | 85 +++++++++++++++++++ .../nvx/sandbox_seatbelt_loopback_test.go | 4 +- .../nvx/sandbox_seatbelt_writescope_test.go | 8 +- internal/nvx/sandbox_write_scope.go | 15 ++++ internal/nvx/sandbox_write_scope_test.go | 2 + 13 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 internal/nvx/sandbox_seatbelt_home_read_test.go diff --git a/internal/nvx/nvx_test.go b/internal/nvx/nvx_test.go index 2cd0d940..22692d9d 100644 --- a/internal/nvx/nvx_test.go +++ b/internal/nvx/nvx_test.go @@ -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)", diff --git a/internal/nvx/remediation_test.go b/internal/nvx/remediation_test.go index aeedd5ce..cf0a59df 100644 --- a/internal/nvx/remediation_test.go +++ b/internal/nvx/remediation_test.go @@ -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") } diff --git a/internal/nvx/sandbox_git_metadata_test.go b/internal/nvx/sandbox_git_metadata_test.go index 39aa307d..e811e75a 100644 --- a/internal/nvx/sandbox_git_metadata_test.go +++ b/internal/nvx/sandbox_git_metadata_test.go @@ -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) diff --git a/internal/nvx/sandbox_landlock_linux.go b/internal/nvx/sandbox_landlock_linux.go index e7411433..a905eb34 100644 --- a/internal/nvx/sandbox_landlock_linux.go +++ b/internal/nvx/sandbox_landlock_linux.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "os/signal" - "path/filepath" "runtime" "strings" "syscall" @@ -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 diff --git a/internal/nvx/sandbox_native_darwin.go b/internal/nvx/sandbox_native_darwin.go index 014ac4df..122ff412 100644 --- a/internal/nvx/sandbox_native_darwin.go +++ b/internal/nvx/sandbox_native_darwin.go @@ -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) diff --git a/internal/nvx/sandbox_seatbelt.go b/internal/nvx/sandbox_seatbelt.go index e28e01a7..7c85c305 100644 --- a/internal/nvx/sandbox_seatbelt.go +++ b/internal/nvx/sandbox_seatbelt.go @@ -116,10 +116,10 @@ func runSeatbeltSandbox(config SandboxConfig, netCtx NetworkLaunchContext) int { // binary's own directory must NOT be writable: this profile used to pass // both as writable roots, which let any sandboxed process rewrite the // global policy, self-approve grants, or trojan the node/npm binaries - // themselves — a full, persistent sandbox defeat. Reads remain broad - // (file-read* below) so the dynamic linker and tooling can still find - // everything they need; only writes are scoped down. - profile := buildSeatbeltProfile(netCtx, guestHome, cwd) + // themselves — a full, persistent sandbox defeat. Reads stay broad outside + // the home directory and nvx's home, so the dynamic linker can find what it + // needs. See buildSeatbeltProfile. + profile := buildSeatbeltProfile(netCtx, guestHome, cwd, config.NvxHome, config.ReadExecRoots) profilePath, removeProfile, err := writeSeatbeltProfile(config.NvxHome, profile) if err != nil { LogError("Failed to write the Seatbelt profile: %v", err) @@ -168,8 +168,12 @@ func runSeatbeltSandbox(config SandboxConfig, netCtx NetworkLaunchContext) int { // The roots are named as given and as resolved, because Seatbelt matches the // resolved path. A project made by mktemp is under /var/folders, really // /private/var/folders, and a rule naming only the first would match nothing. -func buildSeatbeltProfile(netCtx NetworkLaunchContext, guestHome, workDir string) string { +// +// nvxHome and readExecRoots decide what stays readable under the home +// directory. See seatbeltHomeReadRules. +func buildSeatbeltProfile(netCtx NetworkLaunchContext, guestHome, workDir, nvxHome string, readExecRoots []string) string { writeRoots := seatbeltPathForms(sandboxWritableRoots(guestHome, workDir)) + home, _ := os.UserHomeDir() var b strings.Builder b.WriteString("(version 1)\n") @@ -193,11 +197,13 @@ func buildSeatbeltProfile(netCtx NetworkLaunchContext, guestHome, workDir string // Reads are allowed broadly. The dynamic linker must read system libraries // and the dyld shared cache, whose paths vary by macOS version (e.g. the // Cryptexes firmlink on Apple Silicon) and are impractical to enumerate - // reliably. nvx's enforced guarantees are filesystem-WRITE containment and - // egress control, both kept strict below; environment secrets are separately - // scrubbed and $HOME is redirected to an ephemeral guest profile. The user's - // credential stores are carved back out further down. + // reliably. The home directory and nvx's own home are denied straight after, + // with what a run needs reopened. The user's credential stores are denied + // again further down. b.WriteString("(allow file-read*)\n") + for _, rule := range seatbeltHomeReadRules(home, guestHome, workDir, nvxHome, readExecRoots) { + b.WriteString(rule + "\n") + } b.WriteString("(allow file-write*\n") for _, dev := range seatbeltDeviceWrites { b.WriteString(" " + dev + "\n") @@ -219,8 +225,9 @@ func buildSeatbeltProfile(netCtx NetworkLaunchContext, guestHome, workDir string fmt.Fprintf(&b, "(deny file-write* (subpath %q))\n", p) } // The user's credential stores are unreadable, as they are on Windows and - // Linux. These come after the blanket file-read* allow so they win. - home, _ := os.UserHomeDir() + // Linux. These come after the blanket file-read* allow and after the reads + // reopened under the home, so they win over both. A project or an + // allow_read_exec root that holds a store does not expose it. for _, rule := range seatbeltCredentialReadDenies(home) { b.WriteString(rule + "\n") } @@ -309,6 +316,57 @@ var seatbeltDeviceWrites = []string{ `(regex #"^/dev/ttys[0-9]+$")`, } +// seatbeltHomeReadRules denies reading the real home directory and nvxHome, +// then reopens what a contained run reads there. They go after the blanket +// file-read* allow and before the credential-store denies, because Seatbelt +// applies the last rule that matches. +// +// Until 2026-10-06 the profile denied only the credential stores, so a +// contained process could read every other file in the home directory, other +// projects included, and nvxHome's grants, policy and tool_home credentials. +// Windows and Linux already denied reads of the home directory. The reopened +// set is what Linux +// grants under the home (sandboxVisiblePaths): the project and the guest home, +// which are also the writable roots, every allow_read_exec root, and nvx's +// runtime trees. A runtime that lives under the home outside nvx, such as one +// installed by nvm, needs its directory in allow_read_exec, as on Linux. +// +// nvxHome is denied as well as the home, because NVX_HOME can point outside +// the home. Paths are named as given and as resolved, for the reason +// buildSeatbeltProfile gives. +func seatbeltHomeReadRules(home, guestHome, workDir, nvxHome string, readExecRoots []string) []string { + var denied []string + for _, p := range []string{home, nvxHome} { + if p != "" { + denied = append(denied, p) + } + } + if len(denied) == 0 { + return nil + } + denied = seatbeltPathForms(denied) + + var rules []string + for _, p := range denied { + rules = append(rules, fmt.Sprintf("(deny file-read* (subpath %q))", p)) + } + // Metadata stays readable, as it is everywhere else in the profile. Node's + // module resolution stats node_modules in each ancestor of the project, + // and a stat shows no file contents. + for _, p := range denied { + rules = append(rules, fmt.Sprintf("(allow file-read-metadata (subpath %q))", p)) + } + reopened := append(sandboxWritableRoots(guestHome, workDir), readExecRoots...) + reopened = append(reopened, sandboxRuntimeReadRoots(nvxHome)...) + for _, p := range seatbeltPathForms(reopened) { + if p == "" { + continue + } + rules = append(rules, fmt.Sprintf("(allow file-read* (subpath %q))", p)) + } + return rules +} + // Registry tokens, keys and cloud credentials, relative to the real home. // Files are denied as literals and directories as subpaths. pnpm keeps its rc // under Library/Preferences on macOS and under .config elsewhere. None of these diff --git a/internal/nvx/sandbox_seatbelt_connect_test.go b/internal/nvx/sandbox_seatbelt_connect_test.go index 5f725d45..82060eb8 100644 --- a/internal/nvx/sandbox_seatbelt_connect_test.go +++ b/internal/nvx/sandbox_seatbelt_connect_test.go @@ -19,7 +19,7 @@ func TestSeatbeltConnectOpensTheRelayPortAndNotTheService(t *testing.T) { Mode: "proxy", HTTPProxyPort: 8080, ConnectPorts: []connectMapping{{Host: 9222, Inside: 19222}}, - }, tempDir(t), tempDir(t)) + }, tempDir(t), tempDir(t), "", nil) if !strings.Contains(profile, `(allow network-outbound (remote tcp "localhost:19222"))`) { t.Errorf("the contained process cannot reach nvx's relay, so --connect does nothing:\n%s", profile) @@ -42,7 +42,7 @@ func TestSeatbeltConnectWorksInOfflineMode(t *testing.T) { profile := buildSeatbeltProfile(NetworkLaunchContext{ Mode: "offline", ConnectPorts: []connectMapping{{Host: 5432, Inside: 15432}}, - }, tempDir(t), tempDir(t)) + }, tempDir(t), tempDir(t), "", nil) if !strings.Contains(profile, `(allow network-outbound (remote tcp "localhost:15432"))`) { t.Errorf("--connect was dropped in offline mode:\n%s", profile) @@ -62,7 +62,7 @@ func TestSeatbeltConnectEmitsNoRuleForAnUnresolvedPort(t *testing.T) { profile := buildSeatbeltProfile(NetworkLaunchContext{ Mode: "proxy", ConnectPorts: []connectMapping{{Host: 9222}}, - }, tempDir(t), tempDir(t)) + }, tempDir(t), tempDir(t), "", nil) if strings.Contains(profile, `localhost:0`) { t.Errorf("the profile names port 0:\n%s", profile) diff --git a/internal/nvx/sandbox_seatbelt_credentials_test.go b/internal/nvx/sandbox_seatbelt_credentials_test.go index edac6514..1def336f 100644 --- a/internal/nvx/sandbox_seatbelt_credentials_test.go +++ b/internal/nvx/sandbox_seatbelt_credentials_test.go @@ -11,9 +11,10 @@ import ( ) // The Seatbelt profile allows every read so the dynamic linker can find its -// libraries. The user's credential stores are denied after that allow, so the -// deny wins, and each is named under the real home and under the home with -// links resolved, because Seatbelt matches the resolved path. +// libraries, then denies the home and reopens what a run needs. The user's +// credential stores are denied after all of that, so the deny wins, and each is +// named under the real home and under the home with links resolved, because +// Seatbelt matches the resolved path. func TestSeatbeltProfileDeniesReadingCredentialStores(t *testing.T) { home := tempDir(t) t.Setenv("HOME", home) @@ -27,15 +28,18 @@ func TestSeatbeltProfileDeniesReadingCredentialStores(t *testing.T) { guestHome := filepath.Join(home, ".nvx", "sandbox_home", "s1") workDir := filepath.Join(home, "projects", "app") - profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, guestHome, workDir) + nvxHome := filepath.Join(home, ".nvx") + profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, guestHome, workDir, nvxHome, nil) homes := []string{home} if resolved, err := filepath.EvalSymlinks(home); err == nil && resolved != home { homes = append(homes, resolved) } - allowAt := strings.Index(profile, "(allow file-read*)\n") + // The last read rule reopening something under the home: the stores must + // come after it, or a project holding one would expose it. + allowAt := strings.LastIndex(profile, "(allow file-read* (subpath ") if allowAt < 0 { - t.Fatalf("profile has no blanket read allow:\n%s", profile) + t.Fatalf("profile reopens nothing under the home:\n%s", profile) } for _, h := range homes { for _, want := range []string{ @@ -53,13 +57,18 @@ func TestSeatbeltProfileDeniesReadingCredentialStores(t *testing.T) { continue } if at < allowAt { - t.Errorf("%s comes before the read allow it has to override", want) + t.Errorf("%s comes before a read allow it has to override", want) } } } - // What a contained run needs stays readable. No read deny covers the guest - // home, the project, or the home directory itself. + // No credential-store deny covers the guest home, the project, or the home + // directory itself. The deny on the whole home and on nvxHome is the + // exception, and sandbox_seatbelt_home_read_test.go checks what reopens it. + wholeTree := map[string]bool{} + for _, p := range seatbeltPathForms([]string{home, nvxHome}) { + wholeTree[p] = true + } denyRe := regexp.MustCompile(`\(deny file-read\* \((?:literal|subpath) ("(?:[^"\\]|\\.)*")\)\)`) matches := denyRe.FindAllStringSubmatch(profile, -1) if len(matches) == 0 { @@ -70,6 +79,9 @@ func TestSeatbeltProfileDeniesReadingCredentialStores(t *testing.T) { if err != nil { t.Fatalf("unquote %s: %v", m[1], err) } + if wholeTree[p] { + continue + } for _, needed := range []string{guestHome, workDir, home} { if dirWithin(needed, p) { t.Errorf("read deny on %s covers %s, which a contained run needs", p, needed) diff --git a/internal/nvx/sandbox_seatbelt_home_read_test.go b/internal/nvx/sandbox_seatbelt_home_read_test.go new file mode 100644 index 00000000..ac9bbaa4 --- /dev/null +++ b/internal/nvx/sandbox_seatbelt_home_read_test.go @@ -0,0 +1,85 @@ +package nvx + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// The Seatbelt profile denies reading the real home directory and nvxHome after +// its blanket read allow, then reopens the project, the guest home, nvx's +// runtimes and allow_read_exec roots. Until 2026-10-06 it denied only the +// credential stores, and every other project in the home was readable. +func TestSeatbeltProfileDeniesReadsUnderTheHomeOutsideWhatARunNeeds(t *testing.T) { + home := tempDir(t) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // os.UserHomeDir reads this on Windows + + for _, tc := range []struct { + name string + nvxHome string + }{ + {"nvx home under the home", filepath.Join(home, ".nvx")}, + {"nvx home outside the home", tempDir(t)}, + } { + t.Run(tc.name, func(t *testing.T) { + guestHome := filepath.Join(tc.nvxHome, "sandbox_home", "s1") + workDir := filepath.Join(home, "projects", "app") + readExec := filepath.Join(home, "tools", "browsers") + profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, guestHome, workDir, tc.nvxHome, []string{readExec}) + + at := func(rule string) int { + t.Helper() + i := strings.Index(profile, rule) + if i < 0 { + t.Errorf("profile does not contain %s", rule) + } + return i + } + blanket := at("(allow file-read*)\n") + for _, denied := range []string{home, tc.nvxHome} { + deny := at(fmt.Sprintf("(deny file-read* (subpath %q))", denied)) + if deny < blanket { + t.Errorf("the read deny on %s comes before the blanket allow it has to override", denied) + } + if meta := at(fmt.Sprintf("(allow file-read-metadata (subpath %q))", denied)); meta < deny { + t.Errorf("the metadata allow on %s comes before the deny it reopens", denied) + } + for _, needed := range []string{workDir, guestHome, readExec, + filepath.Join(tc.nvxHome, "versions"), filepath.Join(tc.nvxHome, "bin")} { + if reopen := at(fmt.Sprintf("(allow file-read* (subpath %q))", needed)); reopen < deny { + t.Errorf("the read allow on %s comes before the deny on %s it has to override", needed, denied) + } + } + } + + // Nothing reopened reaches the home itself, nvxHome itself, another + // project, or the parts of nvxHome that hold grants and credentials. + reopenRe := regexp.MustCompile(`\(allow file-read\* \(subpath ("(?:[^"\\]|\\.)*")\)\)`) + for _, m := range reopenRe.FindAllStringSubmatch(profile, -1) { + p, err := strconv.Unquote(m[1]) + if err != nil { + t.Fatalf("unquote %s: %v", m[1], err) + } + for _, private := range []string{ + home, tc.nvxHome, + filepath.Join(home, "projects", "other"), + filepath.Join(tc.nvxHome, "grants"), + filepath.Join(tc.nvxHome, "tool_home"), + filepath.Join(tc.nvxHome, "policy.json"), + filepath.Join(tc.nvxHome, "sandbox_home", "s2"), + } { + if dirWithin(private, p) { + t.Errorf("the read allow on %s reopens %s", p, private) + } + } + } + if t.Failed() { + t.Logf("profile:\n%s", profile) + } + }) + } +} diff --git a/internal/nvx/sandbox_seatbelt_loopback_test.go b/internal/nvx/sandbox_seatbelt_loopback_test.go index 9c526bd4..32cf6cb1 100644 --- a/internal/nvx/sandbox_seatbelt_loopback_test.go +++ b/internal/nvx/sandbox_seatbelt_loopback_test.go @@ -23,7 +23,7 @@ func TestSeatbeltGrantsLoopbackOnlyWhereTheModeMeansIt(t *testing.T) { Mode: mode, HTTPProxyPort: 8080, SOCKSProxyPort: 1080, - }, tempDir(t), tempDir(t)) + }, tempDir(t), tempDir(t), "", nil) } t.Run("proxy reaches the proxy and nothing else on loopback", func(t *testing.T) { @@ -82,7 +82,7 @@ func TestSeatbeltGrantsLoopbackOnlyWhereTheModeMeansIt(t *testing.T) { // With no proxy port known there is nothing legitimate to reach, and the old // code's wildcard would have quietly opened all of loopback instead. t.Run("proxy with no known port fails closed", func(t *testing.T) { - p := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, tempDir(t), tempDir(t)) + p := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, tempDir(t), tempDir(t), "", nil) if strings.Contains(p, "network-outbound") { t.Errorf("proxy mode with no proxy port should grant no egress:\n%s", p) } diff --git a/internal/nvx/sandbox_seatbelt_writescope_test.go b/internal/nvx/sandbox_seatbelt_writescope_test.go index acd9453e..65dd6508 100644 --- a/internal/nvx/sandbox_seatbelt_writescope_test.go +++ b/internal/nvx/sandbox_seatbelt_writescope_test.go @@ -38,6 +38,8 @@ func TestSeatbeltProfileDoesNotGrantWriteToNvxHome(t *testing.T) { NetworkLaunchContext{Mode: "proxy"}, "/Users/testuser/.nvx/sandbox_home/session1", "/Users/testuser/projects/app", + "/Users/testuser/.nvx", + nil, ) writes := seatbeltWriteSection(t, profile) @@ -59,6 +61,8 @@ func TestSeatbeltProfileDoesNotGrantWriteToRuntimeBinDir(t *testing.T) { NetworkLaunchContext{Mode: "proxy"}, "/Users/testuser/.nvx/sandbox_home/session1", "/Users/testuser/projects/app", + "/Users/testuser/.nvx", + nil, ) writes := seatbeltWriteSection(t, profile) @@ -81,7 +85,7 @@ func TestSeatbeltProfileDoesNotGrantWriteToRuntimeBinDir(t *testing.T) { func TestSeatbeltProfileWritableRootsAreExactlyExpected(t *testing.T) { guestHome := "/Users/testuser/.nvx/sandbox_home/session1" workDir := "/Users/testuser/projects/app" - profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, guestHome, workDir) + profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, guestHome, workDir, "/Users/testuser/.nvx", nil) writes := seatbeltWriteSection(t, profile) want := append([]string{ @@ -118,7 +122,7 @@ func TestSeatbeltProfileWritableRootsAreExactlyExpected(t *testing.T) { // also catch their return, and this one names what they are. func TestSeatbeltProfileDoesNotGrantWriteToSharedTempOrAllOfDev(t *testing.T) { profile := buildSeatbeltProfile(NetworkLaunchContext{Mode: "proxy"}, - "/Users/testuser/.nvx/sandbox_home/session1", "/Users/testuser/projects/app") + "/Users/testuser/.nvx/sandbox_home/session1", "/Users/testuser/projects/app", "/Users/testuser/.nvx", nil) writes := seatbeltWriteSection(t, profile) for _, root := range []string{"/dev", "/private/tmp", "/private/var/tmp", "/private/var/folders", "/tmp", "/var/folders"} { for _, form := range []string{`(subpath "` + root + `")`, `(literal "` + root + `")`} { diff --git a/internal/nvx/sandbox_write_scope.go b/internal/nvx/sandbox_write_scope.go index 714f38f8..3d4b04b5 100644 --- a/internal/nvx/sandbox_write_scope.go +++ b/internal/nvx/sandbox_write_scope.go @@ -49,6 +49,21 @@ func sandboxWritableRoots(guestHome, workDir string) []string { return roots } +// sandboxRuntimeReadRoots are the parts of nvxHome a contained process may read +// and execute, and nothing else under it. Linux grants exactly these, and macOS +// reopens exactly these after denying reads of nvxHome. See +// landlockReadOnlyRules for what the rest of nvxHome holds. +func sandboxRuntimeReadRoots(nvxHome string) []string { + if nvxHome == "" { + return nil + } + return []string{ + 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 + } +} + // gitMetadataPaths returns the repository metadata inside workDir that a // contained process may read but never write: workDir/.git, and, where that is // a file naming the real git directory (a linked worktree, a submodule, a diff --git a/internal/nvx/sandbox_write_scope_test.go b/internal/nvx/sandbox_write_scope_test.go index c8b01a09..b55fd900 100644 --- a/internal/nvx/sandbox_write_scope_test.go +++ b/internal/nvx/sandbox_write_scope_test.go @@ -89,6 +89,8 @@ func TestSeatbeltProfileNeverGrantsWriteToControlPlane(t *testing.T) { NetworkLaunchContext{Mode: "proxy"}, guestHome, "/Users/testuser/projects/app", + nvxHome, + nil, ) writes := seatbeltWriteSection(t, profile) From 70935117237bc3bf416dd5a074634f1f49542017 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 02:45:48 +0100 Subject: [PATCH 3/3] Document that macOS denies reads under the home directory The enforcement matrix, SECURITY.md, README and PRODUCT.md said macOS allowed every read outside the credential stores. They now say reads under the home directory and nvx's home are denied apart from what a run needs, and that reads elsewhere on the disk stay allowed. The matrix records the two macOS runs: 37399750782 read all three files before the profile change, 37400274341 refused them with every control passing. --- CHANGELOG.md | 15 ++++++ PRODUCT.md | 16 +++--- README.md | 2 +- SECURITY.md | 16 +++--- docs/enforcement-matrix.md | 75 ++++++++++++++++++---------- scripts/sandbox-enforcement-macos.sh | 2 +- 6 files changed, 83 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6207637..a80b6d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 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] ### Added diff --git a/PRODUCT.md b/PRODUCT.md index 2a2539b7..d2985fa2 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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 @@ -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 diff --git a/README.md b/README.md index a19fb7c0..84b25a9b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 02f584e4..7e9dadbd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -152,8 +152,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. @@ -232,11 +233,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 diff --git a/docs/enforcement-matrix.md b/docs/enforcement-matrix.md index 2dc5b1b9..0048f5e7 100644 --- a/docs/enforcement-matrix.md +++ b/docs/enforcement-matrix.md @@ -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⁵ | @@ -60,24 +60,40 @@ 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 an ephemeral guest profile. - -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 an ephemeral guest +profile. + +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`, @@ -98,12 +114,13 @@ true of writes and false of reads. `$HOME` decides where `~` expands to. It does not stop anything opening `/Users//.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 @@ -117,12 +134,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 @@ -134,11 +151,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 diff --git a/scripts/sandbox-enforcement-macos.sh b/scripts/sandbox-enforcement-macos.sh index d82edded..488685ca 100644 --- a/scripts/sandbox-enforcement-macos.sh +++ b/scripts/sandbox-enforcement-macos.sh @@ -310,7 +310,7 @@ expect "USER_CACHE_WRITE=DENIED" "a contained process wrote the user's Darwin c expect "OWN_TMP_WRITE=ALLOWED" "a contained process could not write its own temp directory, so the temp denials above prove nothing" expect "DEV_WRITE=ALLOWED" "a contained shell could not write /dev/null, /dev/zero, /dev/stdout, /dev/fd/1 or /dev/stderr" expect "XCRUN_TOOL=ALLOWED" "a contained process could not run /usr/bin/git, which npm uses for git dependencies" -expect "READ_OUTSIDE=DENIED" "a contained process read a file in the home directory outside the project; other projects there are as readable as that file" +expect "READ_OUTSIDE=DENIED" "a contained process read a file in the home directory outside the project, and other projects there are as readable as that file" expect "NVX_HOME_READ=DENIED" "a contained process read nvx's home outside its runtimes, where grants, policy and other tools' credentials live" expect "READ_INSIDE=ALLOWED" "a contained process could not read its own project, so the read denials above prove nothing" expect "READ_RUNTIME=ALLOWED" "a contained process could not read the runtime it runs from NVX_HOME/versions"