Linux: pin ancestor directories of deny binds against rename - #514
Open
ronleizrowice-ant wants to merge 5 commits into
Open
Linux: pin ancestor directories of deny binds against rename#514ronleizrowice-ant wants to merge 5 commits into
ronleizrowice-ant wants to merge 5 commits into
Conversation
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.
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.
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, sorename/rmdir/RENAME_EXCHANGEon it failEBUSY; 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 theEXDEVcaveat.Why land it now. This logic is already what Claude Code runs in production — it ships there as a
patchedDependenciespatch 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 patcheddist/sandbox/linux-sandbox-utils.jswith comments stripped shows only the second commit's refactors (the walk extracted intocomputeAncestorPinswith injected probes;pathSep/isAbsenceError/canonicalFormhelpers; one error message reworded), commit 5's bookkeeping trims, plusmain'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-fdflake #501 addresses and the Windows job):allowRead carve-out with denyRead at filesystem roottests inallow-read.test.tsregressed. ExpandingdenyRead: ['/']into per-child tmpfs mounts also emitted them for children the caller explicitly allowed; on merged-/usrhosts/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 anallowReadentry 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 depthusedmandatoryDenySearchDepth: 4fora/b/c/.git/config; ripgrep's--max-depthcounts the file itself, so it needs 5.Commit 4 emits each pin as
--ro-bind P Pspliced in straight after--ro-bind / /, instead of a writable--bind P Pplaced after the allow roots. A pin's only job is to make the directory a mountpoint sorename/rmdiron it failEBUSY, 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 app2andrmdir app→EBUSY, writes and renames inside → fine, hook write →EROFS). Three consequences, all simplifications:..between the lstat walk and bwrap'smount(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.computeAncestorPinsloses its two exclusion probes, the carve-out clause goes, pins leavedenyWriteArgsand the re-application passes, and a directory containing a read-deny tmpfs is now pinned rather than left renameable.EXDEVbehaviour change Linux: pin ancestor directories of deny binds against rename #485 documented (straddlingfs.rename/os.rename,git mvacross 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:
pushReadDenyDirMountsreturns the write paths it restored instead of tracking them in both a local and an out-parameter; the pin walk seeds fromdenyWriteRawDests(which the deny loop already keys by every dest) instead of re-collecting dests fromdenyWriteArgs; the root-expansionallowReadcheck drops a comparison the memoized canonical form already implies; and anallowReadrestore compares against the already slash-free normalized spelling.Test plan
bun teston 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/rmdirof a pinned directory →EBUSYwhile a rename out of it succeeds; the previously failingallow-read.test.tsroot-deny cases pass. Whole suite: same failure set asmainon this machine (environment-only). CI here is the real check.