linux: collapse a read-deny glob to its covering directories, and mount every read deny where it really is - #503
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.
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.
|
Merged
Local run (Linux x86-64, bubblewrap 0.11): eslint, prettier and |
…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.
|
Second round on top of 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
Also fixed: a directory the walk cannot list is denied whole and nothing is restored beneath it ( One change to merged behaviour, called out: Left as stated residuals in the description: an Every fix has a test that fails on |
…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.
On Linux a
denyReadglob 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 onesh -cargument that Linux caps at 128 KiB on 4 KiB-page kernels, so past that every command fails withE2BIG.This PR does three things.
src/sandbox/read-deny-glob.ts). AdenyReadglob ending in/**yields one tmpfs per matched directory that has something beneath it, and keeps a file's own mask only where anallowRead/allowWritepath 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.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.sandbox-utils.ts, shared withallowReadexpansion and the Windows ACL path). Onereaddirper 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 onmain. The walk reports where each match really lives.Behaviour changes, not behind a flag
/**denyReadglob 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 anallowReadcarve-out keep their masks, so under a/**pattern only what is created beneath the carve-out later is readable (as onmain).denyReadwhose spelling is a symlink is mounted at the target. This is what keeps a root-leveldenyReadstarting on bubblewrap 0.12 on a usr-merged distro (/bin -> usr/bin);mainfails there. A/deny skips the root's symlinks: what they lead to is denied with the directory that holds it.allowReadwritten 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. AnallowReadthat is a symlink pointing out of the denied directory it lives in (D/lnk -> ../e) is no longer bound atD/lnk:ewas never hidden and stays readable ase.denyReadentry 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,allowWriteroots included.denyWritedirectory with a read-denied directory beneath it and noallowWritepath 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 ofreadonly-deny-dir-stubsandreadonly-deny-dir-binds./opt*/keys/**) is skipped like/**/*.pem; onmainit listed the whole filesystem.getFsReadConfig().denyOnlyon Linux is the collapsed set of real locations: only sound withgetFsWriteConfig()'sallowOnly.Known limits
allowReadpath 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/xexists). This is what makes pnpm-style carve-outs written throughnode_modules/<pkg>work;maincompared strings and did not honour either.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.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
mainfor the root-deny reason above.Tested:
tsc,eslint,prettier, fullbun teston Linux (unprivileged, bubblewrap 0.11.2): 1113 pass, 230 skip, 8 fail, the same 8 asmain(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 throughSandboxManager.wrapWithSandbox(a first command plants a symlink,chmod 311/600, orln -s /; a second, freshly wrapped command tries to read) passes 10/10 under both bubblewrap versions.