The freeze boundary
A capability bundle is resolved the moment it is decoded: there is one type,
Capabilities, and every value of it is frozen — concrete paths, no places —
by construction. The boundary between syntactic profile and resolved
authority is not a type split but a single pass inside the one constructor, so
“an unresolved bundle reaches the dynamic stack or the IPC wire” is
unrepresentable: there is nowhere for one to exist.
The type (core/src/capability/lattice.rs):
Capabilities— per-effect policies (exec,fs,net, and theeditorandshellverb sets), and nothing else: every member is authority, so a bundle carries no switch over what is recorded about it. Path lists hold concrete absolute paths. Bundles compose by stacking on aGrantStack, every judgment meeting the layers’ evidence at the access (authority-tables);Capabilities::extendjoins a base with a fragment into one ceiling bundle.
decode is the one-way door
The sole non-trivial constructor is decode_capability_map
(core/src/guard/decode.rs), the single Value::Map → Capabilities
function. It walks a .ral profile’s terminal record — or the inline
grant [...] { body } record — into a bundle, then runs a freeze pass before
returning. In that pass it:
- resolves every place (
`cwd,`home,`tempdir,`gitdir,`xdg-*,`absolute) and every selector (`path,`system, the package managers) against aFreezeCtx(ownedhomeandcwd,FreezeCtx::of(&Shell);`tempdiragainst the process temp dir). The freeze isguard/freeze.rs:FreezeCtx::placeis the one place a grant’s place becomes aFrozenPath,FreezeCtx::selectorthe one place a selector becomes many, andFrozenPath::from_surfacethe one minting door (capability-is-data); - classifies an
absolutepayload on the raw string, before any folding, host first, so one profile loads on both platforms: a path absolute under the host’s rules is live, one absolute only under the other platform’s is a dead grant (placeanswersNone), and one absolute under neither is refused. A well-formed forward-slash UNC//server/share/xis live on both;\x,C:xand malformed UNC roots (\\or//with no server and share) name no path anywhere. Host folding would otherwise readC:/../xon POSIX as the relativex; - validates a subpath lexically, before joining, so a profile fails alike
everywhere: the empty string, a leading separator and a drive prefix are
refused,
.is the anchor and..folds and may leave it; - rejects a
~orxdg:NAMEpayload under every tag: those prefixes are expanded at run time, never in a grant, and the error names the place meant; - rejects an
xdg-*place that escapeshome— defence in depth against an attacker-setXDG_*_HOME=/etcsilently widening a grant; - rejects an anchored place that freezes relative (a relative
HOMEorTMPDIR) and a`homeorxdg-*place where nothing bindsHOME: no base to resolve against, and the escape guard just above is itself stated inhome’s terms, so an absoluteXDG_*_HOMEcannot stand in for one. The readers answerOption, and the reason the place cannot be frozen is the reason the author is given; - rejects a
`gitdirwhose.gitpointer file names a directory that does not name the working tree back.
A bare string or a relative path is no place at all: the checker refuses it
before the program runs, and the decoder refuses it again for a value that
arrived untyped. Authors write `cwd sub to pin “relative to here” once.
Both callers — eval_grant (core/src/evaluator/scope.rs) and the profile
loader (core/src/load/profile.rs) — are in-crate, and the path-free
root / deny_all / default are the only other ways to obtain a
Capabilities. So resolved by construction is a visibility fact, not a
discipline: no surface admits an unresolved bundle.
What freeze pins, and what the access resolves
Freeze pins the author’s places and follows their symlinks once; it never
touches the path of a future access, which is resolved at the moment of
enforcement. FrozenPath::from_surface is the one disk consultation: it
folds the spelling and canonicalises leniently, so a prefix carries its
surface (the author’s spelling) and its real form (symlinks followed as the
disk stood at decode). That makes the algebra a pure function of the policy.
Enforcement then re-asks the world.
- Allowances are frozen for good; only exclusions re-resolve. An allowance,
fs prefix or exec
Path/Dirkey, keeps the real path it was frozen to and is never re-frozen, so a symlink put in its place later redirects nothing. An exclusion re-freezes against the live disk on every question (FrozenPath::refreeze;FrozenPath::live_realfor exec), so it holds its object under any new spelling the moment one exists. A bare program name resolves afresh at each run. - Freezing refuses a real path that is not Unicode, since the frozen real form is now the allowance’s identity and a lossy form would grant a sibling.
- A guest allowance is not yet frozen inside the machine. synod mints it with
FrozenPath::from_guest, whose real form copies its surface, and sends it with every run, so a redirect made in one guest run can take effect in the next. Freezing it once per session, inside the guest, is deferred (authority-tables, “Freshness”). - The access is canonicalised before matching. A grant of
read: [`home sandbox]is not escaped by a~/sandbox/leak → /etc/passwdlink the body plants once the grant has taken force: a lexical match would admit it, the resolved/etc/passwdis outside the region and denied. This is a time-of-check-to-time-of-use race whose use is theopen(); at decode the link does not yet exist, and there is nothing for freeze to resolve.
So the runtime fold resolves paths against the live environment rather than
reading a fully-canonical bundle: the [[internals/path-judgment|four-stage
rule]] (expand → lex → canonicalise → path_within) runs per access, and
“canonicalise before match” is what closes symlink and .. escape. This is the
access-time complement to the load-time freeze — the same reason the runtime
fold stays a live judgment and not a
precomputed value.
Freezing the rule’s anchors pins its authorship: `cwd resolves to where
the grant was established, so a later cd cannot retroactively widen authority.
The prefixes themselves are a different matter.
Allowances name what they named. A grant reading work/ro and writing
work is not escaped by renaming ro and planting a symlink named ro that
points outside: the allowance keeps its frozen real path, the guard judges by it,
and each backend refuses a launch whose frozen allowance now meets a link or
reparse point rather than following it (Landlock’s RESOLVE_NO_SYMLINKS binds,
Seatbelt’s resolved-path check, Windows’ held handles). Creating a granted
directory after the grant, or rm -rf dir && mkdir dir, keeps working: these
are path grants, not inode pins. Re-resolution is right for exclusions and wrong
for allowances (authority-is-belnap-evidence).
The XDG-escape guard is a per-profile invariant
Because freeze runs once per profile as it loads, the escape check fires on
any input profile that names an xdg-* place resolving outside HOME —
whether or not later composition would keep that path. A restrict profile that
mentions an escaping `xdg-data is rejected even if a meet would have
intersected it away.
The guard asks its containment question on the form the in-process guard
matches, here the fs one, which authorises objects, so the form is the
symlink-followed real one, asked with path_within — the same alias-aware
predicate every fs rule speaks through, FrozenPath::contains — and with HOME canonicalised on the
other side. Asking it of the surface spelling instead read
XDG_DATA_HOME=~/link, with link → /etc, as contained: the surface stayed
under HOME while the frozen real form — the only one fs enforcement
ever sees — was /etc.
A freeze guard and an in-process guard that judge different forms of the same
prefix do not guard it. The general rule is
grants judge objects: every authority is
judged on the real form, and the choice is made once inside
core/src/path/, where the containment kernel now
stays, and each authority reaches it through its own door. No caller elsewhere
holds two paths and picks.
This is fail-closed: the honest reading for a defence-in-depth control is that
the offending input is refused at the door, not that its rejection depends on
what survives the lattice. Error attribution is per-profile — exarch’s loader
prepends --restrict <path> (or --extend), so the message names the file
that carried the bad token.
A `gitdir pointer is answered, not obeyed
Two places read a source the session does not author — an XDG_*_HOME and a
.git pointer file — and each is guarded in the terms of whoever could write
it. The xdg-* guard is containment, because an environment variable’s harm is
where it lands. The gitdir guard cannot be: a linked worktree’s git directory
legitimately lies outside the tree, so no region bounds it. What bounds it is
mutual reference.
- A
.gitfile lives in the working tree, which under exarch’sreasonablebase is granted for write. So the pointer is a sentence the confined body may compose, and`gitdiris granted read and write. - Freeze therefore follows the pointer only as far as a git directory that names
this tree back — a linked worktree’s
gitdirfile, orcore.worktreein the config of a repository split off with--separate-git-dir, the two records git itself keeps. Both ends must agree, and only one end is writable from inside the tree. - A pointer nothing claims is a
PolicyErrorat the door (guard::freeze, which holds the threegitdirrefusals), like an escapingxdg-*place. Silently narrowing to the cwd would be safe and unreadable — the same fail-closed-and-say-so reading the XDG guard takes.
Composition is over resolved paths
When several profiles merge into one ceiling — exarch’s
base ◁ extend ⊓ restrict (policy) or the
--capabilities fold in apply_session_profiles — each is frozen as it loads
and the lattice operations run on the resolved bundles.
- the meet and the extension compare concrete paths, so prefix containment is exact
(
/work/proj/src ⊂ /work/projis seen as such). Strictly more precise than comparing place spellings; no soundness change. - the
FreezeCtxis built once per composition site, home and cwd in hand —eval_grantandapply_session_profilesagainst the live shell’s effective home; exarch’sfor_invocationagainsthost::home()and the session cwd.
The frozen prefixes and the runtime fold are then two fidelities of one
algebra — over the forms frozen at decode here, over forms re-frozen against
the live disk at enforcement time — sharing one meet, Table::meet
(authority-tables), but kept apart deliberately:
exec-authority-partitioned
§“one combinator at two fidelities” and §“the two folds stay separate”.
Composition itself is stacking, not a fold over frozen values: the
GrantStack holds each layer and every judgment meets the layers’
evidence at the access (object-not-name).
Off the wire by construction
Capabilities derives Serialize / Deserialize so the stack’s folded
SandboxProjection crosses to a re-exec’d child —
a trusted boundary between cooperating ral processes, where the parent has
already resolved every path. Since a Capabilities is always resolved, only
concrete paths cross; there is no second, unresolved form that could.
A resolved path is resolved in some namespace, and one carrier crosses to a
different one: synod’s bundle is frozen on the host and enforced by the in-process guard inside
a Linux guest, so its prefixes name /work and /tmp and are folded by the
guest’s rule, not this process’s
(guest-namespace-prefixes — a
Windows host folding /work with Path::components rebuilds it as \work).
The like-for-like promise above is per-namespace and unaffected; what the case
adds is that “the parent has already resolved every path” must be read as in the
namespace the child will match in.
See also grant (the calculus this resolves into), two-enforcers, authority-is-belnap-evidence (frozen allowances, re-resolved exclusions), capability-stage-collapse (the decision to collapse to one always-frozen type), reduced-authority-witness, system-c (the type-based pole), and map: capabilities.