Capability enforcement: one chokepoint, two enforcers
The grant design states the lattice — authority attenuated by
intersection. This is how a check actually runs: an in-process decision layer and
an OS sandbox that backs it for external commands, each authoritative exactly
where the other is blind (two enforcers;
core/src/capability/, core/src/sandbox/).
Every yes/no is a capability::check_*(&Context, …) that folds the whole
stack. Each decision (capability/enforce.rs, capability/sandbox.rs) is a free
function over a borrowed Context, and each meets the dynamic GrantStack
(ctx.grants) before answering, so a verdict reflects authority intersected
across the whole stack, not a single frame:
check_exec_args;check_fs_op(read / write);- the editor/shell bool gates;
sandbox_projection, the OS-renderableSandboxProjection.
The capability module is the only place authority is decided — a module
boundary, not a typestate
(witness-collapse). The fold composes the
layers by Meet:
- a dimension omitted from a grant inherits the ambient authority;
- a dimension present can only narrow;
- a deny is anti-monotonic — a later layer adds denies but never reopens a denied region (dynamic frames).
Exec is three-valued (Allow / Subcommands / Deny). The bundled coreutils and the structured primitives route through this same chokepoint (builtins), which closes the bypass and lets ral stay a single binary.
Path matching is a fixed four-stage rule (core/src/path/):
- expand sigils and
~; - lex;
- canonicalise (resolving symlinks);
- match by prefix (
path_within).
Canonicalising before matching is why a directory scoped by a grant cannot be
escaped through a symlink or ...
The in-process gate covers what ral dispatches; the OS sandbox covers what a spawned process does on its own.
- Exec — gated in-process on every platform:
check_exec_argsvets the arguments before the spawn. On macOS the Seatbelt profile additionally renders aprocess-execallow-list, catching re-execs the in-process check never sees (sh -c,find -exec); bwrap on Linux and the AppContainer on Windows have no path-exec filter, so there the in-process gate stands alone (linux-exec-confinement). That allow-list derives its admits from the sameevaluate_execverdict, per nameable command, so it never denies a command the in-process gate admits nor admits one it denies — a CI-enforced conservatism invariant (exec-projection-defers-to-gate). - Filesystem — gated in-process too (
check_fs_op, read and write), and backed by an OS sandbox that confines a spawned child’s own reads and writes: Seatbelt on macOS, bwrap on Linux, an AppContainer LowBox token on Windows. - Network — no in-process gate at all, since ral dispatches no network operation itself, so the OS sandbox is the sole enforcer; on Windows the enforcement is the withheld network capability SIDs — a LowBox token without them cannot open a socket.
The sandbox is applied per external command, not by re-execing the grant
body. A grant is a local dynamic effect scope: its body evaluates in
process, and transport::dispatch just runs that body locally — nested grants
compose by intersecting authority on the evaluator’s GrantStack, which is not a
process boundary (grant). Confinement happens one level down, at
external dispatch. When build_command (runtime/command/process.rs) spawns an
admitted external or bundled child under a restrictive projection, it routes
through sandboxed_command (sandbox/launch.rs), which confines that one
child:
- Linux wraps each child in
bwrapviamake_command_with_policy, threading the logical cwd in as--chdir; - macOS re-execs a tiny launcher —
ral --sandbox-projection <json> --ral-sandbox-exec <host>for a host external, or--ral-bundled-tool <tool>for a bundled tool — that enters Seatbelt inearly_init(maybe_enter_process_sandbox) and then runs the one target inside it; - Windows attaches the projection’s AppContainer LowBox
SECURITY_CAPABILITIESto the child’s ownCreateProcessW(windows::session::confine), so the parent’s spawn is the confinement point — no re-exec child.
On Windows filesystem authority is projection-keyed: the session mints one AppContainer SID per distinct fs projection, the ACEs behind a SID are exactly that projection’s paths, and the kernel-level check therefore enforces the same projection the in-process gate judges — a narrowed grant or a subagent’s narrowed permissions hold at the OS layer, because the wider paths were never granted to the narrower SID. Stamps persist until session teardown rather than frame exit (a detached worker keeps the authority it was born with; a SID with no live child is inert) (projection-keyed-appcontainer).
The launcher pins the current binary (SANDBOX_SELF, fixed at early_init) so
an on-disk swap cannot subvert it. Because confinement is per-command, the gate
fires only when a child is actually spawned: a grant [net: false] { … } with no
external child does not fail closed, and an offline request on a backend without
kernel network enforcement fails closed at the spawn (projection_enforceable).
The pipeline-stage helper re-exec is unchanged and unrelated: a process-staged
ral stage still runs through run_child_eval over one request/response frame
(pipeline execution;
child-eval-unification). That is a
real process boundary, not a lexical grant body pretending to be one.
The hard rule for any such synchronous child wait: the host must own an
out-of-band cancellation path. A parent blocked in a request/response frame
cannot observe its own foreground CancelScope by cooperative polling — the poll
never runs while the read is parked. Deadline and Esc therefore cannot break a
wedged frame unless the parent has a side channel that signals the confined child
subtree from outside the wait. Extra signal authority inside the child is not a
substitute: it lets a child signal its own descendants, but it does nothing to
free a parent stuck on the IPC edge. This is why the surviving run_child_eval
consumers keep teardown on the parent side rather than trusting the child to
notice cancellation.
A bundled coreutil’s filesystem access has no in-process gate, so under a
restrictive grant it is never inlined: it is spawned as a ral --ral-bundled-tool <tool> child that receives the same per-command sandbox as any external, which
is what floors it
(bundled-tools-as-exec-images;
sandbox-external-children).
This is the boundary exarch reuses unchanged — an agent turn is a host-pushed grant frame over this same stack.
See also grant,
capability-carriers (why the rule, the live
judgment, and the SandboxProjection are distinct, not one); map
capabilities. docs/SPEC.md §11.