Skip to content

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

Open
ant-kurt wants to merge 2 commits into
mainfrom
fix/linux-ancestor-pin
Open

Linux: pin ancestor directories of deny binds against rename#485
ant-kurt wants to merge 2 commits into
mainfrom
fix/linux-ancestor-pin

Conversation

@ant-kurt

@ant-kurt ant-kurt commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

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. Each such intermediate directory now gets a self --bind, so rename/rmdir/RENAME_EXCHANGE on it fail EBUSY; reads, writes and creation inside it are unchanged. Pins are seeded from both deny binds and file masks, skip allowed-write roots, deny destinations, and any directory containing a read-deny tmpfs, and go through the existing emission filter and tmpfs/mask re-application passes.

Behavior changes inside the sandbox:

  • A pinned directory cannot be renamed or removed: mv app app2 fails with EBUSY for any nested repository the mandatory scan finds, and for .git/.claude in the project root; rm -rf of a nested repository leaves an empty husk (as .git/hooks already did).
  • A rename across a pin boundary returns EXDEV for callers without a copy fallback (fs.rename, os.rename); mv falls back to copy+unlink.
  • A directory on a pin path that cannot be verified (e.g. EACCES on lstat) fails the command with an error naming the path.

Test plan

Tests under test/sandbox/ (linux-ancestor-pin*.test.ts, linux-mount-plan-record.test.ts): unit tests for the pin plan (exclusions at/below deny destinations and at/above a read-deny tmpfs, absent directories, nested write roots, ordering), bwrap arg assertions, the rename-aside bypass failing with EBUSY, ancestor-of-credential-mask pinning, errno discrimination, no pin above an implicit tmpfs, and a nested repository within scan depth whose ancestors are pinned — host git init, sandboxed git add/git commit succeed, a staging rename inside the corridor succeeds, a straddling rename fails EXDEV; a repository beyond the scan depth gets no pins. Existing suite unchanged.

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.
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.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor

Rebased this onto current main with your two commits kept as-is and opened it as #514 so it can move while you're out, with three commits on top: the Linux CI failures here (root-denyRead carve-out tests — synthetic /bin,/lib* denies from the / expansion landing under /usr and tripping the burial veto — and the scan depth in the nested-repo test), a change to emit pins as read-only self-binds beneath every other mount rather than writable ones after the allow roots (same EBUSY, no writable bind on attacker-creatable dirs, no EXDEV side effect, and the exclusion/re-application machinery around pins goes away — details in #514), and a small bookkeeping trim. Happy to fold it back into this branch if you'd rather keep #485 as the vehicle.

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