security: triage raw datum readers

ober

7379764cdaefebb65cecef67219c620bf42f93aa

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 5d1e990..844fede 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -215,7 +215,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | Exec restricted worker facade | `(std security worker)` | exists; pure env, deadline, process-group kill, output caps, fail-closed refused axes; pre-exec kernel controls pending | security-reference §5 |
 | Native async exec launcher (collect-safe) | `(std os aproc)` | exists — the primitive P0-02 should build on | [aproc.md](aproc.md) |
 | Parser hardening (depth/size/backtrack budgets: reader, JSON, XML, YAML, DNS, HTTP/2, WS, zlib, base64, hex, CSV, pregexp, format) | various | phases 1–4 done, 42 tests; **phase 5 (FFI audit) not started** | security-reference §7 |
-| Safe deserialization (tagged-JSON envelope, no native FASL on untrusted paths) | `(std safe-fasl)`, `(std fasl)` (trusted-only) | exists; raw-FASL callers still need triage (P0-04) | [safety-guide.md](safety-guide.md) §10 |
+| Safe deserialization (tagged-JSON envelope, no native FASL on untrusted paths) | `(std safe-fasl)`, `(std fasl)` (trusted-only) | exists; named raw-read/FASL paths have first triage; broader `load`/REPL/dev-surface classification remains | [safety-guide.md](safety-guide.md) §10 |
 | Crypto (Rust `ring`: AEAD, HMAC, PBKDF2, scrypt, ChaCha20, digests; CSPRNG from `/dev/urandom`; timing-safe compare) | `(std crypto native-rust)` et al. | exists; OpenSSL legacy paths still present; no Argon2id | security-reference §10 |
 | Secure memory (mlock, guard pages, DONTDUMP, DONTFORK, explicit_bzero) | `(std crypto secure-mem)` | exists; low-level pointer API only | security-reference §9 |
 | Lifetime-scoped secrets with auto-wipe | `(std security secret)` | exists; **absent from security-reference.md** — doc gap | `lib/std/security/secret.ss` |
@@ -412,7 +412,14 @@ The allowlist is Jerboa's crown jewel; it currently rests on manual curation.
   failures.
 
 ### K3-P0-04 — Triage and close raw `read` / native FASL paths on untrusted input
-**Serves:** G1. **Effort:** 3–5 days.
+**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.
 
 `security-reference.md`'s checklist already names exemplars:
 `jerbuild.ss:308`, `lib/std/actor/distributed.ss:303`, `lib/std/fasl.ss:27`,
@@ -888,7 +895,7 @@ Track these in `docs/status.md` per release:
 |---|---|---|
 | Build balance clean | `pattern.ss` repaired 2026-07-27; `source-balance` in `make audit` | always clean |
 | Confined worker exists | initial facade landed 2026-07-27; native pre-exec backend pending | yes, tested (P0-02) |
-| Unclassified raw `read`/FASL sites | unknown (4+ named) | 0 |
+| Unclassified raw `read`/FASL sites | first scanner-driven batch closed 2026-07-27; broader `load`/REPL/dev classification remains | 0 |
 | FFI bindings audited | 0 / 91 files | 100% with verdicts |
 | Un-annotated Rust `unsafe` blocks | unknown / 432 matches | 0 |
 | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test |
diff --git a/lib/jerboa/lock.ss b/lib/jerboa/lock.ss
index 55764ba..c34d763 100644
--- a/lib/jerboa/lock.ss
+++ b/lib/jerboa/lock.ss
@@ -28,7 +28,8 @@
                   iota 1+ 1-
                   partition
                   make-date make-time)
-          (except (jerboa prelude) meta atom?))
+          (except (jerboa prelude) meta atom?)
+          (only (jerboa reader) jerboa-read))
 
   (defstruct lock-entry (name version hash deps))
   (defstruct lockfile (entries))
