Skip to content

Linux: pin ancestor directories of deny binds against rename - #514

Open
ronleizrowice-ant wants to merge 13 commits into
anthropics:mainfrom
ronleizrowice-ant:ron/linux-ancestor-pin
Open

Linux: pin ancestor directories of deny binds against rename#514
ronleizrowice-ant wants to merge 13 commits into
anthropics:mainfrom
ronleizrowice-ant:ron/linux-ancestor-pin

Conversation

@ronleizrowice-ant

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

Copy link
Copy Markdown
Contributor

Summary

On Linux a deny bind, a read-deny file mask or a read-deny tmpfs makes only its destination a mountpoint. The directories between it and the covering allowed write root carry no mount, so a sandboxed command can rename one of them and have the mount travel with it. For a write deny the path can then be recreated unprotected (mv .git aside && mkdir .git && … > .git/hooks/pre-commit); for a read deny the next command's wrap no longer finds the path and leaves it readable under its new name (mv proj/other proj/o2, then cat proj/o2/secrets/key).

Each existing directory between such a mount and its outermost covering allowed write root now gets a read-only self-bind (a "pin"), emitted straight after --ro-bind / / and so beneath every other mount. That is enough for rename/rmdir/RENAME_EXCHANGE on it to fail EBUSY; permissions, deny binds, tmpfs units and masks are decided by the mounts above it, and no mount boundary appears on the lookup path, so renames inside, out of or across a pinned directory behave as before (no EXDEV). A directory that is missing gets no pin. One that exists but cannot be inspected is pinned anyway; if bwrap cannot bind it the sandbox does not start, which is where the deny loop already lands for an unverifiable path.

About half the source diff is the read section, which the pins depend on being exact about where mounts land:

  • The section keeps one record of what it emitted (tmpfs units with the restores they made, and file masks), each with its landing: where the mount sits in the sandbox. bwrap resolves a destination in the root built so far, so a symlink an earlier tmpfs has hidden is created literally on that tmpfs and never reaches its host target. The pins, the deny-bind emission filter and the re-application passes all read that record. Realpaths are taken once per wrap and only after the mandatory-deny scan (which can run long enough for a symlink to be retargeted).
  • Read-deny entries are processed shallow-first by canonical depth. A unit can then restore its write and allowRead paths unconditionally, because nothing emitted so far can lie inside one; the outcome no longer depends on the order same-depth entries are listed in, and no write root is dropped.
  • A destination that lands somewhere an earlier tmpfs hid is spelled by its landing, and an allowRead carve-out is bound at its resolved path. bwrap cannot mount onto a symlink that dangles in the new root: on a merged-/usr host denyRead: ['/'] produced --tmpfs /usr followed by --tmpfs /lib32, and the sandbox did not start (this fails on main too, depending on readdir order).
  • An allowRead entry re-allows the name it is, not what it links to. It is bound back over a read-deny tmpfs only when its name lives inside the denied directory (its parent directories resolved, its last component as written) and it resolves to somewhere inside it too, which is where the bind goes. So /lib/x86_64-linux-gnu under a deny of /lib or / on a merged-/usr host is restored (the name lives inside the /usr tmpfs), while a link planted at an allowed path (proj/docs -> ~/.ssh, with denyRead: ['~/.ssh'] and allowRead: ['proj/docs']) restores nothing, and an entry that is itself a symlink never does. The same rule decides which root children a root read-deny skips as covered, and a file mask is lifted only by an entry that names that very file, not by a symlink to it (sub/.env.example -> ../.env matched by both an allowRead and a denyRead glob leaves .env masked).
  • Expanding denyRead: ['/'] skips a root child an allowRead entry covers, and the stub-skip predictor uses the same expansion.
  • A denyRead path that exists but cannot be inspected (EACCES after chmod 000 on its parent, which a same-uid command can undo from inside the next sandbox) is no longer treated as absent: the deepest directory above it that can be inspected is hidden instead, with nothing restored inside it. ENOENT, ENOTDIR, ELOOP and ENAMETOOLONG still mean absent.
  • When a denyWrite bind re-exposes a read-denied directory and its tmpfs is re-applied, an allowed write path inside comes back read-only (the deny wins) instead of writable (main) or not at all.

