S0 skeleton: (sigil system) capability library with grant-checked fs, exec, and PTY sessions
The S0 core of the system-access program: a generic, grant-checked capability library over the local system, consumed the same way in-process and (later) by a remote node.
Modules (src/sigil/system/): - grant: deny-by-default policy engine. fs ro/rw path allowlists (realpath-canonicalized, component-safe prefix match so /a/b does not cover /a/bc; write targets canonicalize the parent so a symlinked parent cannot escape), exec allowlist matched EXACTLY against argv[0] (a basename match would let /tmp/evil/git satisfy an "git" grant), exec:full, pty flag, and an in-memory decision audit log whose record shape is what a durable log will persist. - fs: thin grant-checked wrappers over native (sigil fs) — read-dir (one stat dict per entry), stat, read-file(/-bytes), atomic tmp+rename write, mkdir, rename, delete (recursive opt-in, does not follow symlinks out of the tree). - process: grant-checked one-shot exec (argv list, never a shell string). stdout and stderr are always drained concurrently under a scheduler, so a stderr-heavy child cannot deadlock on a full pipe buffer; the join completes even if a reader raises; the child is reaped without blocking the VM. - session: the Session plane. session-open spawns a PTY child plus a reader-pump (credit-window flow control: the pump stops reading the master when the window is exhausted, so backpressure reaches a flooding child through the kernel pty buffer with no buffering growth in our stack) and a writer-drain (enqueue-and-drain, partial-write re-slice). Teardown is ordered so the pty master is never closed out from under a goroutine parked on its fd (a select on a closed fd never reports ready): the master is the reader-pump's sole property, closed only after the child is reaped. session-write/resize/signal/ close/credit; session-events is a channel of data events then one exit event. - proof: the headless S0 proof as a runnable entry module.
Fork discipline for the PTY lives entirely in the monorepo's (sigil process) C layer; nothing here forks. Builds against a patched sigil via dev-redirects.sgl until the (sigil process) pty primitives ship in a release.
Proof + tests (sigil test --redirects dev-redirects.sgl): 24/24 green, including the exit criterion (open a pty, run ls, stream output, resize, reap) plus a stdin round-trip, a close-kills-a-sleeper case, and a stderr-heavy no-deadlock regression. sigil run --redirects dev-redirects.sgl runs the standalone headless proof.
.gitignore | 3 ++
dev-redirects.sgl | 11 +++++
package.sgl | 36 ++++++++++++++
sigil.lock | 41 ++++++++++++++++
src/sigil/system/fs.sgl | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/system/grant.sgl | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/system/process.sgl | 120 ++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/system/proof.sgl | 66 ++++++++++++++++++++++++++
src/sigil/system/session.sgl | 426 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-system.sgl | 261 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1367 insertions(+).gitignoreadded
build/.sigil/*.logdev-redirects.sgladded
;; Development redirects — point dependencies at a local Sigil checkout;; when developing against unreleased runtime changes (e.g. the pty-aware;; spawn primitives in (sigil process)).;;;; Usage: sigil build --redirects dev-redirects.sgl;; Adjust dir: if your sigil checkout lives elsewhere.(redirects repos: (list (for-repo url: "codeberg:sigil/sigil" use: (from-path dir: "../sigil"))))package.sgladded
;;; sigil-system - System capability library for Sigil;;;;;; Grant-checked access to the local system: filesystem operations,;;; one-shot process execution, and PTY-backed stream sessions. One;;; shared implementation consumed both in-process (desktop embedding);;; and by remote-node wrappers.(package name: "sigil-system" version: "0.1.0" sigil: "^0.17" description: "System capability library: filesystem, processes, and PTY sessions behind a grant-checked surface" url: "https://codeberg.org/sigil/sigil-system" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") ;; The headless PTY proof (the S0 exit criterion) is the default ;; runnable: `sigil run --redirects dev-redirects.sgl` demonstrates ;; open -> stream ls -> resize -> reap end to end. `sigil run`'s dev ;; loader imports this entry value as a module and calls its `main`, ;; so the entry is the module name alone (not the `(module main)` ;; form used by native-bundled apps). entry: '(sigil system proof) bundle-name: "headless-pty-proof" configs: (list (config name: 'dev output-dir: "build/dev" debug?: #t optimize: 0 bundle?: #t)) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.17") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.17")))sigil.lockadded
;; Auto-generated by sigil deps install. Do not edit.(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "8b3e7f90795b569a5ac3011c5a46311989230648" package-selector: "sigil-stdlib" version: "0.17.13") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "8b3e7f90795b569a5ac3011c5a46311989230648" package-selector: "sigil-test" version: "0.17.13") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "8b3e7f90795b569a5ac3011c5a46311989230648" package-selector: "sigil-test-runner" version: "0.17.13") (package name: "sigil-ansi" url: "codeberg:sigil/sigil-ansi" ref: "^0.16.0" sha: "1e14b6f9d034ca8ef9f404e2ded316d0780ad7cf" version: "0.16.0") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.16.0" sha: "55c9e74712b5f79b45d41c65cfe0aef2c8f7e155" version: "0.16.0") (package name: "sigil-version" url: "codeberg:sigil/sigil-version" ref: "^0.16.0" sha: "2d2c9ef9c2694466f65abc14cddf9bfb9cada4a4" version: "0.16.0") (package name: "sigil-git" url: "codeberg:sigil/sigil-git" ref: "^0.16.0" sha: "2d54eb99d3ca0597b3ccd9d4829d312c54721086" version: "0.16.1"))src/sigil/system/fs.sgladded
;;; (sigil system fs) - Grant-Checked Filesystem Capability;;;;;; The Resource-plane filesystem surface: thin capability wrappers over;;; the native `(sigil fs)` operations, with a grant check at every entry;;; point. Nothing here reimplements filesystem work — the value is the;;; uniform, scoped surface that both an in-process embedding and a;;; remote node wrapper call identically.;;;;;; Every operation takes a grants table (see `(sigil system grant)`);;; as its first argument and raises `grant-denied` when the principal;;; lacks access to the (canonicalized) path.;;;;;; ```scheme;;; (import (sigil system grant);;; (sigil system fs));;;;;; (define g (make-grants));;; (grant-add! g "fs:rw:/tmp/work");;;;;; (fs-write-file g "/tmp/work/hello.txt" "hi\n");;; (fs-read-file g "/tmp/work/hello.txt") ; => "hi\n";;; (fs-read-dir g "/tmp/work");;; ;; => (#{ name: "hello.txt" type: file size: 3 mtime: ... mode: ... });;; ```(define-library (sigil system fs) (import (sigil fs) (sigil path) (sigil io) (sigil system grant)) (export fs-read-dir fs-stat fs-read-file fs-read-file-bytes fs-write-file fs-mkdir fs-rename fs-delete) (begin ;; Build the rich stat dict all read surfaces share. (define (stat->dict name path st) (dict name: name path: path type: (stat-type st) size: (stat-size st) mtime: (stat-mtime st) mode: (stat-mode st))) ;;; Stat a file or directory. Returns a dict with `name:` `path:` ;;; `type:` `size:` `mtime:` `mode:`, or raises if the path does ;;; not exist. (define (fs-stat g path) (: any? string? -> dict?) (grant-assert! g 'fs-read path) (let ((st (file-stat path))) (unless st (error "fs-stat: no such path" path)) (stat->dict (path-basename path) path st))) ;;; List a directory. Returns one stat dict per entry (the one ;;; round-trip a directory view needs). Entries that vanish between ;;; the listing and the stat are skipped. (define (fs-read-dir g path) (: any? string? -> list?) (grant-assert! g 'fs-read path) (let ((names (directory-list path))) (unless names (error "fs-read-dir: cannot list directory" path)) (fold-right (lambda (name acc) (let* ((entry-path (path-join path name)) (st (file-stat entry-path))) (if st (cons (stat->dict name entry-path st) acc) acc))) '() names))) ;;; Read a file's contents as a string. (define (fs-read-file g path) (: any? string? -> string?) (grant-assert! g 'fs-read path) (read-file-string path)) ;;; Read a file's contents as a bytevector. (define (fs-read-file-bytes g path) (: any? string? -> bytevector?) (grant-assert! g 'fs-read path) (read-file-bytes path)) ;;; Write a file atomically (write to a temporary sibling, then ;;; rename over the target). Content may be a string or bytevector. ;;; Returns the new stat dict. (define (fs-write-file g path content) (: any? string? any? -> dict?) (grant-assert! g 'fs-write path) (let ((tmp (string-append path ".sigil-system-tmp"))) (if (bytevector? content) (write-file-bytes tmp content) (write-file-string tmp content)) (rename-file tmp path) (let ((st (file-stat path))) (stat->dict (path-basename path) path st)))) ;;; Create a directory (including missing parents). (define (fs-mkdir g path) (: any? string? -> void?) (grant-assert! g 'fs-write path) (ensure-directory path)) ;;; Rename/move a file or directory. Both endpoints need write ;;; access (removing from the source, creating at the destination). (define (fs-rename g from to) (: any? string? string? -> void?) (grant-assert! g 'fs-write from) (grant-assert! g 'fs-write to) (rename-file from to)) ;;; Delete a file, or a directory with `recursive: #t`. Deleting a ;;; non-empty directory without `recursive:` raises; recursion is ;;; never the default. (define (fs-delete g path (keys: (recursive #f))) (: any? string? (recursive: boolean?) -> void?) (grant-assert! g 'fs-write path) (cond ((not (directory? path)) (delete-file path)) (recursive (delete-tree path)) (else ;; delete-directory raises on non-empty; that is the contract. (delete-directory path)))) ;; Recursive delete helper: post-order walk. (define (delete-tree path) (for-each (lambda (name) (let ((entry (path-join path name))) (if (and (directory? entry) (not (symlink? entry))) (delete-tree entry) (delete-file entry)))) (directory-list path)) (delete-directory path))))src/sigil/system/grant.sgladded
;;; (sigil system grant) - Capability Grant Engine;;;;;; The deny-by-default scoping policy that guards every capability entry;;; point in this library. A grants table declares what a principal may;;; do; each (sigil system fs|process|session) operation checks the table;;; before touching the system.;;;;;; The same engine serves two postures: an in-process desktop embedding;;; typically runs `grant-allow-all` (it is the user's own application),;;; while a remote node runs `make-grants` plus explicit grant entries;;; (deny-by-default). Enforcement logic is identical in both.;;;;;; ## Grant specs;;;;;; Grants are added with compact spec strings (the same grammar a node;;; CLI would accept):;;;;;; - `fs:ro:<path>` — read-only filesystem access under <path>;;; - `fs:rw:<path>` — read-write filesystem access under <path>;;; - `exec:allowlist:<cmd>,<cmd>` — allow executing these commands;;; - `exec:full` — allow executing anything;;; - `pty:on` — allow opening PTY sessions;;;;;; ```scheme;;; (import (sigil system grant));;;;;; (define g (make-grants principal: "worker-1"));;; (grant-add! g "fs:rw:/tmp/work");;; (grant-add! g "exec:allowlist:ls,git");;; (grant-add! g "pty:on");;;;;; (grant-check g 'fs-read "/tmp/work/notes.txt") ; => #t;;; (grant-check g 'fs-write "/etc/passwd") ; => #f;;; (grant-assert! g 'exec "rm") ; raises;;; ```;;;;;; 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;;; append-only log is a later refinement, but the decision record shape;;; is already what that log will persist.(define-library (sigil system grant) (import (sigil fs) (sigil path) (sigil string) (sigil time)) (export ;; Construction make-grants grant-allow-all grants? grants-principal ;; Adding grants grant-add! ;; Checking grant-check grant-assert! ;; Audit grant-decisions) (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 (define (make-grants-vector principal fs-entries exec pty) (vector 'grants principal fs-entries exec pty '())) ;;; Create an empty, deny-by-default grants table. ;;; ;;; ```scheme ;;; (define g (make-grants principal: "worker-1")) ;;; (grant-check g 'pty #f) ; => #f — nothing is allowed yet ;;; ``` (define (make-grants (keys: (principal "owner"))) (: (principal: string?) -> any?) (make-grants-vector principal '() 'off #f)) ;;; Create a fully permissive grants table (the in-process owner ;;; posture: the embedding application is the user's own). (define (grant-allow-all (keys: (principal "owner"))) (: (principal: string?) -> any?) (make-grants-vector principal (list (cons 'rw "/")) 'full #t)) ;;; Check if a value is a grants table. (define (grants? obj) (: any? -> boolean?) (and (vector? obj) (> (vector-length obj) 0) (eq? (vector-ref obj 0) 'grants))) ;;; The principal this grants table scopes. (define (grants-principal g) (: 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 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)) (define (record-decision! g op detail allowed) (vector-set! g 5 (cons (dict op: op detail: detail allowed: allowed at: (current-second)) (vector-ref g 5))) allowed) ;; ============================================================ ;; Adding grants ;; ============================================================ ;;; Add a grant to the table from a spec string. ;;; ;;; Spec grammar: `fs:ro:<path>`, `fs:rw:<path>`, ;;; `exec:allowlist:<cmd>,<cmd>...`, `exec:full`, `pty:on`. ;;; Raises on a malformed spec, and on an fs path that does not ;;; exist (grants are anchored to real, canonical directories). (define (grant-add! g spec) (: any? string? -> void?) (let ((parts (string-split spec ":"))) (cond ;; fs:ro:<path> / fs:rw:<path> — path may itself contain ':' ((and (>= (length parts) 3) (string=? (car parts) "fs")) (let ((mode (cadr parts)) (path (string-join (cddr parts) ":"))) (unless (or (string=? mode "ro") (string=? mode "rw")) (error "grant-add!: fs mode must be ro or rw" spec)) (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 '())))))) ;; pty:on ((and (= (length parts) 2) (string=? (car parts) "pty") (string=? (cadr parts) "on")) (set-grants-pty! g #t)) (else (error "grant-add!: malformed grant spec" spec))))) ;; ============================================================ ;; Checking ;; ============================================================ ;; Canonicalize a path for checking. Read targets must exist. ;; Write targets may be new files: canonicalize the parent ;; directory and re-attach the final component, so a symlinked ;; parent still cannot escape an allowed root. (define (canonical-target path for-write?) (or (realpath path) (and for-write? (let ((parent (realpath (path-dirname path)))) (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 "/"))))) (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)))))))) (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)))) ;;; Check whether an operation is allowed. Records the decision in ;;; the audit log and returns a boolean. ;;; ;;; Operations: `'fs-read <path>`, `'fs-write <path>`, ;;; `'exec <command>`, `'pty <ignored>`. (define (grant-check g op detail) (: any? symbol? any? -> boolean?) (record-decision! g op detail (case op ((fs-read) (fs-allowed? g detail #f)) ((fs-write) (fs-allowed? g detail #t)) ((exec) (exec-allowed? g detail)) ((pty) (grants-pty g)) (else #f)))) ;;; Like `grant-check`, but raises a `grant-denied` error when the ;;; operation is not allowed. Every capability entry point in ;;; (sigil system fs|process|session) calls this first. (define (grant-assert! g op detail) (: any? symbol? any? -> void?) (unless (grant-check g op detail) (error "grant-denied" (dict principal: (grants-principal g) op: op detail: detail))))))src/sigil/system/process.sgladded
;;; (sigil system process) - Grant-Checked One-Shot Execution;;;;;; The Resource-plane exec surface: run a command to completion and;;; capture its output. Commands are argv LISTS, never shell strings —;;; anything that needs a shell goes through a PTY session running the;;; user's shell (see `(sigil system session)`), where that choice is;;; explicit.;;;;;; ```scheme;;; (import (sigil system grant);;; (sigil system process));;;;;; (define g (make-grants));;; (grant-add! g "exec:allowlist:ls,git");;;;;; (define r (process-exec g '("ls" "-la" "/tmp")));;; (dict-ref r exit:) ; => 0;;; (dict-ref r stdout:) ; => "total 42\n...";;; (dict-ref r stderr:) ; => "";;; ```(define-library (sigil system process) (import (sigil process) (sigil fs) (sigil io) (sigil async) (sigil channels) (sigil system grant)) (export process-exec) (begin ;; Read everything from a port. Pipe ports are async-aware in ;; (sigil io): inside a scheduler, read-line yields to the fd ;; waiters instead of blocking the VM, so stdout and stderr readers ;; running as concurrent goroutines interleave and never deadlock on ;; a full pipe buffer. (define (read-all port) (if port (let loop ((chunks '())) (let ((line (read-line port))) (if (eof-object? line) (string-join (reverse chunks) "\n") (loop (cons line chunks))))) "")) ;;; Run a command to completion and capture the result. ;;; ;;; `argv` is a non-empty list: the command and its arguments. ;;; Returns `#{ exit: <integer> stdout: <string> stderr: <string> }`. ;;; Raises `grant-denied` unless the grants table allows executing ;;; the command. ;;; ;;; With `cwd:`, the child runs in that directory (which also needs ;;; a read grant). ;;; ;;; stdout and stderr are ALWAYS drained concurrently, so a ;;; stderr-heavy (or stdout-heavy) child can never deadlock on a full ;;; pipe buffer. When called outside an async context, an internal ;;; scheduler is spun up for the duration so the same concurrent ;;; drain applies. (define (process-exec g argv (keys: (cwd #f))) (: any? list? (cwd: any?) -> dict?) (unless (and (pair? argv) (string? (car argv))) (error "process-exec: argv must be a non-empty list of strings" argv)) (grant-assert! g 'exec (car argv)) (when cwd (grant-assert! g 'fs-read cwd)) (let ((p (if cwd ;; (sigil process) has no native cwd support yet; the ;; working directory is process-global, so scope the ;; change tightly around the spawn (no yield point in ;; between). (with-current-directory cwd (apply process-spawn (car argv) (cdr argv))) (apply process-spawn (car argv) (cdr argv))))) (unless p (error "process-exec: failed to spawn" argv)) (if (in-async-context?) (drain-and-reap p) ;; Sync caller: run the concurrent drain under a private ;; scheduler. The result is captured in a box because ;; with-async returns the scheduler's status symbol, not the ;; body value. (let ((box (vector #f))) (with-async (vector-set! box 0 (drain-and-reap p))) (vector-ref box 0))))) ;; Drain stdout+stderr concurrently, then reap. MUST run inside a ;; scheduler (the two readers are goroutines). (define (drain-and-reap p) (let ((out-ch (make-channel 1)) (err-ch (make-channel 1))) ;; Each reader always reports on its channel, even if read-all ;; raises — otherwise a mid-stream read error would leave the ;; join waiting forever. (go (channel-send out-ch (guarded-read (process-stdout p)))) (go (channel-send err-ch (guarded-read (process-stderr p)))) (let ((out (channel-receive out-ch)) (err (channel-receive err-ch))) (dict exit: (reap p) stdout: out stderr: err)))) (define (guarded-read port) (guard (e (#t "")) (read-all port))) ;; Reap without blocking the scheduler. Both pipes are at EOF here, ;; which usually means the child exited, but not always (it may have ;; closed stdout/stderr and lingered). Poll process-alive? (a ;; non-blocking waitpid(WNOHANG) that reaps and caches the status on ;; exit) so a lingering child never freezes the VM; process-wait then ;; returns the cached status. (define (reap p) (let loop () (if (process-alive? p) (begin (sleep 0.005) (loop)) (process-wait p))))))src/sigil/system/proof.sgladded
;;; Headless PTY proof — the S0 exit criterion.;;;;;; Using ONLY sigil-system (no Slate, no Lantern, no Enclave): open a;;; PTY session, run a command, stream its output, resize the pty, and;;; reap the child cleanly.;;;;;; Run with the DEV sigil built from the patched monorepo (package.sgl;;; sets this module as the entry point):;;; sigil run --redirects dev-redirects.sgl;;;;;; The same open/stream/resize/reap flow is also exercised, assertion;;; by assertion, in test/test-system.sgl ("pty session" group).(define-library (sigil system proof) (import (sigil async) (sigil channels) (sigil io) (sigil string) (sigil system grant) (sigil system session)) (export main) (begin (define (main) ;; Deny-by-default grants, then allow exactly what the proof needs. (define g (make-grants principal: "proof")) (grant-add! g "pty:on") (grant-add! g "exec:allowlist:sh") (with-async (let ((s (session-open g (dict kind: 'pty argv: '("sh" "-c" "ls / ; echo __READY__ ; sleep 0.2 ; stty size") cols: 80 rows: 24)))) (println "session opened: id=~a kind=~a" (session-id s) (session-kind s)) ;; Resize partway through: the script's `stty size` prints the ;; CURRENT window, which must reflect the resize below. (session-resize s 132 50) (let ((acc '())) (for-channel (ev (session-events s)) (case (dict-ref ev type:) ((data) (let ((bytes (dict-ref ev bytes:))) (set! acc (cons (utf8->string bytes) acc)) ;; Replenish the flow-control window as we consume. (session-credit s (bytevector-length bytes)))) ((exit) (let ((text (apply string-append (reverse acc)))) (println "----- child output -----") (display text) (newline) (println "----- end output -----") (println "saw READY marker: ~a" (string-contains? text "__READY__")) (println "saw resized size 50 132: ~a" (string-contains? text "50 132")) (println "exit code: ~a" (dict-ref ev code:)) (println "alive after exit: ~a" (session-alive? s))))))))))))src/sigil/system/session.sgladded
;;; (sigil system session) - Session-Plane Stream Sessions;;;;;; The Session plane: bidirectional, flow-controlled byte streams over;;; long-lived children — PTY shells first. Where the Resource plane;;; (`(sigil system fs)` / `(sigil system process)`) moves whole values;;; at gesture rate, a session is a stateful stream: opened, written to;;; at keystroke rate, read as chunked byte events, resized, signalled,;;; and finally closed and reaped.;;;;;; Sessions REQUIRE an async context (`with-async`): opening a session;;; spawns a reader pump and a writer drain as goroutines.;;;;;; ## Consuming;;;;;; `session-events` is a channel of event dicts, in order:;;;;;; - `#{ type: data session: <id> stream: out bytes: <bytevector> }`;;; - `#{ type: exit session: <id> code: <n> }` (last event; the;;; channel is closed after it);;;;;; Chunk boundaries carry no meaning; consumers must parse;;; incrementally.;;;;;; ## Flow control;;;;;; Credit-based windowing: a session starts with `credit:` bytes of;;; window (default 256 KiB); every delivered data event consumes;;; window; `session-credit` replenishes it as the consumer processes;;; data. When the window is exhausted the pump stops READING the pty;;; master, the kernel buffer fills, and the child blocks on write —;;; backpressure reaches a flooding producer through the kernel's own;;; mechanism with no buffering growth anywhere in between.;;;;;; ## Writing;;;;;; `session-write` enqueues and returns; a drain goroutine feeds the;;; pty as it accepts bytes (handling partial writes and EAGAIN). No;;; caller ever blocks per-keystroke. If the outbound queue fills;;; (default 64 chunks), `session-write` blocks until the drain catches;;; up — bounded memory, honest backpressure.;;;;;; ```scheme;;; (import (sigil async) (sigil channels);;; (sigil system grant) (sigil system session));;;;;; (define g (make-grants));;; (grant-add! g "exec:allowlist:sh");;; (grant-add! g "pty:on");;;;;; (with-async;;; (let ((s (session-open g (dict kind: 'pty argv: '("sh");;; cols: 120 rows: 40))));;; (session-write s "ls\n");;; (for-channel (ev (session-events s));;; (case (dict-ref ev type:);;; ((data) (consume (dict-ref ev bytes:));;; (session-credit s (bytevector-length (dict-ref ev bytes:))));;; ((exit) (println "exit: ~a" (dict-ref ev code:)))))));;; ```(define-library (sigil system session) (import (sigil process) (sigil math) (sigil async) (sigil channels) (sigil system grant)) (export session-open session? session-id session-kind session-events session-write session-resize session-signal session-close session-credit session-alive? session-exit-status *default-credit-window*) (begin ;;; Initial flow-control window in bytes. (define *default-credit-window* (* 256 1024)) ;; Max bytes per read; also the biggest single data event. (define *read-chunk-size* 65536) ;; Outbound queue depth (chunks) before session-write blocks. (define *write-queue-depth* 64) (define *next-session-id* 0) ;; Session record: ;; (vector 'session id kind proc events out-queue window credit-wake ;; exit-status closing?) ;; events: channel of event dicts (reader pump -> consumer) ;; out-queue: channel of string|bytevector (caller -> writer drain) ;; window: remaining credit in bytes (box via vector slot) ;; credit-wake: unbuffered-ish channel the pump parks on at zero window ;; exit-status: #f until reaped ;; closing?: #t once session-close has run (define (session-vector id kind proc events out-queue window credit-wake) (vector 'session id kind proc events out-queue window credit-wake #f #f)) ;;; Check if a value is a session. (define (session? obj) (: any? -> boolean?) (and (vector? obj) (> (vector-length obj) 0) (eq? (vector-ref obj 0) 'session))) ;;; The session's id (a monotonically increasing integer). (define (session-id s) (: any? -> integer?) (vector-ref s 1)) ;;; The session's kind (currently always `pty`). (define (session-kind s) (: any? -> symbol?) (vector-ref s 2)) (define (session-proc s) (vector-ref s 3)) ;;; The session's event channel: `data` events then one `exit` ;;; event, after which the channel is closed. (define (session-events s) (: any? -> any?) (vector-ref s 4)) (define (session-out-queue s) (vector-ref s 5)) (define (session-window s) (vector-ref s 6)) (define (set-session-window! s n) (vector-set! s 6 n)) (define (session-credit-wake s) (vector-ref s 7)) ;;; The child's exit status, or #f while it is still running. (define (session-exit-status s) (: any? -> any?) (vector-ref s 8)) (define (set-session-exit-status! s v) (vector-set! s 8 v)) (define (session-closing? s) (vector-ref s 9)) (define (set-session-closing! s) (vector-set! s 9 #t)) ;;; Is the session's child still running? (define (session-alive? s) (: any? -> boolean?) (and (not (session-exit-status s)) (process-alive? (session-proc s)))) ;; ============================================================ ;; Opening ;; ============================================================ ;;; Open a stream session. ;;; ;;; `spec` is a dict: ;;; ;;; - `kind:` — `'pty` (a pseudo-terminal session; the only kind in ;;; this release. A pipe-based `'process` kind follows.) ;;; - `argv:` — non-empty command list, e.g. `'("bash" "-l")` ;;; - `cwd:` — child working directory (optional) ;;; - `cols:` / `rows:` — initial terminal size (default 80x24) ;;; - `term:` — TERM value (default "xterm-256color") ;;; - `credit:` — initial flow-control window in bytes ;;; ;;; Grant checks: `pty` must be granted, and `exec` must allow ;;; `(car argv)`. Must be called inside `with-async`. (define (session-open g spec) (: any? dict? -> any?) (unless (in-async-context?) (error "session-open: requires an async context (with-async)")) (let ((kind (dict-ref spec kind:)) (argv (dict-ref spec argv:))) (unless (eq? kind 'pty) (error "session-open: unsupported session kind" kind)) (unless (and (pair? argv) (string? (car argv))) (error "session-open: argv must be a non-empty list of strings" argv)) (grant-assert! g 'pty #t) (grant-assert! g 'exec (car argv)) (when (dict-contains? spec cwd:) (grant-assert! g 'fs-read (dict-ref spec cwd:))) (let* ((cols (if (dict-contains? spec cols:) (dict-ref spec cols:) 80)) (rows (if (dict-contains? spec rows:) (dict-ref spec rows:) 24)) (cwd (if (dict-contains? spec cwd:) (dict-ref spec cwd:) #f)) (term (if (dict-contains? spec term:) (dict-ref spec term:) "xterm-256color")) (credit (if (dict-contains? spec credit:) (dict-ref spec credit:) *default-credit-window*)) (proc (apply process-spawn-pty (car argv) cols: cols rows: rows cwd: cwd term: term die-with-parent: #t (cdr argv)))) (unless (process? proc) (error "session-open: failed to spawn" argv)) (set! *next-session-id* (+ *next-session-id* 1)) (let ((s (session-vector *next-session-id* kind proc (make-channel *write-queue-depth*) (make-channel *write-queue-depth*) credit (make-channel 1)))) (go (reader-pump s)) (go (writer-drain s)) s)))) ;; ============================================================ ;; The reader pump ;; ============================================================ ;; Read child output from the pty master and deliver data events, ;; respecting the credit window; on EOF, reap and deliver exit. (define (reader-pump s) (let ((proc (session-proc s)) (events (session-events s))) (let ((fd (process-pty-fd proc))) (let loop () (cond ;; Window exhausted: park until session-credit wakes us. ;; (Or until close: session-close also pokes the wake ;; channel so a zero-window session can still shut down.) ((<= (session-window s) 0) (channel-receive (session-credit-wake s)) (loop)) (else (await-readable-fd fd) (let ((chunk (process-pty-read proc (min *read-chunk-size* (session-window s))))) (cond ((eof-object? chunk) (reap-session s)) ((= (bytevector-length chunk) 0) ;; Spurious wakeup (EAGAIN); await again. (loop)) (else (set-session-window! s (- (session-window s) (bytevector-length chunk))) ;; Bounded delivery: blocks when the consumer lags ;; a full buffer behind, which is exactly the ;; in-process meaning of the window. (channel-send events (dict type: 'data session: (session-id s) stream: 'out bytes: chunk)) (loop)))))))))) ;; EOF on the master: reap the child, deliver the exit event, close ;; the channels. Ordering is load-bearing for teardown safety: ;; reap-child! sets exit-status (and only returns once the child is ;; dead, so the master is writable) BEFORE we close the out-queue and ;; the master. That guarantees we never close the pty master out from ;; under a writer-drain that is still about to await it (a select on a ;; just-closed fd never reports ready and would wedge the goroutine). (define (reap-session s) (let ((proc (session-proc s))) (reap-child! s proc) ;; Release the writer: closing the out-queue wakes a writer parked ;; on the queue receive; exit-status (already set) stops one ;; parked mid-write. Only then close the master. (channel-close! (session-out-queue s)) (process-pty-close! proc) (channel-send (session-events s) (dict type: 'exit session: (session-id s) code: (session-exit-status s))) (channel-close! (session-events s)))) ;; Reap the child cooperatively and set exit-status. process-alive? is ;; a non-blocking waitpid(WNOHANG) that reaps and caches the status on ;; exit, so the following process-wait returns the cached status ;; without blocking the VM. The poll is BOUNDED so a wedged grandchild ;; that keeps the slave open cannot spin the reap forever; if the ;; child outlives the grace window it is force-killed, and if even ;; that fails the session still tears down with an unknown status ;; rather than hanging. Shared by the EOF path and session-close. (define (reap-child! s proc) (unless (session-exit-status s) (let poll ((tries 400)) ; ~4s at 10ms (cond ((not (process-alive? proc)) (set-session-exit-status! s (process-wait proc))) ((> tries 0) (sleep 0.01) (poll (- tries 1))) (else (process-signal! proc 'kill) (let kpoll ((ktries 200)) ; ~2s more (cond ((not (process-alive? proc)) (set-session-exit-status! s (process-wait proc))) ((> ktries 0) (sleep 0.01) (kpoll (- ktries 1))) (else (set-session-exit-status! s -1))))))))) ;; ============================================================ ;; The writer drain ;; ============================================================ ;; Feed queued input to the pty master, handling partial writes and ;; EAGAIN via the fd waiters. Chunks are strings or bytevectors; a ;; partial write re-slices the remainder (copying at most once). The ;; write is guarded: once the child has exited, a write to the master ;; fails (EIO, or the fd is closed) — that surfaces as an error which ;; we swallow and stop, dropping the remaining input rather than ;; letting the goroutine die with an unhandled error. The await is ;; only reached when the session has NOT exited (checked with no yield ;; point in between), so we never park on a closed fd. (define (writer-drain s) (let ((fd (process-pty-fd (session-proc s)))) (for-channel (data (session-out-queue s)) (let wloop ((data data)) (unless (session-exit-status s) (let* ((len (if (bytevector? data) (bytevector-length data) (string-length data))) (n (guarded-pty-write s data))) (cond ((eq? n 'failed) #t) ; child gone: drop the remainder ((< n len) (unless (session-exit-status s) (await-writable-fd fd) (wloop ;; Strings are written as UTF-8; a partial write ;; may split a multi-byte character, so requeue the ;; remainder as raw bytes. (if (bytevector? data) (bytevector-copy data n) (bytevector-copy (string->utf8 data) n)))))))))))) (define (guarded-pty-write s data) (guard (e (#t 'failed)) (process-pty-write (session-proc s) data))) ;; ============================================================ ;; Driving ;; ============================================================ ;;; Queue bytes (a string or bytevector) for the child's stdin. ;;; Enqueue-and-drain: returns as soon as the chunk is queued; ;;; blocks only if the outbound queue is full. ;;; ;;; Raises `session-write: session has exited` if the session is ;;; already gone. If the child exits WHILE this call is blocked on a ;;; full queue (reap closes the out-queue), the enqueue is dropped ;;; rather than surfacing the raw closed-channel error. (define (session-write s data) (: any? any? -> void?) (when (session-exit-status s) (error "session-write: session has exited" (session-id s))) (guard (e ((session-exit-status s) #f) ; queue closed by reap: drop (else (raise e))) (channel-send (session-out-queue s) data))) ;;; Resize the session's terminal. The kernel delivers SIGWINCH to ;;; the child's foreground process group. (define (session-resize s cols rows) (: any? integer? integer? -> void?) (unless (session-exit-status s) (process-pty-resize! (session-proc s) cols rows))) ;;; Send a signal to the session's process group. Accepts the ;;; symbols `(sigil process)`'s process-signal! accepts ;;; (`'int` `'term` `'hup` `'kill` ...) or a signal number. (define (session-signal s sig) (: any? any? -> boolean?) (if (session-exit-status s) #f (process-signal! (session-proc s) sig))) ;;; Grant the producer more flow-control window (bytes). Call as ;;; data events are consumed. ;;; ;;; Contract: a consumer must keep crediting until it observes the ;;; `exit` event, OR call `session-close`. A pump parked at a zero ;;; window is not watching the pty fd, so it cannot observe the child ;;; exiting on its own; a consumer that stops crediting a live-but- ;;; then-exiting session without closing it would leave the session ;;; un-reaped. Crediting normally (or closing) avoids this. (define (session-credit s n) (: any? integer? -> void?) (set-session-window! s (+ (session-window s) n)) ;; Wake a parked pump; try-send so repeated credits don't block. (channel-try-send (session-credit-wake s) #t)) ;;; Close the session: signal the child to exit (SIGHUP, then ;;; SIGKILL if it lingers) and let the reader pump observe EOF and ;;; reap. Blocks (cooperatively) until the exit event is delivered. ;;; Idempotent. ;;; ;;; Note we do NOT close the pty master here: the reader pump is the ;;; sole owner of that fd and closes it in `reap-session`. Closing it ;;; out from under a pump parked in `await-readable-fd` would wedge ;;; the pump (a select on a closed fd never reports readable). Killing ;;; the child instead closes the slave, which makes the master read ;;; EOF and drives the normal reap path. Callers must still be ;;; draining `session-events` (directly or via a goroutine) so the ;;; pump's final data/exit sends do not block. (define (session-close s) (: any? -> any?) (unless (or (session-closing? s) (session-exit-status s)) (set-session-closing! s) ;; Grant generous credit so a window-throttled pump can drain to ;; EOF, and wake a pump parked at zero window. (session-credit s (* 4 *read-chunk-size*)) (let ((proc (session-proc s))) (when (process-alive? proc) (process-signal! proc 'hup)) ;; Grace period: most children exit on hangup. The pump sees ;; the resulting EOF and reaps, setting exit-status. (let grace ((tries 50)) (when (and (> tries 0) (not (session-exit-status s))) (sleep 0.02) (grace (- tries 1)))) ;; Escalate for children that ignore SIGHUP. (when (and (not (session-exit-status s)) (process-alive? proc)) (process-signal! proc 'kill) (let wait-exit ((tries 150)) (when (and (> tries 0) (not (session-exit-status s))) (sleep 0.02) (wait-exit (- tries 1))))) ;; If the pump never reaped (e.g. it is blocked delivering ;; events to a consumer that stopped draining session-events), ;; reap here so session-close is authoritative: it must not ;; return #f — "still alive" — for a child it has already ;; killed. reap-child! is bounded and idempotent with the pump. (reap-child! s proc))) (session-exit-status s))))test/test-system.sgladded
;;; Tests for sigil-system: grant engine, fs capability, one-shot exec,;;; and the PTY session plane. The session tests ARE the S0 exit;;; criterion exercised end to end (open a pty, run a command, stream;;; output, resize, reap).(import (sigil test) (sigil core) (sigil io) (sigil fs) (sigil string) (sigil async) (sigil channels) (sigil system grant) (sigil system fs) (sigil system process) (sigil system session));; ============================================================;; Grant engine;; ============================================================(test-group "grant engine" (test "deny by default" (let ((g (make-grants))) (assert-false (grant-check g 'pty #t)) (assert-false (grant-check g 'exec "ls")) (assert-false (grant-check g 'fs-read "/etc/hosts")))) (test "pty grant" (let ((g (make-grants))) (grant-add! g "pty:on") (assert-true (grant-check g 'pty #t)))) (test "exec allowlist matches argv[0] exactly" (let ((g (make-grants))) (grant-add! g "exec:allowlist:ls,git") (assert-true (grant-check g 'exec "ls")) ;; Exact match only: a same-named binary at another path is NOT ;; allowed (prevents an attacker-controlled /tmp/evil/ls bypass). (assert-false (grant-check g 'exec "/tmp/evil/ls")) (assert-false (grant-check g 'exec "rm")))) (test "exec:full is not downgraded by a later allowlist" (let ((g (make-grants))) (grant-add! g "exec:full") (grant-add! g "exec:allowlist:ls") (assert-true (grant-check g 'exec "anything-still")))) (test "exec full allows anything" (let ((g (make-grants))) (grant-add! g "exec:full") (assert-true (grant-check g 'exec "anything")))) (test "fs ro allows read not write" (let ((g (make-grants))) (grant-add! g "fs:ro:/tmp") (assert-true (grant-check g 'fs-read "/tmp")) (assert-false (grant-check g 'fs-write "/tmp/x")))) (test "fs rw allows read and write under root" (let ((g (make-grants))) (grant-add! g "fs:rw:/tmp") (assert-true (grant-check g 'fs-read "/tmp")) (assert-true (grant-check g 'fs-write "/tmp/new-file")))) (test "fs grant does not leak to sibling prefix" (let ((g (make-grants)) (base (make-temp-directory))) ;; /base/allow granted; /base/allowed must NOT be covered. (make-directory (path-join base "allow")) (make-directory (path-join base "allowed")) (grant-add! g (string-append "fs:rw:" (path-join base "allow"))) (assert-true (grant-check g 'fs-write (path-join base "allow" "x"))) (assert-false (grant-check g 'fs-read (path-join base "allowed" "x"))))) (test "malformed spec raises" (let ((g (make-grants))) (assert-error (grant-add! g "nonsense")))) (test "decisions are recorded" (let ((g (make-grants))) (grant-add! g "pty:on") (grant-check g 'pty #t) (grant-check g 'exec "ls") (assert-equal (length (grant-decisions g)) 2))) (test "allow-all posture" (let ((g (grant-allow-all))) (assert-true (grant-check g 'pty #t)) (assert-true (grant-check g 'exec "anything")) (assert-true (grant-check g 'fs-read "/etc/hosts")))));; ============================================================;; Filesystem capability;; ============================================================(test-group "fs capability" (test "write then read a file" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (let ((path (path-join dir "hello.txt"))) (fs-write-file g path "hi there\n") (assert-equal (fs-read-file g path) "hi there\n")))) (test "read-dir returns stat dicts" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (fs-write-file g (path-join dir "a.txt") "a") (fs-write-file g (path-join dir "b.txt") "bb") (let ((entries (fs-read-dir g dir))) (assert-equal (length entries) 2) (assert-true (dict-contains? (car entries) name:)) (assert-true (dict-contains? (car entries) size:))))) (test "stat reports type and size" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (let ((path (path-join dir "sized.txt"))) (fs-write-file g path "12345") (let ((st (fs-stat g path))) (assert-equal (dict-ref st size:) 5) (assert-equal (dict-ref st type:) 'regular))))) (test "read denied without grant" (let ((g (make-grants)) (dir (make-temp-directory))) ;; No grant added. (assert-error (fs-read-dir g dir)))) (test "write denied under ro grant" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:ro:" dir)) (assert-error (fs-write-file g (path-join dir "x") "nope")))));; ============================================================;; One-shot exec;; ============================================================(test-group "process-exec" (test "captures stdout and exit code" (let ((g (make-grants))) (grant-add! g "exec:allowlist:echo") (let ((r (process-exec g '("echo" "hello world")))) (assert-equal (dict-ref r exit:) 0) (assert-true (string-contains? (dict-ref r stdout:) "hello world"))))) (test "denied without grant" (let ((g (make-grants))) (assert-error (process-exec g '("echo" "no"))))) (test "non-zero exit surfaces" (let ((g (make-grants))) (grant-add! g "exec:allowlist:false") (let ((r (process-exec g '("false")))) (assert-true (> (dict-ref r exit:) 0))))) (test "stderr-heavy command does not deadlock (concurrent drain)" ;; ~256 KiB to stderr while also writing stdout: with a sequential ;; drain-stdout-then-stderr this deadlocks on the stderr pipe buffer. ;; The concurrent drain must complete. Runs outside with-async, so it ;; exercises the internal-scheduler path. (let ((g (make-grants))) (grant-add! g "exec:allowlist:sh") (let ((r (process-exec g '("sh" "-c" "yes 0123456789 | head -n 20000 1>&2; echo done")))) (assert-equal (dict-ref r exit:) 0) (assert-true (string-contains? (dict-ref r stdout:) "done")) (assert-true (> (string-length (dict-ref r stderr:)) 100000))))));; ============================================================;; PTY session plane — the S0 exit criterion;; ============================================================(test-group "pty session" (test "open, stream ls, reap" (let ((g (make-grants))) (grant-add! g "pty:on") (grant-add! g "exec:allowlist:ls") (with-async (let ((s (session-open g (dict kind: 'pty argv: '("ls" "/") cols: 80 rows: 24))) (acc '()) (exit-code #f)) (assert-true (session? s)) (assert-equal (session-kind s) 'pty) (for-channel (ev (session-events s)) (case (dict-ref ev type:) ((data) (set! acc (cons (utf8->string (dict-ref ev bytes:)) acc)) (session-credit s (bytevector-length (dict-ref ev bytes:)))) ((exit) (set! exit-code (dict-ref ev code:))))) (assert-equal exit-code 0) ;; `ls /` output should mention a well-known root entry. (let ((text (apply string-append (reverse acc)))) (assert-true (string-contains? text "usr"))))))) (test "resize is visible to the child" (let ((g (make-grants))) (grant-add! g "pty:on") (grant-add! g "exec:allowlist:sh") (with-async (let ((s (session-open g (dict kind: 'pty argv: '("sh" "-c" "sleep 0.2; stty size") cols: 80 rows: 24))) (acc '())) (session-resize s 132 50) (for-channel (ev (session-events s)) (when (eq? (dict-ref ev type:) 'data) (set! acc (cons (utf8->string (dict-ref ev bytes:)) acc)) (session-credit s (bytevector-length (dict-ref ev bytes:))))) ;; stty prints "rows cols". (let ((text (apply string-append (reverse acc)))) (assert-true (string-contains? text "50 132"))))))) (test "write to stdin round-trips through the shell" (let ((g (make-grants))) (grant-add! g "pty:on") (grant-add! g "exec:allowlist:sh") (with-async (let ((s (session-open g (dict kind: 'pty argv: '("sh") cols: 80 rows: 24))) (acc '())) (go (begin (sleep 0.2) (session-write s "echo round-trip-ok\n") (sleep 0.3) (session-write s "exit\n"))) (for-channel (ev (session-events s)) (when (eq? (dict-ref ev type:) 'data) (set! acc (cons (utf8->string (dict-ref ev bytes:)) acc)) (session-credit s (bytevector-length (dict-ref ev bytes:))))) (let ((text (apply string-append (reverse acc)))) (assert-true (string-contains? text "round-trip-ok"))))))) (test "session-close terminates a long-running child" (let ((g (make-grants))) (grant-add! g "pty:on") (grant-add! g "exec:allowlist:sh") (with-async (let ((s (session-open g (dict kind: 'pty argv: '("sh" "-c" "sleep 30") cols: 80 rows: 24)))) ;; Drain events in the background so the pump can reach EOF. (go (for-channel (ev (session-events s)) #t)) (sleep 0.2) (let ((status (session-close s))) (assert-true (not (eq? status #f))) (assert-false (session-alive? s))))))))