linux: collapse a read-deny glob's expansion to its covering directories - #503
Open
ronleizrowice-ant wants to merge 5 commits into
Open
linux: collapse a read-deny glob's expansion to its covering directories#503ronleizrowice-ant wants to merge 5 commits into
ronleizrowice-ant wants to merge 5 commits into
Conversation
…ectories A denyRead glob such as **/build/** over a monorepo expanded to one bwrap mount per matching file: hundreds of --ro-bind /dev/null masks that deny nothing a single --tmpfs over each build/ directory does not, each paid at sandbox start and all of them squeezed into the one `sh -c` argument Linux caps at 128 KiB. The expansion now collapses to one tmpfs per matched directory, keeping a file's own mount only where the directory's tmpfs would not cover it: a carve-out (allowRead/allowWrite, compared in both the given and the resolved spelling) between the two, or a symlink strictly below the covering directory, whose target is mounted instead since the tmpfs would replace the link with an empty directory. Matches otherwise keep their spelling, as literal directory denies do, so a carve-out written against a link still matches. The denyRead loop also skips an entry a tmpfs it emitted for another entry already hides. The glob walk lists one directory at a time instead of one recursive readdir, so a symlink cycle or an unreadable subtree no longer voids the pattern.
…atch carve-outs in both spellings bubblewrap 0.12 refuses a mount on a symlink destination and earlier releases abort on an absolute link in one, so a directory deny's tmpfs lands on the resolved path. Entries are ordered by where the mount lands, re-exposers are compared in both spellings at emission, the second spelling of a covered inode is skipped, a file carve-out matches in either spelling, an allowRead that is itself a link is bound at its target, and a tmpfs shadowed by a denyWrite bind over its target is re-applied. A glob lists every match reached through a link in its resolved spelling too, leaves a link to / alone, and counts a cycle-blocked directory link as a match; the walk does not descend reparse points on Windows.
This was referenced Aug 29, 2026
apt installs 0.9.0, which mounts on a symlink destination; 0.12.0 refuses, and on a usr-merged system a root-level denyRead used to emit --tmpfs /bin. Build 0.12.0 from source on the Linux jobs and run the suite a second time with it first on PATH.
… at their host realpath A directory deny's tmpfs went on the host realpath of its spelling, and its second spelling was dropped as already covered. When an earlier tmpfs had wiped a symlink on the way and a carve-out re-bind recreated that path as a plain directory, the realpath is not where the path lives inside the sandbox, so the deny landed elsewhere and the entry stayed readable; a symlinked file deny under a denied directory was skipped though its target lay outside; and a tmpfs was re-applied after a denyWrite bind that contained only its link spelling, re-binding the carve-outs over their own file masks. The deny loop now keeps an ordered trail of where each tmpfs and bind landed inside the sandbox, mounts every deny at that landing and at its host target unless a tmpfs already covers it, re-binds carve-outs relative to the landing, and re-applies a tmpfs only for a bind over where it landed. Regression tests for each shape, run under bwrap where available.
…est passed directly No behaviour change: - tmpfsMounts already pairs each emitted tmpfs's spelling with where it landed; isHiddenByTmpfs reads the spelling from it instead of a second, parallel tmpfsDirs array. - pushReadDenyDirMounts takes the landing function for its tmpfs directly rather than a factory it applied to its own arguments; callers build it from the same mount record they pass. - The prefix list a re-bind is re-rooted against no longer names the tmpfs spelling twice (bothForms already leads with it). - README: the Linux glob bullet is split into sub-bullets; same content.
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.
Replaces #499 (closed), rebuilt per review: the collapse on its own, without the argv API. The
--control-fdfix is #501, the write-deny bind dedup #502, and thebwrap --argshandling for a profile that still would not fit one argument is #504.On Linux a
denyReadglob is expanded to one bubblewrap mount per matching entry, so**/build/**over a large multi-package repository produces hundreds of--ro-bind /dev/null <file>mounts that deny nothing a single--tmpfs <build dir>does not already: sandbox start pays for each, and the whole profile is onesh -cargument that Linux caps at 128 KiB, so past that every command fails withE2BIG.Collapse read-deny glob expansion (new module
src/sandbox/read-deny-glob.ts). AdenyReadglob ending in/**now yields one tmpfs per matched directory instead of one mount per file beneath it, keeping a file's own mount only where the directory's tmpfs would not cover it. The denyRead loop also skips an entry already hidden by a tmpfs it emitted for another entry (denyRead: ['big', 'big/**/*.key']no longer costs one mask per key). Nothing is truncated; a pattern still needing more than 256 mounts is logged at warn underSRT_DEBUG.Mechanism
**/build/**matches every entry underbuild/but neverbuild/itself, so the directory form is evaluated over the same listing (walkGlobPattern, whichexpandGlobPatterndelegates to). A directory-form match counts with a match beneath it, or when it is itself a symlink (one the walk did not descend, a link back into its own ancestry, has nothing beneath it yet denies everything it reaches); an emptybuild/gets no mount, as before.allowRead/allowWritepath sits between it and the covering directory (the deny loop re-binds those over the tmpfs). Both expansion sites resolveallowReadbeforedenyReadso the re-exposing set is final;getFsReadConfig()collapses against the session write config and is only sound alongside it (documented on the getter)./is left on its spelling. The deny loop then tracks where each mount lands inside the sandbox (an ordered trail of emitted tmpfs and bind landings; bwrap resolves a destination's symlinks only where the parent is still host-visible, and beneath a tmpfs the components are plain directories it creates). Every directory or file deny is mounted at that landing and at its host target, each unless the last covering mount there is already a tmpfs — bubblewrap 0.12 (2026-08-26) refuses a mount on a symlink destination outright, and earlier releases abort on an absolute link anywhere in one, so a destination is never a live link. Entries are ordered by where they land; carve-outs are re-bound at the landing of their position beneath the tmpfs (so anallowReadthat is a live symlink binds at its target, one beneath a wiped link at the recreated path); a tmpfs shadowed by a laterdenyWritebind is re-applied only when the bind contains where it landed (re-applying for a bind over a mere link spelling would re-bind the carve-outs over their own file masks — a fail-open found in review); a file deny is skipped for an exactallowReadmatch in either spelling.denyRead: ['**/node_modules/foo/**']withallowRead: ['node_modules/foo/public']under pnpm'snode_modules/foo -> ../.pnpm/…/foorenders one--tmpfson the real package, the carve-out bound back over it as the last word on that inode, and no tmpfs on the link; the tests drive the real Linux wrapper in both carve-out spellings, with a file carve-out, with adenyWriteover the store, with literal denies in both spellings, with a deny beneath a recreated symlinked carve-out, a symlinked carve-out nested in another, a carve-out through an absolute intermediate link, a symlinked file deny under a denied directory, and adenyWritecovering only a link's spelling — and, where bwrap runs, check the view from inside. A literaldenyReadof a symlinked directory takes the same path, which it needs on 0.12 as well (symlinked-deny-paths.test.tsnow expects the tmpfs at the target).readdirper directory rather thanreaddirSync({ recursive: true }), so a symlink cycle (Bun throws ELOOP; Node 22.13+ follows it to the kernel's link limit and lists some forty phantom copies) or one unreadable subtree no longer voids the whole pattern; symlinked directories are descended, except a link whose target is at or above a directory on the current descent chain. On Windows, whereexpandGlobPatternalso serves the ACL paths, reparse points are listed but not descended, as before.Blast radius, not behind a flag:
/**denyReadglob is now a tmpfs, with a literal directory deny's semantics: inside the sandbox it lists as empty and is writable, writes into it stay in the tmpfs, and a file added to it later on the host is hidden too. Before, its files carried per-file/dev/nullmasks and the directory itself stayed writable to the host. Entries the glob matched beneath anallowReadcarve-out keep their masks, as before (the carve-out directory lists them, its files read empty); if a carve-out should read its contents instead, that is a one-line change in the collapse — worth a ruling, since macOS's subpath filters give the other answer.denyReaddirectory. In particular adenyWritedirectory containing one is vetoed as a covering directory for the mandatory-deny stub skip, soallowWrite: ['~/code'], denyWrite: ['~/code/proj']with cwd~/code/proj, no.mcp.json, anddenyRead: ['**/build/**']matching~/code/proj/buildkeeps the stub and hits the same bwrap startup abort a literaldenyRead: ['build']hits there today: the literal form's behaviour, newly reachable through a glob.denyRead— literal or from a glob — whose spelling is a symlink is now mounted at the target, and**/node_modules/**over a workspace (node_modules/pkg -> ../packages/pkg) tmpfs'spackages/pkg, the inode main's masks landed on. A link back up the tree denies everything the link reaches, as the tmpfs on its spelling did on bwrap ≤ 0.11. A tmpfs shadowed by a laterdenyWritebind over its target (adenyWriteof the pnpm store) is re-applied; onmainit was not. This is also what keepsmain's root-leveldenyReadworking on bubblewrap 0.12: on a usr-merged distro the root expansion emits--tmpfs /binfor the/bin -> usr/binlink, and 0.12 refuses to start (bwrap: Can't mount on symlink destination /bin) —test/sandbox/allow-read.test.ts's three root-deny cases fail onmainunder 0.12.0 and pass here.main'sreaddirSyncnever descended one on Node before 22.13 (theenginesfloor is 20.11), soallowRead/denyReadglobs there now reach link targets, and**/node_modules/**over a pnpm tree lists a package once per incoming link (CI runs bun, which already did).denyWritebind still compares the bind against the mask's spelling, as onmain; a glob lists a file under both spellings, so it is covered there, and a literal file deny reached through a directory link is a follow-up.linux-sandbox-utils.tsgrows insidegenerateFilesystemArgs(the both-spellings comparison and the cross-entry dedup close over the emitted mounts); the collapse and the walk live in their own module and insandbox-utils.ts.isAtOrUnderis added tosandbox-utils.tsby the same hunk linux: skip write-deny binds already covered by a read-only denied directory #502 adds, in the same import slot; the two merge cleanly in either order. README's Linux path-syntax section, which still said globs were unsupported, is rewritten.CI: the Linux jobs now build bubblewrap 0.12.0 from source and run the suite a second time with it first on
PATH(about two minutes per job), so a profile that mounts on a symlink is caught there; that leg is red onmainfor the root-deny reason above, which is why it lands with this PR.Cost: the landing bookkeeping is per deny entry; a synthetic profile with 2000 directory denies and 50 carve-outs wraps in about 200 ms against 100 ms on
main(real configs have a handful of directory denies; the mandatory-deny entries are file masks). A**/node_modules/**glob over a pnpm tree realpaths every match beneath a linked package, ~300 ms for 200 packages per wrap; resolving directory links before their contents is the follow-up if that matters.Verified with
tsc,eslint,prettier, andbun teston macOS, and the full suite in an Ubuntu 24.04 container as an unprivileged user (bubblewrap from apt, 0.9.0, as CI installs, and 0.12.0 built from source): the branch matchesmainunder 0.9.0 (the same three proxy/seccomp tests fail in that container on both), and under 0.12.0 it has no failures beyond those three wheremainalso fails the root-deny suite.