This supersedes #485 (both of its commits are kept with their authorship); #503 rebases onto it.

Blast radius: Linux only. A config with no deny path, file mask or read-denied directory under an allowed write root gets no pin. Not behind a flag: the pins add mountpoints, not permissions.

A / write root is unchanged from main. allowOnly: ['/'] contains only / itself for the purposes of denies, stubs and pins, so it still emits --bind / / and nothing else, and allowOnly: ['/'] with denyWithinAllow: ['/'] still skips the stubs for absent cwd dotfiles (bwrap could not create them on a read-only root). Both are pinned by tests.

Argument size. Pins add one --ro-bind P P per distinct ancestor, roughly doubling the profile for repositories with many nested deny paths. The wrapped command is a single sh -c string, so it meets Linux MAX_ARG_STRLEN (131072 bytes) at about half the repository size it did before: measured with a 44-character cwd, N first-level nested repos (X/.git/config) cross at N≈334 instead of N≈913, and N X/.vscode/settings.json at N≈515 instead of N≈968. #504 moves the arguments to --args transport and removes the limit; until it lands, very large monorepos hit Argument list too long sooner.

Known follow-up. EBUSY on rename/rmdir of a pinned directory is a new sandbox-caused failure that the Linux violation monitor cannot attribute yet: the directory is inside allowWrite and under no denyWrite entry, so the event is dropped and stderr shows a bare "Device or resource busy". Teaching the monitor about pinned directories is left to a follow-up.

Test plan

bun test on Linux with unprivileged bubblewrap 0.11.2. Same failure set as main on that machine (environment-only); CI is the real check. Every regression test below fails on the previous head of this branch.

  • linux-ancestor-pin.test.ts drives the walk through wrapCommandWithSandboxLinux (leaf to root, nested write roots, pins at and below other deny dests, above a read-deny tmpfs, inside a denied directory, above a read-denied directory) and, under bwrap, checks mv/rmdir/exchange-rename of a pinned directory fail EBUSY while work inside it, renames across it and git commit succeed; that a read-denied directory cannot be renamed out from under its tmpfs; that a carve-out inside a denied directory comes back read-only; and that a sandbox with a symlink and its target directory both read-denied starts and honours the carve-out.
  • linux-ancestor-pin-errno.test.ts: a missing directory gets no pin, an uninspectable one is still pinned, an uninspectable denyRead path hides the directory above it, masks (a FIFO included) seed the walk and an unmasked entry does not.
  • linux-mount-plan-record.test.ts: the plan is the same whichever order a symlink-spelled entry is listed in and keeps the write root; deny binds under the target of an already-hidden symlink are kept; allowRead carve-outs reached through symlinked parent directories are restored, while an entry that is a symlink into a denied directory, to a denied file or to a root child restores and lifts nothing (also driven under bwrap: a planted docs -> ~/.ssh leaves the key unreadable); re-application restores a carve-out and then the deeper deny; a denyRead symlink retargeted during the scan is resolved afterwards; and the two /-root plans match main.

ant-kurt and others added 5 commits September 1, 2026 12:56
Each denyWrite bind or read-deny file mask makes only its destination a
mountpoint; the directories between it and the covering allowed write
root could be renamed, carrying the bind along and leaving the path
recreatable unprotected. Emit a self --bind for each such directory so
rename/rmdir on it fail EBUSY, seeded from both deny binds and file
masks, skipping allowed write roots, deny dests and any directory that
contains a read-deny tmpfs.

Pins ride the existing emission filter and tmpfs/mask re-application
passes, which now compare recorded and canonical spellings, replay the
read section's actual restores instead of re-deriving them, and refuse
restores that would bury an earlier read-deny mount. Only ENOENT/ENOTDIR
count as absence in the pin walk; an unverifiable component aborts the
wrap.

