linux: skip write-deny binds already covered by a read-only denied directory - #502
Open
ronleizrowice-ant wants to merge 5 commits into
Open
Conversation
…ed directory When denyWithinAllow re-binds a directory read-only (--ro-bind <dir> <dir>), every EXISTING deny path strictly beneath it — typically the mandatory-deny files of a write-protected checkout (.git/hooks, .git/config, .mcp.json, ...) — is already unwritable there, yet each still got its own --ro-bind <p> <p>. The non-existent-path branch already skips its creation-blocking stub in exactly this situation; this is the existing-path twin. The skip uses the same evidence and the same vetoes: the covering directory must come from the order-independent pre-pass (so the caller's deny ordering does not matter) and pass coveringDirIsUnsafe (no allowed write path strictly beneath it, incomparable with every read-deny tmpfs), which also guarantees the covering bind survives the emission filter. Only STRICT descendants are skipped — a deny that equals an allowWrite root (cwd both allowed and denied) is the covering bind itself. A dest reached through a symlinked spelling keeps its bind, because the tmpfs/mask re-application passes key off emitted raw spellings that the covering directory's bind does not carry. Non-existent paths, the symlink checks and /dev handling are untouched.
The absent-path stub skip and the new existing-path bind skip each spelled their own "covered by a safe read-only deny directory" check. One predicate now serves both: strictly under a recorded directory (a deny equal to one is that covering bind and must be emitted), stopping at the first vetoed directory, allocating nothing. The stub skip tests the absent path itself rather than its deepest existing ancestor; the two agree wherever a recorded directory exists, root included. isAtOrUnder moves to sandbox-utils.ts so both call sites share one spelling of segment-aware containment. The binds test takes its wrap() arguments in the same order as the stubs test beside it.
This was referenced Aug 29, 2026
…t-aware A recorded '/' compared by string prefix was judged safe for every path: the existing-path skip dropped a project's own bind and the mask beneath it was never re-applied after --ro-bind / /. Containment now uses isAtOrUnder throughout, and a vetoed '/' neither covers a path nor disqualifies an inner recorded directory. Tests pin the root case, the re-application after a lone root bind, a string-prefix sibling, and the helper itself.
…cation over a same-directory bind Two cases the existing suite left to the other veto or to an implicit string-prefix exclusion, plus a comment that described main's root handling inaccurately.
Four sites spelled out "strictly beneath" as `x !== dir && isAtOrUnder(x, dir)`: veto (i), the covered-by-a-safe-read-only-deny walk, and the tmpfs and file-mask re-application passes. isStrictlyUnder in sandbox-utils says it once, next to isAtOrUnder. One comment paragraph re-flowed.
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.
Split out of #499 at review request; independent of the glob work there.
On Linux, a
denyWritepath that exists and lies strictly beneath a directory another deny already re-binds read-only (--ro-bind <dir> <dir>) got its own--ro-bind <p> <p>as well. Under a write-denied checkout that is one redundant bind mount per mandatory-deny file (.git/hooks,.git/config,.mcp.json, …), each a bind plus a read-only remount at sandbox start. The absent-path branch already skipped its/dev/nullstub in that situation, with the same evidence.Fix: one predicate,
coveredBySafeReadOnlyDenyDir, decides both skips. It tests the path strictly against the read-only deny directories the order-independent pre-pass recorded (strictly, because a deny equal to a recorded directory is the covering bind and must be emitted; an absent path never equals one), stops at the first directory acoveringDirIsUnsafeveto rejects, and allocates nothing. The absent-path stub skip now tests the absent path itself rather than its deepest existing ancestor; the two agree for every non-root recorded directory, and a recorded/is handled consistently (main's string-prefix filter matched it only for a path directly beneath it, and its vetoes never fired for it), as below. A deny reached through a symlinked spelling keeps its bind, since the tmpfs/mask re-application passes key off emitted raw spellings.isAtOrUnder(segment-aware, root-aware containment) is a new helper insandbox-utils.ts; #503 adds the identical hunk in the same slot and reuses it, so the two merge cleanly in either order (git would report no conflict with the specifier in different slots either, but the result would name the import twice).Root:
/is a recordable covering directory whenallowOnlyanddenyWithinAllowboth name it. The threecoveringDirIsUnsafevetoes and the two tmpfs/mask re-application tests compared by string prefix, and'/' + '/'is a prefix of nothing, so a recorded/was judged safe for every path: the new existing-path skip dropped a project's own bind as covered, the recursive--ro-bind / /emitted later then shadowed a file mask beneath it with no bind left to key its re-application off, and the read-denied file was readable (writes stayed fail-closed). All five comparisons now useisAtOrUnder, so/is vetoed whenever an allowed write path or an existing read-denied directory sits beneath it (a read-denied file is a mask, not a tmpfs, and counts for nothing there) and, when it is bound, every tmpfs and mask it shadows is re-applied — whichmaindid not do in that configuration either. A vetoed/neither covers a path nor disqualifies an inner recorded directory (everything lies beneath it; a veto that disqualified every skip would stub each absent mandatory-deny dotfile of a write-denied cwd after the cwd's own bind, the startup abort the stubs suite documents), so the cwd's own recorded bind decides, as onmain.Tests (
test/sandbox/readonly-deny-dir-binds.test.ts, Linux): the allow-root deny is bound once and the file beneath it not at all, with a runtime arm where bwrap can run (the file reads, an append through it fails, the host file is unchanged); order-independence; a file whose directory is not denied keeps its bind; a string-prefix sibling (proj2/x.txtbesideproj) keeps its bind; a chain of nested directory denies collapses to the outermost; vetoes (i) and (ii) (an allowed write path strictly beneath the covering directory, a denyRead tmpfs under it) keep the descendant bind — (iii) is shared with the stub path and pinned byreadonly-deny-dir-stubs.test.ts; a symlinked spelling keeps its bind; the root case above keeps the project bind and re-applies the mask after--ro-bind / /(red on this branch before the fix); a write-denied cwd under a vetoed/still skips its stubs; veto (i) alone catches/when there is no read policy; a directory in all three lists is not re-bound writable over its own read-only bind; and with/as the only deny bind, a read-deny mask and tmpfs beneath it are re-applied after it (red onmain's string-prefix comparison).isAtOrUndergets a platform-independent unit test insymlink-boundary.test.ts. Thewrap()helper takes(denyPaths, readDenyPaths, allowPaths, command)in the same order as its siblingreadonly-deny-dir-stubs.test.ts.Blast radius: fewer
--ro-bindmounts in the Linux profile where a covering read-only bind already made the path unwritable; no change to what is writable outside the root configuration. With/in bothallowOnlyanddenyWithinAllow: read-deny tmpfs and masks are re-applied after the root bind, and that re-application re-binds theallowOnlypaths beneath each tmpfs writable, as it already does under a non-root covering bind (main's trailing recursive--ro-bind / /left them read-only there); and when/is vetoed (any otherallowOnlyentry, or any existing read-denied directory, including the implicit/etc/ssh/ssh_config.d), an absent deny path directly under/— the mandatory dotfile denies when cwd is/— gets its creation stub after the root bind and bwrap refuses to start, the abort a non-root covering directory already produces onmainin the same shape. Not behind a flag.Verified with
tsc,eslint,prettier, andbun teston macOS, and the full suite in an Ubuntu 24.04 container as an unprivileged user (bubblewrap 0.9.0, as CI installs), where the branch matchesmain; the Linux-gated suites run on CI.