linux: pass an over-long bwrap profile through --args - #504
Open
ronleizrowice-ant wants to merge 6 commits into
Open
linux: pass an over-long bwrap profile through --args#504ronleizrowice-ant wants to merge 6 commits into
ronleizrowice-ant wants to merge 6 commits into
Conversation
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
force-pushed
the
fix/linux-bwrap-args-overflow
branch
from
August 29, 2026 19:45
34aeb6e to
5bbd064
Compare
…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
left a comment
There was a problem hiding this comment.
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?
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.
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-fdfix and the write-deny dedup.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 profile past that fails every spawn with an opaqueE2BIGbefore the shell starts.bwrap --argsfor a profile that would not fit one argument.wrapCommandWithSandboxLinuxkeeps 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/NviaseccompConfig.applyPath; an earlier revision used fd 3 and took such a descriptor away from the command (found in review, reproduced, fixed). IfapplyPathis itself/proc/self/fd/9the 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 opaqueE2BIGat 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 underos.tmpdir()would let a sandboxed command rewrite the next command's whole profile between the write and the read — a fail-closedE2BIGturned fail-open. The files therefore go into one per-process directory, created underos.tmpdir()on the process's first sandboxed wrap (inside the wrap'stry, 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 createdO_EXCL, mode 0600. The directory lives for the whole process and goes at exit, never atreset(), 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 underos.tmpdir()on its first wrap (mainneeded 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-bindis 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-parentfirst,--proc /proclast, the directory's own--ro-bindtriple the last bind in the file, a--setenvvalue with spaces and quotes verbatim), the trailer kept out of it, a plaincleanupBwrapMountPoints()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; anapplyPathof/proc/self/fd/9moves 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/nullcharacter 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, atouchinto the args directory fails, and the file is gone after the spawn. Also run by hand in the container with aseccompConfig.applyPathof/proc/self/fd/3and 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-bindof the per-process directory (it lands even beneath adenyReadtmpfs over tmpdir, so pending profiles are readable there — the same bytes a sandbox already sees in its own/proc/1/cmdlineand environment). The shell running the returned string must accept redirections on a{ }group andexec(POSIXshdoes), and must run it unmodified as the whole-cscript — Claude Code, the CLI andwrapWithSandboxArgvall 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, andbun teston 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 matchesmain(the same three proxy/seccomp tests fail in that container on both).