Skip to content

linux: pass an over-long bwrap profile through --args - #504

Open
ronleizrowice-ant wants to merge 10 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-bwrap-args-overflow
Open

linux: pass an over-long bwrap profile through --args#504
ronleizrowice-ant wants to merge 10 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-bwrap-args-overflow

Conversation

@ronleizrowice-ant

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

Copy link
Copy Markdown
Contributor

Problem. wrapCommandWithSandboxLinux returns one string that the embedder runs as a single argument of sh -c, and Linux caps one argument at 32 pages (128 KiB with 4 KiB pages). A profile with enough mounts (a checkout with committed read-deny globs, a tree with hundreds of nested repositories) goes past that, and then every sandboxed command fails at spawn with E2BIG, whatever the command is.

Change. When the rendered string would come within 4 KiB of the cap (read from AT_PAGESZ, so 64 KiB-page kernels keep their 2 MiB), the mounts are written NUL-separated to a file and bwrap reads them through --args at the same position. The string becomes /bin/sh -c 'exec 9<"$1" && /usr/bin/rm -f -- "$1" && shift && exec "$@"' srt-args <file> bwrap … --args 9 … -- <shell> -c <cmd>: still a simple command, so a prefix (exec, timeout 30) or a suffix (&& next) composes as it does for the inline form. /bin/sh opens the file on fd 9 (dash takes single-digit redirections only), unlinks it with rm from a fixed system path, and bwrap closes the fd before the command starts. The environment and the command stay on the command line; only mount paths go to the file.

The file lives in a per-process directory, <os.tmpdir()>/.srt-bwrap-args-*, that every profile the process generates binds read-only over itself, so a sandbox the process launched cannot rewrite a profile bwrap has yet to read. The directory is created once, by resolved path, and checked by device and inode on every wrap. If it cannot be created, or is later removed or replaced, profiles that fit are unaffected and an over-long one is refused with an error until the process restarts: a sandbox started earlier would lack a new directory's bind, and the library cannot tell whether it is still running.

Limits, stated in the README. A pending file is readable (not writable) in the process's other sandboxes. A sandbox started by another process of the same user with tmpdir writable, or any sandbox when os.tmpdir() lies below a writable directory, can swap a pending file; keep tmpdir out of allowWrite where profiles can be over-long. bwrap parses at most 9000 arguments: a profile past that, a mount path containing NUL, or a command too long for one argument by itself now fails at wrap time with an error instead of at spawn or inside bwrap.

Blast radius. Profiles that fit render as before plus one trailing --ro-bind of the new directory (skipped when tmpdir is unusable); a sandbox with tmpdir writable sees that directory as an entry it cannot delete. Larger profiles failed at spawn before. Not behind a flag.

Follow-up. The size limit exists because the argv is flattened into one string. Returning [bwrap, ...args] from wrapWithSandboxArgv on Linux would leave only the 2 MiB total and bwrap's 9000 words, with no file, fd or directory; a memfd in place of the file is the smaller step.

Tested. bun test test/sandbox/linux-bwrap-args-file.test.ts on Linux x86-64 with bubblewrap 0.11.2 and unprivileged user namespaces: the exact switch point, the file's contents, cleanup of a file never spawned, the NUL, 9000-argument and over-long-command refusals, and in fresh processes a replaced directory, one that vanishes mid-wrap and an unusable tmpdir. End to end: a profile past the cap applies its masks with timeout 60 <string> && echo AFTER around it, fd 9 is closed inside and the directory is read-only there with tmpdir writable; a tmpdir behind an absolute symlink starts under 0.11.2. The rest of the suite is unchanged from main.

The wrapped command is one `sh -c` argument, and Linux caps a single argv
element at 128 KiB (MAX_ARG_STRLEN), so a profile with enough mounts
failed every spawn with an opaque E2BIG before the shell started.

When the rendered line would exceed the cap, the string form hands the
options to bwrap through `--args 3 -- <shell> -c <script> 3<file`,
NUL-separated in a temporary file that is removed with the other
per-command artifacts; bwrap reads and closes the fd while parsing, before
the command starts. Below the cap the command line is unchanged, so
callers need no change either way.
wrapCommandWithSandboxLinux takes concrete paths; glob expansion happens in
SandboxManager, so the pattern the test passed matched nothing and no
mask was emitted on Linux. Pass the created files themselves.
…file ro-binds

A plain file under tmpdir could be rewritten by a running sandbox before
bwrap read it. The directory is created on the process's first wrap
(inside the try, re-created if tmpdir is cleaned, kept across reset), is
the last ro-bind of every Linux profile, and the rendered string unlinks
the file once the shell has opened it. The suite pins the file's contents,
the exact 128 KiB boundary in bytes, cleanup, and a run under bwrap.
The 2000-mount sandbox took longer than bun's 5 s default on the x86-64
runner, so the synchronous spawn was cut off with a null status. Fewer,
longer-named masks still overflow one argument, the runner's tmpdir is
scanned one level deep, and the case gets a 60 s budget.
…annot fit one argument

