M5.5
Sandboxing execution, and every escape surface
1,388 words · about 9 min
Concept
A sandbox enforces policy below the confined code, so that code cannot revoke it. This is categorically stronger than an M5.4 fence: an in-process fence is bypassed by any path not routing through it, whereas a kernel boundary applies to every syscall from every descendant, including processes the harness never knew about.
The central discipline is fail closed. packages/sandbox/sandbox-local/README.md states the invariant: "Unsupported platforms and unusable runners fail closed with SANDBOX_UNAVAILABLE; execution never silently falls through unconfined." A sandbox that degrades to unconfined execution is worse than none, because everything above it was designed assuming it holds.
Mechanism: four primitives, four escape surfaces
MechanismLandlock (Linux LSM, path-based, unprivileged). An unprivileged process defines an allow-list ruleset and applies it to itself; the restriction inherits across execve and to all descendants, and cannot be dropped. Needing no root and no daemon makes it the cleanest primitive available.
Escape surface: Landlock governs filesystem access classes only. Network sockets, ptrace against unconfined processes, IPC, and the abstract Unix socket namespace fall outside it (later ABIs add network and scoping, but only those the running kernel exposes). Older ABIs confine fewer classes — meaning a kernel can accept your ruleset and enforce less than you asked, which is why partial enforcement must be a reported fact rather than a boolean.
Bubblewrap (namespaces). Mount, PID, and user namespaces build a different filesystem view rather than filtering access to the real one. dsh's profile combines a read-only host root, fresh /dev, and private-PID /proc, with workspace-write adding ephemeral /tmp and a writable workspace bind.
Escape surface: namespaces share the kernel, so a kernel LPE escapes. The subtler one: the private-PID /proc exists specifically because /proc exposes magic links — the README says hiding host /proc/<pid> entries "prevents magic links such as root and fd from bypassing its mounts." /proc/<pid>/root is a path to another process's filesystem root, reaching around your mount namespace entirely. Any sandbox that builds a filesystem view and leaves host /proc visible has a trivially reachable bypass.
macOS Seatbelt (sandbox-exec). A policy language interpreted by a kernel extension; dsh's profile is allow-default with (deny file-write*) plus write allow-lists.
Escape surfaces: three. (1) It is allow-default with targeted denies — the inverse of Landlock's posture — so anything the profile forgot to deny is permitted, making the author's completeness the security boundary. (2) It matches resolved paths, so an allow-list entry must be canonicalized or it simply does not match (/tmp IS /private/tmp). (3) The CLI is deprecated: "Apple marks the sandbox-exec CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes." Building on a deprecated interface is sometimes the only option; the mitigation is detecting its absence rather than assuming its presence.
Windows ACL restricted tokens. Escape surface: dsh reports enforcement: 'partial' and says why — "the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths." Two structural holes, correctly reported as partial "rather than overstating that boundary as full."
Containers. Strong namespace isolation with an ergonomic image story. Escape surface: the kernel is shared, so a kernel LPE is a full escape. Misconfiguration is the commoner failure: a mounted Docker socket is root on the host, --privileged is not a sandbox, and a bind-mounted source directory is a hole exactly its own size. Containers are an ops boundary people mistake for a security boundary.
Remote sandboxes. Execution on a separate, network-isolated, discardable machine — the strongest boundary here, since escape must cross a real network. Escape surface: the boundary moves to the control channel and data sync path, which typically carries credentials. Latency pushes designers toward batching and long-lived sessions, and a long-lived remote session accumulates state exactly like a local one. Network isolation also breaks package installs, pressuring teams to open egress — and open egress reopens M5.1's exfiltration channel.
Worked example — `native/landlock-run`
Worked exampleRoughly 300 lines of C11 over the raw kernel UAPI, statically linked against musl. Nearly every decision is a security decision.
Self-restrict, then exec. The launcher applies the ruleset to itself, then execves the target. The ruleset inherits, so "the command and every process it spawns run confined while the invoking process stays unrestricted" — whole-tree confinement by construction, not by remembering to wrap each child. It also sets no_new_privs, required by Landlock and independently blocking setuid escalation.
A deliberately tiny grammar. landlock-run [--ro <path>]... [--rw <path>]... -- <argv>..., plus --probe. "No other flags, no environment-variable inputs" — that second clause is the security property, since an environment variable is an input the confined content might influence, and "which binary confines a process is never decidable by the ambient environment."
Allow-list semantics. "Everything not granted is denied." Compare Seatbelt's allow-default posture: forgetting a rule here fails closed, forgetting one there fails open.
Functional probing over version checks. The probe attempts real enforcement, reporting landlock: fully enforced or landlock: partially enforced (older ABI), because a version check "would miss a kernel that has the syscalls but refuses enforcement" — containers and hardened kernels do exactly this. A missing binary probes unusable identically to an unenforcing kernel, so "Consumers get one degradation path, not two." One path gets tested; two means one is untested.
The exit-status subtlety. Exit 125 is LAUNCHER_FAILURE_EXIT, but "after a successful exec, every child status is passed through unchanged, including 125." A child legitimately exiting 125 is indistinguishable from launcher failure by status alone, so consumers require status 125 and a landlock-run: fatal stderr line. The general rule: exit status alone never proves runner failure, which dsh encodes as structured RunnerFailureRule data rather than leaving consumers to guess.
Tradeoffs
Confinement breaks things — builds reach outside the workspace, test suites want a global cache, toolchains write to ~ — and every widening is real risk. Portability is the other cost: dsh maintains four backends, and the union of their guarantees is the weakest member, which is why completeness is reported per-run rather than assumed.
One honest weakness: denials are inferred from stderr signatures (EROFS under bwrap, EACCES under Landlock, EPERM under Seatbelt), and the README admits both error directions — "a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed." A cross-backend union of signatures would worsen this, which is why signatures stay per-backend. Contrast the fs fence, which reports FS_SANDBOX_DENIED structurally because it knows exactly what it refused. Whenever you infer a security-relevant fact from unstructured text, you accept both false positives and false negatives.
Note also runnerCommand: a custom runner "skips functional probes and is assumed to implement the bwrap-compatible profile honestly," and if it is itself a Bash script, "its interpreter startup runs before that script applies confinement" — an escape hatch documented as an operator assertion. Runner selection is cached for the provider lifetime, so repairing a runner requires a reload.
How `dsh` does it
How dsh does itSandboxMode is 'read-only' | 'workspace-write' | 'danger-full-access', claiming file effects only — "Network and process visibility are outside this vocabulary." That restraint is a feature: a mode named for what it actually enforces cannot be misread as a general security boundary. Selection is Linux bwrap → Landlock, macOS Seatbelt, Windows ACL. confine() throws SANDBOX_UNAVAILABLE rather than passing through, because "Silent unconfined passthrough is never legal." Policy precedence is approved-mode > session event > deployment default, and the root is canonicalized with filesystem semantics before lexical normalization "so a cwd containing symlink/.. identifies the directory where a spawned process actually runs" — M5.4's discipline applied to the sandbox root. danger-full-access bypasses ctx.sandbox entirely; it "is an explicit unconfined mode, not a wider sandbox profile," which stops it being mistaken for permissive-but-still-confining.
How others differ
Codex CLI defaults to Seatbelt on macOS and Landlock on Linux with network disabled by default, closing the exfiltration channel dsh leaves open — at the cost of breaking dependency installation, which its escalation flow must then handle. Claude Code historically enforced its boundary primarily in-process, layering OS sandboxing on bash, so boundary strength varies by tool. Devin-class hosted agents put every session in a remote VM: the strongest boundary here, moving the threat model onto the control plane and sync path.
Check yourself
4 questions on M5.5