security: hide lsp handler errors

Jaime Fournier

07c5c15ef864d94c7b5d529c6263742321ce7d77

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 55aae9d..0ffee14 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -789,8 +789,10 @@ they are build-time. Close the runtime loop.
   eval-region, malformed request, and request-dispatch failures while logging
   internal detail server-side. `jmcp` now returns opaque references for
   unexpected tool-handler exceptions and framed/unframed JSON parse failures
-  while logging internal detail server-side. Remaining work: router integrations
-  plus LSP surfaces need opaque client error refs.
+  while logging internal detail server-side. `jlsp` now returns opaque
+  references for request-handler failures and logs detailed read/handler
+  failures server-side under the same reference. Remaining work: router
+  integrations 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/security-reference.md b/docs/security-reference.md
index e57c31c..b88ca43 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)`, `(std net thread-httpd)`, `(std repl server)`, and `jmcp` use it for protocol/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)`, `(std repl server)`, `jmcp`, and `jlsp` 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/lsp/server.ss b/lsp/server.ss
index 8322efa..d61853b 100644
--- a/lsp/server.ss
+++ b/lsp/server.ss
@@ -37,7 +37,8 @@
           (lsp handlers configuration)
           (lsp handlers will-rename)
           (lsp handlers apply-edit)
-          (lsp validation))
+          (lsp validation)
+          (std security errors))
 
   ;; Handler registries
   (define *request-handlers* (make-hashtable string-hash string=?))
@@ -47,6 +48,26 @@
   (define *output-port* #f)
   (define *output-mutex* (make-mutex))
 
+  (define (lsp-error-detail exn)
+    (guard (render-exn [#t "unrenderable condition"])
+      (if (message-condition? exn)
+        (condition-message exn)
+        "non-message condition")))
+
+  (define lsp-safe-error-handler
+    (make-safe-error-handler
+     (lambda (reference class-name exn)
+       (lsp-error "safe error ref=" reference
+                  "class:" class-name
+                  "detail:" (lsp-error-detail exn)))))
+
+  (define (lsp-safe-error-message class-name exn)
+    (let ([safe (lsp-safe-error-handler class-name exn)])
+      (string-append (safe-error-response-message safe)
+                     " (Reference: "
+                     (safe-error-response-reference safe)
+                     ")")))
+
   (define (register-request-handler! method handler)
     (hashtable-set! *request-handlers* method handler))
 
@@ -177,7 +198,7 @@
 
       (let loop ()
         (let ([msg (guard (e [#t
-                    (lsp-error "Read error:" (condition-message e))
+                    (lsp-error "Read error:" (lsp-safe-error-message 'bad-request e))
                     #f])
                      (read-lsp-message in-port))])
           (cond
@@ -209,10 +230,10 @@
                      (let ([handler (hashtable-ref *request-handlers* method #f)])
                        (if handler
                          (guard (e [#t
-                           (lsp-error "Handler error for" method ":" (condition-message e))
-                           (send-response! id
-                             (make-jsonrpc-error id INTERNAL-ERROR
-                               (string-append "Internal error: " (condition-message e))))])
+                           (let ([message (lsp-safe-error-message 'internal-error e)])
+                             (lsp-error "Handler error for" method ":" message)
+                             (send-response! id
+                               (make-jsonrpc-error id INTERNAL-ERROR message)))])
                            (let ([result (handler state params)])
                              (when (*validation-enabled*)
                                (validate-response method result))
@@ -236,7 +257,7 @@
                        (when handler
                          (guard (e [#t
                            (lsp-error "Notification handler error for" method ":"
-                                      (condition-message e))])
+                                      (lsp-safe-error-message 'internal-error e))])
                            (handler state params))))])
                   (loop)]
 
diff --git a/lsp/test/unit-test.ss b/lsp/test/unit-test.ss
index cc93c49..dba9a82 100644
--- a/lsp/test/unit-test.ss
+++ b/lsp/test/unit-test.ss
@@ -13,6 +13,8 @@
         (lsp util position)
         (lsp util log)
         (lsp jsonrpc)
+        (lsp transport)
+        (lsp server)
         (lsp types)
         (lsp state)
         (lsp analysis document)
@@ -358,6 +360,48 @@
     (string=? (json-get notif "method") "textDocument/publishDiagnostics"))
   (check "notification has params"
     (hashtable? (json-get notif "params"))))
+(test-section "LSP server: opaque handler errors")
+
+(define (lsp-frame obj)
+  (call-with-string-output-port
+    (lambda (port)
+      (write-lsp-message port obj))))
+
+(define (run-lsp-messages messages)
+  (let* ([input (apply string-append (map lsp-frame messages))]
+         [output (call-with-string-output-port
+                   (lambda (out)
+                     (start-server (open-input-string input) out)))]
+         [in (open-input-string output)])
+    (let* ([first (read-lsp-message in)]
+           [second (read-lsp-message in)])
+      (list first second))))
+
+(register-request-handler!
+  "jerboa/testFailure"
+  (lambda (state params)
+    (error 'jerboa/testFailure "lsp-secret-token-123")))
+
+(let* ([responses
+        (run-lsp-messages
+          (list
+            (json-obj "jsonrpc" "2.0"
+                      "id" 1001
+                      "method" "initialize"
+                      "params" (json-obj "capabilities" (json-obj)))
+            (json-obj "jsonrpc" "2.0"
+                      "id" 1002
+                      "method" "jerboa/testFailure"
+                      "params" (json-obj))))]
+       [err (json-get (cadr responses) "error")]
+       [message (json-get err "message")])
+  (check "handler error response is internal"
+    (= (json-get err "code") INTERNAL-ERROR))
+  (check "handler error response has reference"
+    (and (string-contains message "Internal server error")
+         (string-contains message "Reference:")))
+  (check "handler error response redacts condition text"
+    (not (string-contains message "lsp-secret-token-123"))))
 
 ;;; ── LSP Types ──────────────────────────────────────────────────────
 
@@ -852,9 +896,8 @@
   (check "find-jerboa-home returns something"
     (or (not jh) (string? jh)))
   (when jh
-    (check "jerboa-home ends with expected path"
-      (or (string-suffix? "/jerboa" jh)
-          (string-suffix? "/mine/jerboa" jh)))))
+    (check "jerboa-home has lib directory"
+      (file-exists? (string-append jh "/lib")))))
 
 (test-section "Module resolution: resolve-module-path")