dash takes single-digit redirection fds only, and low fds belong to the
embedder (an extra stdio pipe, or a helper binary handed over as
/proc/self/fd/N), so an fd-3 redirect could take away a descriptor the
command needs. fd 9 is used (8 when seccompConfig.applyPath names 9). A
command still past 128 KiB with the options moved out now fails at wrap
time with the sizes named instead of E2BIG at spawn; the args directory
is re-created with a warning if a tmpdir cleaner removed it; the unlink
uses `command rm -f --`.
@ronleizrowice-ant
ronleizrowice-ant force-pushed the fix/linux-bwrap-args-overflow branch from 34aeb6e to 5bbd064 Compare August 29, 2026 19:45
…er fd check

The overflow branch names the directory returned by the ensureBwrapArgsDir()
call made before the filesystem arguments were generated — the one this
profile ro-binds — instead of calling it a second time, and the fd
collision test reads the optional match in one expression.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-process bwrapArgsDir is mounted read-only into every sandbox, but read-only still exposes its contents. A long-running earlier sandbox can watch/list that directory and read a later invocation's pending profile before bwrap opens/unlinks it; those files include that later invocation's --setenv values and policy. The comment compares this to a sandbox seeing its own env/cmdline, but this is cross-invocation disclosure. Could the profile be handed through an already-open fd/memfd or otherwise not be shared readably between sandboxes?

One conflict in src/sandbox/linux-sandbox-utils.ts: main (anthropics#505) and 
this branch each added top-level declarations at the same spot 
(capabilityArgs and the --args helpers); both are kept.
…mand-size cap to the kernel

The --args directory reaches generateFilesystemArgs as a required
parameter and is ro-bound unconditionally, instead of a skip-if-unset
read of module state. Rendering the invocation (inline, or through the
file) moves into renderBwrapInvocation, out of the wrap function.

When the directory has been removed under the process (an age-based
tmpdir clean), a fresh one is still made so profiles that fit the
command line keep starting, but an over-long profile is refused while a
sandbox that predates the new directory, and so never bound it, may
still be running: such a sandbox could rewrite the next command's
profile between the write and the spawn. The refusal lifts once no
sandbox is active.

The second size check is gone. MAX_ARG_STRLEN is 32 pages, so the
"limit is 131071" error was false on 16 KiB and 64 KiB-page kernels and
refused commands that spawned before; switching to --args at 128 KiB is
harmless everywhere, and a command that alone exceeds the kernel's cap
fails at spawn as it always did.

The fd 9 to 8 step-aside had no caller: nothing in the tree hands the
seccomp helper over on fd 9, and the README already documents fd 9 as
taken. An args file is tracked before it is written, so a failed write
is cleaned up. Docstrings are cut to the invariants; the README note
moves to Filesystem Isolation, where it applies.

Tests: one behaviour per case; the cleanup lifecycle, the over-long
command and the replaced directory get their own.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Merged main (one add/add conflict with #505's capabilityArgs, both kept) and pushed 8bf4437:

  • generateFilesystemArgs takes the --args directory as a required parameter and pushes its read-only bind unconditionally (no skip-if-undefined on module state).
  • The second size check and its "limit is 131071" throw are gone: MAX_ARG_STRLEN is 32 pages, so that message was false on 16K/64K-page kernels and made a command that spawned fine on main fail at wrap time. The switch to --args at 128 KiB stays; a command that alone exceeds the kernel's cap fails at spawn as before.
  • If the --args directory has been removed (an age-based tmp cleaner), a fresh one is made, but an over-long profile is refused while another sandbox this process launched is still active, since that sandbox never bound the new directory read-only. It stops refusing once none is active.
  • The unused fd 9→8 step-aside is dropped (nothing produces /proc/self/fd/9; the README already says fd 9 is consumed). The render/overflow/write phase is renderBwrapInvocation(). The args file is tracked before the write so a failed write is cleaned up.
  • Comments cut to the invariants; tests are one behaviour each; the README note moved out of the mandatory-deny section.

Local run (Linux x86-64, bubblewrap 0.11): eslint and tsc clean; args-file suite 7/7 including the e2e spawn; full npm test shows only the failures main has on the same box. Agree a memfd instead of a file would be a good follow-up.

…mple command, and refuse what bwrap cannot run

The directory is created before the first sandbox is wrapped and never a
second time. A sandbox wrapped earlier would lack a new directory's bind,
and the library cannot tell whether it is still running: reset() does not
end sandboxes and the active count is only as exact as its caller, so the
latch that waited for the count to drain could be cleared by reset(),
skipped when reset() came first, or left set for good by the wrap's own
catch. Now the directory is identified by device, inode, owner and mode
(a same-path replacement was accepted by existsSync), and once it cannot
be created or is found removed or replaced, profiles stop binding it and
an over-long one is refused, with the reason, until the process restarts.
A profile that fits no longer fails because tmpdir is unusable.

The directory is bound by its resolved path, since bwrap before 0.12
cannot follow an absolute symlink in a bind destination, and its name
starts with a dot, so `rm -rf "$TMPDIR"/*` inside a sandbox does not trip
on the mount point. The bind is pushed at the call site;
generateFilesystemArgs is as it was.

Past the cap the string was a brace group that exec'd the caller's shell:
a prefix was a syntax error, a suffix was skipped, csh could not run it,
and `command rm` was looked up in PATH by the unsandboxed shell. It is now
`/bin/sh -c '<open, unlink, exec>' srt-args <file> bwrap ...`, a simple
command like the inline form, with rm from /usr/bin or /bin. Only the
mounts go to the file, which is readable in the process's other
sandboxes; the environment and the command stay on the line, with
`--args` at the mounts' position.

The cap is 32 pages, read from AT_PAGESZ, so a 64 KiB-page kernel keeps
its 2 MiB, and the switch leaves 4 KiB for a prefix of the caller's own.
A command too long for one argument by itself, a profile past bwrap's
9000 arguments, and a mount path containing NUL (which bwrap would split
into several options) fail at wrap time with an error, before a file is
written.

Tests: every case fails on the previous commit. Process-wide state (a
replaced directory, one that vanishes mid-wrap, an unusable or symlinked
tmpdir) runs in a fresh process; the end-to-end case runs the string
between `timeout 60` and `&& echo AFTER`. The bwrap capability probe
moves to test/helpers.
…d what does not protect it

The note now states that a pending file is readable in the process's
other sandboxes, that the directory shows up as an undeletable entry
under a writable tmpdir, that it is created once and an over-long profile
is refused until restart when it is gone, and the two cases the read-only
bind does not cover (another process's sandbox, and a tmpdir below a
writable directory) with what closes them.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Second round on top of 8bf4437 (edfda8e, fe87913), from a deeper review that drove the wrap-time state machine and the rendered shell form case by case. Every runtime claim was then checked under real bubblewrap (0.11.2 and a locally built 0.12.0); none was refuted. Please review this head rather than the earlier one.

The replaced-directory logic is gone, not patched. The earlier version re-created the --args directory when it vanished and refused over-long profiles while "earlier sandboxes may still be running", using a cooperative call counter as the liveness test. That accepted a different directory at the same path (existsSync only), was cleared by reset() while sandboxes were still live, and could wedge every later over-long profile with a false message. The new rule has no latch and no counter: the directory is created once and never re-created; its identity (device, inode, owner, mode) is checked on every wrap, after the ripgrep scan; if it cannot be created, or is found removed or replaced, profiles stop binding it and an over-long one is refused, naming the reason, until the process restarts. reset() has no effect on it.

Callers that never overflow are no longer affected by the directory. An unwritable or missing os.tmpdir() used to fail every restricted wrap at mkdtemp; now only an over-long profile is refused. The directory is realpathed once (bubblewrap before 0.12 cannot resolve an absolute-symlink component in a bind destination, e.g. /tmp -> /scratch/tmp), and its name is dot-prefixed so rm -rf "$TMPDIR"/* inside a sandbox no longer trips over an undeletable mount point (find -delete still does; stated in the README).

The over-long rendering stays a simple command: /bin/sh -c '…' srt-args <file> bwrap … --args 9 …, so exec W, env X=1 W, timeout 30 W and W && post behave as they do for the inline form. rm is taken from /usr/bin or /bin only (never PATH) and omitted if absent. The switch leaves 4 KiB of headroom below the single-argument cap, and the cap is 32 × the page size read from AT_PAGESZ, so 64 KiB-page kernels keep their 2 MiB.

Fail-closed parity with the inline path: a mount path containing NUL is refused (it would split into several options in the file); bubblewrap's 9000-word cap is checked at wrap time on both paths instead of surfacing as the sandboxed command's own failure; a command too long by itself throws before any file is written.

Only the mounts go to the file. The environment and the command stay on the command line with --args at the mounts' position, so a pending file no longer carries per-command values into sibling sandboxes. The README now says pending files are readable there, states the cross-process case with its consequence and mitigation, and the description names the argv-shaped API as the real follow-up.

Twelve new cases fail on 8bf4437. eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box. generateFilesystemArgs is back to main's signature.

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.

2 participants