Capabilities and grant
grant is a dynamic-context operator that attenuates authority for its body by
intersection. grant [exec: [...], fs: [...], net: ...] { body } narrows the
commands, filesystem paths, and network access available to body and
everything it calls. Authority is never amplified:
- a dimension omitted from a grant inherits the ambient authority;
- a dimension present can only narrow it;
- nested grants compose by meet;
- a deny is anti-monotonic — further layers can add denies but never reopen a denied region.
A deny’s reach is the content, not the spelling. “Never reopen a denied region” must hold for what a deny protects, not just the string naming it: a writable region is otherwise mutable end to end, so a confined child that renames or removes a directory on the path to a denied file can make its bytes resurface under a name no deny rule covers, without any layer ever re-granting the denied path itself. The invariant is sharper than “the literal path stays blocked”: no confined child can cause a denied path’s contents to become reachable under a name the deny does not cover. Enforcing it is not free — some name on the path to a denied file stops being renameable or removable, and what that costs differs by backend — see capability-enforcement for the rendering and the per-backend price.
The axes are independent: each is a separate field whose absence is the meet
identity (None = inherit = ⊤, `Option<T>: Meet`).
- Restricting one axis does not restrict another.
grant [fs: [...]] { … }narrows the filesystem but leavesnet,exec,editor, andshellat the caller’s authority; a grant that means to confine network access must saynet: falseitself. nethas no in-process gate. ral has no network primitives, so anet: falseis enforced only by the OS sandbox — it fails closed where no backend exists.
This is a deliberate mental-model fact, recorded so omission is never mistaken for an implicit cross-axis deny — see reduced-authority-witness §B7.
Capability checks gate five dimensions:
- exec over a three-valued lattice (Allow / Subcommands / Deny) — more expressive than orthodox object-capability, since a base profile can veto a name a restrict file never mentions;
- fs by read/write path prefix with denies;
- net as allow/deny;
- detach as allow/deny — the one dimension that gates a verb rather than an action on a resource, and the one that reaches no OS profile: it decides whether a process the session stops owning may be born at all, never what that process may then do (detach-under-a-grant);
- audit as a record of check events.
Filesystem checks are alias-aware and resolve symlinks, so a directory scoped by
within [dir: ...] inside a grant cannot escape its policy. The bundled
coreutils (cp, mv, rm, mkdir) route through the same check chokepoint as
the structured primitives, closing the bypass.
The grant body evaluates locally. RAL-owned filesystem effects are checked in
process by check_fs_op before the syscall; each external or bundled child the
body spawns is confined under the effective projection — per-command Seatbelt
on macOS and bwrap on Linux, the projection’s own AppContainer on Windows,
whose token carries a capability SID per granted path
(path-derived-capability-sids).
A survivor detach births inside the body is confined the same way and then
keeps that confinement for life: the projection is frozen at birth, since no
later frame can name the process to widen it. Only the envelope’s tie to this
process’s death is dropped, which is what makes it a survivor rather than a
receipt for something already killed.
A .ral profile and the inline
grant surface are symmetric: both decode through the same walker into one
frozen Capabilities, so configuration is the grant value written as source,
not a second schema. That walker resolves every sigil as it decodes —
the freeze boundary — so no unresolved form ever
reaches the stack or the IPC wire.
This is the boundary exarch reuses as its sandbox: each agent run evaluates under a profile’s capabilities pushed onto this same stack.
Concessions. Three caveats are inherent to the design, not defects:
-
Bare command names are ambient. A bare exec key like
gitresolves throughPATH, so it is not a strict object-capability; the mitigation is the base profile’s deny on shells together with thefsdeny on writingxdg:bin— the names that matter are pinned, the rest rest onPATHintegrity. -
TOCTOU on path resolution. The resolver-form and bind-form checks share one source, so the OS profile and the in-process check cannot disagree, but symlink races inside the admitted set are a known surface, not a closed one.
-
Exec admission is not containment; the projection is. A prefix that is both
exec-admitted andfs-writable reads like an escape hatch — drop a binary, the next call admits it — but inside one projection it escalates nothing: whatever the confined process writes and then runs is spawned under the very projection that admitted the write, so it can do only what its author could already do. Copying a denied binary under a fresh name defeats the name veto for the same reason and to the same small effect. The overlap is therefore not merely tolerable but required —cargo build && ./target/debug/appis exactly this shape, and every bake-in profile makescwd:,/tmp, andtempdir:both writable and exec-admitted on purpose.capability::deputy_prefixesaccordingly reports and never denies, and no prefix is both is not an invariant this design wants.What does bite is authority that outlives or exceeds the projection: a write escalates when whoever later treats those bytes as code is not confined by the projection that admitted the write — running after the session ends (outlives) or beside it with more authority (exceeds).
xdg:binis the case the base profiles decide correctly for the wrong stated reason. It must stay unwritable not becauseexecalso names it, but because a program left on the human’s$PATHis run tomorrow by the human, unconfined.
An open class: the unconfined reader. Every dimension grant gates names
what the confined process may do. One family of escalations has no such
shape: a write into a region that some process the host runs later —
unconfined — treats as code. A hook under gitdir:, a core.pager or an alias
in .git/config, an .envrc, a package.json script, a Makefile. None is a
binary, none is exec-admitted, and each runs with the user’s full authority the
moment the user reaches for the ordinary tool that reads it. The exec
dimension cannot see this: a file that is never executed, only interpreted, is
invisible to a gate on execution. The default profiles make cwd: and
gitdir: writable, so the class is live rather than hypothetical.
Nothing in this vocabulary expresses it, because the question is not what the
confined process may do but what an unconfined one does afterwards with what
the confined one wrote. Perimeter sandboxes do not answer it either: nono and
its kin draw the boundary at the confined process, which puts the later reader
outside by construction. The only lever available today is an fs deny on the
particular files, a defence at the name layer with the name layer’s limits.
Naming the class as open is worth more than pretending the exec dimension
covers it.
See also syscalls-are-effects (a capability is permission over the effect set), scoping, control-operators, two-enforcers, system-c (the type-based pole of this calculus), access-control-algebra (the security-literature model this lattice instantiates — composition over a Belnap bilattice).
Realised in capability-enforcement.
Cite: RATIONALE §“grant attenuates authority”,
§“Lexical data, dynamic authority”; docs/SPEC.md §12.