Rebuild (sigil system grant) as a profile over (sigil grant)
The table's fs-entries/exec/pty slots become one immutable (sigil grant) value; the module keeps the principal, the decision audit log, and the canonicalisation the algebra cannot have. Public API is unchanged and the 16 internal call sites in fs/process/session/proof are untouched.
DELETED, not adapted: path-under?, the component-safe prefix test. It is component-prefix-covers? in the algebra now, parameterised on its separator, and property-tested there. Also gone: the argv[0]-exact member check, and the hand-written "unless the existing exec is 'full" special case that stopped a later allowlist downgrading exec:full -- that now falls out of the algebra's scalar-kind merge rather than being a rule this module remembers to apply.
KEPT: realpath at construction and canonical-target at check time. Those are the profile's whole reason to exist. The algebra is pure because the browser target has no filesystem; resolving a path against the real one is the check site's job, and this is the check site. realpath also normalises away '.', '..' and relative prefixes, which is exactly what the algebra requires and refuses to do itself.
The check site asks facet-permits? against the grant's facets rather than building a probe grant and calling grant-covers?, and the difference matters here: make-grant RAISES on a resource it considers unconstructible, while facet-permits? is total and fail-closed and DENIES. A check handed a hostile argv[0] must return #f, not throw -- a caller that guards the check and reads "no #f" as a pass would treat a raise as permitted.
grant-add! deliberately accepts a NARROWER grammar than the algebra: fs, exec and pty only. (sigil grant) also parses net/import/eval/ffi, and storing one of those here would record authority nothing in this library ever consults -- a rule that grants nothing, sitting in a config looking like it grants something.
NOT LANDABLE YET: package.sgl still says sigil: "^0.17", and (sigil grant) needs the release that ships it. That one field is the only thing gating this branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DYn8wxSdjveEgK9NRxFcNA
src/sigil/system/grant.sgl | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------
1 file changed, 107 insertions(+), 76 deletions(-)src/sigil/system/grant.sglmodified
;;; (sigil system grant) - Capability Grant Engine;;; (sigil system grant) - Capability Grant Engine (system profile);;;;;; The deny-by-default scoping policy that guards every capability entry;;; point in this library. A grants table declares what a principal may;;; while a remote node runs `make-grants` plus explicit grant entries;;; (deny-by-default). Enforcement logic is identical in both.;;;;;; ## This module is a PROFILE over `(sigil grant)`, not a second engine;;;;;; The grant value, the facet grammar, the partial order and the;;; attenuation meet all live in `(sigil grant)` in the standard library.;;; This module is the OS-facing profile on top of it, and it owns;;; exactly three things the pure algebra cannot have:;;;;;; 1. **Canonicalisation.** `realpath` at construction, so a grant is;;; anchored to a real directory; and `realpath` at check time, so a;;; symlink cannot escape an allowed root. The algebra is pure and;;; compares patterns structurally — it has no filesystem, by design,;;; because the browser/wasm target has none.;;; 2. **The principal** a table is scoped to.;;; 3. **The decision audit log.**;;;;;; What USED to live here and now does not: the component-safe prefix;;; test (`/a/bc` is not under `/a/b`), the argv[0]-exact exec match, and;;; the rule that `exec:full` is not downgraded by a later allowlist.;;; All three are now properties of the algebra — the last one falls out;;; of its scalar-kind merge rather than being a hand-written special;;; case — and all three are property-tested there.;;;;;; ## Grant specs;;;;;; Grants are added with compact spec strings (the same grammar a node;;; (grant-assert! g 'exec "rm") ; raises;;; ```;;;;;; **This profile deliberately accepts a NARROWER spec grammar than;;; `(sigil grant)` does.** The algebra also understands `net`, `import`,;;; `eval` and `ffi`, and this module refuses them — because it does not;;; ENFORCE them. Storing a grant nothing ever checks would put a rule;;; that grants nothing into a config where it looks like it grants;;; something, which is a worse failure than a refusal. When enforcement;;; for a kind arrives here, its spec form is admitted in the same;;; change.;;;;;; Paths are canonicalized (symlinks resolved) before checking, so a;;; symlink escaping an allowed root is denied. Every decision is;;; recorded in an in-memory audit log (`grant-decisions`); a durable (import (sigil fs) (sigil path) (sigil string) (sigil list) (sigil grant) (sigil time)) (export (begin ;; Grants are a small mutable table: ;; (vector 'grants principal fs-entries exec pty decisions) ;; fs-entries: list of (mode . canonical-root), mode in {ro rw} ;; exec: 'off | 'full | list of allowed command names ;; pty: boolean ;; decisions: reverse-chronological list of decision dicts ;; A grants table is a small mutable box around an IMMUTABLE ;; (sigil grant) value: ;; ;; (vector 'grants principal <grant> decisions) ;; ;; The table is mutable because `grant-add!` is part of this module's ;; established API; the authority inside it is not. Every add ;; REPLACES the grant with a new one rather than mutating a list of ;; entries, so an authority value handed to anything else cannot be ;; changed underneath it. (define (make-grants-vector principal fs-entries exec pty) (vector 'grants principal fs-entries exec pty '())) (define (make-grants-vector principal g) (vector 'grants principal g '())) ;;; Create an empty, deny-by-default grants table. ;;; ;;; ``` (define (make-grants (keys: (principal "owner"))) (: (principal: string?) -> any?) (make-grants-vector principal '() 'off #f)) (make-grants-vector principal (empty-grant))) ;;; Create a fully permissive grants table (the in-process owner ;;; posture: the embedding application is the user's own). ;;; ;;; Note this is the top of the THREE KINDS THIS PROFILE ENFORCES, ;;; not the algebra's `(grant-allow-all)`, which is the top of all ;;; seven. Granting kinds nothing here checks would be authority ;;; recorded and never consulted. (define (grant-allow-all (keys: (principal "owner"))) (: (principal: string?) -> any?) (make-grants-vector principal (list (cons 'rw "/")) 'full #t)) (make-grants-vector principal (make-grant '((fs rw "/") (exec full #f) (pty on #f))))) ;;; Check if a value is a grants table. (define (grants? obj) (: any? -> string?) (vector-ref g 1)) (define (grants-fs-entries g) (vector-ref g 2)) (define (grants-exec g) (vector-ref g 3)) (define (grants-pty g) (vector-ref g 4)) (define (set-grants-fs-entries! g v) (vector-set! g 2 v)) (define (set-grants-exec! g v) (vector-set! g 3 v)) (define (set-grants-pty! g v) (vector-set! g 4 v)) ;; The immutable authority value this table currently holds. (define (grants-grant g) (vector-ref g 2)) (define (set-grants-grant! g v) (vector-set! g 2 v)) ;;; The decision audit log, most recent first. Each entry is a dict: ;;; `#{ op: fs-read stream detail: "/path" allowed: #t at: <seconds> }`. (define (grant-decisions g) (: any? -> list?) (vector-ref g 5)) (vector-ref g 3)) (define (record-decision! g op detail allowed) (vector-set! g 5 (vector-set! g 3 (cons (dict op: op detail: detail allowed: allowed at: (current-second)) (vector-ref g 5))) (vector-ref g 3))) allowed) ;; ============================================================ ;; Adding grants ;; ============================================================ ;; Add one facet to the table's grant. `make-grant` re-normalises, ;; which is where `exec:full` absorbing a later allowlist now comes ;; from — the algebra's scalar-kind merge, rather than the ;; hand-written "unless the existing exec is 'full" special case this ;; module used to carry. (define (add-facet! g facet) (set-grants-grant! g (make-grant (cons facet (grant-facets (grants-grant g)))))) ;;; Add a grant to the table from a spec string. ;;; ;;; Spec grammar: `fs:ro:<path>`, `fs:rw:<path>`, (: any? string? -> void?) (let ((parts (string-split spec ":"))) (cond ;; fs:ro:<path> / fs:rw:<path> — path may itself contain ':' ;; fs:ro:<path> / fs:rw:<path> — path may itself contain ':'. ;; ;; THIS BRANCH IS WHY THIS MODULE STILL EXISTS. The algebra ;; cannot canonicalise: it is pure, and the browser target has ;; no filesystem. So the profile resolves the path HERE and ;; hands the algebra a canonical, existing, absolute root. ;; `realpath` also normalises away `.`, `..` and any relative ;; prefix, which is exactly what the algebra requires and ;; refuses to do itself. ((and (>= (length parts) 3) (string=? (car parts) "fs")) (let ((mode (cadr parts)) (let ((root (realpath path))) (unless root (error "grant-add!: fs grant path does not exist" path)) (set-grants-fs-entries! g (cons (cons (if (string=? mode "rw") 'rw 'ro) root) (grants-fs-entries g)))))) ;; exec:full ((and (= (length parts) 2) (string=? (car parts) "exec") (string=? (cadr parts) "full")) (set-grants-exec! g 'full)) ;; exec:allowlist:<cmd>,<cmd> ((and (= (length parts) 3) (string=? (car parts) "exec") (string=? (cadr parts) "allowlist")) (let ((cmds (string-split (caddr parts) ",")) (existing (grants-exec g))) ;; 'full is strictly more permissive than any allowlist, so ;; adding an allowlist entry on top of full must NOT downgrade ;; the grant to that bounded list. (unless (eq? existing 'full) (set-grants-exec! g (append cmds (if (pair? existing) existing '())))))) (add-facet! g (list 'fs (if (string=? mode "rw") 'rw 'ro) root))))) ;; pty:on ((and (= (length parts) 2) (string=? (car parts) "pty") (string=? (cadr parts) "on")) (set-grants-pty! g #t)) ;; The kinds this profile ENFORCES are the only ones it admits. ;; `spec->facet` would also accept net/import/eval/ffi, and ;; storing one of those here would record authority that ;; nothing in this library ever consults. ((or (string=? (car parts) "exec") (string=? (car parts) "pty")) (add-facet! g (spec->facet spec))) (else (error "grant-add!: malformed grant spec" spec))))) (and parent (path-join parent (path-basename path))))))) ;; Is p equal to root, or strictly under it? Component-safe: ;; "/a/bc" is NOT under "/a/b". (define (path-under? root p) (or (string=? p root) (if (string=? root "/") (string-starts-with? p "/") (string-starts-with? p (string-append root "/"))))) ;; Does the table's authority permit this facet? ;; ;; Asked through `facet-permits?` against the grant's own facets ;; rather than by building a probe grant and calling `grant-covers?`, ;; and the difference is load-bearing at a CHECK site: `make-grant` ;; RAISES on a resource it considers unconstructible (a command ;; containing a comma, say), while `facet-permits?` is total and ;; fail-closed and DENIES it. A check handed a hostile argv[0] must ;; return #f, not throw — a caller that guards the check and treats ;; "no #f" as a pass would read a raise as permitted. (define (permits? g facet) (list-any? (lambda (f) (facet-permits? f facet)) (grant-facets (grants-grant g)))) (define (fs-allowed? g path for-write?) (let ((target (canonical-target path for-write?))) (and target (let loop ((entries (grants-fs-entries g))) (cond ((null? entries) #f) ((and (path-under? (cdr (car entries)) target) (or (not for-write?) (eq? (car (car entries)) 'rw))) #t) (else (loop (cdr entries)))))))) (permits? g (list 'fs (if for-write? 'rw 'ro) target))))) ;; argv[0] is matched EXACTLY by the algebra's exec facet: an ;; allowlist entry "git" permits argv[0] "git" and NOT ;; "/tmp/evil/git", i.e. any attacker-controlled binary of the same ;; name. To allow an absolute path, grant that exact path. (define (exec-allowed? g command) (let ((exec (grants-exec g))) (cond ((eq? exec 'full) #t) ((pair? exec) ;; Match argv[0] EXACTLY against the allowlist. Matching on the ;; basename would be a hole: an allowlist entry "git" would then ;; also permit "/tmp/evil/git", i.e. any attacker-controlled ;; binary of the same name. So "git" allows only argv[0] = "git" ;; (resolved via PATH); to allow an absolute path, grant that ;; exact path. (if (member command exec) #t #f)) (else #f)))) (and (string? command) (permits? g (list 'exec 'allowlist (list command))))) ;;; Check whether an operation is allowed. Records the decision in ;;; the audit log and returns a boolean. ((fs-read) (fs-allowed? g detail #f)) ((fs-write) (fs-allowed? g detail #t)) ((exec) (exec-allowed? g detail)) ((pty) (grants-pty g)) ((pty) (permits? g '(pty on #f))) (else #f)))) ;;; Like `grant-check`, but raises a `grant-denied` error when the