Skip to content

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

Open
ronleizrowice-ant wants to merge 5 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 5 commits into
anthropics:mainfrom
ronleizrowice-ant:ron/linux-ancestor-pin

Conversation

@ronleizrowice-ant

Copy link
Copy Markdown
Contributor

Summary

This is #485 (@ant-kurt's ancestor-directory pinning) rebased onto current main, resubmitted so it can be reviewed and landed while the original author is away, plus three commits on top: one fixes the Linux CI failures #485 had, one changes how pins are emitted (read-only, beneath every other mount) after review here turned up a race in the writable form, and one trims duplicate bookkeeping. Both original commits are kept with their authorship. If it is easier to force-push #485's branch from this one and close this PR, that works too; #502 and #503 rebase onto whichever lands.

What #485 does. On Linux a deny bind (or read-deny file mask) makes only its destination a mountpoint. The directories between it and the covering allowed-write root carried no mount, so a sandboxed command could rename one of them, have the bind travel with it, and recreate the path unprotected (mv .git aside && mkdir .git && … > .git/hooks/pre-commit). Each such intermediate directory now gets a read-only self-bind (a "pin") beneath every other mount, so rename/rmdir/RENAME_EXCHANGE on it fail EBUSY; reads, writes, creation and renames inside or across it are unchanged. Pins are seeded from both deny binds and file masks, walk up to the outermost covering allowed-write root, and are verified symlink-free component by component (a pin whose component cannot be verified is dropped). #485's description has the original behaviour notes; commit 4 below changes the emission and drops the EXDEV caveat.

Why land it now. This logic is already what Claude Code runs in production — it ships there as a patchedDependencies patch on @anthropic-ai/sandbox-runtime@0.0.73 — so merging it upstream removes a downstream fork rather than introducing new behaviour, and lets that patch be deleted on the next bump. Compiling this branch and diffing against that patched dist/sandbox/linux-sandbox-utils.js with comments stripped shows only the second commit's refactors (the walk extracted into computeAncestorPins with injected probes; pathSep / isAbsenceError / canonicalForm helpers; one error message reworded), commit 5's bookkeeping trims, plus main's unrelated Java-agent additions.

Commit 3 fixes the two things #485's Linux CI legs failed on (the other two red checks there were the --control-fd flake #501 addresses and the Windows job):

  • The three allowRead carve-out with denyRead at filesystem root tests in allow-read.test.ts regressed. Expanding denyRead: ['/'] into per-child tmpfs mounts also emitted them for children the caller explicitly allowed; on merged-/usr hosts /bin, /sbin, /lib* are symlinks into /usr, so those synthetic denies landed at /usr/bin, /usr/lib, … and the new burial veto then (correctly, by its own logic) refused to restore /usr, leaving nothing executable. A root child whose landing location an allowRead entry equals or contains is now skipped at expansion time; explicit deny entries and the veto are unchanged. The same behaviour is present in the patched build Claude Code ships, so this is worth having independently of the rebase.
  • does not pin above a nested repo deeper than the mandatory-deny scan depth used mandatoryDenySearchDepth: 4 for a/b/c/.git/config; ripgrep's --max-depth counts the file itself, so it needs 5.

Commit 4 emits each pin as --ro-bind P P spliced in straight after --ro-bind / /, instead of a writable --bind P P placed after the allow roots. A pin's only job is to make the directory a mountpoint so rename/rmdir on it fail EBUSY, and the kernel checks that against every mount on the dentry, so a pin buried under later mounts still does it (verified under bwrap: mv app app2 and rmdir appEBUSY, writes and renames inside → fine, hook write → EROFS). Three consequences, all simplifications:

  • No writable bind is ever placed on a directory the sandboxed process can create. In the writable form, a component swapped for a symlink to .. between the lstat walk and bwrap's mount(2) (RENAME_EXCHANGE from a background command) could turn a pin into a writable rbind of the parent tree over every mask beneath it; in the read-only, bottom-of-stack form the same race yields a redundant read-only view under the allow root.
  • 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 harmless because those mounts land on top. computeAncestorPins loses its two exclusion probes, the carve-out clause goes, pins leave denyWriteArgs and the re-application passes, and a directory containing a read-deny tmpfs is now pinned rather than left renameable.
  • No vfsmount boundary sits on the lookup path, so the EXDEV behaviour change Linux: pin ancestor directories of deny binds against rename #485 documented (straddling fs.rename/os.rename, git mv across a .vscode-bearing package) disappears; README updated. An unverifiable pin component now drops that pin instead of aborting every command.

Commit 5 is bookkeeping only, no behaviour change: pushReadDenyDirMounts returns the write paths it restored instead of tracking them in both a local and an out-parameter; the pin walk seeds from denyWriteRawDests (which the deny loop already keys by every dest) instead of re-collecting dests from denyWriteArgs; the root-expansion allowRead check drops a comparison the memoized canonical form already implies; and an allowRead restore compares against the already slash-free normalized spelling.

Test plan

bun test on Linux (bubblewrap 0.11.2 unprivileged, and 0.9.0 in an unprivileged-userns Ubuntu 24.04 container): the pin suites (linux-ancestor-pin*.test.ts, compute-ancestor-pins.test.ts, linux-mount-plan-record.test.ts) re-pointed to the new plan shape plus new cases — a pin above a read-deny tmpfs with the tmpfs still on top, a pin inside a denied directory, the relative-seed guard, and under bwrap: mv/rmdir of a pinned directory → EBUSY while a rename out of it succeeds; the previously failing allow-read.test.ts root-deny cases pass. Whole suite: same failure set as main on this machine (environment-only). CI here is the real check.

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.
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