Skip to content

A / write root (Linux) or read root (macOS) no longer switches off the denies beneath it - #533

Open
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-root-write-root-denies
Open

A / write root (Linux) or read root (macOS) no longer switches off the denies beneath it#533
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-root-write-root-denies

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

If / is in allowWrite, the Linux sandbox silently drops every write deny. denyWrite entries and the built-in protections (.git/hooks, .git/config, shell rc files, .mcp.json, .claude/commands, …) get no read-only bind, over a root that has just been bound writable. This PR makes the two checks responsible treat / as containing every path. macOS has the same slip for allowRead: ['/'] and gets the same fix.

Example

{ "filesystem": { "allowWrite": ["/"], "denyWrite": ["/home/u/.ssh"] } }
bubblewrap arguments result
Before --bind / / and nothing for the denies ~/.ssh and <cwd>/.git/hooks are writable. Each deny is logged "not within allowed paths", at debug level only.
After --bind / /, then a read-only bind (or placeholder) for each deny the denies hold; the rest of the tree stays writable

Cause

"Is this deny path inside the write allowlist?" was written as

candidate === allowed || candidate.startsWith(allowed + '/')

For allowed === '/' the prefix is //, which no path starts with. Two places used that spelling:

  • isWithinAnyAllowedWritePath decides whether a deny gets a bind at all.
  • findSymlinkInPath decides whether a symlinked ancestor of a deny path gets the /dev/null mask that stops it being swapped for a real directory.

#502 added root-aware helpers (isAtOrUnder, isStrictlyUnder) and moved five sibling checks in the same function onto them. These two were missed.

Change

  • Linux: both checks call isAtOrUnder.
  • macOS: the block that re-emits a denyRead nested inside an allowRead directory (Seatbelt is last-match-wins) also compared with a + '/', so allowRead: ['/'] re-opened every denied path and no deny was re-emitted. It now uses isStrictlyUnder, and the macOS module's private copy of that helper delegates to the shared one, so the checks that were wrong now share one helper.
  • Comments that described the old behaviour are corrected. No other logic changes.

Who sees a difference

  • No / in allowWrite (Linux) or allowRead (macOS): nothing changes. For any other directory the helper is the same comparison the code spelled inline.
  • allowWrite: ['/']: write denies now apply, including the built-in ones for the current directory. That is the documented behaviour. For built-in denies whose file does not exist, bubblewrap creates an empty placeholder on the host for the life of the command, as it already does under any other write root.
  • filesystem.disabled: true: unaffected. In that mode the manager passes no write config to the backend, so this code does not run and the built-in protections stay off, as documented.
  • Not behind a flag.

Configurations that now refuse to start

Turning the denies back on also turns on a start-up failure that main already has for every other write root. Until now it could not be reached under /, because every deny was skipped.

  • The shape: allowWrite: ['/'], plus a denyWrite directory that contains the current directory and has some allowed write path beneath it. Through SandboxManager the default write paths (~/.claude/debug, ~/.npm/_logs, /tmp/claude) always count as allowed paths, so denyWrite: ['$HOME'] or denyWrite: ['/'] is enough. No read policy is needed.
  • What happens: the denied directory is bound read-only, the built-in denies for files that do not exist yet (<cwd>/.gitconfig, <cwd>/.mcp.json and friends) still get placeholders, bubblewrap cannot create a placeholder inside a read-only mount, and it exits with Can't create file at <cwd>/.gitconfig: Read-only file system (the first placeholder it reaches). Every command fails.
  • Before this PR that configuration started, with every deny silently off. Refusing to start is the safe direction, but it is a visible change for anyone who has it.
  • Who fixes it: linux: collapse a read-deny glob to its covering directories, and mount every read deny where it really is #503 narrows the rule that keeps those placeholders, and Linux: pin ancestor directories of deny binds against rename #514 handles a root that is both allowed and denied. With either merged, these shapes start and the denies hold.
  • The same applies when the current directory is not writable by the caller (root-owned, a read-only mount, or / itself for a non-root user): the placeholders cannot be created and the sandbox does not start.

Merge order

#514, #503 and #524 rework the same function, so please merge this first: it is small, and it deliberately avoids the lines they change. #503 and #524 still carry the old comparison and pick the fix up when they merge main. #514 already uses the same root-aware rule. It also goes one step further for a root that is both allowed and denied (it treats the paths beneath as covered, where this PR alone emits placeholders bubblewrap cannot create), so when #514 merges main it updates the second and third blocks of this PR's last / test to match.

Testing

