Skip to content

linux: collapse a read-deny glob's expansion to its covering directories - #503

Open
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-read-deny-glob-collapse
Open

linux: collapse a read-deny glob's expansion to its covering directories#503
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-read-deny-glob-collapse

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Replaces #499 (closed), rebuilt per review: the collapse on its own, without the argv API. The --control-fd fix is #501, the write-deny bind dedup #502, and the bwrap --args handling for a profile that still would not fit one argument is #504.

On Linux a denyRead glob 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 one sh -c argument that Linux caps at 128 KiB, so past that every command fails with E2BIG.

Collapse read-deny glob expansion (new module src/sandbox/read-deny-glob.ts). A denyRead glob 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 under SRT_DEBUG.

Mechanism
  • **/build/** matches every entry under build/ but never build/ itself, so the directory form is evaluated over the same listing (walkGlobPattern, which expandGlobPattern delegates 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 empty build/ gets no mount, as before.
  • A match keeps its own mount when an allowRead/allowWrite path sits between it and the covering directory (the deny loop re-binds those over the tmpfs). Both expansion sites resolve allowRead before denyRead so the re-exposing set is final; getFsReadConfig() collapses against the session write config and is only sound alongside it (documented on the getter).
  • Symlinks. A match keeps its spelling, so a carve-out written against a link still matches, and a match that reaches its inode through a link — one the walk descended, or one above the walk — is listed in its resolved spelling as well, so a carve-out written against the target matches too and the target stays denied where the link itself vanishes (a covering directory's tmpfs replaces the links beneath it with nothing). A link to / 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 an allowRead that is a live symlink binds at its target, one beneath a wiped link at the recreated path); a tmpfs shadowed by a later denyWrite bind 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 exact allowRead match in either spelling. denyRead: ['**/node_modules/foo/**'] with allowRead: ['node_modules/foo/public'] under pnpm's node_modules/foo -> ../.pnpm/…/foo renders one --tmpfs on 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 a denyWrite over 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 a denyWrite covering only a link's spelling — and, where bwrap runs, check the view from inside. A literal denyRead of a symlinked directory takes the same path, which it needs on 0.12 as well (symlinked-deny-paths.test.ts now expects the tmpfs at the target).
  • Walk: one readdir per directory rather than readdirSync({ 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, where expandGlobPattern also serves the ACL paths, reparse points are listed but not descended, as before.

Blast radius, not behind a flag:

  • A directory matched by a /** denyRead glob 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/null masks and the directory itself stayed writable to the host. Entries the glob matched beneath an allowRead carve-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.
  • Every other rule now sees such a directory as a literal denyRead directory. In particular a denyWrite directory containing one is vetoed as a covering directory for the mandatory-deny stub skip, so allowWrite: ['~/code'], denyWrite: ['~/code/proj'] with cwd ~/code/proj, no .mcp.json, and denyRead: ['**/build/**'] matching ~/code/proj/build keeps the stub and hits the same bwrap startup abort a literal denyRead: ['build'] hits there today: the literal form's behaviour, newly reachable through a glob.
  • A directory 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's packages/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 later denyWrite bind over its target (a denyWrite of the pnpm store) is re-applied; on main it was not. This is also what keeps main's root-level denyRead working on bubblewrap 0.12: on a usr-merged distro the root expansion emits --tmpfs /bin for the /bin -> usr/bin link, 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 on main under 0.12.0 and pass here.
  • Symlinked directories under a glob are descended on every runtime; main's readdirSync never descended one on Node before 22.13 (the engines floor is 20.11), so allowRead/denyRead globs 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).
  • Unchanged: the file-mask re-application after a late denyWrite bind still compares the bind against the mask's spelling, as on main; 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.ts grows inside generateFilesystemArgs (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 in sandbox-utils.ts. isAtOrUnder is added to sandbox-utils.ts by 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 on main for 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, and bun test on 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 matches main under 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 where main also fails the root-deny suite.

…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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant