Skip to content

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

Open
ronleizrowice-ant wants to merge 6 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 6 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

Replaces the argv API proposed in #499 (closed), per review: fix the string form itself, which is what the CLI and string-form embedders run, with no caller change. Sibling of #503 (the glob collapse, which keeps profiles within bubblewrap's own argument cap); #501 and #502 carry the --control-fd fix and the write-deny dedup.

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 profile past that fails every spawn with an opaque E2BIG before the shell starts.

bwrap --args for a profile that would not fit one argument. wrapCommandWithSandboxLinux keeps its shape while the rendered line fits; from 128 KiB (the byte the kernel counts, its NUL included) the options go to a file bwrap reads NUL-separated, and the string becomes { command rm -f -- <file>; exec bwrap --args 9 -- <shell> -c <script>; } 9<<file>: the shell opens the file on fd 9, unlinks it at once (the open descriptor keeps it readable), and execs bwrap, which reads and closes the fd while parsing, before the command starts. So the file exists only from the wrap to the spawn and nothing leaks into the sandbox. fd 9 because dash (Debian/Ubuntu's /bin/sh) only takes single-digit redirections and low fds belong to the embedder — an extra stdio pipe, or a helper binary handed over as /proc/self/fd/N via seccompConfig.applyPath; an earlier revision used fd 3 and took such a descriptor away from the command (found in review, reproduced, fixed). If applyPath is itself /proc/self/fd/9 the file moves to fd 8. A command that does not fit one argument even with the options moved out now fails at wrap time with a message naming the sizes, instead of an opaque E2BIG at spawn. bwrap's own limit of 9000 parsed arguments (about 3000 mounts) remains, which is what the collapse in #503 keeps in reach; the command itself must still fit one argument.

Where the file lives. bwrap reads it only when the embedder spawns the string, and a sandbox this process launched earlier may still be running with tmpdir writable (allowWrite: ['/tmp'], or no write config at all), so a plain file under os.tmpdir() would let a sandboxed command rewrite the next command's whole profile between the write and the read — a fail-closed E2BIG turned fail-open. The files therefore go into one per-process directory, created under os.tmpdir() on the process's first sandboxed wrap (inside the wrap's try, so a failure gives the active-sandbox count back; re-created if an age-based tmpdir clean removes it) and ro-bound over itself as the last bind of every Linux profile this process generates, small profiles included, under the same invariant as the masked-file store beside it; each file is created O_EXCL, mode 0600. The directory lives for the whole process and goes at exit, never at reset(), since a sandbox launched before a reset may still be running with it bound; a file never spawned goes with the other per-command artifacts (cleanupAfterCommand(), deferred while a sandbox is active), and cleanup never throws at the caller. That covers every sandbox this process launches, the threat model the masked-file store (and the proxy sockets under tmpdir) already work under; a sandbox launched by a different srt process with tmpdir writable is outside it for both, and the shared fix — a fixed per-user root every srt process ro-binds — is a follow-up if that gap matters. New requirement on every Linux process that wraps at all: a creatable directory under os.tmpdir() on its first wrap (main needed tmpdir only for stubs, sockets and credential masks).

Tests (Linux, test/sandbox/linux-bwrap-args-file.test.ts): a 20-mask profile stays on the command line and its last --ro-bind is the directory's; a 2000-mask profile renders as { rm -f <file>; exec bwrap --args 3 -- <shell> -c <script>; } 3<<file>, under 128 KiB, with the file in the per-process directory, every option word NUL-terminated and unquoted (--new-session --die-with-parent first, --proc /proc last, the directory's own --ro-bind triple the last bind in the file, a --setenv value with spaces and quotes verbatim), the trailer kept out of it, a plain cleanupBwrapMountPoints() removing an unspawned file and { force: true } leaving the directory; the switch happens at exactly 128 KiB measured in bytes (a two-byte character in the padding) and not one byte before; an applyPath of /proc/self/fd/9 moves the file to fd 8, and a 128 KiB command is refused with the sizes named; and, where bwrap can run, a 700-mask profile (long names, still past 128 KiB) boots with tmpdir writable, a masked file with content shows as the /dev/null character device inside (opening a device node inside the user namespace is not portable across hosts, so its type is the oracle) and is intact on the host, fd 9 is closed inside, a touch into the args directory fails, and the file is gone after the spawn. Also run by hand in the container with a seccompConfig.applyPath of /proc/self/fd/3 and a descriptor passed there, under dash and bash, uid 1001 strict and uid 0 weaker: the helper runs in every combination.

Blast radius, not behind a flag: only profiles over 128 KiB change shape, and those failed to spawn before; every Linux profile gains one trailing --ro-bind of the per-process directory (it lands even beneath a denyRead tmpfs over tmpdir, so pending profiles are readable there — the same bytes a sandbox already sees in its own /proc/1/cmdline and environment). The shell running the returned string must accept redirections on a { } group and exec (POSIX sh does), and must run it unmodified as the whole -c script — Claude Code, the CLI and wrapWithSandboxArgv all do. If an age-based tmpdir cleaner removes the per-process directory it is re-created with a warning, since sandboxes started earlier never bound the new one. Not applicable to macOS or Windows.

Verified with tsc, eslint, prettier, and bun test on macOS, and the full suite in an Ubuntu 24.04 container as an unprivileged user with bubblewrap 0.9.0 (as CI installs): the suite passes there, including the bwrap run; the branch matches main (the same three proxy/seccomp tests fail in that container on both).

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?

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