(sigil grant)
Capability Grants: the Value and the Algebra
An immutable authority value and the two operations that compare and narrow it. This module is PURE: it performs no filesystem access, no process access, and no I/O of any kind. It compares resource PATTERNS structurally.
What a grant is
A grant is an immutable set of facets. A facet is a three-element list (kind mode resource), and a facet is a PERMISSION — absence is denial. There is no "off" facet; a grant that does not mention pty denies pty.
(import (sigil grant))
(define g (grant-from-specs '("fs:rw:/work" "exec:allowlist:git,ls")))
(grant-covers? g (grant-from-specs '("fs:ro:/work/src"))) ; => #t
(grant-covers? g (grant-from-specs '("fs:rw:/etc"))) ; => #fThe partial order and the meet
(grant-covers? big small) is the partial order "has at least as much authority as": true iff every facet of small is permitted by some facet of big. (empty-grant) is the bottom — covered by everything, permitting nothing. (grant-allow-all) is the top.
(attenuate g narrowing) is the meet: the greatest grant covered by both. (grant-covers? g (attenuate g narrowing)) holds for ALL inputs — attenuation can never widen. This is the property every consumer of this module leans on.
grant=? is defined as MUTUAL COVERING, not as structural equality, and the choice is load-bearing rather than stylistic. An equality DERIVED from the partial order cannot disagree with the partial order. A structural equality can, and would then make a true law ("attenuate is idempotent") read false for reasons that have nothing to do with authority — a different facet ordering, or a redundant facet that is subsumed by another one. The laws in this module are claims about authority, so they are stated in the authority ordering.
Purity, and why it is not a style preference
(sigil system grant) canonicalises paths with realpath when a grant is constructed and again when a target is checked. This module deliberately does NOT. Two reasons:
- The property tests are only meaningful over pure functions.
- The browser/wasm target has no filesystem to canonicalise against. A grant algebra that touched the filesystem could not run there at all.
Canonicalising a concrete path against a grant, and anchoring a grant to a directory that exists, are the CHECK site's job and stay in the system profile. This module is the vocabulary they share.
Facet grammar
| kind | mode | resource | |
|---|---|---|---|
fs | ro \ | rw | absolute path prefix, trailing / stripped |
exec | full \ | allowlist | #f, or a sorted list of argv[0] strings |
net | connect \ | listen | [<scheme>://]<host>[:<port>], host may be * |
import | full \ | prefix | #f, or a dot-joined library-name prefix |
eval | on | #f | |
ffi | on | #f | |
pty | on | #f |
connect and listen are SEPARATE authorities: neither covers the other, and a grant that means "all networking" must hold both.
Resources are compared LITERALLY — this module never canonicalises. What a resource may contain is therefore constrained at construction so that the value space and the spec grammar agree (see "The spec string is an interchange format" below):
- fs paths must be absolute and may not contain a
.or..component. They MAY contain:and,— neither can split them, andrealpathpreserves a comma, so an ordinary directory like/home/u/Music/Artist, Themust stay grantable. - exec allowlist commands may not be empty, nor contain
,or:. This is the ONLY kind where a comma is a delimiter: the exec tail genuinely splits on it. - import prefixes may not contain
:. A,is allowed — the branch takes the prefix whole, so a comma cannot split it either. - a net host is a literal host or exactly
*, and contains no/.*.ioandx.io/pathare both refused rather than silently taken as literal hosts matching nothing./is structural here: it appears only in the://scheme separator.
The test for whether a character must be refused is NOT "does the grammar use it". It is: CAN THIS CHARACTER CAUSE A VALUE TO BE READ AS MORE THAN ONE THING? Write that down before adding a facet kind, because the naive reading forbids too much and gets the two cases below backwards.
A character that is merely STRUCTURAL cannot inject: it defines what a component is, so the worst it causes is inexpressibility, not authority appearing out of nothing. Those are different defects and only the second is what §5.1a exists to stop.
Three characters are therefore deliberately carryable:
:in an fs path. The fs branch rejoins everything after the mode, sofs:ro:/home/u/a:bis a single path and round-trips as one. The suite asserts this rather than assuming it. A naive reading would forbid:here and be wrong.,in an fs path or an import prefix. Neither branch splits on it — fs rejoins the tail, import takes the prefix whole. The first version of this module refused it everywhere, and that broke a real configuration:realpathpreserves a comma, so a grant over/home/u/Music/Artist, Thestopped being expressible. The comma is a delimiter in the exec allowlist tail, and in the env-var surface that packs a spec LIST into one string (t-deb92a) — borrowing another layer's delimiter is not this module's job, and doing so cost a working grant..in an import prefix. It is the component separator, so it cannot produce an unintended component — it produces the intended ones.
ASSUMPTION, with its expiry condition, because it is an assumption and not a fact: no library-name component contains a literal dot. That holds today because a dot is not legal in those symbols. If Sigil ever admits a dot into a library-name component, this grammar becomes ambiguous and the import kind needs a different separator — it does not degrade gracefully, so check this before assuming it still holds.
The spec string is an interchange format, not a pretty-printer
Consumers persist a grant with grant->specs and reload it with grant-from-specs, so a resource whose text contains one of the grammar's own delimiters is an INJECTION — and in a capability system, injection means authority appearing out of nothing. The case that forced the rule above: an exec command "a,b" unparses to exec:allowlist:a,b, which re-parses as the TWO commands a and b. The original grant permitted neither, so the round trip WIDENED it, and the reloaded grant was not even a lower bound of the original. Such values are refused at construction rather than escaped: no real command name contains a comma, and a loud refusal when the grant is built beats a silent re-interpretation when it is loaded.
The round trip is therefore required to preserve AUTHORITY, not just to produce a string that parses — (grant=? g (grant-from-specs (grant->specs g))) for every g, which the suite asserts over its whole corpus.
Spec strings
Every spec string (sigil system grant) accepts today parses here to the same authority, and unparses back:
fs:ro:<path>/fs:rw:<path>— the path may itself contain:exec:fullexec:allowlist:<cmd>,<cmd>,...— argv[0], matched EXACTLYpty:on
Added by this module:
net:connect:<pattern>/net:listen:<pattern>— the pattern may itself contain:, sonet:connect:https://codeberg.org:443parses. An IPv6 literal must be bracketed ([::1],[::1]:8080); an unbracketed host containing:is refused rather than guessed at.import:full/import:prefix:<dotted-prefix>— a library name is rendered dot-joined, so(sigil fs)is"sigil.fs"and the prefix"sigil"covers it. This rests on an assumption with an expiry condition; it is stated once, above, under "carryable".eval:onffi:on
Two properties that are easy to lose and expensive to lose
Component-safe prefixes. /a/bc is NOT under /a/b. A bare string prefix test would say it is.
argv[0]-exact exec matching. An allowlist entry "git" permits argv[0] "git" and NOT "/tmp/evil/git". Matching on the basename would let any attacker-controlled binary of the same name through. To allow an absolute path, grant that exact path.
Named extension point: argv-exact
This module constrains exec by argv[0] only. Constraining the WHOLE command line — (exec (argv-exact "git" "status")), permitting git status and denying git push — is a third exec mode, added alongside full and allowlist rather than replacing either. The facet shape (kind mode resource) already carries it: mode argv-exact, resource the argv list. It belongs with the enforcement work that knows a process's real argv, not here.
Exports
facet-kindprocedureThe facet's kind: one of fs, exec, net, import, eval, ffi, pty.
facet-modeprocedureThe facet's kind-specific mode.
facet-resourceprocedureThe facet's kind-specific resource pattern, or #f for kinds that have none.
facet?procedureIs obj a well-formed facet — the right shape AND a mode and resource its kind actually accepts?
This validates the resource, not merely the arity and the kind, and that is what lets facet-permits? keep its promise to be total. A shallow predicate would pass (fs rw 42) and the type error would surface inside the comparison instead — and a caller that guards a capability check and treats "no #f" as a pass would read that raise as PERMITTED.
component-prefix-covers?procedureComponent-safe prefix test, generalised over the separator.
True iff p is root itself or lies strictly beneath it, where "beneath" means the next character after the prefix is the separator. This is the whole component-safety property:
(component-prefix-covers? "/" "/a/b" "/a/b/c") ; => #t
(component-prefix-covers? "/" "/a/b" "/a/bc") ; => #fA bare string-starts-with? would answer #t to both, which is the hole this function exists to close. The root-ends-with-sep clause is what makes "/" behave.
facet-permits?procedureDoes facet big permit facet small?
Total, pure, and fail-closed: anything unrecognised answers #f rather than raising, so a facet kind this build does not know denies rather than passes.
make-grantprocedureBuild a grant from a list of facets.
Facets are canonicalised, same-kind scalar facets are merged, and facets subsumed by another facet of the same grant are dropped. Raises on a malformed facet.
(make-grant '((fs rw "/work") (exec allowlist ("git"))))grantprocedureBuild a grant from per-kind keyword arguments — the call-site spelling from the model.
(grant fs: '(rw "/work")
exec: '(allowlist "git" "sigil")
net: '(connect "codeberg.org:443"))The multi-resource kinds also take a list of entries:
(grant fs: '((rw "/work") (ro "/etc/ssl")) pty: 'on)empty-grantprocedureThe bottom of the lattice: permits nothing, and is covered by every grant.
grant-allow-allprocedureThe top of the lattice: covers every grant. This is the existing in-process owner posture — the embedding application is the user's own.
BOTH net modes are here deliberately. connect and listen are separate authorities, so a top holding only connect would not cover a listen facet — and a corpus that tested only connect would never notice.
grant?procedureIs obj a grant?
grant-facetsprocedureThe grant's facets, for introspection — a consent UI walks this.
grant-covers?procedureDoes big have at least as much authority as small?
True iff every facet of small is permitted by some facet of big. Total and pure. (grant-covers? g (empty-grant)) is vacuously true for every g — that is what "bottom" means.
grant=?procedureEquality in the authority ordering: mutual covering.
Deliberately NOT structural equality. See the module comment: an equality derived from the partial order cannot disagree with the partial order, so facet ordering and subsumed-but-harmless facets cannot make a true law read false.
attenuateprocedureThe meet: the greatest grant covered by both g and narrowing.
(grant-covers? g (attenuate g narrowing)) holds for ALL inputs, and so does the same claim for narrowing. Attenuation can never widen. Total, pure, and available to anyone — this is the operation a caller uses to hand out less authority than it holds.
(attenuate (grant-from-specs '("fs:rw:/work" "exec:full"))
(grant-from-specs '("fs:rw:/work/src")))
; => a grant holding only (fs rw "/work/src")spec->facetprocedureParse one spec string into a facet. Raises on a malformed spec.
Grammar: fs:ro:<path>, fs:rw:<path>, exec:full, exec:allowlist:<cmd>,<cmd>, pty:on, eval:on, ffi:on. The fs path may itself contain : — everything after the mode is rejoined, exactly as (sigil system grant) does it.
facet->specprocedureRender a facet back to its canonical spec string.
grant-from-specsprocedureBuild a grant from a list of spec strings.
grant->specsprocedureRender a grant to its canonical list of spec strings.