Remote-Dev: homespace
computeAncestorPins is a pure, exported walk with injected probes and
direct unit tests. The unverifiable-component abort now names the path
and the remedy. The nested-repo behavioral test keeps the repo within
the default scan depth, quotes its rename paths correctly, and a new
case pins the depth rule. README documents pinned-directory behavior.

Remote-Dev: homespace
…ready covers

Expanding denyRead ['/'] into per-child tmpfs mounts also emitted them for
children the caller explicitly allowed (/bin, /usr, /lib, ...). On merged-/usr
hosts /bin, /sbin and /lib* are symlinks into /usr, so those synthetic denies
landed at /usr/bin, /usr/lib, ... and the read-deny burial veto then refused
to restore /usr, leaving nothing executable inside the sandbox (the three
'denyRead at filesystem root' tests in allow-read.test.ts). A child whose
landing location an allowRead entry equals or contains is now skipped at
expansion; explicit deny entries and the veto itself are unchanged.

Also corrects the scan depth in the nested-repository pin test: ripgrep's
--max-depth counts the matched file, so a/b/c/.git/config needs 5.

Remote-Dev: homespace
… mount

A pin's only job is to make the directory a mountpoint so rename/rmdir on it
fail EBUSY; the kernel checks that against every mount on the dentry, so a
pin buried under later mounts still works. Emitting each pin as
'--ro-bind P P' straight after '--ro-bind / /', before the allow roots, deny
binds, tmpfs units and masks, means:

- no writable bind is ever placed on a directory the sandboxed process can
  create, so a component swapped for a symlink between the walk and bwrap's
  mount yields at worst a redundant read-only view under the allow root
  instead of a writable rbind of the parent tree;
- nothing has to be excluded or re-applied around pins (a pin at or below a
  deny dest, inside a carve-out, or above a read-deny tmpfs is fine — those
  mounts land on top), so the exclusion probes and the carve-out clause go;
- no vfsmount boundary sits on the lookup path, so renames across a pinned
  directory no longer fail EXDEV; only rename/rmdir of the pinned directory
  itself fail;
- an unverifiable pin component drops that pin instead of aborting every
  command.

Tests re-pointed to the new plan shape; new cases for a pin above a read-deny
tmpfs, a pin inside a denied directory, the no-EXDEV property under bwrap,
and the relative-seed guard in computeAncestorPins. README updated.

Remote-Dev: homespace
No behaviour change:
- pushReadDenyDirMounts returns the write paths it actually re-bound
  instead of recording them in both a local array and an optional
  out-parameter; the read section reads the return value.
- The ancestor-pin walk seeds from denyWriteRawDests, which the deny
  loop already keys by every dest it produces, rather than re-collecting
  dests from denyWriteArgs.
- The root-expansion allowRead check drops `form === child`: child and
  allowPath go through the same memoized mountForms, so an allow entry
  equal to the child always also matches the child's canonical location.
- The allowRead restore compares the resolved path against the entry
  itself; readAllowPaths arrive slash-free from normalizePathForSandbox.
Conflicts in src/sandbox/linux-sandbox-utils.ts with anthropics#502's root-aware 
containment helpers: the two re-application filters keep this branch's 
canonical-location and mount-form tests and now go through 
isStrictlyUnder / isAtOrUnder; the stub-skip INVARIANT comment carries 
main's wording plus this branch's rationale.
The pin and read-deny code compared paths with a local pathSep() and
inline `x === d || x.startsWith(d + '/')` tests, two of which were not
root-aware. They now go through isAtOrUnder / isStrictlyUnder from
sandbox-utils, which the rest of the file already uses, and pathSep is
gone. The two root-unaware sites only see a write path of '/' when it
lies beneath a read-deny tmpfs, which cannot happen (a '/' deny is
expanded into its children), so no emitted plan changes.
…ad the emitted record

