Skip to content

Take host-side helpers only from where the sandboxed command cannot write - #594

Open
ronleizrowice-ant wants to merge 25 commits into
mainfrom
fix/host-helpers-from-trusted-paths
Open

ronleizrowice-ant wants to merge 25 commits into
mainfrom
fix/host-helpers-from-trusted-paths

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

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, socat and ripgrep on 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 when PATH leads with a directory of the project, as under npx and npm run (<project>/node_modules/.bin) with the project in allowWrite.

+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 of wayTo() in src/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() searches PATH in the process, under Node.js as under Bun. Under Node.js it used to run which, the first one on PATH.
  • The Linux wrap judges by its own write paths together with the session's, entries marked literal among them. bubblewrap, socat and the shell are named by their paths in the wrapped string.
  • 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 a PATH with the same places left out, and a global install of the helper that the command may write is passed over.
  • The probe that asks whether uid 0 can be mapped ran true by its bare name inside --dev-bind / /. It runs bubblewrap's own --version.
  • macOS: env and log are run from /usr/bin. The shell starts under sandbox-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-profile link, version-manager shims): initialize() and checkDependencies() name the copies passed over, and a wrap throws LinuxSandboxProfileError with code host_helper_unavailable. Naming the program by its path (bwrapPath, socatPath, ripgrep.command, the wrap's binShell) 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 on PATH is that typed error too, where it was a plain Error. Docker with / as the working directory, Homebrew on Linux, CI tool caches and NixOS see nothing.

Limits, written in the README

  • With writes allowed everywhere (filesystem.disabled, allowWrite: ['/']) nothing is passed over but a relative PATH entry, and file permissions are not judged.
  • A file left under a wider set of write paths, granted earlier by a per-call configuration or before updateConfig() narrowed it, is not recognised.
  • socat is named to the relays by its host path, so a denyRead entry that hides that copy stops them.

Review

  • The whole diff against main was 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.
  • The whole diff was reviewed again at 9f3106d. Nothing had to be fixed. Three things should have been and are in ef2a6d7: a call with narrower write paths of its own cleared the session's; a name with glob characters right under / switched the comparison off; one case left a configuration behind for later suites. End to end with real bubblewrap, a sandboxed command planted bwrap, socat, rg, bash, sh, env, npm, node, which and true in six places PATH reaches: nothing planted ran, where main ran the planted rg, bwrap and socat.
  • 122 mutations of the source, 116 caught. The six left: the wording of one reason, a default that no caller omits, the Windows guard, and three in the part of real-path.ts that A deny keeps the symbolic links on the way to it in place #693's tests cover.
  • Left as they are, on purpose: a bare seccomp.applyPath in argv0 mode is looked up inside the sandbox as on main; the warning for a named helper does not subtract denyWrite.

Merge order

src/utils/real-path.ts changes the same way in #693. Both add a last parameter to generateFilesystemArgs(), so whichever lands second takes both. The commits titled WIP are squashed away at the merge.

main merged in on 2026-11-06

main at 27fce0d is merged in at cf3bd57, 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

main at 951e641 is merged in at 4c67c24. One conflict, in initialize(): main put a stopIfReset() 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 no await to initialize(), so main'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

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.
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.
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.
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.
… 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.
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
The field was renamed from `candidate`. This file is plain JavaScript run
by node, so neither the type-check nor `bun test` saw it.
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
ronleizrowice-ant enabled auto-merge (squash) October 20, 2026 06:43
claude and others added 2 commits October 20, 2026 12:28
… 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>
claude and others added 5 commits October 20, 2026 13:00
…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
ronleizrowice-ant enabled auto-merge (squash) October 20, 2026 14:12
claude and others added 3 commits October 20, 2026 14:13
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>

This branch has not been deployed

No deployments
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