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 sigils —
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/types/capability.rs):
Capabilities— per-effect policies (exec,fs,net,editor,shell) plus anauditflag. Path lists hold concrete absolute paths. The latticemeet/joincompose two of them into a third.
decode is the one-way door
The sole non-trivial constructor is decode_capability_map
(core/src/capability/decode.rs), the single Value::Map → Capabilities
function. It walks a .ral profile’s terminal map — or the inline
grant [...] { body } map — into a bundle, then runs a freeze pass before
returning. In that pass it:
- resolves every
~/xdg:/cwd:/tempdir:/gitdir:sigil against aFreezeCtx { home, cwd }(andtempdir:against the process temp dir); - rejects an
xdg:value that escapeshome— defence in depth against an attacker-setXDG_*_HOME=/etcsilently widening a grant; - rejects a
~orxdg:entry where nothing binds$HOME: no base to resolve against, and the escape guard just above is itself stated inhome’s terms, so an absolute$XDG_*_HOMEcannot stand in for one. Old rule, long unreachable — boot seeding used to substitute.for an unsetHOME, so the guard saw a home that was merely wrong, and such a grant fell through to the relative-entry rejection below carrying advice about the wrong problem (use cwd:~/.gitconfig). The readers now answerOption, and the reason the entry cannot be frozen is the reason the author is given; - rejects a
gitdir:whose.gitpointer file names a directory that does not name the working tree back; - rejects a non-sigil relative entry — it would otherwise anchor to the
live cwd at the access rather than the grant, so the same rule would mean a
different region after a
cd. Authors writecwd:subto pin “relative to here” once; a bare command name inexecis a name, not a path, and is exempt.
Both callers — eval_grant (core/src/evaluator/scope.rs) and the profile
loader (core/src/capability/load.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 a sigil-bearing bundle.
What freeze does not resolve: the access and its symlinks
Freeze resolves the grant’s own paths; it never canonicalises symlinks, and it never touches the path of a future access — those belong to the moment of enforcement, not the moment of decode. Sigil expansion is intrinsic to the profile and safe to pin once. Symlink resolution is not: the symlink that defeats a grant is created at runtime, inside an already-granted region, on the very path a running body asks to open.
- A grant of
read: [~/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 —~/sandbox/leaksits squarely under the granted prefix — so what saves us is canonicalising the access before matching: resolved to/etc/passwd, outside the region, it is 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. - Canonicalisation is therefore a property of the access, not the rule. Pinning the grant’s prefixes to canonical form at freeze would defend nothing here — the threat rides the opened path, not the prefix — and would shackle the grant to the symlink topology as it stood at decode, drifting from the live filesystem thereafter.
So the runtime fold resolves paths against the live environment rather than
reading a fully-canonical bundle: the [[internals/capability-enforcement|four-stage
rule]] (expand → lex → canonicalise → path_within) runs per access, and
“canonicalise before match” is exactly 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.
The two are dual defences against a confined body cheating its fence. Freezing the
rule’s sigils pins its authorship: cwd: resolves to where the grant was
established, so a later cd cannot retroactively widen authority — the body cannot
widen by moving. Deferring symlink resolution to the access binds the rule to the
live world — so the body cannot escape by re-pointing. Pin the intent; resolve
the world.
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: path 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.
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-base), so the message names the file
that carried the bad token.
A gitdir: pointer is answered, not obeyed
Two sigils 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, andgitdir:is 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, like an escapingxdg:. 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.
meet/joincompare concrete paths, so prefix containment is exact (/work/proj/src ⊂ /work/projis seen as such). Strictly more precise than comparing sigil strings; 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 composition meet and the runtime fold are then two fidelities of one
algebra — lexical over the resolved strings here, canonical over
symlink-resolved paths at enforcement time — sharing their atoms
(meet_prefix_sets_by, meet_literal_exec) but kept apart deliberately:
exec-authority-partitioned
§“one combinator at two fidelities” and §“the two folds stay separate”.
Off the wire by construction
Capabilities derives Serialize / Deserialize so its meet-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, sigil-bearing 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 a gate 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, capability-stage-collapse (the decision to collapse to one always-frozen type), reduced-authority-witness, system-c (the type-based pole), and map: capabilities.