Capability enforcement: one model, two enforcers
A grant is data, and two enforcers read it: an in-process guard for what ral
does itself, and an OS sandbox for what a spawned program does on its own.
Each is the authority exactly where the other is blind
(two enforcers). The grant design
states the lattice; this page says how a check runs. The code lives in
core/src/capability/, core/src/guard/ and core/src/sandbox/.
The detail is split by mechanism:
- path-judgment — how ral judges a filesystem path, and the one walk every open goes through;
- exec-judgment — how ral judges which program may run, and the limit of a name veto;
- holding-a-deny — how a
denyholds against a child that moves, links or creates files, and the one case Linux cannot hold; - linux-envelope — the bwrap envelope: what it mounts, and how it copes with hosts that refuse it;
- confined-launch — how one external command is confined on each platform, and how it fails closed;
- seatbelt-profile — the macOS profile, rule by rule.
One model, read in two places
The capability module decides authority; guard and sandbox only read
it (capability-is-data). The
dependency order is capability < sandbox < guard. This is a module boundary,
not a typestate (witness-collapse).
Every in-process yes or no is a guard::check_*(&Context, …). Each check
in guard/enforce.rs is a free function over a borrowed Context. It folds
the whole dynamic GrantStack (ctx.grants) before it answers, so an answer
reflects every frame on the stack, not just the top one. The exec and fs checks
return a typed refusal (ExecDenial, FsRefusal), and Shell::refused (guard/shell.rs) records it on
the trail. The doors are:
check_exec, which returns theAdmittedtoken every launch needs. OnlyGrantStack::admitmints one.Shell::check_fs_read, a read by name: a predicate, a listing, a module load.Shell::locate, the door every open goes through. It judges the object it lands on withcheck_fs_exact.- the verbs of
editorandshell: oneFlag, checked by oneShell::check(Flag, who). - on the sandbox’s side,
SandboxProjection::of, the part of the policy the OS can render, folded from the same tables.
The fold keeps the layers apart
A judgment combines what each layer says about this access. It never
flattens the layers into one frame. A Capabilities is a layer, and the
GrantStack is their meet. Every judgment is evidence, a pair of Booleans
(positive, negative), and ↓ closes it to permit or refuse once, at the
boundary that enforces (authority-is-belnap-evidence).
The stack is an ordered elaboration of layers, then the pointwise meet: a
layer reads its own prefixes and the run places of the exec meet over itself
and the layers beneath it, so pushing a layer appends a conjunct and changes
none beneath it.
There is no Capabilities::meet, because an authored ExecGrant is not closed
under intersection: a bare key means whatever file the host PATH finds at
run time (object-not-name). So the fold
compiles first, then meets:
rulescompiles every layer into anExecRulestable for each question, and meets the tables.everycompiles toExecScope::Any.regionfolds every layer’s prefixes for one operation into aTable<FsScope>and meets those. Allowances stay as frozen at decode; only deny paths are re-frozen. A read takes each layer’s read and write prefixes (writing reads) and, throughGrantStack::read, its run places, then joinsfloor().
Both are a Table, whose meet is the pointwise law ⟦a ∧ b⟧ = ⟦a⟧ ∧ ⟦b⟧
(authority-tables).
access-control-algebra reads the design
against Bruns and Huth’s PBel.
Extension happens once, before the first push. Capabilities::extend takes
a Fragment, the loader’s result for exarch’s --extend file, in which an
omitted axis states nothing; in a policy an omitted axis is t. A Fragment
can never be pushed, so extension cannot follow restriction.
Three rules govern how layers compose:
- A dimension a layer omits restricts nothing.
- A dimension a layer states can only narrow.
- An exclusion beats every allowance. A later layer can add exclusions, but never reopens an excluded region (dynamic frames).
Activation is denotational. A layer restricts exactly where some axis does
not denote t (is_top): every with no exclusion, or an fs that writes the
root and excludes nothing, activates nothing. Launch hardening, the projection
and exarch’s credential layer all read GrantStack::is_restrictive.
Exec is judged over a target and never its arguments, in two parts: the table
(ExecDenial::Run) and what running reads (ExecDenial::Read). The bundled
coreutils and the structured primitives go through the same chokepoint
(builtins). That closes the bypass, and lets ral
stay a single binary.
Who enforces what
The guard covers what ral dispatches. The OS sandbox covers what a spawned process does on its own.
- Exec. The guard checks every platform.
check_execjudges the program and its argv before the spawn and returnsAdmitted, whichbuild_launchlaunches from. The kernel then holds the same rules against programs the child starts itself (sh -c,find -exec): Seatbelt on macOS, Landlock on Linux. Windows has no kernel exec filter (exec-judgment). - Filesystem. The guard checks ral’s own reads and writes
(
check_fs_exact). An OS sandbox confines the child’s: Seatbelt on macOS, bwrap on Linux, an AppContainer LowBox token on Windows. - Network. There is no guard, because ral performs no network operation itself. The OS sandbox is the only enforcer. On Windows it is the withheld network capability SIDs: a LowBox token without them cannot open a socket.
For the filesystem, guard and sandbox cannot disagree, by construction.
Both read one fold (capability/fs.rs). region meets each layer’s table
for the operation: the allows meet and the deny_paths join. A read then joins
floor(), the platform’s system paths, after the meet, so no read narrows the
floor and only a deny removes part of it. No backend carries a system set of
its own. So the conservatism invariant needs no differential test. The only
difference is when the fold runs:
- the guard folds afresh on every check, and asks
withheld, which cites an exclusion that applies or a missing allowance; - the sandbox folds once at spawn, when it writes the profile, and renders
the read region (
read: Vec<FsScope>, run places and live support among them), the write prefixes and the exclusions. Every allowance renders as its frozen real path and as nothing else; only exclusions are rendered from their re-resolved names. A backend refuses a launch whose frozen allowance has been redirected rather than follow it.
The projection never carries an allow beneath an exclusion (live()). Such
an allow is dead. It must never reach a backend whose primitive puts explicit
allows before inherited denies, as a Windows ACL does. A launch whose fs region
holds the universal prefix (every path) is refused on Windows before anything
is stamped.
Confinement is per command
The sandbox wraps each external command. It never re-runs the grant body.
A grant is a local dynamic effect scope: its body evaluates in process, and
transport::dispatch runs it locally. Nested grants compose by intersection on
the GrantStack, which is not a process boundary (grant).
Confinement happens one level down: when build_launch spawns an admitted
external or bundled child under a restrictive projection, it calls
sandboxed_command, which confines that one child
(confined-launch).
Two consequences follow:
- Each command is its own launch. Two commands in one grant body run in two envelopes, each rendered afresh from the filesystem as it then stands.
- Confinement engages only when a child is actually spawned. A
grant [net: `offline] { … }that runs no external command does not fail closed. An offline request on a backend that cannot enforce it fails closed at the spawn (projection_enforceable).
exarch reuses this boundary unchanged: an agent run is a host-pushed grant frame over the same stack.
See also capability-carriers (why the rule, the
live judgment and the SandboxProjection are three things, not one); map
capabilities; docs/SPEC.md §12.