Repository navigation
Run the Linux sandbox's command as the user, and say when a browser download is deleted - #187
Merged
Merged
Conversation
The contained process ran as root in a user namespace that maps only the user's own id. A tool that unpacks an archive as root gives each file the owner the archive records, and any owner the namespace does not hold is EINVAL. Measured on Linux 6.18 in a Debian 13 container, a contained `npm install sqlite3@6.0.1` stopped at "prebuild-install warn install EINVAL: invalid argument, lchown", because the archive of its prebuilt binary is owned by uid 1001, and the fallback build from source failed as well. The supervisor's user namespace now maps the user to itself, so the command runs with the user's own uid and gid. A process that is not root keeps no capability across exec, so the supervisor carries its own across its exec as ambient capabilities. On the thread that forks the command it then clears its inheritable set, which clears the ambient set with it, and empties the bounding set. The command now holds no capability at all, where it held every one but the four the supervisor dropped. A user who runs nvx as root still runs the command as root, and it holds none there either. The supervisor sets the namespace's unprivileged port floor to 0, so a contained server can still listen below 1024, as it could as root. The same install now exits 0 and a contained node loads the module. The new test fails on the old code on its uid and capability sets, both as an ordinary user and as root. The privileged CI set passes as root, and as an ordinary user apart from the test that needs root to lower the inotify limit. network.mode loopback, --expose to port 80 inside, and bun and pnpm installs were also run contained in the same container.
… sandbox puppeteer's postinstall and `playwright install` keep their browsers under the home directory. Inside the sandbox that is the guest home, which nvx deletes when the command ends, so the browser went with it and the install still exited 0. Measured in a Debian 13 container with storage.googleapis.com allowed, the puppeteer cache in the guest home reached 856 MB during a contained `npm install puppeteer`, nvx said nothing, and no browser was left. Before it deletes an ephemeral guest home, nvx now looks in the folders puppeteer and Playwright use. When one holds 1 MiB or more it names the tool and prints the command that installs the browser where that tool looks for it, such as `nvx --no-sandbox npx puppeteer browsers install chrome`. A blocked download leaves only small records there, 103 bytes for Playwright, and says nothing. On Windows Playwright's folder is under the AppContainer package's own LOCALAPPDATA, which a contained node reported as AppData\Local\Packages\nvx.sandbox.<id>\AC under the guest home. PUPPETEER_CACHE_DIR and PLAYWRIGHT_BROWSERS_PATH join the variables nvx names when it removes them from a contained command's environment. Checked with real downloads on Linux, puppeteer 25.12.0 and Playwright 1.64.0, and by writing a browser-sized file into the guest home on Linux and on Windows. Following the printed puppeteer command installed Chrome where puppeteer's own code then found it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two Linux and cross-platform sandbox fixes.
A contained install can unpack an archive another user owns (Linux)
The contained process ran as root in a user namespace that maps only the user's own id. Tools that unpack an archive as root (node-tar, tar-fs, GNU tar) give each file the owner the archive records, and any owner the namespace does not hold is
EINVAL. Measured on Linux 6.18 in a Debian 13 container, a containednpm install sqlite3@6.0.1stopped atprebuild-install warn install EINVAL: invalid argument, lchown. The archive of its prebuilt binary is owned by uid 1001.Two ways out were weighed.
preserveOwner: falsefor its own extraction. tar-fs, which prebuild-install uses, and node-tar, which node-gyp uses for headers, decide fromprocess.getuid() === 0and read no variable or config for it. Only GNU tar has one (TAR_OPTIONS=--no-same-owner). A preload that makesgetuid()lie would reach Node alone.getuid()is the user's own id and no tool tries. This is the change made here.What the identity map changes:
SysProcAttr.AmbientCaps). Its mounts, the.envwatcher'ssetns, themax_user_namespaceswrite and the bounding-set drops work as before, andipandiptablesget the capabilities as ambient ones too.CapInh,CapPrm,CapEff,CapBndandCapAmball zero), where it held all but four. The existing per-feature drops stay as a second layer.net.ipv4.ip_unprivileged_port_startto 0 in that namespace, which is what Docker sets for its containers. Nothing else in the supervisor or the tests depended on uid 0.After the change the same install exits 0 and a contained
nodeloads the module.A browser a contained install downloads is deleted without a word (all platforms)
puppeteer's postinstall and
playwright installkeep their browsers under the home directory, which inside the sandbox is the guest home, deleted when the command ends. Withstorage.googleapis.comallowed, the puppeteer cache in the guest home reached 856 MB during a containednpm install puppeteer. npm exited 0, nvx said nothing, and no browser was left.Before it deletes an ephemeral guest home, nvx now checks the folders puppeteer and Playwright use. When one holds 1 MiB or more it prints:
A blocked download leaves only small records (103 bytes for Playwright) and prints nothing. On Windows, Playwright's folder is under the AppContainer package's own
LOCALAPPDATA, which a contained node reported asAppData\Local\Packages\nvx.sandbox.<id>\AC.PUPPETEER_CACHE_DIRandPLAYWRIGHT_BROWSERS_PATHjoin the variables nvx names when it removes them.A cache variable pointing into the project was not chosen. The browser would land in the project, and puppeteer's own code, which runs uncontained, would still look in the home. Documentation alone would stay silent at the moment the browser is lost.
Verification
gofmt -l internal cmdprints nothing.go vet ./...passes on Windows, Linux and macOS.go test ./internal/nvx -count=1passes (133.6 s). Run from a nested.claude/worktreescheckout, six tests fail on both this branch andorigin/main, and pass from any other path. That is the location, not this change.-run 'TestNetns|TestReap|TestSupervisorClone|TestContainedProcess|TestNvxTermination|TestGitMetadata') passes as root, the way CI runs it, with 27 passed and 0 skipped. As an ordinary user 26 pass, and the one that lowers a host sysctl skips. The full unit suite passes as an ordinary user.sandbox-smoke.sh,sandbox-smoke-egress.shandsandbox-enforcement-linux.shpass as an ordinary user, andnvx doctorreports that the sandbox starts.TestContainedProcessRunsAsTheUserWithNoCapabilitiesfails onorigin/mainon its uid and capability sets, both as a user and as root. It fails again with the capability drop removed, and onlow_portwith the port floor removed.TestBrowserDownloadInTheGuestHomeIsNamedfails with the warning removed, and with the size floor set to 1 byte. The env notice test fails onorigin/mainfor the two new names.network.mode: loopbackstill reaches a host service over raw TCP. A contained server on port 80 is reachable through--expose 80:18080. Containedbun installand pnpm 10 installs work. Real puppeteer 25.12.0 and Playwright 1.64.0 downloads print the warning, and following the puppeteer line installed Chrome where puppeteer's own code found it.npm run check:contentpasses.