Repository navigation
Take host-side helpers only from where the sandboxed command cannot write - #594
Open
ronleizrowice-ant wants to merge 25 commits into
Open
ronleizrowice-ant wants to merge 25 commits into
ronleizrowice-ant wants to merge 25 commits into
Conversation
Under Node, whichSync ran `which` by that bare name, so whatever `which` came first on PATH was executed, on the host, on every wrap: the shell a command runs in is resolved through it each time. Under Bun it was Bun.which, which executes nothing. It is now one search made inside the process on both runtimes: each PATH entry in order, the first holding a regular file of that name this process may execute. A name that already carries a directory is checked where it is and not searched for. Bun.which is not used any more, so that the two runtimes cannot drift: on Bun 1.4.2 it does not see a PATH changed inside the process, and ignores an empty entry.
…rite bubblewrap, socat and ripgrep are run by the library itself, on the host, outside the sandbox, and were named bare: `bwrap` at the head of the wrapped command, `socat` for the bridges, `rg` for the scan every wrap makes. On macOS the wrapped command began with a bare `env`, ahead of the hard-pathed sandbox-exec, and the violation monitor ran a bare `log`. Whoever runs a bare name takes it from PATH, and PATH often leads with a directory the sandboxed command may write: under `npx` and `npm run` it is <project>/node_modules/.bin, with the project in allowWrite. A file of that name left there by one command was run, unsandboxed, by the next wrap. The rule is about the file that gets chosen: the library never executes on the host a file the sandbox policy lets the wrapped command write. A helper is looked up inside the process, and a copy is passed over when the file as spelled, any symbolic link followed on the way to it, or the file it resolves to lies at or under an allowed write path; relative and empty PATH entries are never used. The result is an absolute path, used wherever the bare name was. A remembered result is looked at again under the current write paths on every use. With nothing left, the dependency check reports an error naming the copies passed over and a wrap throws LinuxSandboxProfileError `host_helper_unavailable`, rather than fall back to the bare name. A policy that allows writes everywhere gives the rule nothing to prefer and is not filtered. `/usr/bin/env` and `/usr/bin/log` are fixed paths on macOS, as sandbox-exec already was. The shell a command runs in keeps the whole PATH: it runs inside the sandbox. A helper the operator names (bwrapPath, socatPath, a ripgrep.command with a directory, seccomp applyPath) is run as given, and the dependency check warns when the sandboxed command may write it. The uid-0 probe runs the resolved bubblewrap's own --version inside its user namespace instead of a bare `true` looked up with the whole filesystem bound writable.
This was referenced Sep 21, 2026
Where the seccomp helper, or the JVM agent's jar, is not beside the library, a global install is looked for by asking `npm root -g`. That went through /bin/sh and the PATH as it was, and npm is a script that finds `node` by name, so resolving one file would not have been enough: it is the PATH the child gets that has to leave the writable places out. hostSearchPath() gives that PATH, by the same test as for a host helper (the entry as spelled, any link on the way, where it resolves; relative and empty entries never), and the manager passes it from the two places the look-up is reached with a configuration in hand: initialize(), for the jar, and the asynchronous dependency check, for the helper. The first answer is kept for the life of the process, so those are what decide in the ordinary order of calls. A synchronous dependency check made before either still uses the PATH as it is; the README says so. The README's earlier sentence named the helper alone. The jar's look-up goes the same way and is reached more often: a build from source has no jar beside it.
hostSearchPath() left out the entries of PATH that lie in, or lead into, a place the sandboxed command may write. What a child runs is a file, though, and a link kept in a directory nobody can write may still lead to a file somebody can: a `node` in /usr/local/bin linked into a version manager's directory that is an allowed write path. The file-level test already made for bubblewrap, socat and ripgrep is now put to each entry's copy of the programs the child is known to look up, and an entry whose copy would be refused is left out with it. The manager passes `npm` and `node` for `npm root -g`.
Two ways the judgement of an entry's copy of a program differed from what the shell that does the look-up would do. The candidate was built with path.join, which folds `..` away by spelling, while the shell resolves it through the file system: through a link, `<dir>/link/..` is another directory than `<dir>`. It is now the entry, a slash and the name, and the walk resolves the rest. And a copy was only judged if it was an executable file at that moment, so a link into a writable place with nothing behind it yet, or a file not yet made executable, let the entry through for the sandboxed command to complete afterwards. Whatever is at the name is now judged; only a name that is not there at all has nothing to say.
This was referenced Sep 25, 2026
Closed
Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. Kept: every invariant and what could be done without it, the documentation of exported names, and the reasons for what is not done the obvious way. Cut: how the code came to be as it is, narration of the lines that follow, and explanations repeated at several sites.
This was referenced Sep 28, 2026
An allow entry that resolved to `/` switched the filter off, and the sandboxed command can make one: wherever one allowed write path lies inside another (`/tmp` and `/tmp/claude`), it replaces the inner one with a link. With `<project>/dist -> /` the planted `<project>/node_modules/.bin/bwrap` was run on the host. A link to the directory that holds the real helpers made every copy look writable instead, and every later command was refused. The Linux wrap binds nothing for such an entry. The filter now uses the wrap's own test, so an absent entry and a link leading out of its place count for nothing, and only a literally spelled `/` means there is no place to prefer. Also: the PATH handed to `npm root -g` is never empty, which a shell reads as the current directory; the host-side socat bridge has a test; README: the write paths are those in force at the look-up.
A wrap may ask bubblewrap about itself while it is built. This pins that such a question goes to the copy the wrap runs and never to the first `bwrap` on PATH, in each combination of allowAllUnixSockets and enableWeakerNestedSandbox.
This was referenced Sep 28, 2026
… the home directory The child wraps with `allowOnly: ['~']`. Under Bun an empty HOME falls back to the passwd entry, so the whole home directory is writable, and a ripgrep or bubblewrap on PATH that lies inside it (~/.cargo/bin, a CI runner's temp directory) is passed over: the wrap threw `host_helper_unavailable` and the test failed on where the tools are installed, which is not what it is about.
ronleizrowice-ant
added a commit
that referenced
this pull request
Sep 29, 2026
A writable home directory with bubblewrap, socat or ripgrep installed under it: every command is refused there, where it used to run.
An embedder whose users cannot set `bwrapPath` has to word its own
remedy, and could only cut the message apart. `.cause` now holds
`{ helper, skipped: { path, reason, writePath? }[] }`; `writePath` is
the allowed write path that covers the copy.
The dependency check hands out strings only, and embedders tell its
errors apart by them: the opening words of this refusal, and the
"not installed" strings, are stated as invariants and pinned by tests.
ronleizrowice-ant
added a commit
that referenced
this pull request
Sep 29, 2026
# Conflicts: # README.md
The field was renamed from `candidate`. This file is plain JavaScript run by node, so neither the type-check nor `bun test` saw it.
ronleizrowice-ant
added a commit
that referenced
this pull request
Sep 29, 2026
Two files with blocks. In linux-sandbox-utils.ts both sides' additions stay, and the relays main rewrote name socat by the path the wrap resolved, never by the bare name. In the README bwrapPath, socatPath, seccomp and ripgrep have entries of their own on main, so this branch's three are dropped and bwrapPath's points to "Where these programs are taken from". Joins that no conflict marker shows: - The probe for --disable-userns looked bwrap up on PATH and ran --help on the host, which this branch forbids. It asks the copy the wrap resolved. - hostHelperWritePaths() reads the write roots through writeRootsOf(), or misses a path marked literal. - host-helpers.ts asks realPathOf(): eslint refuses fs.realpathSync in src. - Tests, the version probe: the filter for the bare `--version` spawn also dropped this branch's uid-0 probe, which runs `--version` inside. A bwrap named outright is asked its version; one passed over is not. - Tests, the relays: each names socat twice, for both families and for IPv4 alone. Both are the resolved path. - Tests, the warning for a copy of the library in a write path: three cases expect the dependency check to pass over a policy it cannot read. Here the check looks for the host's helpers outside the write paths first, so it reads the write configuration and stops there. Two take either answer, as macOS makes no such search in the check; initialize() builds the search path for a global npm from the write paths on every platform, so the third expects the refusal there. host_helper_unavailable stands first in the union of codes and its sentence last in the comment on the error, apart from what the read-deny glob budget adds there, so that neither conflicts with the other once one has landed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
One case of the suite for mount points that cannot be made, new on main, wraps with `allowOnly: ['/usr', '/etc', …]`. The bubblewrap and the ripgrep on PATH lie inside /usr, so they were passed over and the wrap threw `host_helper_unavailable`: the case failed on where the tools are installed, which is not what it is about. It runs only where a sandbox can be started. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
ronleizrowice-ant
enabled auto-merge (squash)
October 20, 2026 06:43
ronleizrowice-ant
disabled auto-merge
October 20, 2026 09:56
… brought along) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…802); the two which suites still to follow Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…uites follow the joined paths Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
The shell is looked up after the mount plan again, as before this branch: three existing cases refuse a wrap late through a shell that is nowhere. The case for a manager with no configuration runs in a process of its own, since nothing takes a configuration out of the manager again. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…Sandbox It strips trailing slashes, resolves symlinks and keeps a link that leads out of its place as spelled already, for a pattern's base too. socat is looked up where it is used, like the shell. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
- A wrap judges by the session's write paths together with the call's own. A call with narrower write paths of its own, or with none restricted, took a program an earlier command of the session could have planted. - On Linux an allowed write path is a name, as it is to the wrap. A name with glob characters right under / read as a pattern from the root, which switched the comparison off. - The test file leaves a configuration behind that names nothing of its own. - README: what checkDependencies() sees, and where a skipped scan is logged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
ronleizrowice-ant
enabled auto-merge (squash)
October 20, 2026 14:12
Where it stood, another open pull request adds a line of its own, and the second of the two to land would have needed a merge for that alone. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
ronleizrowice-ant
disabled auto-merge
November 11, 2026 16:13
This branch has not been deployed
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.
Human: @ronleizrowice-ant
Requested by Ron · project thread
The programs the library runs with more authority than the wrapped command are never taken from a place that command may write:
bubblewrap,socatandripgrepon the host, and the shell that starts the relays and the seccomp helper. Such a file is whatever an earlier sandboxed command left there. It matters whenPATHleads with a directory of the project, as undernpxandnpm run(<project>/node_modules/.bin) with the project inallowWrite.+1,266 −113 in 20 files: 599 lines of source, 13 of README, 654 of tests.
The shape
src/sandbox/host-helpers.ts(new) holds all of it.writablePlaces(list | 'anywhere' | undefined)is made once per policy. "No write is restricted" has to be said outright; a policy that is not known yet counts the working directory and the default write paths as writable.findHostHelper(),hostSearchPath(),isOutside()and the warning for a helper named outright all judge by one function. It walks with the watcher ofwayTo()insrc/utils/real-path.ts(the same change A deny keeps the symbolic links on the way to it in place #693 carries): the file as spelled, each link followed, each directory a link's target goes up from again, and the file it resolves to.whichSync()searchesPATHin the process, under Node.js as under Bun. Under Node.js it used to runwhich, the first one onPATH.npm root -g, asked for a global install when the seccomp helper or the JVM agent's jar is in none of the package's own places, gets aPATHwith the same places left out, and a global install of the helper that the command may write is passed over.trueby its bare name inside--dev-bind / /. It runs bubblewrap's own--version.envandlogare run from/usr/bin. The shell starts undersandbox-exec, so the plain search stays.Who sees a difference
A writable home directory with the programs installed under it (
~/.local/bin,~/.cargo/bin, a~/.nix-profilelink, version-manager shims):initialize()andcheckDependencies()name the copies passed over, and a wrap throwsLinuxSandboxProfileErrorwith codehost_helper_unavailable. Naming the program by its path (bwrapPath,socatPath,ripgrep.command, the wrap'sbinShell) lifts it;checkDependencies()then warns. Without a usable ripgrep the wrap goes on as it does where none is installed: the scan is skipped. A shell that is nowhere onPATHis that typed error too, where it was a plainError. Docker with/as the working directory, Homebrew on Linux, CI tool caches and NixOS see nothing.Limits, written in the README
filesystem.disabled,allowWrite: ['/']) nothing is passed over but a relativePATHentry, and file permissions are not judged.updateConfig()narrowed it, is not recognised.socatis named to the relays by its host path, so adenyReadentry that hides that copy stops them.Review
mainwas reviewed at 4f8bc03: 28 findings, several of them ways a planted program was still run. The source was redesigned to close them, then simplified, and the tests cut./switched the comparison off; one case left a configuration behind for later suites. End to end with real bubblewrap, a sandboxed command plantedbwrap,socat,rg,bash,sh,env,npm,node,whichandtruein six placesPATHreaches: nothing planted ran, wheremainran the plantedrg,bwrapandsocat.real-path.tsthat A deny keeps the symbolic links on the way to it in place #693's tests cover.seccomp.applyPathinargv0mode is looked up inside the sandbox as onmain; the warning for a named helper does not subtractdenyWrite.Merge order
src/utils/real-path.tschanges the same way in #693. Both add a last parameter togenerateFilesystemArgs(), so whichever lands second takes both. The commits titled WIP are squashed away at the merge.main merged in on 2026-11-06
mainat27fce0dis merged in atcf3bd57, without a conflict. What this pull request changes is the same as before, +1,266 −113 in 20 files.main merged in again on 2026-11-09
mainat951e641is merged in at4c67c24. One conflict, ininitialize():mainput astopIfReset()after the look for the JVM agent's jar, on the lines where this pull request hands that look the writable places. Both are kept. This pull request adds noawaittoinitialize(), somain's rule of a check after every one holds. What it changes is the same as before, +1,266 −113 in 20 files.Claude session #01FydxqU
Generated by Claude Code