Summary
On Linux, an allowWrite path nested inside an allowRead carve-out that is itself inside a denyRead directory is silently read-only in the sandbox. The write path's --bind is emitted, then the carve-out's --ro-bind is emitted after it and shadows it — bwrap stacks later mounts over earlier ones.
This is the nested sibling of #171/#190: those fixed allowWrite equal to the read carve-out (the skip clause in pushReadDenyDirMounts handles a read allow covered by a write path). The uncovered case is a write path strictly deeper than a read carve-out.
macOS is unaffected: Seatbelt evaluates path rules per access, so the same config composes correctly there. The asymmetry means a profile can be developed and verified on macOS and silently lose its write grants on Linux.
Repro
mkdir -p /home/user/project/state
srt --debug -s settings.json -- touch /home/user/project/state/probe
# touch: cannot touch '/home/user/project/state/probe': Read-only file system
The debug output shows the ordering:
[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: /home/user/project/state
[Sandbox Linux] Re-allowed read access within denied region: /home/user/project
The rw-bind for state goes down first; the ro-bind for project lands on top of it.
Cause
pushReadDenyDirMounts (src/sandbox/linux-sandbox-utils.ts) emits, per read-denied directory: the tmpfs, then the restored write binds, then the read carve-out ro-binds. For a write path nested under a read carve-out, the carve-out's later --ro-bind covers the earlier --bind.
The read loop's existing skip clause only handles the inverse nesting (read allow at-or-under a write path).
Fix
Swap the two loops: emit the read carve-out ro-binds first, then the restored write binds, so deeper rw mounts stack on top of their ancestor's ro-bind. The skip clause is order-independent (it tests the allowedWritePaths array, not emission state), so it needs no change. PR attached.
Verification (arm64 Ubuntu, orbstack, 0.0.70)
| tree location |
code |
result |
under $HOME (inside denyRead) |
unpatched |
EROFS on the allowWrite path |
outside $HOME (no deny ancestor, same profile semantics) |
unpatched |
writes work — confirming the re-bind path is the only broken spot |
under $HOME |
loops swapped |
writes work; sibling denyRead carve-outs and denyWrite ro-binds still hold |
Found running scheduled agent sessions under a fail-closed profile (deny $HOME wholesale, ro-allow one project tree back, rw-allow named state dirs inside it); happy to provide more detail.
Summary
On Linux, an
allowWritepath nested inside anallowReadcarve-out that is itself inside adenyReaddirectory is silently read-only in the sandbox. The write path's--bindis emitted, then the carve-out's--ro-bindis emitted after it and shadows it — bwrap stacks later mounts over earlier ones.This is the nested sibling of #171/#190: those fixed
allowWriteequal to the read carve-out (the skip clause inpushReadDenyDirMountshandles a read allow covered by a write path). The uncovered case is a write path strictly deeper than a read carve-out.macOS is unaffected: Seatbelt evaluates path rules per access, so the same config composes correctly there. The asymmetry means a profile can be developed and verified on macOS and silently lose its write grants on Linux.
Repro
mkdir -p /home/user/project/state srt --debug -s settings.json -- touch /home/user/project/state/probe # touch: cannot touch '/home/user/project/state/probe': Read-only file systemThe debug output shows the ordering:
The rw-bind for
stategoes down first; the ro-bind forprojectlands on top of it.Cause
pushReadDenyDirMounts(src/sandbox/linux-sandbox-utils.ts) emits, per read-denied directory: the tmpfs, then the restored write binds, then the read carve-out ro-binds. For a write path nested under a read carve-out, the carve-out's later--ro-bindcovers the earlier--bind.The read loop's existing skip clause only handles the inverse nesting (read allow at-or-under a write path).
Fix
Swap the two loops: emit the read carve-out ro-binds first, then the restored write binds, so deeper rw mounts stack on top of their ancestor's ro-bind. The skip clause is order-independent (it tests the
allowedWritePathsarray, not emission state), so it needs no change. PR attached.Verification (arm64 Ubuntu, orbstack, 0.0.70)
$HOME(inside denyRead)EROFSon the allowWrite path$HOME(no deny ancestor, same profile semantics)$HOMEdenyReadcarve-outs anddenyWritero-binds still holdFound running scheduled agent sessions under a fail-closed profile (deny
$HOMEwholesale, ro-allow one project tree back, rw-allow named state dirs inside it); happy to provide more detail.