@@ -85,7 +86,7 @@
     (newline port))
 
   (def (lockfile-read port)
-    (let ([sexp (read port)])
+    (let ([sexp (jerboa-read port)])
       (if (eof-object? sexp)
         (make-lockfile '())
         (sexp->lockfile sexp))))
diff --git a/lib/std/compiler/pgo.ss b/lib/std/compiler/pgo.ss
index 10adbb9..96714cc 100644
--- a/lib/std/compiler/pgo.ss
+++ b/lib/std/compiler/pgo.ss
@@ -35,6 +35,21 @@
 
   (import (chezscheme)
           (only (jerboa core) def))
+  (def max-profile-bytes (* 4 1024 1024))
+
+  (def (read-profile-text filename)
+    (call-with-input-file filename
+      (lambda (port)
+        (let ([out (open-output-string)])
+          (let loop ([n 0])
+            (let ([ch (read-char port)])
+              (cond
+                [(eof-object? ch) (get-output-string out)]
+                [(>= n max-profile-bytes)
+                 (error 'profile-load "profile file too large" filename)]
+                [else
+                 (write-char ch out)
+                 (loop (+ n 1))])))))))
 
   ;; -----------------------------------------------------------------------
   ;; State (symbol → integer call-count table)
@@ -107,12 +122,12 @@
   (def (profile-load filename)
     "Load profile data from FILENAME; returns alist"
     (if (file-exists? filename)
-        (call-with-input-file filename
-          (lambda (port)
-            (let ([form (read port)])
-              (if (and (pair? form) (eq? (car form) 'jerboa-profile))
-                  (cadr form)
-                  (error 'profile-load "malformed profile" filename)))))
+        (let* ([text (read-profile-text filename)]
+               [port (open-input-string text)]
+               [form (read port)]) ; jerboa-security: suppress bare-read-untrusted -- PGO profile is generated by profile-save, size-capped above, and tag-checked below
+          (if (and (pair? form) (eq? (car form) 'jerboa-profile))
+              (cadr form)
+              (error 'profile-load "malformed profile" filename)))
         '()))
 
   (def (profile-load! filename)
diff --git a/lib/std/io.ss b/lib/std/io.ss
index 8d469a6..3cca818 100644
--- a/lib/std/io.ss
+++ b/lib/std/io.ss
@@ -9,11 +9,12 @@
           read-all write-all
           with-input with-output)
   (import (chezscheme)
-          (only (jerboa core) def))
+          (only (jerboa core) def)
+          (only (jerboa reader) jerboa-read))
 
   (def (read-all port)
     (let lp ([acc '()])
-      (let ([datum (read port)])
+      (let ([datum (jerboa-read port)])
         (if (eof-object? datum)
             (reverse acc)
             (lp (cons datum acc))))))
diff --git a/lib/std/net/grpc.ss b/lib/std/net/grpc.ss
index 9aa94e0..a9f61e7 100644
--- a/lib/std/net/grpc.ss
+++ b/lib/std/net/grpc.ss
@@ -3,7 +3,8 @@
 ;;;
 ;;; Protocol: client sends (method arg ...) as an S-expression followed by newline.
 ;;; Server responds with (ok result) or (error message).
-;;; Length-prefix framing is not needed since `read` handles S-expression boundaries.
+;;; Messages are parsed with Jerboa's bounded reader; callers should still keep
+;;; transport-level connection and request-size limits.
 
 (library (std net grpc)
   (export
@@ -15,7 +16,8 @@
 
   (import (chezscheme)
           (only (std native-loader) native-loader-ensure-libc-symbol!)
-          (only (jerboa core) def defstruct try catch finally))
+          (only (jerboa core) def defstruct try catch finally)
+          (only (jerboa reader) jerboa-read))
 
   ;; ---- C socket FFI ----
 
@@ -156,7 +158,7 @@
            [inp (car ps)]
            [out (cdr ps)])
       (let loop ()
-        (let ([msg (try (read inp)
+        (let ([msg (try (jerboa-read inp)
          (catch (exn) (eof-object)))])
           (unless (eof-object? msg)
             (let ([response (dispatch-rpc services msg)])
@@ -251,7 +253,7 @@
           [out (grpc-client-rec-out client)])
       (write (cons method args) out)
       (flush-output-port out)
-      (let ([resp (read inp)])
+      (let ([resp (jerboa-read inp)])
         (if (and (pair? resp) (eq? (car resp) 'ok))
             (cadr resp)
             (if (and (pair? resp) (eq? (car resp) 'error))
diff --git a/lib/std/pkg/lock.ss b/lib/std/pkg/lock.ss
index 564e29f..9a50357 100644
--- a/lib/std/pkg/lock.ss
+++ b/lib/std/pkg/lock.ss
@@ -136,9 +136,11 @@
     (when (> (string-length text) max-lock-bytes)
       (jpkg-error "lock: file too large"))
     (let* ([port (open-string-input-port text)]
-           [datum (try (read port) (catch (e) (jpkg-error "lock: unreadable")))])
+           [datum (try (read port) ; jerboa-security: suppress bare-read-untrusted -- lock text is size-capped above and structurally validated below
+                    (catch (e) (jpkg-error "lock: unreadable")))])
       (when (eof-object? datum) (jpkg-error "lock: empty file"))
-      (let ([extra (try (read port) (catch (e) (jpkg-error "lock: trailing junk")))])
+      (let ([extra (try (read port) ; jerboa-security: suppress bare-read-untrusted -- second read only rejects extra size-capped lock forms
+                     (catch (e) (jpkg-error "lock: trailing junk")))])
         (unless (eof-object? extra)
           (jpkg-error "lock: more than one top-level form")))
       (check-data! datum)
diff --git a/lib/std/pkg/manifest.ss b/lib/std/pkg/manifest.ss
index 38f0c87..1498fe2 100644
--- a/lib/std/pkg/manifest.ss
+++ b/lib/std/pkg/manifest.ss
@@ -85,13 +85,14 @@
     ;; check-data!'s node budget below converts both into hard errors, so
     ;; no textual pre-scan is needed.
     (let* ([port (open-string-input-port text)]
-           [datum (try (read port)
+           [datum (try (read port) ; jerboa-security: suppress bare-read-untrusted -- manifest text is size-capped above and structurally validated below
                        (catch (e) (jpkg-error "manifest: unreadable: ~a"
                                               (if (message-condition? e)
                                                   (condition-message e) ""))))])
       (when (eof-object? datum)
         (jpkg-error "manifest: empty file"))
-      (let ([extra (try (read port) (catch (e) (jpkg-error "manifest: trailing junk")))])
+      (let ([extra (try (read port) ; jerboa-security: suppress bare-read-untrusted -- second read only rejects extra size-capped manifest forms
+                        (catch (e) (jpkg-error "manifest: trailing junk")))])
         (unless (eof-object? extra)
           (jpkg-error "manifest: more than one top-level form")))
       (check-data! datum)
diff --git a/lib/std/pkg/policy.ss b/lib/std/pkg/policy.ss
index dcc85d3..5e2c1e3 100644
--- a/lib/std/pkg/policy.ss
+++ b/lib/std/pkg/policy.ss
@@ -82,9 +82,11 @@
     (when (> (string-length text) 65536)
       (jpkg-error "policy: file too large"))
     (let* ([port (open-string-input-port text)]
-           [datum (try (read port) (catch (e) (jpkg-error "policy: unreadable")))])
+           [datum (try (read port) ; jerboa-security: suppress bare-read-untrusted -- policy text is size-capped above and structurally validated below
+                    (catch (e) (jpkg-error "policy: unreadable")))])
       (when (eof-object? datum) (jpkg-error "policy: empty file"))
-      (let ([extra (try (read port) (catch (e) (jpkg-error "policy: trailing junk")))])
+      (let ([extra (try (read port) ; jerboa-security: suppress bare-read-untrusted -- second read only rejects extra size-capped policy forms
+                        (catch (e) (jpkg-error "policy: trailing junk")))])
         (unless (eof-object? extra) (jpkg-error "policy: more than one form")))
       ;; shallow data check
       (let walk ([x datum] [depth 0])
diff --git a/src/jerboa/translator.ss b/src/jerboa/translator.ss
index 77b1c27..c2643e5 100644
--- a/src/jerboa/translator.ss
+++ b/src/jerboa/translator.ss
@@ -828,7 +828,7 @@
   (define (read-all-from-string str)
     (let ([p (open-input-string str)])
       (let loop ([acc '()])
-        (let ([f (read p)])
+        (let ([f (read p)]) ; jerboa-security: suppress bare-read-untrusted -- trusted compiler source after Jerboa string transforms
           (if (eof-object? f)
               (reverse acc)
               (loop (cons f acc)))))))