Skip to content

linux: skip write-deny binds already covered by a read-only denied directory - #502

Open
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-covered-write-deny-binds
Open

linux: skip write-deny binds already covered by a read-only denied directory#502
ronleizrowice-ant wants to merge 5 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-covered-write-deny-binds

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Split out of #499 at review request; independent of the glob work there.

On Linux, a denyWrite path 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/null stub 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 a coveringDirIsUnsafe veto 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 in sandbox-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 when allowOnly and denyWithinAllow both name it. The three coveringDirIsUnsafe vetoes 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 use isAtOrUnder, 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 — which main did 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 on main.

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.txt beside proj) 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 by readonly-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 on main's string-prefix comparison). isAtOrUnder gets a platform-independent unit test in symlink-boundary.test.ts. The wrap() helper takes (denyPaths, readDenyPaths, allowPaths, command) in the same order as its sibling readonly-deny-dir-stubs.test.ts.

Blast radius: fewer --ro-bind mounts 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 both allowOnly and denyWithinAllow: read-deny tmpfs and masks are re-applied after the root bind, and that re-application re-binds the allowOnly paths 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 other allowOnly entry, 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 on main in the same shape. Not behind a flag.

Verified with tsc, eslint, prettier, and bun test on 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 matches main; the Linux-gated suites run on CI.

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

1 participant