linux: real-argv bwrap invocation, collapsed read-deny globs, deduplicated write-deny binds - #499
Closed
ronleizrowice-ant wants to merge 7 commits into
Closed
Conversation
…stics
wrapWithSandboxArgv() on Linux used to return [binShell, '-c',
<wrapWithSandbox string>], so an embedder spawning with {shell:false}
still handed the kernel the ENTIRE mount profile as one argv element.
Linux caps each element at MAX_ARG_STRLEN (128 KiB) independently of the
~2 MiB ARG_MAX total; a profile that grew past it (a repo whose settings
contributed broad read-deny globs -> ~1,000 mounts) made every spawn of
the session fail with E2BIG.
wrapCommandWithSandboxLinuxArgv() now builds the invocation as
['bwrap', ...options, '--', shell, '-c', innerScript] (null when the
params need no sandbox), with the same side-effect ordering as before;
wrapCommandWithSandboxLinux() is a thin quote() over it so string callers
are unchanged. SandboxManager derives the platform params once
(preparePosixSandboxParams) and wrapWithSandboxArgv returns the real
vector on Linux, so only the inner script is subject to the per-element
cap. macOS keeps the string form: sandbox-exec -p takes the profile
inline as one argument, so a vector would not shrink the largest element.
describeBwrapArgv(argv) is a pure helper that breaks a vector down by
mount/env term with execve-style byte accounting (largest element, inner
script, per-term counts/bytes) so an embedder's E2BIG diagnostic can say
which part of the profile is responsible.
…ectories On Linux a denyRead glob is expanded (bwrap has no globbing) into one mount per match. `**/build/**` matches every ENTRY beneath each build/ dir but never build/ itself, so a monorepo produced a --ro-bind /dev/null per artefact — hundreds of mounts that deny nothing a single --tmpfs over the directory doesn't, and the main contributor to the E2BIG profiles. expandReadDenyGlobLinux() adds the directories matched by the pattern's directory form (the pattern minus its trailing /**; real directories only, so a file or symlink named `build` keeps today's semantics), then collapseReadDenyMounts() walks the candidates shallow-first and drops a match whose kept proper ancestor already covers it — unless an allowRead or allowWrite path lies between the two, because the denyRead loop re-binds those over the tmpfs and the descendant would be readable again without its own mount. Nothing is ever truncated: a pattern that still needs more than READ_DENY_GLOB_MOUNT_WARN_THRESHOLD (256) mounts after collapsing is logged at warn level with its count. Non-glob entries, and patterns without a trailing /**, behave as before (the latter collapse only among their own matches). Both expansion sites (getFsReadConfig and the per-call wrap path) now resolve allowRead before denyRead so the re-exposing set is final when the globs are expanded.
…ed directory When denyWithinAllow re-binds a directory read-only (--ro-bind <dir> <dir>), every EXISTING deny path strictly beneath it — typically the mandatory-deny files of a write-protected checkout (.git/hooks, .git/config, .mcp.json, ...) — is already unwritable there, yet each still got its own --ro-bind <p> <p>. The non-existent-path branch already skips its creation-blocking stub in exactly this situation; this is the existing-path twin. The skip uses the same evidence and the same vetoes: the covering directory must come from the order-independent pre-pass (so the caller's deny ordering does not matter) and pass coveringDirIsUnsafe (no allowed write path strictly beneath it, incomparable with every read-deny tmpfs), which also guarantees the covering bind survives the emission filter. Only STRICT descendants are skipped — a deny that equals an allowWrite root (cwd both allowed and denied) is the covering bind itself. A dest reached through a symlinked spelling keeps its bind, because the tmpfs/mask re-application passes key off emitted raw spellings that the covering directory's bind does not carry. Non-existent paths, the symlink checks and /dev handling are untouched.
A denyRead glob collapsed to a directory tmpfs dropped the /dev/null mask of a symlink inside that directory, leaving the link's target readable, and turned an empty matched directory into a tmpfs that swallowed later writes. The glob walk now records the symlinks it sees so the collapse never crosses one, and a directory-form match is taken only when a real match lies beneath it. Also from review: the argv diagnostics and the glob collapse move to their own modules (bwrap-argv.ts, read-deny-glob.ts); the collapse and covering-directory predicates take named fields instead of positional same-typed arguments; BwrapArgvSummary keeps count and bytes per term in one record; the bwrap option table is a Map so an operand spelled like an Object.prototype member cannot resolve; the threshold test checks that the warning fires instead of pinning the constant; README's Linux path-syntax section no longer claims globs are unsupported.
A match reached through a symlink kept its own mount under a collapsed directory, but the denyRead loop emits the covering tmpfs first, so bwrap created that mount as an empty directory inside the tmpfs and the link's target stayed readable. Every match that is, or lies beneath, a symlink is now resolved to the path it names before collapsing; a link escaping to an ancestor or a top-level directory is still denied but warns on stderr. The glob walk is an explicit per-directory traversal instead of a single recursive readdir, so a symlink cycle or an unreadable subtree no longer voids the whole pattern (Bun threw ELOOP and the glob denied nothing). The denyRead loop skips an entry already hidden by a tmpfs it emitted for another entry, so overlapping entries stop costing one mount per file. Also: the srt CLI spawns the argv form on every platform; the string form warns with a per-mount-type breakdown when the rendered line exceeds MAX_ARG_STRLEN (describeBwrapStringOverflow and LINUX_MAX_ARG_STRLEN are exported); argv[0] is bwrap resolved on PATH at wrap time; shared isAtOrUnder, a strict allocation-free covering-directory predicate, a single-walk collapse, and a four-entry bwrap option table.
…lapse expandReadDenyGlobLinux now expects its re-exposer paths already normalized (the wrapper does that once per command), so the test that fed it a trailing-slash spelling directly asserted a contract that no longer exists and failed on Linux. Replace it with a test through SandboxManager.wrapWithSandbox: an allowRead carve-out written with a trailing slash still keeps the file's own /dev/null mask beneath the collapsed build tmpfs.
srt --control-fd read the control fd with fs.createReadStream, which parks
a threadpool thread in a blocking read(2); process.exit() on the wrapped
command's exit then waited for that thread, so srt stayed alive until the
parent closed the fd or killed it. A pipe or socket fd is now read through
a net.Socket, driven by the event loop and unref'd so it never keeps srt
alive; a regular file keeps the fs stream.
The --control-fd tests waited out that hang with a 2 s timeout and a
SIGKILL, and the next test's spawn could then race Bun's asynchronous
teardown of the fd-3 socket ("Failed to connect"), a flake seen on Linux
CI. They now wait for srt to exit on its own and assert exit code 0, so
the hang is a failing test rather than a timeout.
ronleizrowice-ant
marked this pull request as ready for review
August 28, 2026 20:26
This was referenced Aug 29, 2026
Contributor
Author
|
Closing in favour of four smaller PRs, per review:
The branch stays on the fork for reference. |
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.
On Linux the whole bubblewrap invocation is handed to the kernel as one
sh -cargument, and Linux caps a single argv element atMAX_ARG_STRLEN(128 KiB on 4 KiB-page kernels) regardless of the ~2 MiB total. A checkout whose settings contribute broad read-deny globs (**/build/**over a monorepo) expands to one mount per matching file, crosses that cap, and every command in the session then fails withE2BIGbefore the shell starts. Three changes, each with tests:wrapCommandWithSandboxLinuxArgv()returns['bwrap', …options, '--', shell, '-c', script], ornullwhen the params need no sandbox;wrapCommandWithSandboxLinux()is a thinquote()over it, so string callers are unchanged, and it now warns on stderr (with a per-mount-type byte breakdown) when the rendered line would exceed the cap.SandboxManager.wrapWithSandboxArgv()returns that vector on Linux, so only the inner script is subject to the per-element cap, and thesrtCLI spawns it on every platform.describeBwrapArgv()/describeBwrapStringOverflow()(new pure modulesrc/sandbox/bwrap-argv.ts) are the embedder-side diagnostics. macOS keeps the string form.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. Entries reached through a symlink are mounted at the path the link resolves to. The glob walk is an explicit, cycle-proof directory traversal, so a symlink loop or one unreadable subtree no longer voids the whole pattern. The denyRead loop also skips an entry already hidden by a tmpfs it emitted for another entry. Nothing is truncated; a pattern still needing more than 256 mounts (READ_DENY_GLOB_MOUNT_WARN_THRESHOLD) is logged at warn underSRT_DEBUG.--ro-bind p p; the absent-path branch already skipped its stub in that case. Both skips now share one predicate with the samecoveringDirIsUnsafevetoes.Also fixed on the way, since it kept this PR's CI red:
srt --control-fdnever exited after the wrapped command finished while the parent still held the control pipe open. The fd was read withfs.createReadStream, which parks a threadpool thread in a blockingread(2)thatprocess.exit()waits for; a pipe or socket fd is now read through anet.Socket(event-loop driven, unref'd). The--control-fdtests used to wait out that hang with a 2 s timeout and SIGKILL, and the next test'sspawnthen raced Bun's asynchronous teardown of the fd-3 socket (Failed to connect, the linux/x86-64 flake also seen onmain); they now assert a clean exit instead.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 only with a match beneath it; an emptybuild/gets no mount, as before. A match keeps its own mount when anallowRead/allowWritepath sits between it and the covering directory (the deny loop re-binds those over the tmpfs). Every match that is, or lies beneath, a symlink is realpath'd before collapsing: the deny loop emits the covering tmpfs first, so a mount spelled through a link beneath it would be created inside that tmpfs and never reach the link's target. A link that escapes to an ancestor of the glob's base or to a top-level directory is still denied (that is what the glob asks for) but warns once on stderr. 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).readdirper directory; symlinked directories are descended, except a link whose target is at or above a directory on the current descent chain.sandbox-exec -ptakes the macOS profile as one argument and Darwin has no per-element cap, so the macOS wrapper stays a string behind<shell> -c. On Linuxargv[0]isbwrapPath, elsebwrapresolved on PATH at wrap time, since the vector is spawned without a shell.Blast radius, not behind a flag:
wrapWithSandboxArgv()now receiveargv[0] === <bwrap path>(or[shell, '-c', command]when no sandbox applies) instead of[shell, '-c', <string>]. The string API is unchanged apart from the new stderr warning past the cap; macOS and Windows wrappers are unchanged.srtCLI spawns the argv form with{shell: false}on every platform; on macOS and unsandboxed Linux the outer shell isbinShell(default/bin/bash) rather thansh./**denyReadglob is now a tmpfs, with a literal directory deny's semantics: inside the sandbox it lists as empty and writes into it stay in the tmpfs. Before, its files carried per-file/dev/nullmasks and the directory itself stayed writable to the host. A glob entry reached through a symlink now denies the link's target; a link escaping the tree warns on stderr. Write-deny binds are dropped only where the covering read-only bind already made the path unwritable.sandbox-manager.tsgrows by ~60 lines, mostly the existing platform switch reindented underpreparePosixSandboxParams(the formerwrapWithSandboxbody);linux-sandbox-utils.tsgrows by ~100 lines because the write-deny skip and the cross-entry tmpfs dedup live insidegenerateFilesystemArgs's closures over the veto state and the emitted mounts. Splitting either module is out of scope here; the two new concepts were extracted to sibling modules instead.Verified with
tsc,eslint,prettier, andbun test(975 pass / 0 fail on macOS; the Linux-gated sandbox suites run on CI).