security: gate unsafe deserialization sites

ober

d370617647f95070e5287a5097f9c8522be04de5

diff --git a/Makefile b/Makefile
index 1e8dc03..c893f12 100644
--- a/Makefile
+++ b/Makefile
@@ -1736,10 +1736,16 @@ source-balance:
 restrict-closure-check:
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tools/check-restrict-closure.ss
 
+unsafe-deserialize-check:
+	@git ls-files '*.ss' '*.sls' \
+		| grep -E '^(lib|src|tools|mcp|lsp)/' \
+		| grep -v '^vendor/' \
+		| xargs $(SCHEME) --libdirs $(LIBDIRS) --script tools/check-unsafe-deserialize.ss
+
 import-policy-check: build
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tools/check-import-policy.ss examples
 
-audit: source-balance restrict-closure-check import-policy-check security-audit audit-native data-check security-production unification-check
+audit: source-balance restrict-closure-check unsafe-deserialize-check import-policy-check security-audit audit-native data-check security-production unification-check
 
 test-gaps:
 	$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-gaps.ss
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 603bbfd..b92de4e 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -412,31 +412,32 @@ The allowlist is Jerboa's crown jewel. It now has a CI-enforced manifest in
   internals, tracked as P3-06.
 
 ### K3-P0-04 — Triage and close raw `read` / native FASL paths on untrusted input
-**Serves:** G1. **Effort:** 3–5 days. **Status:** first scanner-driven
-batch landed 2026-07-27: gRPC request/response parsing, general `(std io)`,
-and legacy lockfile readers use `jerboa-read`; package manifest/policy/lock
-readers keep plain Scheme datum syntax but are size-capped, structurally
-validated, and locally justified; PGO profile loading is size-capped and tagged
-as trusted generated input; translator source reads are explicitly trusted
-compiler input after string transforms. Remaining work: complete the broader
-`load`/REPL/dev-surface classification and add a dedicated audit gate.
+**Serves:** G1. **Effort:** 3–5 days. **Status:** scanner-driven
+classification gate landed 2026-07-27. gRPC request/response parsing, general
+`(std io)`, and legacy lockfile readers use `jerboa-read`; package
+manifest/policy/lock readers keep plain Scheme datum syntax but are
+size-capped, structurally validated, and locally justified; PGO profile loading
+is size-capped and tagged as trusted generated input; translator source reads
+are explicitly trusted compiler input after string transforms. `make audit`
+now runs `tools/check-unsafe-deserialize.ss` over `lib/`, `src/`, `tools/`,
+`mcp/`, and `lsp/`; existing trusted/dev surfaces are recorded in
+`docs/unsafe-deserialize-allowlist.sexp` by exact source line and reason, and
+new or stale raw sites fail audit.
 
 `security-reference.md`'s checklist already names exemplars:
 `jerbuild.ss:308`, `lib/std/actor/distributed.ss:303`, `lib/std/fasl.ss:27`,
 `lib/std/net/grpc.ss:162`. There are more.
 
-- **Do:** (a) Enumerate every call site of bare `read`, `fasl-read`, and
-  Chez `load` in `lib/`, `src/`, `tools/`, `mcp/`, `lsp/` (the MCP scanner
-  already flags these — run it repo-wide, `changed_only: #f`). (b) Classify
-  each site: *trusted build-time input* (comment + lint suppression with
-  justification) or *possibly adversarial* (convert to bounded
-  `jerboa-read` / `safe-fasl` envelope / reject). (c) For `(std fasl)`,
-  add a loud docstring/contract: trusted provenance only, never network/
-  workspace/IPC bytes. (d) Add an `unsafe-deserialize` lint rule so new
-  raw-`read` sites fail `make audit`.
-- **Accept:** scanner reports zero unclassified raw-read/FASL sites; each
-  remaining site has an inline provenance justification; `jerbuild.ss`,
-  `distributed.ss`, `grpc.ss` either bounded or documented-trusted.
+- **Done:** (a) The dedicated audit enumerates bare `(read ...)`, raw
+  `(fasl-read ...)`, and runtime `(load ...)` call forms. (b) Possibly
+  adversarial sites were converted in earlier batches; remaining trusted build,
+  compiler, REPL/dev, and test harness surfaces are manifest-classified by
+  exact source line. (c) `(std fasl)` remains loudly documented as trusted
+  provenance only. (d) `make audit` runs the gate, reports zero unclassified
+  sites, and fails for new raw sites or stale classifications.
+- **Residual:** the current classification source of truth is the central
+  manifest rather than scattered inline comments, to keep language-repository
+  security documentation consolidated.
 
 ### K3-P0-05 — Authenticate distributed actor messages
 **Serves:** G2. **Effort:** 3–4 days. **Status:** initial implementation
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 1dd4da4..ebb0b72 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -177,6 +177,23 @@ the same change.
   `jerboa-read`; Chez already rejects `#.` and does not export `read-eval`
 - `gensym` (leaks runtime state via monotonic counter)
 