Linux, bubblewrap 0.11.2.

  • New cases, all failing on main and passing here:
    • a denyWrite file and directory that no other allow entry covers get their binds under a / root;
    • a symlinked ancestor of a deny path gets its mask;
    • the built-in denies under a / root: the ones that exist get binds, the absent ones get placeholders;
    • at runtime, the denied write fails and the rest of the tree stays writable;
    • macOS: a literal denyRead nested under allowRead: ['/'] is re-emitted.
  • Two existing / assertions could not fail: they searched for --ro-bind / /, which the base read-only root mount always matches, so they passed whether or not the deny's own bind of / was emitted. They now count whole <flag> <source> <dest> triples, and were checked by removing the deny-side bind: both fail without it.
  • The refuse-to-start shapes above were measured on both commits under bubblewrap 0.11.2, directly and through SandboxManager: main starts with no deny bind at all, this branch aborts, a cwd outside the denied directory starts on both, and a denied directory with no allowed path beneath it starts with its absent built-in denies skipped as covered.
  • allowWrite: ['/'] + denyWrite: ['/'] with no read policy now also runs under bubblewrap: it boots, a read through the read-only root succeeds, and a write fails and leaves the host file unchanged.
  • allowWrite: ['/'] + denyWrite: ['/'] with no read policy is pinned: exactly --ro-bind / /, --bind / /, --ro-bind / /, every per-path deny skipped, and the per-path denies come back as soon as a second allow entry or one read-deny directory exists.
  • tsc, eslint and prettier clean on the changed files. Full npm test has the same failures as a clean main checkout on the same host (all environment-dependent), with five more tests passing. CI is green on all eight jobs.

"/" is a legal entry in the write allowlist — normalization keeps it, and
the allow loop binds it writable — but isWithinAnyAllowedWritePath, the
one gate deciding whether a write-deny destination lies inside that
allowlist, spelled containment as an `allowedPath + '/'` string prefix.
For "/" that prefix is "//", which matches nothing, so every denyWrite
entry and every mandatory deny (.git/hooks, .git/config, .bashrc,
.mcp.json, .claude/commands, ...) that no other allow entry happened to
cover was logged "not within allowed paths" and got neither a read-only
bind nor a placeholder. The deny list failed open on top of a root the
allow loop had already bound writable.

findSymlinkInPath compared the same way, so a symlinked ancestor of a
deny path was never seen to be inside a writable root either, and the
/dev/null mask that stops that component being deleted and recreated as
a real directory was never emitted.

Both now use the root-aware isAtOrUnder helper. For any directory other
than "/" the helper is the same comparison the code spelled inline, so a
configuration without "/" in the write allowlist renders exactly as
before.

Also corrects the example on isAtOrUnder's docstring, which had the pair
backwards: "/x" does not start with "/xy" under a plain prefix test
either, so it demonstrates nothing; "/xy" against "/x" is the pair that
shows the segment boundary.
Seatbelt is last-match-wins, so a literal denyRead sitting inside a
literal allowRead directory is re-emitted after the allow block or the
allow wins. That containment test was a `dir + '/'` string prefix, so an
allowRead entry of "/" — which re-opens every denied path — matched
nothing and no deny was re-emitted at all.

Use the module's own root-aware isStrictlyUnder for the test, and have
that function delegate to the shared helper in sandbox-utils so the root
rule has one home instead of two copies to keep in step.
…ifiable

New cases, each of which fails without the fix: with allowWrite ["/"], a
user denyWrite file and directory each get a real --ro-bind, an existing
mandatory deny gets one too, an absent one gets its placeholder, a
symlinked ancestor of a deny path gets the /dev/null mask, and — where
the host can run bwrap namespaces — a write to the denied file fails on
a read-only mount while a write elsewhere still succeeds.

The two existing "/" cases could not fail. Both located the deny-side
root bind with lastIndexOf('--ro-bind / /'), which falls back to the base
root mount that spells it identically, so "> -1" and the ordering
assertions held even with the deny-side bind gone. They now count whole
argv triples and expect exactly two. The runtime arm's "the write is
denied" check asserted only a non-zero exit, which a bwrap startup abort
or a spawn timeout satisfies just as well; it now asserts the
read-only-mount failure itself.
…h deny is skipped

allowOnly ['/'] with denyWithinAllow ['/'] and nothing else makes '/' a
recorded covering deny directory that no veto reaches: no allowed write
path lies strictly beneath it and there is no read-deny tmpfs anywhere. So
every other deny — the mandatory binds and the creation-blocking stubs
alike — is skipped as already covered, and the deny-side --ro-bind / /
emitted after the allow's writable --bind / / is the whole protection.

The shape is only reachable now that the within-allowlist predicate is
root-aware, and nothing covered it. The test also pins the first veto in
either direction: a second allow entry lies strictly beneath '/', and a
single read-deny directory is the re-application's trigger, so either one
brings every per-path deny back.
…eny bind

The end-to-end arm under a "/" write root opened with
`if (!BWRAP_CAN_NAMESPACE) return`, so on a host without user namespaces it
passed with no assertions at all and the runner called it green -- while it
is the only place the change is executed rather than read off an argv.
`it.skipIf` reports a skip instead.

- The two runtime arms carried a verbatim copy of the same `run` closure.
  One now sits beside `wrap`.
- `expectDeniedByReadOnlyMount` dropped the `expect(status).not.toBe(0)` it
  replaced, so a command that never ran satisfied the rest. Put it back
  inside the helper.
- `/` allowed and denied whole pinned an argv where a single `--ro-bind / /`
  after `--bind / /` is the entire protection, with nothing executing it. A
  runtime arm now boots that sandbox (payload prefixed with `echo BOOTED`),
  reads the file through the bind, and shows the write failing and leaving
  the host file unchanged. Checked by removing the deny: the write then
  succeeds and the arm fails.
- Comment: the pre-pass's lockstep note now names the one exception its own
  re-check documents -- a symlink appearing between the two passes makes the
  loop mask that component and emit no bind for the directory.
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