Realpaths were memoized by a helper that a config-derived list of
read-deny locations filled before the mandatory-deny scan's await. That
scan can run arbitrarily long, so a symlink retargeted meanwhile left
every later predicate (the restore's covering test, the emission filter,
the burial veto) comparing against where the link used to point.
canonicalForm is now the memoized primitive, nothing calls it before the
await, and the stub-skip veto inputs use it instead of resolving by hand.
A test retargets a denyRead symlink from a stand-in ripgrep and checks
the write path under the new target is the one restored.

The config-derived list is gone. The re-application vetoes against the
record of read-deny mounts actually emitted (tmpfs units, file masks and
now the credential masks), which is what the first emission already did:
the derived list missed root-expanded children and the implicit
ssh_config.d tmpfs and counted entries that never mount.

The pin phase reads the same record. It runs after the read section and
seeds from the deny dests and the emitted masks rather than re-deriving
which denyRead entries become masks, and it is one function,
ancestorPinArgs. A directory that is missing, cannot be inspected or has a
symlink component gets no pin, under one policy: the walk used to keep a
pin whose stat failed with EACCES and then drop it when lstat failed the
same way. computeAncestorPins is no longer exported for its unit test;
the wrap-level suites cover the walk.

Shapes: one record per tmpfs unit instead of two index-parallel lists,
a named-field argument for pushReadDenyDirMounts, a three-state union for
a pin path component.
A typed fixture tree instead of Record<string, unknown> and a cast; run()
pins LC_ALL=C for the strerror matches; blocks that execute bwrap assert
the command's DONE marker so a rejected mount plan cannot pass them; the
exchange-rename probe is its own case, skipped without python3, rather
than a silent `true`; a space-terminated --tmpfs match where one path is
a prefix of another; the git case turns commit signing off so a global
commit.gpgsign cannot fail it. The four-in-one rename case and the
implicit-tmpfs file repeated single-behaviour cases and are removed, as
are comments that narrate the assertion beneath them.
Each existing directory up to the outermost allowed write root, and what
rm -rf leaves behind; the mount-ordering clause a reader cannot act on
is dropped.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Merged main (conflicts with #502's root-aware containment helpers resolved) and pushed a review-fix round on top (cb8f28c..4302210):

  • Reuse: the local pathSep is gone; every containment test goes through isAtOrUnder / isStrictlyUnder from sandbox-utils.ts. The two sites that had lost root-awareness are unreachable (a / deny is expanded into its children); the commit message says so.
  • Freshness (fix): nothing takes a realpath before the mandatory-deny scan's await any more, so a symlink swapped during the scan cannot leave a stale mount location cached. The re-application now vetoes against the record of what was actually mounted (which includes credential masks) instead of a list re-derived from config. A new scan-window test fails on the previous code.
  • Shape: the pin phase is one function (ancestorPinArgs) that runs after the read section; a path component is 'plain' | 'symlink' | 'uninspectable' and only 'plain' pins; one record per tmpfs unit; pushReadDenyDirMounts takes named fields; no !.
  • Tests/docs: the computeAncestorPins export and its unit-test file are dropped (the wrap-level suites cover the walk); the nested-repo git test passes -c commit.gpgsign=false so a global signing config cannot fail it; README states what the pinned-directories guarantee covers.

Local run (Linux x86-64, bubblewrap 0.11, unprivileged userns): eslint and tsc clean; the PR's suites 33/33; full npm test shows only the failures main has on the same box. The description above is updated to match.

…d-denied directories

A read-denied directory did not seed ancestor pins, so `mv proj/other
proj/o2` still moved its tmpfs aside and the next command, no longer
finding proj/other/secrets, left it readable. Every read-deny mount now
seeds the walk: tmpfs units as well as file and credential masks.

The read section keeps one record of what it emitted, tmpfs units with the
restores they made and file masks, each with its landing: where the mount
sits in the sandbox. bwrap resolves a destination in the root built so far,
so a symlink an earlier tmpfs has hidden is created literally on that tmpfs
and never reaches its host target. The emission filter used to treat such
an entry as mounted at its host realpath and dropped the deny binds under
that target as hidden, mandatory ones included; it now decides by the
landing. The pins and both re-application passes read the same record.

Entries are processed shallow-first by canonical depth rather than by
spelling. A unit can then restore its write and allowRead paths
unconditionally, since nothing emitted so far can lie inside one, so the
burial veto and its bookkeeping are gone. The veto dropped a whole allowed
write root (often the cwd) whenever a symlink-spelled entry inside it
happened to sort first, and dropped an allowRead carve-out in the
re-application pass whenever any read-deny entry lay inside it.

bwrap cannot mount onto a symlink that dangles in the new root. A
destination that lands somewhere an earlier tmpfs hid is therefore spelled
by its landing, and an allowRead carve-out is bound at its resolved path.
On a merged-/usr host a root read-deny produced `--tmpfs /usr` followed by
`--tmpfs /lib32` and the sandbox did not start; that also happens before
this branch, depending on readdir order.

An allowRead entry spelled through a symlink is a carve-out of a unit when
its target lies inside the denied directory (/lib/x under a deny of /lib
or /). The previous guard skipped every symlink-spelled entry.

A denyRead path that exists but cannot be inspected (EACCES after chmod 000
on its parent, which a same-uid command can undo from inside the next
sandbox) was treated as absent and got a pin but no mask. The deepest
directory above it that can be inspected is now hidden instead, with
nothing restored inside it. ENOENT, ENOTDIR, ELOOP and ENAMETOOLONG still
mean absent. The pin walk applies the same split: a missing directory gets
no pin, an uninspectable one is pinned anyway, and the per-component lstat
walk that turned "cannot inspect" into a silently dropped pin is removed.

When a denyWrite bind re-exposes a read-denied directory and its tmpfs is
re-applied, an allowed write path inside comes back read-only: it lies
under the deny too. The previous filter always reduced the restore list to
nothing, so the carve-out vanished.

The write-allowlist predicates go back to plain prefix tests. Made
root-aware, an allowOnly of '/' contained every path: every cwd stub and a
pin for each ancestor up to the root appeared where only `--bind / /` was
emitted before, and with denyWithinAllow ['/'] and any read-deny tmpfs the
stubs for absent cwd dotfiles landed after the read-only root, where bwrap
cannot create them. An earlier commit on this branch called those sites
unreachable; they are reachable with allowWrite ['/'].

The stub-skip predictor now takes its tmpfs set from the same root
expansion as the read section, so a root child that an allowRead covers no
longer keeps a stub for a tmpfs that is never mounted.

Test hygiene: the exchange-rename probe uses the process's own libc and
skips where it has no renameat2 wrapper, the FIFO case skips without
mkfifo, the nested-repo commit ignores a global hooksPath, and a pin that
is the same string as a deny bind is counted rather than located.
A read-denied directory now pins its ancestors as well, so spell out what
"a protected path" covers.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Second round on top of 4302210 (68c4bae, 00b4ff8), from a deeper review that compared bwrap plans against main case by case. Several of these were regressions against main that the first version of this PR introduced, so please look at this head rather than the earlier one:

  • Symlink-spelled allowRead carve-outs were being dropped (a guard continued on any allowRead path with a symlink component). On a merged-/usr host, denyRead: ['/'] with allowRead: ['/lib/…'] lost the loader directory. A symlink-spelled entry is now accepted when its target lies under the unit and is bound at the resolved path.
  • Mounts are recorded by where they land. A symlink-spelled denyRead entry already hidden by an earlier, shallower tmpfs is created by bwrap on that tmpfs, not at the host realpath; the deny-bind filter assumed the realpath and dropped binds under the real target (including mandatory ones) as "hidden", leaving them writable. The filter now compares landings.
  • Read-deny units are sorted by canonical depth, so the plan no longer depends on the listing order of same-depth denyRead entries and can no longer silently drop an allowed write root. wouldBuryReadDeny and its self-exemption are gone. Canonical ordering exposed a case main also fails (--tmpfs /usr before --tmpfs /bin cannot mount onto a symlink that dangles in the new root); destinations that land somewhere already hidden are now spelled by their landing, so a root read-deny with merged-/usr carve-outs starts.
  • allowWrite: ['/'] behaves as on main again (--bind / / only); my earlier commit message calling those sites unreachable was wrong and 68c4bae says so.
  • Read-denied directories now seed pins (mv proj/other proj/o2 used to expose a read-denied directory to the next command). An unstattable denyRead path hides the deepest inspectable directory above it instead of being skipped. The pin walk pins uninspectable directories and skips only missing ones.
  • A carve-out inside an emitted denied directory comes back as --ro-bind W W (visible, read-only) after re-application instead of vanishing.
  • Tests: eighteen new or rewritten cases fail on 4302210; assertions are space-terminated or counted; the renameat2 probe and mkfifo skip where unavailable; the nested-repo commit ignores global hooks and signing config.

Not fixed here, stated in the description: pins roughly double the profile size, so E2BIG arrives at about half the repository size until #504's --args transport lands; and the Linux violation monitor cannot yet attribute the new EBUSY on renaming a pinned directory.

Verified on Linux x86-64 under real bubblewrap 0.11 with main as control (nine adversarial cases through the srt CLI); eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box.

…links to

The previous commit accepted an allowRead entry as a carve-out of a
read-denied directory when the entry's canonical form lay inside it. An
entry that merely points at the directory from outside then counted: with
allowWrite [proj], denyRead [~/.ssh] and allowRead [proj/docs], a command
that ran `rm -rf docs; ln -s ~/.ssh docs` (or a repository that ships that
link) made the next wrap emit `--tmpfs ~/.ssh --ro-bind ~/.ssh ~/.ssh`,
and the key was readable. The same test decided which root children a
root read-deny skips as covered, so `docs -> /etc` left /etc unhidden.

An allowed path is now bound back only when its name lives inside the
denied directory: its parent directories are resolved, its last component
is kept as written, and that location must be at or under where the tmpfs
landed, as must what the path resolves to, which is where the bind goes.
/lib/x86_64-linux-gnu under a deny of / or /lib on a merged-/usr host is
still restored (its parent /lib resolves to /usr/lib, so the name lives
inside the /usr tmpfs); proj/docs -> ~/.ssh is not (the name lives in
proj). An entry that is itself a symlink restores nothing.

The file-mask exemption follows the same rule: a mask is lifted only by an
entry that names that very file, through whatever symlinked directories,
not by a symlink to it. A planted sub/.env.example -> ../.env matched by
both an allowRead glob and a denyRead glob leaves .env masked.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

One more commit, 2db24d0, closing a hole the previous round opened. Round 2 accepted a symlink-spelled allowRead entry when its target lay under the read-denied unit. That also accepted an entry that merely points at the denied place from outside: with allowWrite [proj], denyRead [~/.ssh], allowRead [proj/docs], a sandboxed command could run rm -rf docs && ln -s ~/.ssh docs, and the next command's plan bound ~/.ssh back over its own tmpfs (reproduced through the built CLI under bubblewrap 0.11.2; main denies it). With denyRead ['/'], docs -> /etc likewise left /etc unhidden, because root-child coverage used the same test.

The rule is now the one #503 uses too, so the two PRs converge: an allowRead entry re-allows the name it is, not what it links to. It is bound back only when its name lives inside the denied directory (parent directories resolved, last component as written) and it resolves to somewhere inside it too, which is where the bind goes. Root-child coverage and the file-mask exemption use the same name location. /lib/x86_64-linux-gnu under denyRead ['/'] on a merged-/usr host still restores and the sandbox still starts; an allowRead entry that is itself a symlink (a bare /lib) restores nothing on its own, which is what main did.

Six tests fail on 00b4ff8 (directory, file inside a directory, the mask-lifting pair, the root child, and an end-to-end run under bubblewrap). eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box.

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