+### Unsafe Deserialization Gate
+
+`make audit` runs `tools/check-unsafe-deserialize.ss` over tracked `.ss` and
+`.sls` files under `lib/`, `src/`, `tools/`, `mcp/`, and `lsp/`. The gate flags
+new unclassified call forms for:
+
+- bare Chez `(read ...)`, which has no Jerboa size/depth/container budgets;
+- raw `(fasl-read ...)`, which is trusted-provenance only;
+- runtime `(load ...)`, which evaluates caller-selected code.
+
+Existing trusted or intentionally unsafe developer/build surfaces are recorded
+in `docs/unsafe-deserialize-allowlist.sexp` by exact file, line, rule, source
+text, and reason. New sites fail audit until converted to `jerboa-read` /
+`safe-fasl`, given an inline `jerboa-security: suppress ... -- ...`
+justification, or added to the manifest with a reviewed provenance reason.
+Stale manifest entries also fail audit.
+
 ### Usage
 
 ```scheme
diff --git a/docs/unsafe-deserialize-allowlist.sexp b/docs/unsafe-deserialize-allowlist.sexp
new file mode 100644
index 0000000..c02ebf4
--- /dev/null
+++ b/docs/unsafe-deserialize-allowlist.sexp
@@ -0,0 +1,46 @@
+(
+ ("lib/jerboa/build.ss" 160 "bare-read-untrusted" "              (let ([form (read port)])" "trusted build-system source forms read from local project/build inputs")
+ ("lib/jerboa/build.ss" 421 "bare-read-untrusted" "              (let ([form (read port)])" "trusted build-system source forms read from local project/build inputs")
+ ("lib/jerboa/hot.ss" 108 "runtime-load-trusted" "                    (try (begin (load path)" "explicit hot-reload developer surface; caller selects local code path")
+ ("lib/jerboa/wasm/gc-backend/driver.ss" 26 "bare-read-untrusted" "      (let ([form (read port)])" "trusted compiler driver input from the local build pipeline")
+ ("lib/std/build.ss" 78 "bare-read-untrusted" "                         (let ([form (read port)])" "trusted build metadata/source forms under build-tool control")
+ ("lib/std/build.ss" 151 "bare-read-untrusted" "          (let ([form (read port)])" "trusted build metadata/source forms under build-tool control")
+ ("lib/std/build/reproducible.ss" 461 "bare-read-untrusted" "    (let ([sexp (read port)])" "trusted reproducibility report data generated by repository tooling")
+ ("lib/std/build/sbom.ss" 159 "bare-read-untrusted" "    (let ([sexp (read port)])" "trusted SBOM metadata generated by repository tooling")
+ ("lib/std/build/watch.ss" 356 "bare-read-untrusted" "            (let ([form (read port)])" "trusted local source forms watched by developer tooling")
+ ("lib/std/capability/sandbox.ss" 201 "runtime-load-trusted" "    (try (load file-path)" "explicit capability sandbox load of caller-selected local code")
+ ("lib/std/dev/pgo.ss" 162 "bare-read-untrusted" "          (let ([header (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader")
+ ("lib/std/dev/pgo.ss" 166 "bare-read-untrusted" "              (let ([entry (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader")
+ ("lib/std/dev/pgo.ss" 206 "bare-read-untrusted" "          (let ([header (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader")
+ ("lib/std/dev/pgo.ss" 208 "bare-read-untrusted" "              (let ([entry (read port)])" "trusted PGO profile generated by Jerboa profile-save and structurally checked by loader")
+ ("lib/std/doc.ss" 141 "bare-read-untrusted" "              (try (let ([got (eval (read (open-input-string expr-str)) env)]" "developer documentation examples evaluated only by opt-in doc tooling")
+ ("lib/std/doc.ss" 142 "bare-read-untrusted" "                      [expected (eval (read (open-input-string expected-str)) env)])" "developer documentation examples evaluated only by opt-in doc tooling")
+ ("lib/std/interpolate.ss" 52 "bare-read-untrusted" "               [expr (read p)])" "compile-time interpolation expression parser for local source text")
+ ("lib/std/lint.ss" 566 "bare-read-untrusted" "        (let ([form (read port)])" "lint tool reads local source files supplied by developer tooling")
+ ("lib/std/mmap-btree.ss" 335 "bare-read-untrusted" "          (let ([magic (read port)])" "trusted mmap-btree metadata file, not a network or IPC format")
+ ("lib/std/mmap-btree.ss" 337 "bare-read-untrusted" "              (let* ([_order (read port)]" "trusted mmap-btree metadata file, not a network or IPC format")
+ ("lib/std/mmap-btree.ss" 338 "bare-read-untrusted" "                     [size   (read port)]" "trusted mmap-btree metadata file, not a network or IPC format")
+ ("lib/std/mmap-btree.ss" 339 "bare-read-untrusted" "                     [root   (read port)])" "trusted mmap-btree metadata file, not a network or IPC format")
+ ("lib/std/os/platform.ss" 163 "runtime-load-trusted" "          (load path)" "platform helper loads a caller-selected local xpatch in controlled build/runtime setup")
+ ("lib/std/os/platform.ss" 175 "runtime-load-trusted" "          (load path))" "platform helper loads a caller-selected local xpatch in controlled build/runtime setup")
+ ("lib/std/os/supervise.ss" 365 "bare-read-untrusted" "           (read (open-input-string s))" "process status parser consumes local supervise status text, not attacker protocol data")
+ ("lib/std/repl.ss" 652 "runtime-load-trusted" "    (try (load path (lambda (x) (eval x env)))" "explicit REPL load command for user-selected local code")
+ ("lib/std/repl.ss" 902 "bare-read-untrusted" "                    [expr (read p)]" "explicit REPL inspect/disassemble command over user-entered expressions")
+ ("lib/std/repl.ss" 903 "bare-read-untrusted" "                    [n    (let ([v (read p)]) (if (eof-object? v) 100 v))])" "explicit REPL inspect/disassemble command over user-entered expressions")
+ ("lib/std/repl.ss" 946 "runtime-load-trusted" "             (try (begin (load path (lambda (x) (eval x env)))" "explicit REPL reload command for user-selected local code")
+ ("lib/std/repl.ss" 1009 "bare-read-untrusted" "                    [expr (read p)]" "explicit REPL expand/trace command over user-entered expressions")
+ ("lib/std/repl.ss" 1010 "bare-read-untrusted" "                    [n    (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions")
+ ("lib/std/repl.ss" 1030 "bare-read-untrusted" "                    [expr (read p)]" "explicit REPL expand/trace command over user-entered expressions")
+ ("lib/std/repl.ss" 1031 "bare-read-untrusted" "                    [n    (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions")
+ ("lib/std/repl/server.ss" 346 "runtime-load-trusted" "           (load (car args) (lambda (x) (eval x *server-env*)))" "explicit REPL server load command for authenticated/user-selected local code")
+ ("lib/std/secure/compiler.ss" 626 "bare-read-untrusted" "          (let ([form (read port)])" "Slang compiler consumes local source text after secure subset checks")
+ ("lib/std/secure/preamble.ss" 241 "bare-read-untrusted" "       (filesystem (read path) (write path))" "Slang capability-plan DSL data, not a runtime reader call")
+ ("lib/std/secure/wasm-target.ss" 1390 "bare-read-untrusted" "                        (let ([form (read p)])" "Slang/WASM target compiler consumes local source text")
+ ("lib/std/security/capsicum.ss" 302 "bare-read-untrusted" "    `((0 . (read fstat))            ;; stdin: read-only" "Capsicum rights data literal, not a runtime reader call")
+ ("lib/std/typed/check.ss" 289 "bare-read-untrusted" "          (let ([form (read port)])" "typed checker consumes local source forms")
+ ("lsp/build-jlsp-cross.ss" 79 "runtime-load-trusted" "(load xpatch)" "cross-build helper loads configured local xpatch script")
+ ("lsp/build-jlsp-freebsd-cross.ss" 78 "runtime-load-trusted" "(load xpatch)" "cross-build helper loads configured local xpatch script")
+ ("mcp/test/security-test.ss" 13 "runtime-load-trusted" "(load (string-append repo-root \"/mcp/server.ss\"))" "test harness loads the repository-local MCP server under test")
+ ("tools/check-restrict-closure.ss" 121 "bare-read-untrusted" "                      [form (read port)]" "audit checker reads repository-controlled allowlist manifest from security-reference.md")
+ ("tools/check-restrict-closure.ss" 122 "bare-read-untrusted" "                      [extra (read port)])" "audit checker rejects trailing data in repository-controlled allowlist manifest")
+)
diff --git a/tools/check-unsafe-deserialize.ss b/tools/check-unsafe-deserialize.ss
new file mode 100644
index 0000000..871335a
--- /dev/null
+++ b/tools/check-unsafe-deserialize.ss
@@ -0,0 +1,216 @@
+#!chezscheme
+;;; check-unsafe-deserialize.ss -- audit raw datum/FASL/load trust boundaries.
+
+(import (scheme))
+
+(define findings '())
+(define classification-path "docs/unsafe-deserialize-allowlist.sexp")
+(define classified-sites '())
+(define matched-sites '())
+
+(define (add-finding file line rule detail)
+  (set! findings (cons (list file line rule detail) findings)))
+(define (valid-classification? entry)
+  (and (list? entry)
+       (= (length entry) 5)
+       (string? (car entry))
+       (integer? (cadr entry))
+       (string? (caddr entry))
+       (string? (cadddr entry))
+       (string? (car (cddddr entry)))))
+
+(define (load-classifications path)
+  (guard (exn [else
+               (add-finding path 0 "classification-read-error"
+                            "could not read unsafe deserialization classification manifest")
+               '()])
+    (call-with-input-file path
+      (lambda (port)
+        (let ([entries (read port)]) ; jerboa-security: suppress bare-read-untrusted -- repository-controlled audit manifest, not runtime input
+          (if (and (list? entries)
+                   (let loop ([rest entries])
+                     (or (null? rest)
+                         (and (valid-classification? (car rest))
+                              (loop (cdr rest))))))
+              entries
+              (begin
+                (add-finding path 0 "classification-schema-error"
+                             "expected entries shaped as (file line rule source reason)")
+                '())))))))
+
+(define (classification-matches? entry file number rule line)
+  (and (string=? (car entry) file)
+       (= (cadr entry) number)
+       (string=? (caddr entry) rule)
+       (string=? (cadddr entry) line)))
+
+(define (classified? file number rule line)
+  (let loop ([rest classified-sites])
+    (cond
+      [(null? rest) #f]
+      [(classification-matches? (car rest) file number rule line)
+       (set! matched-sites (cons (car rest) matched-sites))
+       #t]
+      [else (loop (cdr rest))])))
+
+(define (matched-classification? entry)
+  (let loop ([rest matched-sites])
+    (and (pair? rest)
+         (or (equal? entry (car rest))
+             (loop (cdr rest))))))
+
+(define (check-stale-classifications)
+  (let loop ([rest classified-sites])
+    (unless (null? rest)
+      (let ([entry (car rest)])
+        (unless (matched-classification? entry)
+          (add-finding (car entry) (cadr entry) "stale-classification"
+                       "manifest entry no longer matches an audited source line"))
+        (loop (cdr rest))))))
+
+(define (string-contains? haystack needle)
+  (let ([hlen (string-length haystack)]
+        [nlen (string-length needle)])
+    (let loop ([index 0])
+      (cond
+        [(= nlen 0) #t]
+        [(> (+ index nlen) hlen) #f]
+        [(string=? (substring haystack index (+ index nlen)) needle) #t]
+        [else (loop (+ index 1))]))))
+
+(define (string-prefix? prefix s)
+  (let ([plen (string-length prefix)])
+    (and (>= (string-length s) plen)
+         (string=? prefix (substring s 0 plen)))))
+
+(define (trim-left s)
+  (let loop ([index 0])
+    (cond
+      [(= index (string-length s)) ""]
+      [(char-whitespace? (string-ref s index)) (loop (+ index 1))]
+      [else (substring s index (string-length s))])))
+(define (strip-string-literals line)
+  (let loop ([index 0] [in-string? #f] [escaped? #f] [out '()])
+    (if (= index (string-length line))
+        (list->string (reverse out))
+        (let ([ch (string-ref line index)])
+          (cond
+            [in-string?
+             (cond
+               [escaped? (loop (+ index 1) #t #f (cons #\space out))]
+               [(char=? ch #\\) (loop (+ index 1) #t #t (cons #\space out))]
+               [(char=? ch #\") (loop (+ index 1) #f #f (cons #\space out))]
+               [else (loop (+ index 1) #t #f (cons #\space out))])]
+            [(char=? ch #\") (loop (+ index 1) #t #f (cons #\space out))]
+            [else (loop (+ index 1) #f #f (cons ch out))])))))
+
+(define (comment-line? line)
+  (let ([trimmed (trim-left line)])
+    (or (string-prefix? ";;" trimmed)
+        (string-prefix? "#;" trimmed))))
+
+(define (suppressed? rule line)
+  (let ([needle (string-append "jerboa-security: suppress " rule " -- ")])
+    (string-contains? line needle)))
+(define (delimiter-after-symbol? ch)
+  (or (char-whitespace? ch)
+      (char=? ch #\))
+      (char=? ch #\])))
+
+(define (quoted-form-prefix? line index)
+  (and (> index 0)
+       (let ([ch (string-ref line (- index 1))])
+         (or (char=? ch #\')
+             (char=? ch #\`)
+             (char=? ch #\,)))))
+
+(define (next-nonspace-index line index)
+  (let loop ([i index])
+    (cond
+      [(>= i (string-length line)) #f]
+      [(char-whitespace? (string-ref line i)) (loop (+ i 1))]
+      [else i])))
+
+(define (call-form? line name)
+  (let ([nlen (string-length name)]
+        [llen (string-length line)])
+    (let loop ([index 0])
+      (cond
+        [(>= index llen) #f]
+        [(and (char=? (string-ref line index) #\()
+              (not (quoted-form-prefix? line index))
+              (<= (+ index 1 nlen) llen)
+              (string=? (substring line (+ index 1) (+ index 1 nlen)) name)
+              (< (+ index 1 nlen) llen)
+              (delimiter-after-symbol? (string-ref line (+ index 1 nlen))))
+         (let ([arg-index (next-nonspace-index line (+ index 1 nlen))])
+           (and arg-index
+                (not (char=? (string-ref line arg-index) #\"))
+                (not (char=? (string-ref line arg-index) #\)))
+                (not (char=? (string-ref line arg-index) #\.))
+                (not (and (> index 0)
+                          (char=? (string-ref line (- index 1)) #\()))))]
+        [else (loop (+ index 1))]))))
+
+(define (bare-read-call? line)
+  (call-form? line "read"))
+
+(define (unsafe-fasl-call? line)
+  (call-form? line "fasl-read"))
+
+(define (runtime-load-call? line)
+  (call-form? line "load"))
+(define (report-unclassified file number rule line detail)
+  (unless (classified? file number rule line)
+    (add-finding file number rule detail)))
+
+(define (check-line file number line)
+  (unless (comment-line? line)
+    (let ([code (strip-string-literals line)])
+      (when (and (bare-read-call? code)
+                 (not (suppressed? "bare-read-untrusted" line)))
+        (report-unclassified file number "bare-read-untrusted" line
+                             "use jerboa-read/safe schema parsing or add an inline trusted-provenance suppression"))
+      (when (and (unsafe-fasl-call? code)
+                 (not (suppressed? "unsafe-fasl-deserialize" line)))
+        (report-unclassified file number "unsafe-fasl-deserialize" line
+                             "use safe-fasl for untrusted bytes or add an inline trusted-provenance suppression"))
+      (when (and (runtime-load-call? code)
+                 (not (suppressed? "runtime-load-trusted" line)))
+        (report-unclassified file number "runtime-load-trusted" line
+                             "runtime code loading needs an inline trusted-provenance suppression")))))
+
+(define (scan-file file)
+  (guard (exn [else
+               (add-finding file 0 "scan-error"
+                            "could not read file during unsafe deserialize audit")])
+    (call-with-input-file file
+      (lambda (port)
+        (let loop ([line (get-line port)] [number 1])
+          (unless (eof-object? line)
+            (check-line file number line)
+            (loop (get-line port) (+ number 1))))))))
+
+(define files
+  (let ([args (cdr (command-line))])
+    (if (null? args)
+        '()
+        args)))
+
+(set! classified-sites (load-classifications classification-path))
+(for-each scan-file files)
+(check-stale-classifications)
+
+(unless (null? files)
+  (if (null? findings)
+      (begin
+        (printf "unsafe-deserialize: OK (~a file(s))\n" (length files))
+        (exit 0))
+      (begin
+        (for-each
+          (lambda (f)
+            (printf "unsafe-deserialize: ~a:~a [~a] ~a\n"
+                    (car f) (cadr f) (caddr f) (cadddr f)))
+          (reverse findings))
+        (printf "unsafe-deserialize: FAIL (~a finding(s))\n" (length findings))
+        (exit 1))))