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 ...
A command head is judged by three identities, and the two directions of the
gate read different numbers of them. A head carries the surface spelling, the
PATH-walked form, and the file both canonicalise to
(runtime/command/identity.rs). Admission reads the spellings alone
(policy_names), so a planted /tmp/evil/rg cannot inherit an outer grant’s
bare rg: allow. Every veto reads all three plus their basenames
(deny_names_from), so a bare bash: deny stops an absolute /bin/bash and a
symlink to it alike — and longest_dir_match takes the same asymmetry down to
directories, matching allow_dirs against the narrow set and deny_dirs
against the broad one. The rule is that widening a veto must never widen
admission; both directions are pinned by mirrored tests.
The limit is worth knowing rather than discovering: a name veto is not a
containment boundary. A copy of a denied binary under another name is a
different file, carrying no trace of the name refused, and an allow dir admits
it — on the gate and, since Seatbelt’s (deny process-exec (regex #"/bash$"))
also sees only the new name, in the macOS profile too. What holds there is the
projection, not the name: the copy is spawned under the same confinement as its
author, so it reaches nothing new. capability/deputy.rs reports the writable
exec-admitted prefixes that make such a copy runnable — only where a grant
restricts both dimensions, since an unrestricted fs is not “everything
writable” — but it reports rather than denies, and the overlap is not itself an
escalation (grant §Concessions,
a-head-has-three-identities).
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. Gate and profile read one fold (capability/fs.rs:allow_regionmeets a region across layers,deny_regionunions it), so here the conservatism invariant needs no differential test — the two cannot disagree. All that separates them is when the fold runs: afresh on every check for the gate, once at spawn for the profile, because that is when the profile is written. One target is excused before either region is consulted: the discard device —/dev/null, orNULon Windows — whichResolvedPath::is_discardnames on either host, and which needs no authority because nothing reaches the disk through it.GrantStack::admits_fsdeliberately does not share the exemption: it decides membership in a region, and a device excused from an access is not thereby a member of anything. The same predicate settles whether the act is a fact at all (audit). - 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.
A deny must survive the filesystem moving around it, not just name a path
to refuse. grant states the invariant: no confined child can
cause a denied path’s contents to become reachable under a name the deny does
not cover. Writing, unlinking, or hard-linking a deny_paths entry itself is
already blocked — the Seatbelt profile renders a subpath deny for each — but
an ancestor of that entry sits outside its subpath and inside the write
prefix’s own allow, so a confined mv or rm could relocate the ancestor
directory and carry the denied bytes to a name nothing covers.
SandboxBindSpec::pinned_dirs (core/src/types/capability.rs) closes the
gap: every proper ancestor of a deny_paths entry that lies within some write
prefix — the write prefix root included — is collected, over both a deny’s
surface spelling and its symlink-resolved target, so a symlink swapped in
after sandbox entry is covered too. build_profile
(core/src/sandbox/macos.rs) emits (deny file-write-unlink (literal "<dir>")) for each pinned directory, after the write prefix’s own covering
allow (Seatbelt is last-match-wins); literal, never subpath, is what keeps
a pinned directory’s entries mutable — only its own name-in-parent is
frozen. The price lands on macOS specifically: a grant that writes a repo and
denies .git/config also refuses mv .git .git.bak and rmdir .git, since
both .git and the repo root are pinned ancestors of the denied entry.
Linux renders no pin, and that is not an oversight. bwrap realizes a
deny as a mount laid over the denied path (DenyMask,
core/src/sandbox/linux.rs); a mount is anchored to the inode it covers, not
to the path string that named it at mount time, so renaming a non-mountpoint
ancestor carries the mask along and it keeps covering the real file at its new
location, while renaming or removing the mountpoint itself fails with EBUSY.
The invariant already holds by construction, so Linux pays a narrower price
than macOS: only the denied path’s own name is frozen, and every ancestor —
the write prefix root included — stays freely renameable and removable.
The shape of the denied path forces which mount, and the cost of confusing
them is the launch. --tmpfs mkdirs its own mountpoint, so over an existing
regular file it dies with ENOTDIR before bwrap execs anything — a deny that
denies nothing because nothing runs. Hence DenyMask::over as the only
constructor: an existing non-directory takes --ro-bind /dev/null, bound
without MS_DEV and so unopenable either way; a directory or an absent name
takes --perms 0000 --tmpfs, absent names included because a mask must occupy
the name before the body runs or a child creates the file itself. Nothing
mounts over a symlink, so a symlinked deny is masked at the resolved target
sandbox_projection carries beside the surface spelling.
An absent name under a read-only bind is the one deny that must not be
masked. Its mountpoint does not exist, bwrap mkdirs one, and on a read-only
bind that mkdir returns EROFS and kills the envelope — reasonable denies
xdg:config/gcloud beneath a readable xdg:config, so on any host that never
installed gcloud every external command under the grant died in sandbox setup
rather than running. The deny survives its own absence: creation is the only
access an absent name has, and the read-only bind already refuses it.
mountpoint_is_creatable decides by membership, not depth: every writable bind
is mounted after every read-only one and each is an identity bind, so a
writable bind governs its whole subtree however shallow it is beside a
read-only one — which is also what the capability model reads off a write
prefix, so backend and model agree. A write prefix nested inside a read prefix
(a project tree inside a readable home) therefore keeps the mask, and a name
under no bind at all falls on the new root’s own tmpfs, where creation
succeeds, so it keeps the mask too. Both masks refuse
with EACCES against macOS’s EPERM, so a cross-platform test should assert
the bytes are unreachable rather than an errno — and because the failure mode
is a sandbox that never launches, one test per backend must spawn the envelope
for real
(sandbox::linux::tests::a_denied_path_refuses_every_access_while_the_body_still_runs).
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 path-keyed, and the token selects. Each
(canonical path, kind) grant derives a deterministic capability SID from a
hash of the canonical path; its ACE is stamped once, ever, and never reverted,
and session::confine mints into the child’s token exactly the capability SIDs
its projection names. 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 narrower token does not
carry the wider paths’ capabilities. Persistence is safe because 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 the owning user’s own. A detached worker
therefore keeps the authority it was born with by construction. The residual is
that an ACE lives on the NTFS object while a grant rule names a path, and
Windows does not re-inherit on a same-volume rename: a file moved into a granted
tree stays dark, and one moved out of an rw tree keeps that tree’s capability, so
path-based rules and object-sticky stamps agree only while the tree is still
(path-derived-capability-sids).
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).
Confinement can also be unavailable for a reason no pre-flight can see. On Linux
the envelope is bwrap, a host package rather than part of ral, and a host
without it fails closed at the spawn with ENOENT. The subtlety is whose
ENOENT it is: vet has already resolved the target on PATH, and an envelope
execs its target itself, reporting a missing one as an exit status. So under an
active projection a spawn-time NotFound can only be the envelope’s, and
spawn_error says so — reading it as the target’s would accuse the one program
known to exist, and make a host lacking bubblewrap look like a grant that denies
everything. A launch therefore carries the envelope it execs
(Launch::confinement), set by the one backend whose envelope is a separate
binary, so blame is read off what the launcher did rather than re-derived from
the shell’s state. Both routes end in the same refusal,
sandbox::confinement_unavailable: nothing ran, and the sandbox is why.
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 run 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 §12.