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/CHANGELOG.md b/CHANGELOG.md index ba6ef000..7e85dd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 2ade4294..81eb9092 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 7e353276..41e256fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. @@ -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 diff --git a/docs/enforcement-matrix.md b/docs/enforcement-matrix.md index c978bee2..7e9214d6 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,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`, @@ -100,12 +115,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 @@ -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 @@ -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 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) diff --git a/scripts/sandbox-enforcement-macos.sh b/scripts/sandbox-enforcement-macos.sh index de472233..488685ca 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, 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" +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"