Skip to content

linux: collapse a read-deny glob to its covering directories, and mount every read deny where it really is - #503

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

linux: collapse a read-deny glob to its covering directories, and mount every read deny where it really is#503
ronleizrowice-ant wants to merge 14 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

On Linux a denyRead glob is expanded to one bubblewrap mount per matching entry. **/build/** over a large multi-package repository becomes hundreds of --ro-bind /dev/null <file> mounts that deny nothing one --tmpfs <build dir> does not. Sandbox start pays for each, and the whole profile is one sh -c argument that Linux caps at 128 KiB on 4 KiB-page kernels, so past that every command fails with E2BIG.

This PR does three things.

  1. Collapse (src/sandbox/read-deny-glob.ts). A denyRead glob ending in /** yields one tmpfs per matched directory that has something beneath it, and keeps a file's own mask only where an allowRead/allowWrite path sits between the file and that directory (the deny loop binds those back over the tmpfs). Matches are collapsed by where they really live, and the expansion lists real locations only. Nothing is truncated. A directory the walk cannot list is denied whole; a matched link that resolves to / or to nothing is dropped.
  2. Where a read deny is mounted (linux-sandbox-utils.ts, all read denies, literal ones included). Every read-deny mount goes to the resolved location of its entry, one mount per location, shallow first, never on a symlink (bubblewrap 0.12 refuses one). An allowed path is bound back over a denied directory only where it resolves to, and only when its name lives inside that directory (symlinked directories resolved, the last component as written) and it resolves inside it. No host directory ever shows under a second name inside the sandbox, and what a symlink at an allowed path points to is never re-allowed on that account.
  3. The glob walk (sandbox-utils.ts, shared with allowRead expansion and the Windows ACL path). One readdir per real directory, so an unreadable subtree, a symlink cycle or a directory reached along many link chains costs only itself. Only directories the pattern can match beneath are listed. Symlinked directories are descended on POSIX, except a link back into its own ancestry; on Windows reparse points are listed and not descended, as on main. The walk reports where each match really lives.

Behaviour changes, not behind a flag

  • A directory matched by a /** denyRead glob is a tmpfs, with a literal directory deny's semantics: empty and writable inside the sandbox, writes never reach the host, later files hidden. Before, its files were masked one by one and the directory stayed writable to the host. Entries matched beneath an allowRead carve-out keep their masks, so under a /** pattern only what is created beneath the carve-out later is readable (as on main).
  • A directory denyRead whose spelling is a symlink is mounted at the target. This is what keeps a root-level denyRead starting on bubblewrap 0.12 on a usr-merged distro (/bin -> usr/bin); main fails there. A / deny skips the root's symlinks: what they lead to is denied with the directory that holds it.
  • An allowRead written through a symlink is honoured against a denied directory only when the name it ends in lives inside that directory. allowRead: ["/bin/bash"] is an exception to a deny of /usr (its name lives in /usr/bin); allowRead: ["/bin"], the link itself, is not: write /usr/bin. An allowRead that is a symlink pointing out of the denied directory it lives in (D/lnk -> ../e) is no longer bound at D/lnk: e was never hidden and stays readable as e.
  • Fail-closed: a denyRead entry that cannot be inspected (parent readable, not searchable) hides the nearest directory that can, and a read-denied directory that cannot be listed gets nothing bound back over it, allowWrite roots included.
  • A denyWrite directory with a read-denied directory beneath it and no allowWrite path beneath it no longer keeps creation stubs (or the binds of existing deny paths) under it: kept, the stub made bwrap abort inside the directory's read-only bind. This changes the stub-skip guard added for write-denied checkouts, and flips one test in each of readonly-deny-dir-stubs and readonly-deny-dir-binds.
  • A pattern whose only literal directory is the root (/opt*/keys/**) is skipped like /**/*.pem; on main it listed the whole filesystem.
  • getFsReadConfig().denyOnly on Linux is the collapsed set of real locations: only sound with getFsWriteConfig()'s allowOnly.

