security: hide repl protocol errors

Jaime Fournier <jaimef@linbsd.org>

f1c0e089cd7658ac327e0d3ea7c95d67cf7e6a04

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 1056529..65a1d55 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -784,9 +784,11 @@ they are build-time. Close the runtime loop.
   embedded boot/bundle build-root byte sequences before C embedding, and gate
   both `dist/jerboa` and packaged `.tar.gz` artifacts with
   `support/check-release-path-leaks.sh`; the release evidence bundle records
-  `path-leaks.txt` and requires `path_leak_status=pass`. Remaining work: router
-  integrations plus MCP/LSP/repl-protocol surfaces need opaque client error
-  refs.
+  `path-leaks.txt` and requires `path_leak_status=pass`. `(std repl server)`
+  now returns generic client errors with opaque references for eval,
+  eval-region, malformed request, and request-dispatch failures while logging
+  internal detail server-side. Remaining work: router integrations plus MCP/LSP
+  surfaces need opaque client error refs.
 
 ### K3-P1-11 — Parameterized-only SQL in the safe surface
 **Serves:** G1. **Effort:** 2–3 days.
diff --git a/docs/repl-protocol.md b/docs/repl-protocol.md
index 636d345..96a6cfd 100644
--- a/docs/repl-protocol.md
+++ b/docs/repl-protocol.md
@@ -56,9 +56,12 @@ Success:
 
 Error:
 ```
-(id :error "error message")
+(id :error "Internal server error (Reference: opaque-id)")
 ```
 
+Client-visible protocol errors are intentionally generic. The server logs the
+full condition with the same opaque reference for correlation.
+
 Server push (unsolicited):
 ```
 (:push type payload)
@@ -78,7 +81,7 @@ Request:  (2 eval "(begin (display 42) 99)")
 Response: (2 :ok (:value "99" :stdout "42"))
 
 Request:  (3 eval "(/ 1 0)")
-Response: (3 :error "undefined for ~s")
+Response: (3 :error "Internal server error (Reference: opaque-id)")
 ```
 
 ### eval-region
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 4c73c50..0e9bc85 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -821,7 +821,7 @@ These modules are implemented but not covered in depth above.
 | Module | Purpose |
 |--------|---------|
 | `(std security sanitize)` | Context-aware sanitization: `sanitize-html`, `sanitize-html-attribute`, `sanitize-url-attribute`, `sql-escape`, `sanitize-path`, `safe-path-join`, `sanitize-header-value`, `sanitize-url`. Raises `&path-traversal`, `&header-injection`, `&url-scheme-violation`. |
-| `(std security errors)` | Error classification (internal vs client-safe). Generates opaque error references for correlation. Prevents leaking internal details in error responses; `(std net grpc)` and `(std net thread-httpd)` use it for handler failures. |
+| `(std security errors)` | Error classification (internal vs client-safe). Generates opaque error references for correlation. Prevents leaking internal details in error responses; `(std net grpc)`, `(std net thread-httpd)`, and `(std repl server)` use it for protocol/handler failures. |
 | `(std security audit)` | Append-only JSONL audit log with SHA-256 hash chain. `audit-log!`, `verify-audit-chain`, `check-capability!/audit`. |
 | `(std security audit-log)` | Per-run structured audit records with JSONL/summary rendering, per-log redactors, and `audit-log-use-env-policy!` to scrub `(std security env)` registered secret values before emission. |
 | `(std security auth)` | API key stores, session tokens with expiry, auth middleware pattern, rate limiting for auth attempts. |
diff --git a/docs/unsafe-deserialize-allowlist.sexp b/docs/unsafe-deserialize-allowlist.sexp
index 105f8bf..e1f3ab1 100644
--- a/docs/unsafe-deserialize-allowlist.sexp
+++ b/docs/unsafe-deserialize-allowlist.sexp
@@ -32,7 +32,7 @@
  ("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/repl/server.ss" 356 "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")
