security: redact mcp log secrets

Jaime Fournier <jaimef@linbsd.org>

f12715f0d5a314ca395e500f508fb0d7e070e2da

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index edadff8..28d6456 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -958,6 +958,12 @@ Standardize on `(std security secret)` (auto-wipe lifetimes) +
 command-line argv (`ps`-visible — Philosophy rejects this), secrets in
 error messages/logs. Scanner rules from P2-05 enforce going forward.
 
+- **Status:** started for repository-local MCP logging. `mcp/server.ss` now
+  centrally redacts sensitive MCP request fields, env overrides, free-form
+  command strings with secret markers, and slow-process log entries before
+  writing `JERBOA_MCP_LOG_FILE`. A runtime smoke verified that a fake token in
+  `jerboa_eval` env/command plumbing reaches the log only as `<redacted>`.
+
 ### K3-P2-08 — SHSTK default decision for daemon binaries
 **Serves:** G1. **Effort:** 2 days.
 
diff --git a/mcp/server.ss b/mcp/server.ss
index 33c428f..39612ad 100644
--- a/mcp/server.ss
+++ b/mcp/server.ss
@@ -211,28 +211,82 @@
               ch))
         (string->list s))))
 
+(def jmcp-sensitive-key-fragments
+  '("token" "secret" "password" "passwd" "api-key" "api_key"
+    "access-token" "access_token" "refresh-token" "refresh_token"
+    "authorization" "bearer" "credential" "private-key" "private_key"
+    "webhook-secret" "webhook_secret" "totp"))
+
+(def (jmcp-key->string key)
+  (cond
+    [(symbol? key) (symbol->string key)]
+    [(string? key) key]
+    [else (pp-to-string key)]))
+
+(def (jmcp-sensitive-key? key)
+  (let ([name (string-downcase (jmcp-key->string key))])
+    (any (lambda (frag) (string-contains name frag))
+         jmcp-sensitive-key-fragments)))
+
+(def (jmcp-secret-looking-string? s)
+  (let ([lower (string-downcase s)])
+    (or (string-contains lower "authorization")
+        (string-contains lower "bearer ")
+        (string-contains lower "--token") ; jerboa-security: suppress secret-via-argv-flag -- redaction marker, not argv parsing
+        (string-contains lower "--access-token")
+        (string-contains lower "--refresh-token")
+        (string-contains lower "--secret")
+        (string-contains lower "--password")
+        (string-contains lower "--passwd")
+        (string-contains lower "--api-key")
+        (string-contains lower "api_key=")
+        (string-contains lower "api-key=")
+        (string-contains lower "token=")
+        (string-contains lower "secret=")
+        (string-contains lower "password=")
+        (string-contains lower "passwd=")
+        (string-contains lower "private_key=")
+        (string-contains lower "private-key=")
+        (string-contains lower "webhook_secret=")
+        (string-contains lower "webhook-secret="))))
+
+(def (jmcp-redact-log-string s)
+  (if (and (string? s) (jmcp-secret-looking-string? s))
+      "<redacted>"
+      s))
+
+(def (jmcp-log-normalize-field key value depth)
+  (if (jmcp-sensitive-key? key)
+      "<redacted>"
+      (jmcp-log-normalize value depth)))
+
 (def (jmcp-log-normalize v depth)
   (cond
     [(<= depth 0) '...]
     [(hash-table? v)
      (map (lambda (kv)
-            (cons (car kv) (jmcp-log-normalize (cdr kv) (- depth 1))))
+            (cons (car kv)
+                  (jmcp-log-normalize-field (car kv) (cdr kv) (- depth 1))))
           (take-n (hash->list v) 40))]
     [(list? v)
      (map (lambda (x) (jmcp-log-normalize x (- depth 1)))
           (take-n v 40))]
     [(pair? v)
-     (cons (jmcp-log-normalize (car v) (- depth 1))
-           (jmcp-log-normalize (cdr v) (- depth 1)))]
+     (if (jmcp-sensitive-key? (car v))
+         (cons (jmcp-log-normalize (car v) (- depth 1)) "<redacted>")
+         (cons (jmcp-log-normalize (car v) (- depth 1))
+               (jmcp-log-normalize (cdr v) (- depth 1))))]
     [(vector? v)
      (list 'vector
            (vector-length v)
            (map (lambda (x) (jmcp-log-normalize x (- depth 1)))
                 (take-n (vector->list v) 40)))]
+    [(string? v) (jmcp-redact-log-string v)]
     [else v]))
 
 (def (jmcp-log-value v limit)
-  (shorten (jmcp-clean-string (pp-to-string (jmcp-log-normalize v 4))) limit))
+  (jmcp-redact-log-string
+   (shorten (jmcp-clean-string (pp-to-string (jmcp-log-normalize v 4))) limit)))
 
 (def (jmcp-file-length path)
   (if (file-exists? path)
@@ -357,7 +411,8 @@
           "    printf 'ts_sec=%s event=process.slow elapsed_ms=" slow-ms
           " slow_ms=" slow-ms " timeout_ms=" (number->string timeout-ms)
           " pid=%s cmd=%s\\n' \"$(date +%s)\" \"$child\" "
-          (shell-quote cmd) " >> " (shell-quote log-file) " 2>/dev/null || true\n"
+          (shell-quote (jmcp-redact-log-string cmd))
+          " >> " (shell-quote log-file) " 2>/dev/null || true\n"
           "  fi\n"
           ") &\n"
           "slow_watcher=$!\n")