AtlatestRepositorysigil-system
sigil-system / tree / testtest-system.sgl
1
;;; Tests for sigil-system: grant engine, fs capability, one-shot exec,2
;;; and the PTY session plane. The session tests ARE the S0 exit3
;;; criterion exercised end to end (open a pty, run a command, stream4
;;; output, resize, reap).6
(import (sigil test)7
(sigil core)8
(sigil io)9
(sigil fs)10
(sigil path)11
(sigil process) ; getenv (fs-home tests)12
(sigil string)13
(sigil async)14
(sigil channels)15
(sigil system grant)16
(sigil system fs)17
(sigil system process)18
(sigil system session))20
;; ============================================================21
;; Grant engine22
;; ============================================================24
(test-group "grant engine"26
(test "deny by default"27
(let ((g (make-grants)))28
(assert-false (grant-check g 'pty #t))29
(assert-false (grant-check g 'exec "ls"))30
(assert-false (grant-check g 'fs-read "/etc/hosts"))))32
(test "pty grant"33
(let ((g (make-grants)))34
(grant-add! g "pty:on")35
(assert-true (grant-check g 'pty #t))))37
(test "exec allowlist matches argv[0] exactly"38
(let ((g (make-grants)))39
(grant-add! g "exec:allowlist:ls,git")40
(assert-true (grant-check g 'exec "ls"))41
;; Exact match only: a same-named binary at another path is NOT42
;; allowed (prevents an attacker-controlled /tmp/evil/ls bypass).43
(assert-false (grant-check g 'exec "/tmp/evil/ls"))44
(assert-false (grant-check g 'exec "rm"))))46
(test "exec:full is not downgraded by a later allowlist"47
(let ((g (make-grants)))48
(grant-add! g "exec:full")49
(grant-add! g "exec:allowlist:ls")50
(assert-true (grant-check g 'exec "anything-still"))))52
(test "exec full allows anything"53
(let ((g (make-grants)))54
(grant-add! g "exec:full")55
(assert-true (grant-check g 'exec "anything"))))57
(test "fs ro allows read not write"58
(let ((g (make-grants)))59
(grant-add! g "fs:ro:/tmp")60
(assert-true (grant-check g 'fs-read "/tmp"))61
(assert-false (grant-check g 'fs-write "/tmp/x"))))63
(test "fs rw allows read and write under root"64
(let ((g (make-grants)))65
(grant-add! g "fs:rw:/tmp")66
(assert-true (grant-check g 'fs-read "/tmp"))67
(assert-true (grant-check g 'fs-write "/tmp/new-file"))))69
(test "fs grant does not leak to sibling prefix"70
(let ((g (make-grants))71
(base (make-temp-directory)))72
;; /base/allow granted; /base/allowed must NOT be covered.73
(make-directory (path-join base "allow"))74
(make-directory (path-join base "allowed"))75
(grant-add! g (string-append "fs:rw:" (path-join base "allow")))76
(assert-true (grant-check g 'fs-write (path-join base "allow" "x")))77
(assert-false (grant-check g 'fs-read (path-join base "allowed" "x")))))79
(test "malformed spec raises"80
(let ((g (make-grants)))81
(assert-error (grant-add! g "nonsense"))))83
(test "decisions are recorded"84
(let ((g (make-grants)))85
(grant-add! g "pty:on")86
(grant-check g 'pty #t)87
(grant-check g 'exec "ls")88
(assert-equal (length (grant-decisions g)) 2)))90
(test "allow-all posture"91
(let ((g (grant-allow-all)))92
(assert-true (grant-check g 'pty #t))93
(assert-true (grant-check g 'exec "anything"))94
(assert-true (grant-check g 'fs-read "/etc/hosts")))))96
;; ============================================================97
;; Filesystem capability98
;; ============================================================100
(test-group "fs capability"102
(test "write then read a file"103
(let ((g (make-grants))104
(dir (make-temp-directory)))105
(grant-add! g (string-append "fs:rw:" dir))106
(let ((path (path-join dir "hello.txt")))107
(fs-write-file g path "hi there\n")108
(assert-equal (fs-read-file g path) "hi there\n"))))110
(test "fs-home returns the home directory when granted"111
;; Exists so a client can expand "~" without hardcoding a host convention —112
;; on a remote node the home that matters is the NODE's, not the one typing.113
(let ((g (make-grants))114
(home (getenv "HOME")))115
(grant-add! g (string-append "fs:ro:" home))116
(assert-equal (fs-home g) (or (realpath home) home))))118
(test "fs-home is DENIED to a principal scoped elsewhere"119
;; Grant-checked as a read OF home on purpose: a principal scoped to a project120
;; directory has no business learning where the owner's home is, and everything121
;; it could do with the answer would be denied anyway.122
(let ((g (make-grants))123
(dir (make-temp-directory)))124
(grant-add! g (string-append "fs:rw:" dir))125
(assert-true (guard (e (#t #t)) (fs-home g) #f))))127
(test "read-dir returns stat dicts"128
(let ((g (make-grants))129
(dir (make-temp-directory)))130
(grant-add! g (string-append "fs:rw:" dir))131
(fs-write-file g (path-join dir "a.txt") "a")132
(fs-write-file g (path-join dir "b.txt") "bb")133
(let ((entries (fs-read-dir g dir)))134
(assert-equal (length entries) 2)135
(assert-true (dict-contains? (car entries) name:))136
(assert-true (dict-contains? (car entries) size:)))))138
;; ---- canonical listing order --------------------------------------------139
;; Sorted at the source so every consumer agrees and no client re-sorts per140
;; render. `directory-list` order is the filesystem's (arbitrary), so these141
;; assert the ORDER itself, not merely the membership.143
(define (entry-names entries) (map (lambda (e) (dict-ref e name: "")) entries))145
(test "read-dir sorts: dirs before files, hidden before visible in each"146
;; David's four tiers: hidden dirs, dirs, hidden files, files. A repo should147
;; read .git/ -> src/ -> .gitignore -> README.md.148
(let ((g (make-grants))149
(dir (make-temp-directory)))150
(grant-add! g (string-append "fs:rw:" dir))151
;; created in deliberately scrambled order152
(fs-write-file g (path-join dir "README.md") "r")153
(fs-mkdir g (path-join dir "src"))154
(fs-write-file g (path-join dir ".gitignore") "i")155
(fs-mkdir g (path-join dir ".git"))156
(assert-equal (entry-names (fs-read-dir g dir))157
(list ".git" "src" ".gitignore" "README.md"))))159
(test "read-dir sorts alphabetically WITHIN a tier, case-insensitively"160
;; A file list is read by a human: ASCII order would file Zebra before apple.161
(let ((g (make-grants))162
(dir (make-temp-directory)))163
(grant-add! g (string-append "fs:rw:" dir))164
(fs-write-file g (path-join dir "Zebra.txt") "z")165
(fs-write-file g (path-join dir "apple.txt") "a")166
(fs-write-file g (path-join dir "Mango.txt") "m")167
(assert-equal (entry-names (fs-read-dir g dir))168
(list "apple.txt" "Mango.txt" "Zebra.txt"))))170
(test "read-dir order is total: names differing only in case are stable"171
(let ((g (make-grants))172
(dir (make-temp-directory)))173
(grant-add! g (string-append "fs:rw:" dir))174
(fs-write-file g (path-join dir "b.txt") "b")175
(fs-write-file g (path-join dir "B.txt") "B")176
;; equal case-insensitively -> the case-sensitive name breaks the tie, so the177
;; order is deterministic rather than filesystem-dependent178
(assert-equal (entry-names (fs-read-dir g dir))179
(list "B.txt" "b.txt"))))181
(test "read-dir puts every directory ahead of every file"182
(let ((g (make-grants))183
(dir (make-temp-directory)))184
(grant-add! g (string-append "fs:rw:" dir))185
(fs-write-file g (path-join dir "aaa.txt") "a") ; sorts first alphabetically186
(fs-mkdir g (path-join dir "zzz")) ; but a dir outranks it187
(assert-equal (entry-names (fs-read-dir g dir))188
(list "zzz" "aaa.txt"))))190
(test "stat reports type and size"191
(let ((g (make-grants))192
(dir (make-temp-directory)))193
(grant-add! g (string-append "fs:rw:" dir))194
(let ((path (path-join dir "sized.txt")))195
(fs-write-file g path "12345")196
(let ((st (fs-stat g path)))197
(assert-equal (dict-ref st size:) 5)198
(assert-equal (dict-ref st type:) 'regular)))))200
(test "read denied without grant"201
(let ((g (make-grants))202
(dir (make-temp-directory)))203
;; No grant added.204
(assert-error (fs-read-dir g dir))))206
(test "write denied under ro grant"207
(let ((g (make-grants))208
(dir (make-temp-directory)))209
(grant-add! g (string-append "fs:ro:" dir))210
(assert-error (fs-write-file g (path-join dir "x") "nope")))))212
;; ============================================================213
;; One-shot exec214
;; ============================================================216
(test-group "process-exec"218
(test "captures stdout and exit code"219
(let ((g (make-grants)))220
(grant-add! g "exec:allowlist:echo")221
(let ((r (process-exec g '("echo" "hello world"))))222
(assert-equal (dict-ref r exit:) 0)223
(assert-true (string-contains? (dict-ref r stdout:) "hello world")))))225
(test "denied without grant"226
(let ((g (make-grants)))227
(assert-error (process-exec g '("echo" "no")))))229
(test "non-zero exit surfaces"230
(let ((g (make-grants)))231
(grant-add! g "exec:allowlist:false")232
(let ((r (process-exec g '("false"))))233
(assert-true (> (dict-ref r exit:) 0)))))235
(test "stderr-heavy command does not deadlock (concurrent drain)"236
;; ~256 KiB to stderr while also writing stdout: with a sequential237
;; drain-stdout-then-stderr this deadlocks on the stderr pipe buffer.238
;; The concurrent drain must complete. Runs outside with-async, so it239
;; exercises the internal-scheduler path.240
(let ((g (make-grants)))241
(grant-add! g "exec:allowlist:sh")242
(let ((r (process-exec243
g244
'("sh" "-c"245
"yes 0123456789 | head -n 20000 1>&2; echo done"))))246
(assert-equal (dict-ref r exit:) 0)247
(assert-true (string-contains? (dict-ref r stdout:) "done"))248
(assert-true (> (string-length (dict-ref r stderr:)) 100000))))))250
;; ============================================================251
;; PTY session plane — the S0 exit criterion252
;; ============================================================254
(test-group "pty session"256
(test "open, stream ls, reap"257
(let ((g (make-grants)))258
(grant-add! g "pty:on")259
(grant-add! g "exec:allowlist:ls")260
(with-async261
(let ((s (session-open g (dict kind: 'pty262
argv: '("ls" "/")263
cols: 80 rows: 24)))264
(acc '())265
(exit-code #f))266
(assert-true (session? s))267
(assert-equal (session-kind s) 'pty)268
(for-channel (ev (session-events s))269
(case (dict-ref ev type:)270
((data)271
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))272
(session-credit s (bytevector-length (dict-ref ev bytes:))))273
((exit)274
(set! exit-code (dict-ref ev code:)))))275
(assert-equal exit-code 0)276
;; `ls /` output should mention a well-known root entry.277
(let ((text (apply string-append (reverse acc))))278
(assert-true (string-contains? text "usr")))))))280
(test "resize is visible to the child"281
(let ((g (make-grants)))282
(grant-add! g "pty:on")283
(grant-add! g "exec:allowlist:sh")284
(with-async285
(let ((s (session-open g (dict kind: 'pty286
argv: '("sh" "-c" "sleep 0.2; stty size")287
cols: 80 rows: 24)))288
(acc '()))289
(session-resize s 132 50)290
(for-channel (ev (session-events s))291
(when (eq? (dict-ref ev type:) 'data)292
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))293
(session-credit s (bytevector-length (dict-ref ev bytes:)))))294
;; stty prints "rows cols".295
(let ((text (apply string-append (reverse acc))))296
(assert-true (string-contains? text "50 132")))))))298
(test "write to stdin round-trips through the shell"299
(let ((g (make-grants)))300
(grant-add! g "pty:on")301
(grant-add! g "exec:allowlist:sh")302
(with-async303
(let ((s (session-open g (dict kind: 'pty304
argv: '("sh")305
cols: 80 rows: 24)))306
(acc '()))307
(go (begin308
(sleep 0.2)309
(session-write s "echo round-trip-ok\n")310
(sleep 0.3)311
(session-write s "exit\n")))312
(for-channel (ev (session-events s))313
(when (eq? (dict-ref ev type:) 'data)314
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))315
(session-credit s (bytevector-length (dict-ref ev bytes:)))))316
(let ((text (apply string-append (reverse acc))))317
(assert-true (string-contains? text "round-trip-ok")))))))319
(test "session-close terminates a long-running child"320
(let ((g (make-grants)))321
(grant-add! g "pty:on")322
(grant-add! g "exec:allowlist:sh")323
(with-async324
(let ((s (session-open g (dict kind: 'pty325
argv: '("sh" "-c" "sleep 30")326
cols: 80 rows: 24))))327
;; Drain events in the background so the pump can reach EOF.328
(go (for-channel (ev (session-events s)) #t))329
(sleep 0.2)330
(let ((status (session-close s)))331
(assert-true (not (eq? status #f)))332
(assert-false (session-alive? s))))))))334
;; ============================================================335
;; Process (pipes) session plane — the P0b `'process` kind, the same336
;; Session-plane contract carried over stdin/stdout pipes instead of a337
;; pty (for `tmux -C` and other control-protocol children that hard-fail338
;; on a tty). No `pty` grant: a pipe session allocates no pty, so `exec`339
;; alone gates it, matching Resource-plane process spawning.340
;; ============================================================342
(test-group "process session"344
(test "open, stream stdout, reap — no pty grant needed"345
(let ((g (make-grants)))346
(grant-add! g "exec:allowlist:sh") ; deliberately NO pty:on347
(with-async348
(let ((s (session-open g (dict kind: 'process349
argv: '("sh" "-c" "echo process-out"))))350
(acc '())351
(exit-code #f))352
(assert-true (session? s))353
(assert-equal (session-kind s) 'process)354
(for-channel (ev (session-events s))355
(case (dict-ref ev type:)356
((data)357
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))358
(session-credit s (bytevector-length (dict-ref ev bytes:))))359
((exit)360
(set! exit-code (dict-ref ev code:)))))361
(assert-equal exit-code 0)362
(let ((text (apply string-append (reverse acc))))363
(assert-true (string-contains? text "process-out")))))))365
(test "cwd and environment overrides reach the child and leave the parent unchanged"366
(let ((g (make-grants))367
(before (getenv "SIGIL_SESSION_ENV_TEST")))368
(grant-add! g "exec:allowlist:sh")369
(grant-add! g "fs:ro:/tmp")370
(with-async371
(let ((s (session-open372
g373
(dict kind: 'process374
argv: '("sh" "-c" "printf '%s|%s' \"$PWD\" \"$SIGIL_SESSION_ENV_TEST\"")375
cwd: "/tmp"376
env: #{ "SIGIL_SESSION_ENV_TEST" "task-value" })))377
(acc '()))378
(for-channel (ev (session-events s))379
(when (eq? (dict-ref ev type:) 'data)380
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))381
(session-credit s (bytevector-length (dict-ref ev bytes:)))))382
(assert-equal (apply string-append (reverse acc)) "/tmp|task-value")383
(assert-equal (getenv "SIGIL_SESSION_ENV_TEST") before)))))385
(test "environment overrides reject non-string values"386
(let ((g (make-grants)))387
(grant-add! g "exec:allowlist:true")388
(with-async389
(assert-error390
(session-open g (dict kind: 'process argv: '("true")391
env: #{ "COUNT" 3 }))))))393
(test "process sessions optionally merge stderr into captured output"394
(let ((g (make-grants)))395
(grant-add! g "exec:allowlist:sh")396
(with-async397
(let ((s (session-open g (dict kind: 'process398
argv: '("sh" "-c" "printf ERR >&2")399
stderr: 'merge)))400
(acc '()))401
(for-channel (ev (session-events s))402
(when (eq? (dict-ref ev type:) 'data)403
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))404
(session-credit s (bytevector-length (dict-ref ev bytes:)))))405
(assert-equal (apply string-append (reverse acc)) "ERR")))))407
(test "the process kind is denied without an exec grant"408
(let ((g (make-grants)))409
;; No exec grant at all: even with pty:on, exec gates the spawn.410
(grant-add! g "pty:on")411
(with-async412
(assert-error413
(session-open g (dict kind: 'process argv: '("sh" "-c" "true")))))))415
(test "write to stdin round-trips through cat (bidirectional pipes)"416
(let ((g (make-grants)))417
(grant-add! g "exec:allowlist:cat")418
(with-async419
(let ((s (session-open g (dict kind: 'process argv: '("cat"))))420
(acc '()))421
;; cat echoes stdin -> stdout; close stdin to end the stream.422
(go (begin423
(sleep 0.2)424
(session-write s "round-trip-ok\n")425
(sleep 0.3)426
(session-close s)))427
(for-channel (ev (session-events s))428
(when (eq? (dict-ref ev type:) 'data)429
(set! acc (cons (utf8->string (dict-ref ev bytes:)) acc))430
(session-credit s (bytevector-length (dict-ref ev bytes:)))))431
(let ((text (apply string-append (reverse acc))))432
(assert-true (string-contains? text "round-trip-ok")))))))434
(test "resize is a harmless no-op for a pipe session"435
(let ((g (make-grants)))436
(grant-add! g "exec:allowlist:cat")437
(with-async438
(let ((s (session-open g (dict kind: 'process argv: '("cat")))))439
;; No pty window exists; resize must neither error nor affect I/O.440
(session-resize s 132 50)441
(go (for-channel (ev (session-events s)) #t))442
(sleep 0.1)443
(let ((status (session-close s)))444
(assert-true (not (eq? status #f)))445
(assert-false (session-alive? s)))))))447
(test "session-close terminates a long-running pipe child"448
(let ((g (make-grants)))449
(grant-add! g "exec:allowlist:sh")450
(with-async451
(let ((s (session-open g (dict kind: 'process452
argv: '("sh" "-c" "sleep 30")))))453
(go (for-channel (ev (session-events s)) #t))454
(sleep 0.2)455
(let ((status (session-close s)))456
(assert-true (not (eq? status #f)))457
(assert-false (session-alive? s))))))))