diff --git a/lib/std/repl/server.ss b/lib/std/repl/server.ss
index 47a603c..47cb7f8 100644
--- a/lib/std/repl/server.ss
+++ b/lib/std/repl/server.ss
@@ -57,6 +57,7 @@
   (import (chezscheme)
           (jerboa reader)
           (std repl)
+          (std security errors)
           (only (jerboa core) def defstruct try catch finally))
 
   ;; ========== Server Record ==========
@@ -218,6 +219,19 @@
 
   ;; ========== Evaluation Environment ==========
   (def *server-env* (interaction-environment))
+  (def repl-safe-error-handler
+    (make-safe-error-handler
+     (lambda (reference class-name exn)
+       (fprintf (current-error-port)
+                "repl-protocol error ref=~a class=~a detail=~s\n"
+                reference
+                class-name
+                exn))))
+  (def (safe-repl-error-message class-name exn)
+    (let ([safe (repl-safe-error-handler class-name exn)])
+      (format "~a (Reference: ~a)"
+              (safe-error-response-message safe)
+              (safe-error-response-reference safe))))
 
   (def (capture-eval expr-str)
     ;; Evaluate expression string, capturing stdout and stderr
@@ -230,9 +244,7 @@
                 (format "~s" result)
                 stdout-str))
          (catch (exn) (values 'error
-                         (if (message-condition? exn)
-                           (condition-message exn)
-                           (format "~s" exn))
+                         (safe-repl-error-message 'internal-error exn)
                          ""))))
 
   (def (capture-eval-region str)
@@ -258,9 +270,7 @@
         (values 'ok (format "~s" result) stdout-str))
            )
          (catch (exn) (values 'error
-                         (if (message-condition? exn)
-                           (condition-message exn)
-                           (format "~s" exn))
+                         (safe-repl-error-message 'internal-error exn)
                          ""))))
 
   ;; ========== Request Handling ==========
@@ -431,10 +441,12 @@
                       :protocol "1.0"))]
 
           [else
-           `(,id :error ,(format "unknown method: ~a" method))])
-         (catch (exn) `(,id :error ,(if (message-condition? exn)
-                                   (condition-message exn)
-                                   (format "~s" exn)))))))
+           `(,id :error ,(safe-repl-error-message
+                          'bad-request
+                          (format "unknown method: ~a" method)))])
+         (catch (exn) `(,id :error ,(safe-repl-error-message
+                                     'internal-error
+                                     exn))))))
 
   ;; ========== String helpers ==========
   (def (string-contains* haystack needle)
@@ -528,7 +540,9 @@
                        (set-repl-server-running! server #f)
                        (loop)))]
                   [else
-                   (write `(:push :error "malformed request") outp)
+                   (write `(:push :error ,(safe-repl-error-message
+                                           'bad-request
+                                           "malformed request")) outp)
                    (newline outp)
                    (flush-output-port outp)
                    (loop)]))))
diff --git a/tests/test-repl-server.ss b/tests/test-repl-server.ss
index c8acc37..cb26953 100644
--- a/tests/test-repl-server.ss
+++ b/tests/test-repl-server.ss
@@ -133,6 +133,14 @@
       (check-true (string-contains* resp ":error")))
     (brief-pause)
 
+    ;; Test: eval errors are opaque and do not leak condition text
+    (let ([resp (nc-request port "(12 eval \"(error 'boom \\\"repl-secret-token-123\\\")\")")])
+      (check-true (string-contains* resp ":error"))
+      (check-true (string-contains* resp "Internal server error"))
+      (check-true (string-contains* resp "Reference:"))
+      (check-true (not (string-contains* resp "repl-secret-token-123"))))
+    (brief-pause)
+
     ;; Test: complete
     (let ([resp (nc-request port "(4 complete \"string-\")")])
       (check-true (string-contains* resp ":ok"))