Skip to content

linux: real-argv bwrap invocation, collapsed read-deny globs, deduplicated write-deny binds - #499

Closed
ronleizrowice-ant wants to merge 7 commits into
anthropics:mainfrom
ronleizrowice-ant:csec/argv-and-mount-dedup
Closed

linux: real-argv bwrap invocation, collapsed read-deny globs, deduplicated write-deny binds#499
ronleizrowice-ant wants to merge 7 commits into
anthropics:mainfrom
ronleizrowice-ant:csec/argv-and-mount-dedup

Conversation

@ronleizrowice-ant

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

Copy link
Copy Markdown
Contributor

On Linux the whole bubblewrap invocation is handed to the kernel as one sh -c argument, and Linux caps a single argv element at MAX_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 with E2BIG before the shell starts. Three changes, each with tests:

  1. Real argv. wrapCommandWithSandboxLinuxArgv() returns ['bwrap', …options, '--', shell, '-c', script], or null when the params need no sandbox; wrapCommandWithSandboxLinux() is a thin quote() 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 the srt CLI spawns it on every platform. describeBwrapArgv() / describeBwrapStringOverflow() (new pure module src/sandbox/bwrap-argv.ts) are the embedder-side diagnostics. macOS keeps the string form.
  2. 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. 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 under SRT_DEBUG.
  3. Skip redundant write-deny binds. An existing deny path strictly beneath a directory already re-bound read-only got its own --ro-bind p p; the absent-path branch already skipped its stub in that case. Both skips now share one predicate with the same coveringDirIsUnsafe vetoes.

Also fixed on the way, since it kept this PR's CI red: srt --control-fd never exited after the wrapped command finished while the parent still held the control pipe open. The fd was read with fs.createReadStream, which parks a threadpool thread in a blocking read(2) that process.exit() waits for; a pipe or socket fd is now read through a net.Socket (event-loop driven, unref'd). The --control-fd tests used to wait out that hang with a 2 s timeout and SIGKILL, and the next test's spawn then raced Bun's asynchronous teardown of the fd-3 socket (Failed to connect, the linux/x86-64 flake also seen on main); they now assert a clean exit instead.

Mechanism
  • Collapse: **/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 only with a match beneath it; 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). 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 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).
  • Walk: one readdir per directory; symlinked directories are descended, except a link whose target is at or above a directory on the current descent chain.
  • Argv: sandbox-exec -p takes the macOS profile as one argument and Darwin has no per-element cap, so the macOS wrapper stays a string behind <shell> -c. On Linux argv[0] is bwrapPath, else bwrap resolved on PATH at wrap time, since the vector is spawned without a shell.
  • Write-deny skip: symlinked spellings keep their bind, since the tmpfs/mask re-application passes key off emitted raw spellings.

Blast radius, not behind a flag:

  • Linux embedders calling wrapWithSandboxArgv() now receive argv[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.
  • The srt CLI spawns the argv form with {shell: false} on every platform; on macOS and unsandboxed Linux the outer shell is binShell (default /bin/bash) rather than sh.
  • 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 writes into it stay in the tmpfs. Before, its files carried per-file /dev/null masks 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.
  • Module sizes: sandbox-manager.ts grows by ~60 lines, mostly the existing platform switch reindented under preparePosixSandboxParams (the former wrapWithSandbox body); linux-sandbox-utils.ts grows by ~100 lines because the write-deny skip and the cross-entry tmpfs dedup live inside generateFilesystemArgs'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.
  • README's Linux path-syntax section, which still said globs were unsupported, is corrected.

Verified with tsc, eslint, prettier, and bun test (975 pass / 0 fail on macOS; the Linux-gated sandbox suites run on CI).

…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

Copy link
Copy Markdown
Contributor Author

Closing in favour of four smaller PRs, per review:

The branch stays on the fork for reference.

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