Known limits

  • A directory component of an allowRead path replaced by a link to a denied directory that holds an entry of the same name is honoured (allowRead: ["proj/pub/x"], proj/pub -> ~/.aws, ~/.aws/x exists). This is what makes pnpm-style carve-outs written through node_modules/<pkg> work; main compared strings and did not honour either.
  • A transient error (EMFILE, EIO) listing a directory during the walk denies that directory whole, but the loop's own listability check may pass a moment later and bind an allowed path back over it without the masks the walk could not compute.
  • Link chains still multiply spellings (bounded by the ancestry guard): 7 packages linking to each other give 13,699 spellings, 178 ms per pattern after this change against 973 ms before; the expansion is the 7 real locations.

CI: the Linux jobs also build bubblewrap 0.12.0 and run the suite against it (about two minutes per job). That leg is red on main for the root-deny reason above.

Tested: tsc, eslint, prettier, full bun test on Linux (unprivileged, bubblewrap 0.11.2): 1113 pass, 230 skip, 8 fail, the same 8 as main (1053 pass). The read-deny, glob-walk, symlinked-deny, stub and bind suites also pass under a locally built bubblewrap 0.12.0. Every regression test was run against the previous head and fails there. An adversarial drive through SandboxManager.wrapWithSandbox (a first command plants a symlink, chmod 311/600, or ln -s /; a second, freshly wrapped command tries to read) passes 10/10 under both bubblewrap versions.

…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.
Two conflicts. src/sandbox/sandbox-utils.ts: this branch and main 
(anthropics#502) both added the same isAtOrUnder helper; main's copy is kept, 
with its isStrictlyUnder sibling. src/sandbox/linux-sandbox-utils.ts: 
the re-application loop keeps this branch's landed-mount iteration and 
takes main's root-aware containment (isStrictlyUnder) and comment.
…s one owner each

The mount trail, the landing computation and the both-spellings cache were
closures inside generateFilesystemArgs, threaded into pushReadDenyDirMounts
as two callbacks. They are now a SandboxMountView beside it, which records a
tmpfs and each bind where it is pushed, so no query-named callback mutates
the trail and callers no longer pair the record with the call by hand. The
re-bind destination drops three guards no caller could reach.

pathSpellings() and properAncestors() in sandbox-utils.ts replace three
realpath-or-fallback helpers and three hand-rolled prefix walks, so the glob
collapse and the deny loop compare the same spellings under the same root
rule. walkGlobPattern derives the directory form itself (withDirectoryForm)
instead of taking a second pattern nothing checked, and reports its base as
an optional record rather than two empty strings. resolveReadPathEntries
takes the glob expander as a required argument instead of an optional thunk
whose absence meant allowRead, and expandReadDenyGlobLinux normalizes the
re-exposing paths itself. collapseReadDenyMounts and the warn threshold are
no longer exported: their tests go through expandReadDenyGlobLinux over a
temp tree, and the two 513-file tests of a debug-only hint are dropped.

Comments that narrated the previous implementation or restated their
neighbours are cut to the constraint. Tests get their own fixtures where they
shared a mutable one, cleanup in finally, one scenario per test, and
descriptive names for describe-scoped bindings.
… deny an unlistable directory whole

Read-deny paths were sorted by resolved depth alone. A deny spelled beneath a
denied directory's symlinked carve-out (denyRead [D, D/lnk/sub], allowRead
[D/lnk]) resolves shallower than D when the link's target is shallower, so it
was mounted first and at the target only; D's tmpfs and the carve-out bound
back over it then showed the target's unmasked contents at D/lnk/sub. Paths
are now ordered so that each follows every path containing it in either
spelling, with resolved depth breaking ties.

