Map: exarch / policy
policy/ composes the session’s effective grant —
ral’s capability lattice — from the CLI flags. The
boundary is ral’s grant: exarch never invents its own sandbox, it just hands a
frozen GrantStack to the host that pushes it. There is no Capabilities::meet
— composition is layering, and the stack’s own per-check fold is the
one meet that ever runs (object-not-name,
capabilities).
ceiling = base ◁ extension?
stack = [ceiling, restrict₁, restrict₂, ..., deny(restricts)?, deny(credentials)?, self]for_invocation builds that stack in a fixed order — a single optional extension
adds to the ceiling, once, in its own layer; every other attenuation is its own
pushed layer:
- resolves the named base (
resolve_base) as the ceiling; - loads an optional
--extendfile as aFragmentand computesbase.extend(fragment)as the ceiling (◁: an allowance never removes authority, an exclusion always wins, an axis the base leaves unrestricted stays so less the fragment’s exclusions; still one layer; authority-is-belnap-evidence); - pushes each
--restrictfile as its own layer (attenuates); - pushes a deny layer carving out each restrict file’s own path (below);
- pushes a deny layer for
exarch’s and synod’s own credential files
the same way, wherever some layer’s fs restricts (the layer is pushed on the
denotation, so
dangerousextended with a positive-only fs gets none); - pushes last the self layer: an exec exclusion of exarch’s own binary, by
the path it was pinned from, built as
deny_layeris (join_topover a fragment of one exclusion), wherever some layer’s exec restricts, so every spawned agent of a restricted session inherits it. The agent cannot start exarch as a process there;dangerousis ambient authority, its stack restricts nothing, and it needs no OS sandbox.
Composition emits no confused-deputy warning naming every prefix both
exec-admitted and writable. That predicate is not the one that matters —
in-projection overlap escalates nothing and every bake-in requires it, so such
a line would fire on every session naming `cwd, `absolute /tmp, and `tempdir, all
three deliberate (grant §Concessions). The report lives
where it is asked for rather than announced: ral’s audit trail records
one deputy check per flagged prefix when a grant frame is entered
(capabilities).
Every profile is frozen as it loads — resolving each place (`home, `xdg-*, `cwd,
`tempdir, `gitdir) and the dir selectors against the session’s home, working
directory, and the host’s live tool roots inside
ral_core::guard’s decode pass (guard/decode.rs, guard/freeze.rs) — so composition runs entirely on
already-resolved Capabilities (freeze boundary).
An `xdg-* place escaping HOME is rejected at the profile that names it, before
composition could discard it. Loading reuses
ral_core::load::profile::load_capabilities_from_* — the same surface as ral’s
--capabilities <path>.ral (policy/load.rs wraps it with exarch’s error
format and the absolute_in cwd-join helper).
base_layer(base_name, cwd) resolves a bake-in base, frozen against the
child’s working directory, as one way of naming the single layer a
sub-agent spawn pushes. It does not take the parent: the
desk behind the “ exarch-agents `start “ tag
(agent/desk/agents.rs’s fork_seat, through core’s
IdentityTransport::adopt_parked) takes the parent’s forked shell — which
carries the parent’s own GrantStack — and pushes this layer onto it
(SpawnGrant::narrow_onto), so the same stack that carries the root’s
authority also carries a spawned child’s attenuation. The stack’s per-check
fold ANDs every layer’s evidence, so an added layer can only remove authority:
a spawn can reduce a child’s reach but never escalate it past the parent’s —
naming a base looser than the parent changes nothing (a network-off parent
stays offline even under minimal).
A spawn’s grant is one field with six spellings, because at a spawn
--base and --restrict are the same act — the parent’s stack is already
underneath, so a base pushed here can only narrow, exactly as a restrict does
(a-spawn-is-one-layer). The layer is
resolved by SpawnGrant::layer (core/src/guard/grant.rs), which both seats
call: `inherit is ⊤, a layer the fold leaves no trace of;
`confined/`read-only/`edit-only/`reasonable reach
base_layer through the GrantNarrower the resolution is handed; and
`restrict R hands the record to
ral_core::guard::decode_capability_map — the same walker, off the same
Form::Grant declared table, that grant [...] { body } and each bake-in
profile below already pass through, so one keyset earns one wording.
`dangerous is a --base name only: at a spawn the lattice top is a layer
that says nothing, which means inherit-the-parent, and `inherit says
that without borrowing the root’s reading of ⊤. A spawn’s R gets no
self-denial layer either — it is a value computed in the parent’s shell, with
no file on disk for the child to rewrite.
deny_layer(paths, ctx) is a pure constructor, not a mutator: it returns a
fresh Capabilities layer holding only those paths as fs.deny_paths, for the
caller to push, rather than folding them into whatever layer already carries
an fs opinion. Each path is frozen by freeze::host_absolute, and one it
refuses — not UTF-8, or not absolute under this host’s rules — fails the
layer whole, since a deny that named nothing would fail open.
for_invocation pushes two such layers, each making a kind
of bytes structurally unreachable: a restrict file’s own path, so the agent
cannot edit the file that shapes its authority, and — once some layer already
holds an fs opinion — provider::credential_files(), so the agent cannot
read the credentials that authorise its own turn
(a-grant-never-reaches-its-own-key).
Pushed as its own layer, a credential deny is one no profile can omit by
neglect and no --extend can widen back open — the stack’s fold can
only narrow past it. Only the user-supplied lexical form is pushed — both
capability enforcers expand a deny entry to its canonical (and, on macOS,
firmlink) variants themselves, so canonicalising here would duplicate, less
completely, work that belongs to core. Each path is frozen through the same
lexer the grant decoder uses, so deny entries land as FrozenPathes in
the grant-side normal form. The --extend file is not denied: it
extends the ceiling, so denying writes to it is a trust-source concern, not a
self-protection one. The credential deny is pushed only where some layer’s fs restricts: for plain
dangerous it would confine a session that asked not to be, against an agent
that can read the same bytes a hundred other ways.
policy/base.rs embeds the six bake-in profiles from exarch/data/*.exarch.ral
via include_str!, ordered from most to least authority:
dangerous—Capabilities::root, lattice top, no attenuation;reasonable— default; everyday tooling + standard binary dirs;edit-only— reasonable’s reads, writes to the working tree and scratch, network on; for patch/refactor jobs that never run a build;read-only— reasonable’s reads/exec, writes only to scratch; the scratch and its tool homes are shared tree-wide, so a narrowed child can plant files a wider sibling runs (accepted; see the-seed-rides-the-protocol-sealed);minimal— system binaries + cwd + scratch + net + chdir; a deliberately narrow base for additive--extend;confined— offline build jail; host binaries by subpath, with explicit bare-name entries for core’s bundled tools (which have no path to match).
Every profile that restricts exec must name the bundled tools (core/src/uutils.rs
— coreutils, and diffutils/ripgrep under their features). Head::resolve makes
those a Program::Tool and skips the PATH walk entirely, so they reach the
in-process guard as a tool with no place on disk: a directory prefix cannot
match one, and silence denies it. That is why read-only, edit-only,
reasonable and confined each carry a per-name coreutils block that looks
redundant beside their `dir `system rule and is not.
every_grantable_base_settles_the_bundled_tools pins it, per build
configuration — turn on the ripgrep feature without naming rg in a profile
and it fails, naming the tool.
The consequence for spawning: a base whose exec is prefixes alone is
unusable as a child’s grant, since the child cannot widen its own ceiling to
recover ls. minimal is such a base, and is offered by --base only —
policy::SPAWN_BASES withholds it from exarch-agents `start,
as it does dangerous (agents).
Each is a ral script whose terminal expression is a map shaped like the argument
of grant [...] { body }, loaded through
ral_core::load::profile::load_capabilities_from_str — the same surface
--capabilities <path>.ral consumes at the ral CLI. Two surfaces, one model. The
host reads the resulting authority only through core’s accessors, never its
representation (no-core-repr-leak-into-exarch).
The bake-ins name exec authority portably through the dir selectors —
core’s spelling of live tool roots, each a row of one
table (ral_core::guard::toolchains, rules in grant
§Selectors). `dir `system is the standard binary dirs on Unix (Homebrew is
its own selector, `dir `homebrew, expanding to the prefixes that exist,
HOMEBREW_PREFIX first); %SystemRoot%\System32, the PowerShell home, and
Git-for-Windows’ usr\bin on Windows. reasonable lists a selector for every
package manager in the table and writes no read entry for what one runs;
running reads cover it. It excludes ~/.dotnet/corefx from fs. No profile
writes xdg-cache: reasonable and minimal write the working tree and scratch only,
and exarch points XDG_CACHE_HOME and the legacy tool homes into the scratch
(exarch). RUSTUP_HOME is not redirected, since it holds what
runs. Two consequences ride the selectors:
- interactive shells are denied by literal override in every attenuated
profile —
bash/zshand their Windows analoguescmd/powershell/pwsh(which`dir `systemwould otherwise admit viaSystem32);shstays allowed forconfigure/makeshell-outs; minimalleaves Homebrew unlisted, so brew tools are opt-in through--extend;reasonable,confined,edit-onlyandread-onlyadd`dir `homebrew.
resolve_base also drops dead grants as a profile loads: exec literals
naming the Unix-only bundled coreutils (drop_dead_exec_grants) are removed
off Unix, and the freeze pass discards foreign-rooted Unix literals on
Windows — a rendered profile never advertises authority the platform cannot
back.
The in-module tests pin the load-bearing per-profile properties so a future edit can’t silently widen a jail:
dangerousisCapabilities::default(lattice top);gitadmitted inreasonable/read-only(commit flows work without--extend);minimaladmits only the system git under/usr/bin/(a brew git is opt-in via the git extension);- every exec-declaring base carries an
Allowfor each live system tool root; read-onlyreads but does not write`cwd;confinedis net-off, admits host execs by subpath and only bundled tools by bare name, and has no home-reaching prefixes;`cwd/`tempdirplaces freeze into the per-invocation working and temp dirs without exarch injecting them dynamically.
exarch/examples/git.exarch.ral is the canonical --extend that lifts
minimal/confined into a git-capable shape; a test pins that extending minimal with it keeps git admitted and adds
~/.gitconfig, and that FsPolicy::join
unions deny sets — a one-sided veto survives the join, so an overlay stays
purely additive without re-stating the base’s credential denies.
The system-prompt assembly that renders this frozen authority into the Grant
section lives on exarch, which owns prompt.rs and data/.