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.
The model — core/src/capability/
Authority as data, at rank 3: Capabilities (one layer), GrantStack (their
dynamic composition) and the folds over it, with no reader of its own. The
in-process guard (core/src/guard/) and the OS sandbox
(core/src/sandbox/projection.rs) are its two consumers, in the order
capability < sandbox < guard
(capability-is-data). Every judgment
combines the layers’ evidence (authority-is-belnap-evidence); there is no Capabilities::meet
(object-not-name). Why Capabilities,
the live judgment, and SandboxProjection are distinct and not one is argued in
capability-carriers.
Submodules:
lattice.rs— the types and their algebra: the single always-frozenCapabilities, resolved byguard::freezeat decode (freeze boundary);FsPolicy,Net(Offline, the one opinion a layer holds on the network), the verb setsBTreeSet<EditorVerb>andBTreeSet<ShellVerb>(eachOption: absent inherits),GrantStack,Meet,Join,Axis(join_top,is_top),Evidence { positive, negative }andFragment, the extension file’s result, consumed only byCapabilities::extend(roles are types: a policy’s omitted axis ist, a fragment’s is⊥); and the authored exec grantExecGrant { every, rules }, both fields private, built only byDefault,FromIterator<(ExecKey, Evidence)>,Join,join_topand the wire decoder, every entry throughjoin_insert, read throughevery,iterandget—ExecKey::{Name, Path, Dir}, bare keys, frozen path keys and frozen dir keys;everyis a flag onlyjoin_topsets and has no negative form;Displayspells a key as a grant does (a dir as`dir). It rides the wire (as a pair sequence,{"every", "rules"}) and is never matched directly;ExecRulesis what judges.Flag(Editor(EditorVerb),Shell(ShellVerb)) andGrantStack::permits(Flag)are the one verb vocabulary: membership, a meet over the layers, silence permitting.is_restrictiveis the denotational activation predicate, each axis throughis_top: exec unlesseverywith no exclusion, fs unless it writes the root and excludes nothing,NetwhenOffline, a verb set unless it lists every verb;table.rs—Table<K: Scope>, authority as a table of scoped evidence (authority-tables):Scope(holds::<P>,own; no rank),supports,excludes,evidence,live,denies,withheld(aWithheld:MissingAllowance, orExcludedciting an exclusion that applies, chosen deterministically), andMeet/Join for Table, denies joining and allows meeting at their own subjects; the meet’s obligations and the generic law test are its property tests;fs.rs— the fs instance:FsOp,FsScope { Prefix(FrozenPath), Place(RealPath) }(a run place reads as a prefix), both regionsTable<FsScope>, andregion(stack, resolver, op, places), each supplying layer’s read and write prefixes (writing reads) and, forplaces, its run placesP≤L, with the floor joined; allowances stay as frozen and only deny paths are re-frozen against theResolver.GrantStack::readis the read judgment:region(…, false), thenregion(…, true)only on a missing allowance;exec.rs— the exec instance (exec-rules) and its judgment:Program(ToolorFile { path, real }) and theSubjectit is judged as (Tool,File,Under,Any);ExecScope(Any,Dir,File,Tool,Name),Namenegative only andAnypositive only by construction;ExecRules = Table<ExecScope>;ExecRules::compile, one layer’sExecGrantagainst this host, a bare key resolved on the hostPATH(live per run), a path key’s allowance at the real path frozen at decode and its exclusion at the real path its surface reaches now (FrozenPath::live_real);rules, the stack’s table, compiled per question;places, the run places (positiveFileandDirreal paths).GrantStack::admit(program, args, resolver)is the one exec judgment, the table then the read judgment and the only mint ofAdmitted(the program, its argv and the table that judged them, private fields, all a launch reads); a refusal is data,Refused { program, args, why: ExecDenial },ExecDenial::{Run(Withheld<ExecScope>), Read(Withheld<FsScope>)};admits(&Program)is head-only admission.ExecRules::for_kernel(support)is the table less bundled-tool scopes, joined with the live support asFileallows, what the kernel is handed;allowed_filesthe live file scopes the carriers are computed over. Vetoes key onpath::command_name_key;deputy.rs—deputy_prefixes, the confused-deputy report: the prefixes a grant makes bothexec-admitted andfs-writable, the live part ofR ∧ W, the exec region (each exec layer’sDirkeys with full evidence, the root forevery) met withfs::region(FsOp::Write), so each writable island under a runnable directory is named and a directory wholly excluded on either side is not. 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 are recorded only into an open audit trail, as a layer is pushed (ral --audit --capabilities <profile> -c true; grant).
The load module (rank 10), not this one, holds load/profile.rs —
load_capabilities_from_path / _from_str for .ral capability profiles,
compiled under grant’s own declared table (typecheck::contract,
Form::Grant): a profile’s dimensions are the form’s options, so a returned
row is held to them statically and the decoder below is where a profile
returning a map meets the same six keys.
The guard — core/src/guard/
The model’s in-process reader, at rank 7: every runtime yes/no over the dynamic
capability stack is a free guard::check_*(&Context, …) function that folds
the whole stack (ctx.grants) — a module boundary rather than a typestate
(witness-collapse) — and the one place a
grant string becomes a frozen path.
enforce.rs— the guards over the model’s judgments:check_exec(GrantStack::admitand the wording of its refusal),check_fs_exact(the objectShell::locatewalked to),check_flag,check_device_name, anddeny_head, the refusal of a head before its argv is known. The exec and fs checks returnResult<_, Denial>, aDenial { check, error }, overFsRefusal::{Guarded, Withheld}: the fact the trail keeps and the error the caller raises. The fs guard is split so the judgment is reusable without the report: the fs judgment is the decision —FsRefusal::Guardedfirst, for a write onto a boot-pinned sandbox binary (sandbox::pinned_binary, by inode), before any grant is folded — andcheck_fs_exactwords it.GrantStack::admits_fs_exactis the quiet twin a write asks before reading its before- and after-images;shell.rs— the guard’simpl Shell:check(Flag, who),check_exec,check_fs_read(a read by name, canonicalised leniently, excusing the discard deviceLexicalPath::is_discardand refusing a reserved device name before either region is consulted),locate,locate_existing,locate_if_admitted,sandbox_projection, and the grant scope guards (with_capabilities=with_layers(GrantStack::of(c), f),push_session_capabilities,audit_deputy_prefixes,has_active_capabilities).Shell::locateis the door every open takes: the same name refusal,path::walkto the object, thencheck_fs_exactonLocated::real.Shell::refusedmints aDenial’sBreakand records its check throughShell::record_checkwith noMooring, so on the trail alone;freeze.rs— the one place a grant’sPlaceorSelectorbecomes aFrozenPath, against one anchor:FreezeCtx(FreezeCtx::of(&Shell), an ownedEnvVarsandCwdPath: theResolverpair,FreezeCtx::atfor a caller with no shell) and itsplace(Noneis a dead grant, anabsolutepayload classified on the raw string bylive_absolutethroughlex::grant_eligibility(EligibleOn/GrantRefusal) overlex::parse_bytes: aHeadKind::DeviceorHeadKind::Malformedhead, a mixed-separator opening (/\x,\/x,GrantRefusal::MixedHead) included, is refused first, though Windows reads it as absolute; a\??\NT object-manager head (HeadKind::DrivelesswithNamespace::Object,GrantRefusal::NtObject), read as Win32 reads it, is refused under every tag with its own refusal; thenHeadKind::PosixisEligibleOn::Posix, a non-verbatim//srv/shshare isEligibleOn::Both, and aHeadKind::Driveor otherHeadKind::Unc, a verbatim root included, isEligibleOn::Windows, dead under POSIX) andselector;host_absolute, a path the host holds classified on its native text and frozen as anabsolutegrant, refused withGrantRefusal::ForeignOrRelativewhere a payload would be dead, so exarch’s deny layer fails closed; the tagsabsolute,cwd,home,tempdir,gitdirandxdg-*,PolicyError, thegitdirrefusals (From<BadPointer>),Selector(Path | Toolchain(Toolchain)) and thepathselector (path_dirs, over the purepath_dirs_in), andrefuse_the_world, the guard both it and the toolchain overrides call;FrozenPathfreezing refuses a real path that is not Unicode;toolchains.rs— every other directory selector as one table:enum Toolchain(systemand the package managers), each a fewLocations of overrides and defaults, from one exhaustivematch; the pureexpand_inover an injected environment, home andexists, answering nativePathBufs (shown only in a refusal), its live wrapperexpand(whatFreezeCtx::selectorcalls), andFLOOR, the four system-wide prefixes (homebrew,macports,nix,pkgsrc) thatfs::floorreads throughfloorand keeps only outside the process’s home (grant §Selectors states the rules);decode.rs—decode_capability_map, which walks agrant [...]/--capabilitiesValuemap into a frozenCapabilities, one dimension decoder perexec/fs/net/editor/shellkey, nested tables and verb lists read as sets — every one of them authority, none of them a recording switch (audit); its exec targets freeze toExecKey::{Name, Path, Dir}(adirselector expanding to oneDirper directory), collected throughExecGrant::from_iter;grant.rs—SpawnGrant(Inherit,Base,Restrict), a spawn’s grant as it crosses to a child that has not yet resolved it, frozen against the child’s ownFreezeCtx.
Path resolution for grant matching is core/src/path/: a fixed staged rule,
plus which.rs for PATH search.
- expand —
sigil.rs(run-time~andxdg:expansion of ordinary paths, never in a grant; the placescwd,tempdir,gitdirare policy-only, expanded at freeze byguard::freeze;git.rsbacksgitdirdiscovery, following a.gitpointer file only to a git directory whosegitdirback-pointer orcore.worktreenames the working tree back; a bad pointer is a path-levelBadPointer, phrased as a grant error by the caller),tilde.rs(~and~/subonly; an unsetHOMEisNone, and each caller picks its own honest answer;TildePath::shownis the one spelling~,homeand a~/…command head share, andTildePath::expandthe path such a head runs). The expansionexpand_path_prefixanswers is native, in the host’s own spelling, for the resolver to hand on to the OS.sigil::SigilHeadis the one reading of a path’s head —Tilde,Xdg, orUnknownXdg(anxdg:no kind answers to, passed through literally) — that expansion,literal_relativeand a grant’s refusal of a sigil all ask. Anxdg:sub-path naming a volume of its own under Windows rules — a drive component, or a share or namespace head opening it — would re-root the join, soexpand_path_prefixleaves the whole path as written: it fails closed, a prefix matching nothing rather than a fabricated path, while a grant refuses the gap outright inguard::freeze; - lex —
lex.rs: one structural parser,parse_bytes, reading a byte slice underPathRulesintoParsed { raw, rules, kind: HeadKind, spelling: HeadSpelling, head_len }, from which resolution,./..folding and every head judgment project.HeadKindnames the structural head (Posix,Drive { letter, volume },Unc { volume },Driveless,DriveRelative { letter },Relative,Devicefor\\.\, andMalformedfor a two-separator or\\?\head that is no well-formed drive, share or device);HeadSpellingrecords the opening separator and namespace facts once (namespace: Option<Namespace>—Verbatim { exact }for\\?\,Device { exact }for\\.\,Objectfor\??\, withexact/Namespace::is_exacttrue when all three separator bytes of that 4-byte prefix are\—mixed_openingfor/\or\/, andopens_with_slash).fold_dots_under(overfold_bytes) is one kernel under either rules from any host,fold_dotsits host instance on a held path:head_lenmarks where the head ends and only the names past it fold, joined with the rules’ separator, so a fold preservesHeadKind,volumeand (save when..climbs into the??component of a\??\driveless path)HeadSpelling::namespace; under Windows rules a non-namespaceDrive,Unc,Driveless,DriveRelativeorRelativehead (Parsed::respell_head) is respelled with\too (C:/a/bfolds toC:\a\b,//srv/shto\\srv\sh), so such a path has one separator throughout, while every head whose kind or meaning turns on its spelling is kept as written: aPosix/(as\it would beDriveless), a slash-spelled verbatim head (//?/C:/xis a device path Win32 normalises, so its tail pasthead_lenfolds), and aDeviceorMalformedpath, or an exact\\?\verbatim path (Namespace::is_exact,head_len == raw.len(): Win32 hands its rest to the kernel uninterpreted,/a name byte and./..names), all head and kept whole,..included; a relative path’s drive-shaped first name keeps a.\before it;is_unparsed_head, a Windows path absolute to the kernel in whichstd::pathfinds no prefix and so would descend or climb from the current drive, which the walk and the lenient canonicaliser’s ancestor climb refuse. Every head predicate projects fromparse_bytes: whether a path is absolute under either rules set (absolute_under/Parsed::is_absolute: POSIX,HeadKind::Posix; Windows,Drive,Unc,DeviceorMalformed) and on this host (is_absolute,absolute_underthe host’s rules, the one absoluteness ral asks,anchorincluded;Path::is_absoluteis banned in clippy.toml in its favour);is_working_directory(Posix, orDrive/Uncunder Windows);is_bare_root(one separator alone under the givenPathRules);has_drive(non-namespaceDriveorDriveRelative);Parsed::is_mixed_headandParsed::is_nt_object, shared bygrant_eligibilityandclassify_subpath(SubpathRefusal), whichguard::freezemaps to its messages;is_network(HeadKind::Unc),is_verbatim_drive(Namespace::Verbatim+HeadKind::Drive) andis_volume_guid(has_os_namespace_headwith aVolume{namespace_tail), which the Windows DACL backend asks rather than parsing prefixes itself;Parsed::has_os_namespace_head(Namespace::is_exact), whatshowleaves unfolded;Parsed::without_verbatim_prefix, what the Windows identity fold slices; andParsed::namespace_tail, what the discard device check reads underHeadKind::Device;utf8, a path’s native spelling as text orNotUtf8naming it, the one door for a path that must travel as a string (--capabilitiesprofiles,SourceFile); a final component’sextension, and whether a component is a Windows drive or stream (is_stream_or_drive);relative_suffixstrips a sub-path’s leading separators under the rules, andjoin_underjoins a sub-path onto a base string one component at a time with the separator of the given rules, on every host alike (GuestPath::joinis it under Posix rules), and refuses (Rerooting) a sub-path naming a volume of its own under Windows rules — absolute there, or a drive at any component;join_relativeis it under the host’s rules onto a host-held path, andjoin_slashis that for a/-separated constant subpath the program spells, panicking if one re-roots;identity.rs: name identity and containment,windows_collision_keynaming a kernel object by an absolute path undercollision_keyper component, coarser than NTFS’s own fold so a lock merges every spelling NTFS equates;device.rs: device names, andwin32_trimmed, a final component with the trailing dots and blanks Win32 drops, the one copy of that rule (reserved device names, batch images); - complete —
complete.rs, the path half of completing a typed word:PartialPathsplits it into the directory to list and the needle, says which entries are offered, and spells a chosen one back as ral shows a path (typed~kept,/-separated; expanded viaTildePath::shownonly when the spelling must be quoted); - slug —
slug.rs,project_slug: a launch cwd as one directory-name component (/Users/x/proj→-Users-x-proj), for keying per-project state; - canonicalise —
canon.rs, for a read by name; - locate —
walk.rs, for an open:walkdescends from the root through directory handles with no symlink followed by the kernel, splicing each link into the name and re-walking, and hands back aLocated— the directory handle, the leaf, and the symlink-freerealpath — whose every operation is handle-relative withFollowSymlinks::No(cap-primitivessupplies the*atcalls on Unix and Windows). A..is physical, the kernel’s rule, even from a link target (the user’s own path keeps its logical fold); names are spelled as the disk stores them on macOS (getattrlistat), so case-variant names meet their deny; the splice count is bounded byMAX_HOPS; every restart, the given name and each splice alike, is put to onerootedcheck, so a link target relative to a drive’s current directory or with an unparsed head is refused as the name itself would be;walk_vettedis the same walk with a per-object check, whichcarriers::trusted_realruns; - match —
identity::path_within, the form-blind containment kernel, which takes anIdentityand folds it over the alias pairs.Storedis bytes, or under Windowswindows_stored_components, preserving stored case and unifying/vs\, and\\?\-verbatim spellings;Collisioncompares components byidentity::collision_key, compatibility caseless matching of the uppercase image (ICU4X), the coarsest identity any filesystem gives a name. A rule’sPolarity(identity::Allow,identity::Deny) picksStoredorCollision(denies-hold-under-every-spelling); on Windowswalk::dealiasnames every leaf by its stored case and long name, using the parent’s stored name for an absent leaf, and refuses one it cannot name; - name a device —
device::is_discard_device, behindLexicalPath::is_discard:/dev/null, or on Windows exactly the device-namespace spelling\\.\NUL(either slash, any case), and no other path. Every other Windows path whose last component is a DOS reserved device name —NUL,CON,PRN,AUX,COM1–COM9,LPT1–LPT9,COM¹–COM³,LPT¹–LPT³, any case, any extension, trailing dots and blanks trimmed — is refused, not excused:device::reserved_device_refusalphrases the error (forNUL, as the question “did you mean\\.\NUL?”), andguard::check_device_nameasks it at the three fs doors,check_fs_read,Shell::locateandShell::locate_existing, ahead of any grant. A resolved path keeps its last component, so the check needs no raw spelling. Like the identity rules above both takewindowsas a parameter rather than readingcfg!, so neither table is dark on the other host.
resolver.rs composes the stages — Resolver::resolve and
Resolver::anchor are the sole constructors of a LexicalPath
(forms.rs, with its grant-side twin FrozenPath), so canonicalisation
cannot run before lexing: the ordering is in the types, not convention.
resolve takes surface text and expands sigils first; anchor takes a path
the host already holds, a walked entry say, and expands nothing, so a file
named ~ stays that file and a non-UTF-8 name is never renamed by a string
round trip.
A Resolver is an EnvVars and a CwdPath: ~ reads env.home(), xdg:
the XDG_*_HOME of env through get_or_host, so a within [env: …] overlay
reaches both. Resolver::at builds one for a caller with no shell to mint one; resolve_in_script is
resolve for a path written in a ral script, which reads it beside itself:
it is resolve itself with the script’s directory for the cwd, so the two
doors agree: sigils expand first, only a still-relative result joins the
script’s directory, and an absolute one, a malformed head included, is kept
as resolve keeps it, never lent the directory’s drive. The directory is
the native parent of the file’s SourceOrigin, never a re-parsed rendering
of it, anchored as anchor takes it, so a script under a directory named
~ stays there. A virtual source has no directory,
and its paths resolve against the cwd.
(which::ral_path_entries lists RAL_PATH; use searches it through the
shell’s resolver, and the plugin loader folds it into its own candidates.)
tree/walk.rs is the one directory-tree walk, Walk, and the one
ignore::WalkBuilder::build site; tree/glob.rs is glob’s pattern language,
enumerated by it. A Walk is told what it may enter (tracked: hidden and
ignored entries skipped; everything; a depth; a keeping predicate; link
following), polls the cancel scope at each entry, and skips every walk error
but an unfollowable link, which is still an entry. sightings yields entries
judged by no grant, for host-side walks (the sandbox’s linker discovery).
admitted judges each entry by its own path, anchored with Resolver::anchor
and admitted silently with admits_fs_exact, and names it in the shape its
root was written (Resolver::is_relative): a cwd-relative root gives
cwd-relative names where it can, any other absolute ones. A name reads back as
itself: one a sigil would claim is spelled ./~/x (sigil::literal_relative),
and one no ral string can hold is refused when asked for.
glob and exarch’s grep-files, grep-hidden, find-files and
explore-dir all walk through it.
which.rs is the one PATH walk. Its anchor is an Option<&CwdPath>: the
shell’s cwd cell, or None for a shell-less caller — and CwdPath has one
door, so no call site can choose “here” for itself
(one-walk-one-anchor).
names_a_pathis the one answer to “is this command name a path?”: it bears a separator under the host’sPathRules(PathRules::is_separator:/everywhere,\under Windows alone), so off Windowsa\bis a bare name forPATH.names_a_path_fortakes the rules, and the lexer’sWord::Path,ir::CommandName::of, the walk itself and the grant decoder’s`namecheck all ask it.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.locate_inis the same walk off Windows, unmemoised, over directories a caller already holds (path_dirs_absolutefor bwrap’s pin), so no list is re-joined into a string to be split again.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. ExecRules::compilewalks the process’s ownPATHwith no anchor, so neither a scopedPATHnor a relative entry can redirect a bare key.
A FrozenPath (forms.rs) carries its surface form (lexical — what
the author wrote, kept for display) and its real form (symlinks
followed), both fixed by one disk consultation at the freeze door; a guest
prefix is minted by from_guest from a GuestPath (namespace.rs) under the
POSIX fold, and refreeze
re-resolves an exclusion against a live Resolver (an allowance is never
re-frozen), the bare root minted rather than frozen.
Every authority is judged on `real`:
contains::<P> for fs, FrozenPath::real for the exec table,
— and no other door: identity::path_within and its string twin are pub(super),
so the form-blind kernel does not leave core/src/path/, surface_path is
private to forms.rs, and outside the module the surface leaves the type
only as a string (as_str, into_string) for rendering. That is enforced
rather than documented because the xdg-* freeze guard once chose the form for
itself — asking on the surface while the check it guarded matched the real
form — and read a symlink out of HOME as contained.
The set-level algebra over prefixes is not in path/: it is
capability::table, which names polarities and never identities, so every
spelling question stays here. forms.rs also holds RealPath, the exec subject and the identity of a run place,
ordered as the host identifies files; FrozenPath::real for an allowance,
FrozenPath::live_real for an exclusion.
XDG base directories resolve through one resolver, path/basedir.rs
(XdgKind, the pure resolve_xdg_in; host::xdg reads the host env, the xdg: sigil and the freeze an EnvVars; app_dir and app_dir_or_temp spell an application’s own $XDG_<kind>_HOME/<app>/ for ral and exarch alike): 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 (state that must land somewhere —
exarch’s directories, ral’s crash log, the Windows sandbox ledger — takes the
temp dir through app_dir_or_temp, keeping itself out of the cwd). Both the xdg-* grant places
(sigil.rs, guard::freeze) and the host’s own config/data loaders (host::xdg) 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 — and exec, wherever a
backend carries the allow-list into the kernel, so a grant whose only opinion
is exec engages the sandbox too (sandbox::EXEC_ENFORCED, the one statement
of that fact, read by SandboxProjection::of). Exec is
guarded in-process on every platform (guard::check_exec) before the
spawn; both Unix
backends additionally render the same rules into the kernel, catching the
re-execs the in-process guard never sees (sh -c, find -exec) — macOS as
Seatbelt process-exec forms, positives then negatives, Linux as a Landlock Execute
ruleset the payload enters inside the bwrap envelope over admits the parent
opened in the host (linux/landlock.rs;
Landlock being allow-list only, a deny inside an allowed directory is
subtracted from it, entry by entry over the tree at launch; Any lowered against
the bind plan, which is computed before the ruleset). carriers.rs computes the
live support the kernel must admit beside the table for a script to start,
under any confined launch whose fs or exec is restricted: system-vouched #!
interpreters and macOS shims, by trusted_real — regular files only, judged
against the effective uid — never env’s target
(exec-carriers). The AppContainer on
Windows has no path-exec filter, so there the in-process guard stands alone (the deny-by-default fs projection still bounds
which images a child can read, and so load, at all). Exec-deny ACEs and
directory execute/traverse semantics remain unimplemented. Native Windows
verification of stored-case walks, verbatim image launch, and launch refusals
is outstanding; cross-compilation alone does not establish them.
Inside a guest — a VM whose engine runs under ral-daemon, signalled by
RAL_GUEST, which carries the jail’s cgroup root — 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 guards apply unchanged
(docs/SPEC.md §12.11).
pin()andboot()— startup:pinpins ral’s own executable (reexec::OWN) and, on Linux, the bwrap envelope (linux::pin_envelope, walked on the process’s ownPATHbefore any shell exists);bootispinand, on Windows, the boot-time orphan sweep (windows::session::boot_recover) that deletes a crashed prior session’s AppContainer profiles, matching their ledger owner by PID, image and creation time. 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).invocation::serve(&role, installers)(core/src/invocation.rs) is the one pre-maindispatch, over the rolecore/src/role.rs’sclassifynamed — run byral, exarch and every test#[ctor]alike, surfacing a served role’s exit code so the caller can terminate. It serves aWarrantrole first (serve_warrant), before any pin, so the trampoline pins and opens nothing before it is confined; the pipeline anchor spawns nothing and runs unpinned. Every other role — the engine, the shell — runsbootfirst, and theBundledToolmulticall onlypins, so an--engineprocess has bwrap pinned exactly as the shell does. Skip it andOWNstays unpinned, so the per-command launcher refuses to re-exec a binary it cannot vouch for.reexec.rs—Pinned, an executable pinned at boot: the one shape everyCommandthe sandbox execs is built from, so nothing a session does to its environment can choose the file. Two are pinned: this executable (OWN), so a confined re-exec runs the same binary even under an on-disk swap, and on Linux the bwrap envelope (linux::ENVELOPE). Its methods arearg0(the on-disk path,argv[0]of every exec),exec_path(whatexecveis handed), on Linuxfd(the pinned descriptor) andnames(the inode’s current name,Noneonce unlinked), and on macOSverify;reexec::launch(role)is the one launch door for the unconfined re-execs (the pipeline anchor, a bundled tool), through the pin when there is one, andreexec::own()is ral’s own pin as aResult, so a sandboxed launch refuses when ral could not pin itself. 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 Windows there is no child re-entry at all — confinement is the AppContainer token the parent attaches atCreateProcessW, so a child asking to confine itself (--warrant) is refused with 126, and the pinned self serves to grant the container read on the bundled-tool re-exec image.Pinned::verify, the parent-side swap guard, is macOS’s alone, which execs the trampoline by name; Linux execs it from ral’s pin lent atSlot::Trampoline, so there is no name to verify.projection.rs— the sandbox’s own view of authority:SandboxProjection::of(&GrantStack, &Resolver, Option<&Admitted>)meet-folds the stack once, at spawn, intoFsProjection(read: Vec<FsScope>, the live positives of the read region with run places and live support among them, beside the write prefixes, the floor and the exclusions; allowances stay frozen up to rendering, exclusions are re-resolved throughrender_paths;FsRules, whoserendered()derivespinned_dirs; no write reach classification) andExecProjection::Restricted(ExecRules),ExecRules::for_kernelof theAdmitted’s own table joined with the live support (sandbox::carriers), so the profile renders the table that judged the launch rather than a second compile. It takes noShell;Shell::sandbox_projection(guard/shell.rs) is the door.projection_enforceable(sandbox.rs) — rejects an offline (net: `offline) projection on a backend with no kernel network enforcement, so an unenforceable request fails closed rather than running ignored.Refusal(sandbox.rs) — why no confined child exists: nothing ran.Unavailableis a confinement this host cannot establish, whetherprojection_enforceablesaw it coming, there was no bwrap onPATHto pin at boot (linux::envelope, asked at the first launch that needs it), or the pinned envelope failed to spawn.Launch::new(process/launch.rs) builds the unsandboxed external exec image;build_launchreaches it directly when no projection is active.launch.rs(sandboxed_command) — the per-command launcher.build_launch(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, theAdmitted’s program: aProgram::Fileby the real path the guard judged, its spelling asargv[0], or aProgram::Tool. macOS and Linux share one trampoline: the pinned ral itself, whose whole argv isral --warrant(Role::Warrant). The confinement to enter and the program to run cross in aWarrant(sandbox/warrant.rs, its fields private) compiled in the parent. The confinement is theConfinementtrait —spell,parse,enter— implemented on macOS bySeatbelt(String), the profilemacos::build_profilecompiled, and on Linux byOption<Landlocked>, spelledunconfined,landlockorlandlock+refer; the program is aRun,File(path, args)orTool(tool, args).Warrant::confineis the only way to the program: receive and verify the warrant, enter the confinement (macos::apply_profile,Landlocked::enter), close the handoff range, and yield aConfined, whoserunexecves the host program or runs the bundled tool in-process (uutils::run, shared with the--ral-bundled-toolmulticall).serve_warrantserves the child: a failure before the program starts exits 126, and anexecverefusal takes its code fromSpawnFailure(From<&io::Error>) — 126, or 127 for a missing program. The encoding is NUL-terminated fields behind aral-warrant/2magic — afileis its real path, then itsargv[0]spelling, then the exclusion covering its script’s interpreter (empty if none), then its argv; atoolits name, then its argv — canonical (decode re-encodes and compares) and at most 8 MiB. It rides fd 99, never argv: a sealed memfd on Linux, checked again after sealing, and on macOS a prefilled socketpair whose writer closes before the child exists; the child refuses an unsealed memfd or a non-socket. The fixed descriptor layout is defined once asSlotinwarrant.rs: 98 bwrap--args, 99 the warrant, 100--info-fd(a socketpair, which unlike a pipe cannot be reopened through/proc), 101 the Landlock ruleset, 102 ral’s pin, the trampoline bwrap execs, 103.. the seccomp programs (sealed memfds), 107.. one mount handle per bind, open-ended and last. OneHandofflifts every source above every target and a singlepre_execdup2s each home; the child sweeps everything from 98 up withclose_rangeonce confined, andLandlocked::enterconsumes the ruleset.bwrap_commandrefuses to launch if bwrap’s pin fd sits on one of these slots: thedup2would close it before the exec of/proc/self/fd/<N>, which would then run whatever landed there. macOS spawns the trampoline directly; Linux spawns it underbwrap(bwrap_command), giving the full shape bwrap → ral trampoline → Landlock →execve. The real argv isbwrap --args 98 -- /proc/self/fd/102 --warrant:bwrap_argvis pure in descriptors and returns bwrap’s options alone, which bwrap takes from--args. The order is forced rather than chosen: a Landlock domain handling any fs right forbidsmount(2), bwrap’s first act, so the layer can only be entered inside the envelope bwrap has already built. Linux’s confinement names no path: the parent opens every admit by handle, never through a symlink, so each rule reaches the file a grant froze.landlock::buildplans the handled set from the parent’s one probe (plan), creates theRulesetby raw syscall, and admits each allowed file as itself and each live allowed directory as its hierarchy less what the table blocks beneath it (expand, which stops where a bind mount loops back onto a directory it is listing), each opened in the host byopen_real(RESOLVE_NO_SYMLINKS); an absent admit is dropped, a file admit whose path has since become a directory is dropped, not admitted as a hierarchy, a symlink since planted refuses the launch, and a restricting exec projection is refused where Landlock is unavailable. The payload probes nothing: it takes the ruleset at its slot, adds onlyReferon its own root, which exists nowhere else, and fails closed if a promised ruleset never arrived.bwrap_commandtakes theEnvelope(bwrap’s pin, the trampoline’s, and theHostEnvelopeprobed once by running that trampoline as a pipeline anchor), the image (Program::File’s real path, the file the trampoline execs), the handoff so far and the rendered projection, and refuses outright under a bwrap that builds no envelope, in bwrap’s own words, or one without--ro-bind-fd(HostEnvelope::builds).Binds::openis the one place a launch opens what it mounts: the read-only defaults, the read and write prefixes, the image, each allowed file and live allowed directory of theExecRulesof theExecProjection, and the cgroup tree in theOverlayer, each object (render_objects: a path’s canonical spelling, its other spellings mounted too unless within another bind) opened once byopen_realand lent per bind atSlot::Mount(i). A read-only bind within another bind is dropped, a writable one only within a writable one; an absent object drops its binds, and so does a system default this user may not reach, while a name now reaching outside what was rendered refuses the launch. Every allowance is bound as its frozen real path, a redirected one refusing the launch, and each deny mask goes on wherever aShownbind shows the denied path (Binds::names): a mount laid inside one bind of an object does not show at another, so a prefix written through a symlink, two binds of one handle, is masked at both. A deny hiding/procis refused, the envelope starting its trampoline through it. The two pins join theOverlayer by copies of their own descriptors, read-only at the names their inodes hold now (pinned, overPinned::names), an unlinked pin binding nothing.bwrap_argvemits--ro-bind-fd/--bind-fdfrom the same vector,Shown, before/procand/dev,Overafter.--chdirrefuses non-UTF-8, and so does every rendered name, rather than go lossy into bwrap’s argv. 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; a bundled tool there is a plainral --ral-bundled-tool <tool>carrying no warrant, andserve_warrantrefuses--warrantoutright. 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 decides the two ties between the session and the envelope — death (--die-with-parent) and address (--info-fd) — so adetached survivor keeps the birthing frame’s projection, namespaces included, for life while dropping both (runtime). The grant body itself evaluates locally, external children being confined per-command (sandbox-external-children).- Backends:
macos.rs(Seatbelt,macos-base.sbpl;Profile, a record whose field order is the rule precedence (no freeze section), and in every profile ral’s own file is write-denied and its ancestors unlink-denied),process/limits/fork_brake.rs(macOS’s process budget: the confined child’sRLIMIT_NPROC, the user’s count at launch plus 512, bounded by the system ceiling and inherited hard limit; failedsetrlimitrefuses the launch, andlimits::failure_hintexplains a possible fork refusal on status 128 without asserting its cause),linux.rs(bwrap: thePinnedenvelope, exec’d by descriptor and never by name, its own file read-only bound inside every envelope after the projection’s binds; the argv —--new-session, the ipc/uts/cgroup namespaces and, by host fact, the pid one,--procon both projections, the seccomp deny-set (sandbox/linux/seccomp.rs:Filter,Syscall,explain);InfoFd, the payload’s pid read back for aKeptlaunch;linux/host.rs,HostEnvelope, the probed host facts the render is pure in), andwindows.rs(Job Objects capping the child tree at 512 processes, plus the AppContainer backend inwindows/:appcontainer.rsowns profiles and LowBoxSECURITY_CAPABILITIES;dacl/mod.rsexposes the path-derived capability-SID engine;session.rscomposes them). Withindacl/,stamp.rsowns completion witnesses,mutex.rsper-path locks (named by the path’s Windows collision key, folded beyond ASCII and at least as far as NTFS folds, where the witness and capability name are not: a lock errs toward merging, a witness toward splitting, and the stamp store is locked by an environment-spelled path two sessions may spell apart),ledger.rsprofile recovery, andacl.rsqueried descriptors and borrowed ACEs.windows/local.rsowns Win32 local allocations. Kernel handles useOwnedHandle; the path mutex guard stays on its acquiring thread. Ported units retain their MXC provenance.
The Windows backend is path-keyed: each (canonical path, kind) grant —
kind ∈ {rw, ro, ls} — 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_frozen_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 completion store (stamps.json, atomic tmp+rename, per-path
named-mutex merge) recording completed propagations, and a probe of the root’s
own DACL confirming its ACE remains. Before an ACE is reapplied, its old
completion witness is removed under the path mutex; 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/mod.rs’s module
header records the resulting drift in both directions
(path-derived-capability-sids).
Every frozen allowance is checked before any stamp filter and stamped through a held handle on the very object checked, no redirecting reparse point on its path (a redirected one refuses the launch; a name surrogate redirects, a cloud placeholder does not; confined-launch); the projection key covers the whole read list, places and live support included; and a launch whose fs projection holds the universal prefix is refused before anything is stamped.
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 stamps nothing: the AppContainer pass of the
access check matches a deny ACE against the token’s ordinary groups only, never
its capability SIDs, so Windows cannot take back what a grant’s ACE admits
(path-derived-capability-sids,
amendment of 2026-10-20). So session::confine first carves each exclusion out
of the allowances around it: sandbox::carve::carve, a pure planner over an
injected lister and tested on every host, replaces such an allowance with its
existing entries less the excluded ones, down to each exclusion, and grants each
directory on the way only its own listing (GrantKind::List, an ACE nothing
inherits), so a name made there later, the excluded one included, reaches
nothing granted; links among the entries are neither granted nor followed. An
exclusion on or around an allowance, the program image included, cannot be
carved, and session::refuse_deny_meeting_grant refuses it before anything is
stamped. For every exclusion dacl/deny.rs’s ensure_unreachable then runs AuthzAccessCheck at each
launch, in an AuthZ context carrying the child’s package and capability SIDs,
on every object beneath it, refusing the launch if any right is granted — an
ALL APPLICATION PACKAGES ACE, or a hard link carrying a granted file’s ACE.
The walk checks reparse points as themselves without following them, polls
cancel per entry, and refuses an exclusion it cannot read. The child’s program image
is granted read-only so a user-installed binary or the bundled-tool self image
can load at all, handed to session::confine as a RealPath, so the
canonical form the pin and the stamp’s local-prefix allow-list demand is the
type’s, not the caller’s care; and net: `offline 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, and build_profile lays the grant’s rules over it. Both are
read rule by rule — what each admits, what it withholds and why, and what the
backend pays to express an object policy in Seatbelt’s name language — in
seatbelt-profile.
Path-scoped exec confinement on Linux is a Landlock layer the payload enters inside the envelope; a deny inside an allowed directory is subtracted over the tree at launch, and only a veto under a trusted admit stays with the in-process guard — landlock-exec-layer.
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. The operand’s
class decides the remedy, and the hint answers once per class present: a
file-read* path is offered to the grant’s read set and a file-write* one
to write (advice that fails twice, otherwise), a process-exec path to the
exec set — the layer a re-exec through sh -c or find -exec reaches, and
the only one that sees it — a network-* denial to the net axis alone, and a
mach-*/ipc-* operand to nothing at all, because a door is the base
profile’s to decide and no grant widens one. Denied is that taxonomy as a
type, so a service name cannot arrive where a path is expected; withheld doors
carrying a reason (macos.rs::withheld_doors, the .sbpl’s prose as data) are
quoted with it and lead the hint, since they are the one class the reader
cannot act on — the securityd denial that broke cargo fetch was once ranked
below a .GlobalPreferences.plist probe and answered with a grant that would
have changed nothing. macOS logs fully-resolved paths, so the hint names the
exact path with the symlink caveat; on Linux, an operandless denial first tries
describe_denial, which consults
the seccomp deny-set by
syscall number and, when it names one, repeats that rule’s own reason instead
of guessing at an fs grant — a foreign-ABI record (an arch= mismatch) is
named as such rather than misread as an unlisted syscall; only where the
deny-set has nothing to say does the hint fall back, and it then says the
record names no operand rather than guessing at a set to widen. 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 guard, 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 reviewed syscall site: the
workspace bans the raw constructors via clippy disallowed_methods, so each call
site carries an #[allow(… reason = "[…]")] classifying it as a surfaced
exec image (Launch::new), silent infrastructure (the self re-exec, the
pinned binaries’ exec (Pinned::command), the ps denial sampler, the
boot-time binary pin, the stamp-store and profile-ledger lifecycle), or
test scaffolding. The site
shapes and their rail rendering live in io-surface; here
the sites are only declared and accounted, with core/tests/syscall_sites.rs
failing CI on any unaccounted constructor.