A directory the glob walk could not list (any error but absence) was logged
and skipped, so a denyRead glob emitted no mount for anything beneath it: a
command with write access to the tree could make a directory unlistable and
have the next wrap leave its contents readable by name. The walk now records
such directories and the deny expansion mounts over them whole.
An empty matched directory gets no mount, a pattern whose first wildcard
follows / is skipped, a trailing /** on a write path is honoured, only
bubblewrap 0.12 and later refuse a symlink destination, and a directory the
expansion cannot list is denied whole.
@ronleizrowice-ant ronleizrowice-ant changed the title linux: collapse a read-deny glob's expansion to its covering directories linux: collapse a read-deny glob to its covering directories, and mount every read deny where it lands inside the sandbox Sep 10, 2026
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Merged main (both this branch and #502 had added the same isAtOrUnder; main's copy is kept) and pushed a review-fix round (f59ad79, dcf5d80, 709f544):

  • Fixed, fail-open: read denies were ordered by resolved depth alone. With denyRead: [D, D/lnk/sub], allowRead: [D/lnk] and D/lnk pointing somewhere shallower than D, D/lnk/sub was mounted first and only at the target; D's tmpfs plus the carve-out bound back over it then showed the unmasked target at D/lnk/sub (reproduced under bwrap: the secret was readable). Denies are now ordered by containment in either spelling.
  • Fixed, fail-open: a directory the walk could not list was logged and skipped, so a glob denied nothing beneath it. It is now denied whole.
  • Refactor: the mount trail is a SandboxMountView that records each mount where it is pushed; one pathSpellings()/properAncestors() pair replaces three realpath-or-fallback helpers and three prefix walks; walkGlobPattern takes withDirectoryForm and returns base?; resolveReadPathEntries takes the expander as a required argument; collapseReadDenyMounts and the warn threshold are no longer exported.
  • Not taken: removing the collapse in favour of the deny loop's own skip (the loop needs three or more syscalls per listed entry and the public read config would carry every file); moving the glob functions to glob-expand.ts and splitting this PR into walk / landing / collapse (structural, left for the maintainers to call).

Local run (Linux x86-64, bubblewrap 0.11): eslint, prettier and tsc clean; the three suites this PR owns 93/93; full npm test shows only the failures main has on the same box. With the ordering and unlisted-directory lines reverted, both new tests fail. Title and description updated to match.

…attern can match

walkGlobPattern descended every directory under the pattern's base whatever
the pattern was, and read a directory again for every symlink chain that led
to it: N packages linking to each other cost exponentially many readdir and
realpath calls on every wrapped command.

The listing of a directory and the target of a link are now read once, keyed
on where they really are; each spelling is still reported. Only directories
the pattern can match beneath are listed, segment by segment up to the first
`**`, so `certs/*.pem` lists `certs` alone and an unlistable directory the
pattern cannot reach is no longer reported as one to deny whole. The walk
also reports where each match really lives (it already tracks it), which
saves the caller a realpath per match.

A pattern whose only literal directory is the root (`/opt*/keys/**`) is
skipped like `/**/*.pem`: it used to list the whole filesystem. The match is
made with the `s` flag, so a name holding a line terminator matches `**`.
…here it really is

A read-denied path was mounted where its spelling landed inside the sandbox
and at its host target, and an allowed path was bound back at a landing
derived from either spelling. That gave one host directory two names in the
sandbox, and the second name had none of the mounts that protect the first:

- an allowRead entry was matched by what it resolved to, so a symlink at an
  allowRead path (planted by a sandboxed command with write access there, or
  shipped by the repository) bound the denied directory it pointed at back
  over its own tmpfs, or lifted the mask of the denied file;
- an allowed write path was bound writable at the second name, without the
  denyWrite binds and file masks emitted at the first;
- a bind at a landing outside the tmpfs being restored buried read-deny
  mounts already made there;
- which name a deny covered depended on the order of the denyRead entries.

Every read-deny mount now goes to the resolved location of its entry, one
mount per location, shallow first. An allowed path is bound back over a
denied directory only when its name lives inside it (symlinked directories
resolved, the last component as written) and it resolves inside it, at the
place it resolves to; a file mask is lifted only by an allowRead entry that
names that very file. A glob's matches are collapsed by where they live for
the same reason: the spelled parent of a match reached through a second link
does not contain it.

Fail-closed cases: a denyRead entry that cannot be inspected (its parent is
readable but not searchable) hides the nearest directory that can, and a
read-denied directory that cannot be listed has nothing bound back over it,
since a pattern's matches beneath an allowed path there cannot be found. A
matched link that resolves to / or to nothing is dropped instead of being
mounted on, which stopped every later command. A '/' deny no longer denies
the root's symlinks as entries of their own (/sbin landed inside an allowed
/usr). A denyWrite bind is dropped as hidden only when its resolved
destination is under a read-deny tmpfs, and a file denied through a
symlinked directory is masked, and re-masked, once.
… a write-denied directory

The stub-skip guard kept the stub of an absent deny path whenever a
read-denied directory sat at or beneath the covering write-denied directory.
Kept, the stub makes bwrap create a mount point inside that directory's
read-only bind and abort, and with denyRead patterns collapsed to directory
mounts any `**/build/**` matching inside a write-denied checkout did that to
every command.

What the re-applied tmpfs makes writable again is the allowed write paths
beneath it, which the guard vetoes on their own, and the tmpfs itself, whose
contents never reach the host. The veto is removed; an existing deny path
beneath such a directory likewise needs no bind of its own.
@ronleizrowice-ant ronleizrowice-ant changed the title linux: collapse a read-deny glob to its covering directories, and mount every read deny where it lands inside the sandbox linux: collapse a read-deny glob to its covering directories, and mount every read deny where it really is Sep 10, 2026
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Second round on top of 709f544 (92ad199, 0301b30, 1e40743, 2e7295e), from a deeper review that ran this branch's own argument generation against main case by case. It found fifteen problems, several of them read-deny or write-deny bypasses the first version of this PR introduced. All fifteen were reproduced under real bubblewrap (0.11.2 and a locally built 0.12.0) before any change; none was refuted. Please review this head rather than the earlier one.

The mount model changed. The first version mounted a read deny both where its spelling lands in the sandbox and at its host target, and bound carve-outs back at a landing derived from either spelling. That gives one host directory a second name in the sandbox with none of the first name's mounts, and it could not be made sound: a writable re-bind with no denyWrite binds on top, a bind-back that lands outside the tmpfs it restores and buries mounts made elsewhere, a link back up the tree re-exposing the directory it lives in. It is replaced by resolved-location mounts, the same direction #514 takes, so the two converge:

  • every read deny is mounted once, at the resolved location of its entry, shallow first;
  • an allowed path is bound back only where it resolves to, and only when its name lives inside the denied directory (parents resolved, last component as written), so an allowRead entry that is, or is later replaced by, a symlink to a denied directory or file no longer cancels that deny;
  • a file mask is lifted only by an allowRead entry naming that very file, not a link to it.

Also fixed: a directory the walk cannot list is denied whole and nothing is restored beneath it (chmod 311 . with denyRead ['**/.env'] and allowWrite ['.'] used to end with zero masks); a readable-but-unsearchable parent (mode 0600) no longer makes existsSync skip the entry; a matched link to / (or to nothing) is dropped instead of becoming --tmpfs <live symlink>, which stopped every later command; the / deny no longer pushes symlinked root children (--tmpfs /usr/sbin inside a re-allowed /usr); a denyWrite bind is no longer dropped when only its symlink spelling sits under a read-denied directory; one mask per inode (older bwrap's ensure_file aborted on the second). The glob walk lists each real directory once and descends only where the pattern can match: seven mutually linked packages went from 13,699 entries in 973 ms to the 7 real locations in 178 ms; proj/*.pem no longer denies unrelated unlistable directories; a /opt*/… pattern no longer walks the whole filesystem; globToRegex gained the s flag.

