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-renderable SandboxProjection.

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_args vets the arguments before the spawn. On macOS the Seatbelt profile additionally renders a process-exec allow-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 same evaluate_exec verdict, 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 bwrap via make_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 in early_init (maybe_enter_process_sandbox) and then runs the one target inside it;
  • Windows attaches the projection’s AppContainer LowBox SECURITY_CAPABILITIES to the child’s own CreateProcessW (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.