Skip to content

Linux: allowWrite nested inside an allowRead carve-out under denyRead is silently read-only (mount-order bug in pushReadDenyDirMounts) #446

Description

@simple10

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

// settings.json
{
  "filesystem": {
    "allowRead": ["/home/user/project"],
    "denyRead":  ["/home/user"],
    "allowWrite": ["/home/user/project/state"],
    "denyWrite": []
  },
  "network": { "allowedDomains": [], "deniedDomains": [] }
}
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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions