Launching a confined command
ral confines one external command at a time, at the moment it spawns it, and
nothing in that path can run a program unconfined. When build_launch
(runtime/command/process.rs) spawns an admitted external or bundled child
under a restrictive projection, it calls sandboxed_command
(sandbox/launch.rs), which confines that one child. Why it is per command is
capability-enforcement.
One confinement point per platform
- Linux wraps the child in bwrap (linux-envelope).
The trampoline
execves the host file by the real path the guard judged, as macOS does; a#!script therefore sees that path as$0. - macOS re-execs a small launcher,
ral --warrant. The warrant carries the Seatbelt profile, compiled in the parent, and the program to become: a host file, by the real path the guard judged and with the user’s spelling asargv[0], or a bundled tool.serve_warrantenters Seatbelt from the profile alone, thenexecves exactly that file. - Windows attaches the projection’s AppContainer LowBox
SECURITY_CAPABILITIESto the child’s ownCreateProcessW(windows::session::confine). The parent’s spawn is the confinement point; there is no re-exec.
The child is handed its confinement on a descriptor
Both Unix backends re-exec ral as ral --warrant, and that argv names
nothing. The confinement and the program arrive in a Warrant the parent
compiled, on fd 99.
- No other process can write the channel.
- On Linux it is a sealed memfd. A same-uid process may reopen any descriptor
through
/proc, and a sealed copy is as read-only as ours. The parent reads it back and checks it once sealed. - On macOS, which has no
/proc, it is the read end of a socketpair whose writer closes before the child exists.
- On Linux it is a sealed memfd. A same-uid process may reopen any descriptor
through
- The child refuses anything unexpected: an unsealed memfd, a non-socket, or a warrant not in canonical form. Decoding re-encodes and compares.
- On Linux the real argv is
bwrap --args 98 -- /proc/self/fd/102 --warrant.bwrap_argvis pure in descriptors and returns only bwrap’s options, which bwrap reads from--args. The trampoline is ral’s own pin, lent at fd 102. - Every descriptor a launch hands down has a slot defined once (
Slot): bwrap’s--args, the warrant,--info-fd, the Landlock ruleset, ral’s pin, the seccomp programs, and the mount handles, which come last because their number is open-ended. OneHandoffplaces them all. Once confined, the child closes everything from the first slot up, so the program inherits none. serve_warrantruns beforesandbox::boot. The trampoline pins and opens nothing before it is confined.- Exit codes are honest. A failure before the program starts exits 126, a
missing program 127. An
execverefusal takes its code from the sameSpawnFailurethe in-process spawn uses.
Failing closed is the whole stance
Warrant::confine is the only road to the program, and it runs only after the
confinement is entered and the handoff closed. The warrant’s Confinement is
the one thing the child enters: Seatbelt(profile) on macOS; on Linux
unconfined, landlock or landlock+refer, a Landlocked promise or none.
- On Linux the parent decides everything. It probes Landlock once, and
landlock::build:- refuses every launch if the probe failed;
- refuses a restricting exec grant where Landlock is absent;
- creates the ruleset with exactly the handled set the kernel accepts;
- admits each rule by a handle opened without following symlinks. A file admit that has become a directory is dropped, not widened to a hierarchy, and one now reached through a symlink refuses the launch.
- The child probes nothing. It takes the ruleset from its slot, adds
Referon its own root when promised (that root exists only inside), and enters it. A promised ruleset that never arrived, or a descriptor that is not one, refuses the launch. - A sandboxed launch needs ral’s own pin (
reexec::own, fixed atsandbox::boot, so a swap on disk cannot subvert it). Without the pin, the launch is refused.- On Linux bwrap execs that pin by descriptor. The trampoline is the boot inode whatever holds its name now, and nothing re-stats the name. A build swapped in mid-session runs under the parent that pinned the old one, consistently. A filesystem that cannot exec an unlinked file (virtiofs) fails the launch instead. The trampoline runs before the payload enters its policy: trusted construction, not an authored exec. No policy admits ral’s image on its own account; ral’s binary is an ordinary file the table admits or not (authority-is-belnap-evidence).
- macOS execs the pin by name, so it re-verifies it first (
Pinned::verify).
Windows keys authority by path, and the token selects
Each (canonical path, kind) grant derives a fixed capability SID from a hash
of the canonical path (dacl::fs_capability_name). Its ACE is stamped once,
ever, and never reverted (dacl::ensure_frozen_grant). session::confine mints
into the child’s token exactly the capability SIDs its projection names, from
the session’s SessionSandbox.
- The kernel enforces the projection the guard judges. A narrowed grant, or a subagent’s narrowed permissions, hold at the OS layer, because the narrower token lacks the wider paths’ capabilities.
- Stamps that stay are safe. A capability SID is evaluated only in the AppContainer pass of the access check, whose result intersects the normal user pass. An ACE no live token names is inert, and can never widen a process’s reach past its owning user’s. So a worker keeps the authority it was born with, by construction.
- A frozen allowance is stamped through a held handle, never by name. Its
real path was frozen at decode, and
canonicalisethenSetNamedSecurityInfoWwould follow a junction planted on the path or on any ancestor and leave a window between check and stamp. So the invariant is: resolution meets no redirecting reparse point anywhere on the path, and the grant lands on the very object checked. Components are held root first, each opened relative to its parent’s handle withFILE_OPEN_REPARSE_POINT, without delete sharing; the tag of a reparse point is read on the held handle, and only a name surrogate (symlink, junction, mount point) refuses the launch, naming both; a cloud-files placeholder passes, whichOBJ_DONT_REPARSEwould have refused (dacl/frozen.rs). - The check comes before every filter, on every launch. Three filters drop paths before anything is stamped: the projection’s memo of what it granted, existence, and the ambient-access probe. A path granted at an earlier launch and since replaced by a junction would pass all three and skip a check placed in the stamp alone, so every frozen allowance is checked first, warm cache or not. A read-write grant also sets a mandatory integrity label by name; it goes through the held handle too, so both operations reach the object checked.
- The projection key covers the whole
readlist, run places and live support included, not only its prefixes: capabilities accumulate per key and reach every later token minted under it, so two launches with the same prefixes and different places never share one. Every run place is stamped as a read prefix is, frozen. The guest-jail launch branch still bypasses per-command projection and installs no kernel exec policy. - An exclusion is never stamped. The AppContainer pass ignores a deny ACE
naming a capability, so nothing can take back what a grant’s ACE admits.
session::confinecarves an exclusion out of the allowances around it, granting their existing entries less the excluded ones and each directory on the way only its own listing by an ACE nothing inherits (sandbox::carve), and refuses one on or around an allowance, the program image among them, before anything is stamped; for every exclusion it then asksAuthzAccessCheck, with the child’s own package and capability SIDs, whether the token could open anything beneath it, and refuses the launch, naming the object, if so (dacl::ensure_unreachable; path-derived-capability-sids, amendment of 2026-10-20). - A bundled tool’s child is
ral.exeitself, loaded by the AppContainer, whose image keeps its read and execute grant: the spawn’s, by the parent, not an exec judged in a child. An fs exclusion overral.exe’s folder holds the image, soconfinerefuses the launch, as the guard already refuses every bundled tool in that case; Windows needs no exception. - Residual: an ACE lives on the NTFS object, but a grant names a path, and Windows does not re-inherit on a same-volume rename. A file moved into a granted tree stays dark; one moved out of a writable tree keeps that tree’s capability. Path rules and object stamps agree only while the tree stays still (path-derived-capability-sids).
When confinement is unavailable
A launch that cannot be confined is refused, and the refusal says the sandbox
is why (sandbox::Refusal::Unavailable).
- Some causes show only at spawn. On Linux the envelope is bwrap, a host
package, and a host without it fails at the spawn with
ENOENT. - The
ENOENTis the envelope’s, never the target’s.vethas already found the target onPATH, and an envelope execs its target itself, reporting a missing one as an exit status. So under an active projection a spawn-timeNotFoundcan only be the envelope’s, andspawn_errorsays so. Reading it as the target’s would accuse the one program known to exist, and make a host without bubblewrap look like a grant that denies everything. - The launch records which envelope it execs (an
Envelope, read back byLaunch::confinement). Only a backend whose envelope is a separate binary sets one; Windows has none. Blame is read off what the launcher did, not re-derived from the shell’s state. - An offline request on a backend that cannot enforce it fails closed at
the spawn (
projection_enforceable).
Related rules
- A bundled coreutil is never inlined under a restrictive grant. Its
filesystem access has no in-process guard, so it is spawned as a re-exec child
of ral (
--ral-bundled-tool <tool>, or a warrant naming the tool) and gets the same per-command sandbox as any external (bundled-tools-as-exec-images; sandbox-external-children). - A ral-written pipeline stage is not a launch. It runs on its own thread of the parent and shares the parent’s memory; it is never a re-exec’d child (pipeline execution; stages-are-threads).
- Any synchronous child wait a host still performs must have an out-of-band
cancellation path. A parent blocked reading a framed response cannot see its
own foreground
Scopeby polling, since the poll never runs while the read is parked. Deadline and Esc can break a wedged wait only if the parent has a side channel that signals the confined child’s subtree from outside. Giving the child more signal authority is no substitute: it lets the child signal its own descendants, but does nothing for a parent stuck on the IPC edge.