One change to merged behaviour, called out: 1e40743 removes the "read-deny tmpfs beneath a write-denied directory" veto from the stub-skip guard and flips one expectation each in readonly-deny-dir-stubs.test.ts and readonly-deny-dir-binds.test.ts. With **/build/** now collapsing to directory mounts, that veto made a write-denied project directory with such a read deny abort at start (the stub emitted after --ro-bind proj proj).

Left as stated residuals in the description: an allowRead path whose directory component is replaced by a link to a denied directory holding a same-named entry is still honoured (pnpm-style carve-outs need it; main was purely lexical); a transient EMFILE/EIO on readdir can still leave masks missing beneath an allowed path.

Every fix has a test that fails on 709f544. eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box (+60 tests); the affected suites also pass under bubblewrap 0.12.0. This will conflict with #514 in linux-sandbox-utils.ts only; whichever lands second I will merge and resolve by taking #514's structure and carrying over the name-location restore rule, the no-restore-beneath-unlistable rule, the root-deny symlink skip and the veto removal.

…eccomp helper out of its command

The suite is collected on every platform, so listing / while it is collected
ran on macOS too and threw on a dangling root symlink. The listing moves
into the test and skips a link that does not resolve.

Where the apply-seccomp helper has been built, the wrapped command runs
through it, and it lives in the checkout, which a denyRead of / hides: the
shell could not exec it and nothing was printed. The test passes
allowAllUnixSockets, as the '/' denies in allow-read.test.ts do, and asserts
on status, stdout and stderr together so a failure explains itself.
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