Map: core / capabilities & sandbox
The grant mechanism in two halves: an in-process decision layer
and an OS process sandbox that enforces it for external commands — each
authoritative exactly where the other is blind ([[design/two-enforcers|two
enforcers]]). Authority is attenuated by intersection — a grant block can only
narrow.
Decision layer — core/src/capability/
Every runtime yes/no over the dynamic capability stack is a free
capability::check_*(&Context, …) function that folds the whole stack
(ctx.grants): admits_head, check_exec_args, check_fs_op, the
editor/shell bool gates, and the OS-renderable sandbox_projection. The
capability module is the only place authority is decided — a module boundary
rather than a typestate
(witness-collapse). Why Capabilities,
the live judgment, and SandboxProjection are distinct and not one is argued in
capability-carriers.
Submodules:
enforce.rs— the point-of-use gates: head admission, the audit-bearing exec/fs checks (check_exec_args,check_fs_op), and the editor/shell bool gates. The fs gate is split so the judgment is reusable without the report:fs_verdictis the pure decision, andcheck_fs_opis the layer that audits it and mints theBreak, and the one layer that excuses the discard device (ResolvedPath::is_discard) before either region is consulted;sandbox.rs— the OS-renderablesandbox_projectionbuilder;deputy.rs—deputy_prefixes, the confused-deputy report: the prefixes a grant makes bothexec-admitted andfs-writable, judged withpath::coverson a foldedCapabilities(neither layer of a meet is guilty alone). What it locates is where a write becomes runnable, not an escalation: within one projection the dropped binary is spawned under the confinement that wrote it, and only a runner outside the projection turns the shape into an escape — so it reports and never denies. Findings surface at grant push and at an exarch profile load (grant);exec.rs— per-layer exec verdicts; the admitted arm carriesAdmit(Any/Subcommands), so aDenycannot reach an allowed verdict; the literal comparison is case- and PATHEXT-insensitive under Windows path semantics, so a baregitgrant admits a resolvedGIT.EXE; every fold-equal key is met before the verdict, so agitdeny still vetoes an exactGITallow;decode.rs—decode_capability_map, which walks agrant [...]/--capabilitiesValuemap into a frozenCapabilities, one dimension decoder perexec/fs/net/detach/editor/shell/auditkey; its exec-map freeze expands the two exec-only sigilspath:(every$PATHcomponent) andsystem:(the platform’s tool roots,sigil::system_tool_roots), and drops bundled-tool grants for coreutils a host does not ship (COREUTILS_UNIX_ONLY_TOOLS);load.rs—load_capabilities_from_path/_from_strfor.ralcapability profiles.
The capability types live in capability: the
single always-frozen Capabilities, resolved at decode by the freeze pass
inside decode_capability_map (freeze boundary);
plus FsPolicy, GrantStack,
Meet, Join, and the exec authority
ExecMap { literals, allow_dirs, deny_dirs } — literals keyed by name/path
under the three-valued ExecPolicy, the two directory sets stored already
partitioned by verdict as BTreeSet<NormalizedPrefix>, so a meet folds the
partition it will use rather than re-deriving it, and a deny survives the
spelling and the depth it is judged on
(exec-authority-partitioned).
Path resolution for grant matching is core/src/path/: a fixed staged rule,
plus which.rs for PATH search.
- expand —
sigil.rs(the five path-prefix sigils:~,xdg:,cwd:,tempdir:,gitdir:— the last three policy-only, expanded at freeze;git.rsbacksgitdir:discovery, following a.gitpointer file only to a git directory whosegitdirback-pointer orcore.worktreenames the working tree back),tilde.rs(~userresolves honestly per platform: off-Unixget_user_homedeclines rather than fabricating a home, and each call site picks its own fallback); - lex —
lex.rs; - canonicalise —
canon.rs; - match —
lex::path_within, which foldsstarts_with_identityover the alias pairs; under Windows path semantics that comparison unifies case,/vs\, and\\?\-verbatim spellings, so the fs-grant, exec-dir, and prefix-set matchers all inherit one notion of path identity; - name a device —
lex::is_discard_device, behindResolvedPath::is_discard:/dev/null, or on Windows a last component namedNUL(the reserved name answers from any directory, in any case, behind an extension, and under\\.\). Like the identity rules above it takeswindowsas a parameter rather than readingcfg!, so neither table is dark on the other host.
resolver.rs composes the stages — Resolver::resolve is the sole
constructor of a ResolvedPath (resolved.rs, with its grant-side twin
NormalizedPrefix), so canonicalisation cannot run before
sigil-expansion-then-lex: the ordering is in the types, not convention.
(ral_path.rs in the same directory owns RAL_PATH module search, used by use
and the plugin loader, not by grant matching.)
which.rs is the one PATH walk. Its anchor is a SearchCwd, minted only
from a named provenance — Context::search_cwd, Resolver::search_cwd,
SearchCwd::of for a front end already holding the shell’s cwd, or
SearchCwd::nowhere — so no call site can choose “here” for itself
(one-walk-one-anchor).
path_dirsis the sole directory list behindlocate,commands_on_pathandsearch; it drops emptyPATHelements on every platform, so a trailing;or:never means the cwd..and./binare honoured, as written.searchreturnsPathSearch::{Executable, FoundNotExecutable, Missing}from one traversal — the executable half memoised, the presence half not — soruntime::command::vetreads a verdict rather than taking a second walk.- On Windows,
%PATHEXT%suffixes append:build.ps1yieldsbuild.ps1,build.ps1.EXE, …, neverbuild.exe. capability::sandbox::resolve_literalanchors its exec-key resolution throughResolver::search_cwd, so the OS profile and the in-ral gate name the same binary.
A NormalizedPrefix (resolved.rs) carries its surface form (lexical —
what the OS profile emits, since the sandbox matcher works lexically), its
resolved form (symlinks followed — what containment and intersection are
judged on), and its Namespace, all fixed by one disk consultation at the
freeze door. The duality is load-bearing, not redundant: enforce the ceiling
on the resolved form, emit the surface form the sandboxed body will actually
open.
prefix_set.rs therefore contributes only the set-level algebra, pure and
disk-free: covers is the one containment judgment, keyed on
(namespace, resolved) so prefixes in different namespaces never overlap and
a cross-namespace meet is the empty, fail-closed intersection; meet_prefixes
is the kernel PrefixSet::meet and every types::capability lattice meet
share. PrefixSet::resolve is the lone door here that still holds a
Resolver — the sandbox-projection fold, which must render a prefix that was
never frozen (a bare exec-dir string, a ~-headed fs prefix).
XDG base directories resolve through one resolver, basedir.rs
(XdgKind, resolve_xdg): an absolute $XDG_*_HOME override else the
home-joined Linux default on every platform, and None where there is neither
— a host fact answers with an Option, so each caller picks its own fallback
rather than inheriting a fabricated home (the Windows sandbox ledger takes the
temp dir, keeping itself out of the cwd). Both the xdg: grant sigil
(sigil.rs) and the binary’s own config/data loaders (config.rs) defer to
it, so a grant and the rc/history/plugin paths can never name different
directories — xdg-resolver-consolidation.
OS sandbox — core/src/sandbox/
External commands inside a grant block run under an OS sandbox enforcing the
declared filesystem and network capabilities. Exec is gated in-process on
every platform (capability::check_exec_args) before the spawn; on macOS the
Seatbelt profile additionally renders a process-exec allow-list, catching the
re-execs the in-process check never sees (sh -c, find -exec), while bwrap on
Linux and the AppContainer on Windows have no path-exec filter so there the
in-process gate stands alone (on Windows the deny-by-default fs projection
still bounds which images a child can read, and so load, at all).
Inside a guest — a VM whose engine runs under ral-daemon, signalled by
RAL_GUEST — the per-command OS backend is not engaged at all: every spawn
is already confined by the spawn jail (a fresh
unprivileged uid and a per-exec cgroup), the daemon disables the
unprivileged user namespaces bwrap needs, and the guest has no network
device for net to govern; the in-process gates apply unchanged
(docs/SPEC.md §12.11).
early_init(argv)— startup: consumes--sandbox-projection, pinsSANDBOX_SELF, on Unix enters the OS sandbox for a per-command--sandbox-projectionchild (maybe_enter_process_sandbox), and on Windows runs the boot-time orphan sweep (windows::session::boot_recover) that deletes a crashed prior session’s AppContainer profiles and restores the per-session ACEs of any legacy pre-capability ledger. A test binary is the same multicall executable a confined child re-execs, so it must serve these flags from its own pre-main#[ctor](it reachesmainonly through libtest);serve_sandbox_early_initis the sharedOption<u8>building block the pre-maindispatch uses for that — run bymainand every test#[ctor]alike, surfacing the re-exec child’s exit code so the caller can terminate, then serving the per-command re-exec tails (serve_sandbox_execfor a host external,try_run_bundled_toolfor a bundled tool). Skip it andSANDBOX_SELFstays unpinned, so the per-command launcher cannot pin the binary it re-execs.reexec.rs— pins an immutable handle on this executable at boot so a confined re-exec runs the same binary even under an on-disk swap. ThePinvariants say where a swap is even askable:Fdon Linux (the retained descriptor, so/proc/self/fd/Nresolves to the boot inode),Staton macOS (a(dev, ino)snapshot re-checked before each spawn), andUnguardedon Windows, which has no parent-side self re-exec for a guard to protect. On Unixmaybe_enter_process_sandboxenters the OS sandbox in a per-command--sandbox-projectionchild; on Windows there is no child re-entry at all — confinement is the AppContainer token the parent attaches atCreateProcessW, so a supplied--sandbox-projectionis rejected as an error (no legitimate caller emits it), and the pinned self serves to grant the container read on the bundled-tool re-exec image.verify_unswapped, the parent-side swap guard, iscfg(target_os = "macos"): only macOS re-execs the pinned self parent-side (Linux re-execs through the fd, where a swap is already moot; Windows has no parent-side self re-exec).projection_enforceable(sandbox.rs) — rejects an offline (net: false) projection on a backend with no kernel network enforcement, so an unenforceable request fails closed rather than running ignored.confinement_unavailable(sandbox.rs) — the one refusal for a confinement this host cannot establish, whetherprojection_enforceablesaw it coming or the envelope binary turned out to be missing at the spawn.make_command— wraps an external command in the active policy.launch.rs(sandboxed_command) — the per-command launcher.build_command(runtime/command/process.rs) routes an external or bundled child through here whenever a projection is active and the process is not already confined, confining that one child: aLaunchTarget::Hostexternal, or aLaunchTarget::BundledToolplaced asral --ral-bundled-tool <tool>. Linux wraps each child inbwrap(make_command_with_policy); macOS re-execs the pinned self (ral --sandbox-projection <json> --ral-sandbox-exec <host>, or--ral-bundled-tool <tool>) so the child enters Seatbelt inearly_init, thenserve_sandbox_execexecves the host target inside it; Windows builds the target’sLaunchdirectly andwindows::session::confineattaches its projection’s AppContainer LowBoxSECURITY_CAPABILITIES, so the parent’s own spawn is the confinement point — never a re-exec child. The--ral-sandbox-execsentinel andserve_sandbox_exec’s execve arm arecfg(target_os = "macos"), the only platform that emits the host re-exec tail. The launcher also takes anOwnership(Kept/Surrendered, the second variantcfg(unix)since only there does the verb that makes the distinction exist): it reaches the Linux backend alone, which is the one that builds an envelope process to tie the child to us, so adetached survivor keeps the birthing frame’s projection for life while dropping that tie (runtime). The grant body itself evaluates locally, external children being confined per-command (sandbox-external-children).- Backends:
macos.rs(Seatbelt,macos-base.sbpl),linux.rs(bwrap), andwindows.rs(Job Objects capping the child tree at 512 processes, plus the AppContainer backend in three submodules —appcontainer.rs, the profile lifecycle and LowBoxSECURITY_CAPABILITIESconstruction;dacl.rs, the path-derived capability-SID engine (fs_capability_name,ensure_fs_grant) over a durably persisted stamp store, per-path named mutexes, and boot-time orphan recovery;session.rs, the session state the two compose into). The module docs of those three files carry the full protocol; the shape is imitated from MXC’s Tier-3 processcontainer backend, breadcrumbed per unit.
The Windows backend is path-keyed: each (canonical path, kind) grant —
kind ∈ {rw, ro, deny} — derives a deterministic capability name
ral.fs.<kind>.<128-bit-truncated SHA-256 of the canonical path> — hashed as-is,
never case-folded, so a case-sensitive directory’s two distinct names cannot
merge into one authority — and thence a capability SID via
DeriveCapabilitySidsFromName (dacl::fs_capability_name).
dacl::ensure_fs_grant stamps that SID’s
inheritable ACE once and never reverts it. A read-write grant is two
permanent mutations, and neither witnesses the other: the mandatory-integrity
check runs before the AppContainer pass, and an unlabeled object defaults to
Medium, refusing every Low-IL child whatever the DACL says — so
ensure_low_integrity_label also stamps a Low SYSTEM_MANDATORY_LABEL_ACE,
asked before the ACE’s witness is consulted and witnessed under its own stamp
key and its own SACL probe. For each mutation two witnesses gate skipping it —
the grow-only stamp store (stamps.json, atomic tmp+rename, per-path
named-mutex merge) recording completed propagations, and a probe of the root’s
own DACL confirming the tree was not deleted and recreated. Recording follows
the apply, so a crash mid-propagation leaves no witness and the next grant
re-stamps idempotently while a child in the interim fails closed. A spawn’s
kernel-checked reach is then exactly the capability SIDs session::confine
mints into its token, so attenuation shrinks-only: a narrowed grant or subagent
gets a token lacking the wider paths’ capabilities. An ACE lives on the NTFS
object and Windows does not re-inherit on a same-volume rename, so stamped
authority is object-sticky where a grant rule is path-based — dacl.rs’s module
header records the resulting drift in both directions
(path-derived-capability-sids).
Within that shape: an AppContainer profile is still minted per distinct fs
projection (SessionSandbox maps bind_spec identity to profile) for the
deny-by-default token and named-object namespace separation, carrying no fs
authority, and DaclManager is the profile ledger — teardown deletes profiles
but restores no ACEs; a deny_paths entry is its own per-path deny capability
the token opts into, which canonical ACL ordering places ahead of any allow, so
projection-specific denies coexist on a shared path; the child’s program image
is granted read-only so a user-installed binary or the bundled-tool self image
can load at all; and net: false is enforced by withholding the network
capability SIDs — a LowBox token without them cannot open a socket, so
net_enforced() holds on Windows.
macos-base.sbpl is the policy-independent Seatbelt base every rendered macOS
profile inherits: deny-default, libSystem/dyld startup allowances, common device
writes, and the runtime support needed before the policy-derived fs/net/exec rules
can matter. Its broad file-ioctl compatibility allowance deliberately leaves
/dev/tty configurable, because sandboxed full-screen children need termios and
window-size ioctls when a run has an explicit terminal loan. This is a known
hole: the current SandboxProjection carries fs/net/exec, not terminal-loan
state, so the Seatbelt profile cannot yet deny /dev/tty ioctls for ordinary
Denied-terminal tool runs while admitting them for _ed-tui-style children.
The notification-center carve-out is named in the POSIX shared-memory namespace
(ipc-posix-name "apple.shm.notification_center"), matching Apple’s profiles.
net: false on macOS is enforced by absence: build_profile emits (allow network*) only under net: true, and the base admits network-outbound for
nothing. That silence is what closes DNS, because getaddrinfo reaches
mDNSResponder over the UNIX socket /private/var/run/mDNSResponder, which
Seatbelt gates as network-outbound rather than as mach-lookup — measured on
Darwin 25.5.0: denying mach-lookup outright still resolves names, and
admitting that socket alone resolves them with mach-lookup denied. So the
base’s unfiltered (allow mach-lookup), which dyld needs before main(), is
not a resolver door and scoping it would buy nothing here. The invariant worth
keeping is the narrower one: admit network-outbound for no local socket, or a
hostname becomes an egress channel — an attacker-chosen query label leaves via
the resolver daemon, which is outside the sandbox — while net: false still
reads as closed. mac_profile_denies_network_when_disabled asserts it over
every rule in the rendered profile, with a net: true positive control so the
denial cannot pass vacuously.
Path-scoped exec confinement is unenforced on Linux (no landlock backend) — linux-exec-confinement.
diag.rs (with per-platform readers in diag/macos.rs / diag/linux.rs) turns
a kernel-reported sandbox denial into an actionable hint on the
failing command’s Error: it reads the kernel log over the call’s wall window
(Seatbelt on macOS, the seccomp record inside bwrap on Linux), keeps only lines
attributable to the call’s descendant PIDs, and appends them. Only a file-*
denial yields a concrete path to grant — ipc/mach/network operands name a
service or endpoint, not a filesystem path, so they reproduce verbatim for
transparency but never fill the path-to-grant slot. macOS logs fully-resolved
paths, so the hint names the exact path with the symlink caveat; the Linux audit
record carries no path, so the hint degrades to “a sandboxed syscall was denied”.
Windows has no kernel denial log to scrape at all, so its arm gates on the exit
code alone: only an access-denied-shaped exit (ERROR_ACCESS_DENIED /
STATUS_ACCESS_DENIED) under an active sandbox yields the fixed, pathless hint —
never a fabricated path.
This boundary is what exarch reuses as its sandbox. Bundled
tools route through the exec chokepoint in-process; their filesystem
access has no in-process gate, so a bundled tool is never inlined — it is
spawned as a ral --ral-bundled-tool child and, under a restrictive grant,
floored by the OS profile of the per-command sandbox it runs in
(bundled-tools-as-exec-images,
bundled-tools-always-reexec).
That single binary carrying both ral and its coreutils is part of why ral
is a single-binary. docs/SPEC.md gives the
formal capability calculus.
Every fs/process constructor in this layer is a closed I/O door: the
workspace bans the raw constructors via clippy disallowed_methods, so each call
site carries an #[allow(… reason = "[io-door:…]")] classifying it as a surfaced
exec image (make_command), silent infrastructure (the self re-exec, the
ps denial sampler, the boot-time binary pin, the stamp-store and profile-ledger
lifecycle), or
test scaffolding. The door
shapes and their rail rendering live in io-surface; here
the doors are only declared and accounted, with core/tests/io_door_set.rs
failing CI on any unaccounted constructor.