security: fail closed worker memory limits

Jaime Fournier

34b05a8ac39de5b20e854643995b4c96e5ff7a60

diff --git a/docs/chez-limits.md b/docs/chez-limits.md
index 3cfa7cd..8484e10 100644
--- a/docs/chez-limits.md
+++ b/docs/chez-limits.md
@@ -26,6 +26,23 @@ time.
   `/usr/bin/time -l`, macOS `sample <pid>`, and per-thread "what did I
   do this second" counters in the program itself.
 
+## Security Boundary Note: Untrusted Eval Memory Limits
+
+Chez gives Jerboa useful in-process tripwires, but a heap cap on the current
+thread is not a security boundary. `run-safe-eval` therefore refuses non-`#f`
+`max-memory-size` requests instead of pretending to confine the current runtime.
+
+Use `(std security worker)` `worker-run-eval` for untrusted expressions. The
+worker policy exposes `memory-limit-bytes:` as the memory-control intent. On
+the current Scheme-only exec facade, requesting it fails closed by default with
+status 126 and refused axis `memory-limit`. A caller can set `'fail-closed?: #f`
+to launch for diagnostics, but the result reports
+`(memory-limit-installed? . #f)` and must not be treated as memory isolation.
+
+Chez GC knobs such as collection cadence or collect-trip-style settings can be
+useful defense-in-depth for cooperative workloads. They do not replace a
+process boundary plus pre-exec `rlimit`/platform sandbox installation.
+
 ## 1. Allocator: `S_get_more_room` is a global serialisation point
 
 The Chez allocator gives each thread a thread-local allocation buffer
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 01b70b0..3fedd7b 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -696,15 +696,17 @@ arithmetic — itself an FFI footgun (`security-reference.md` §13).
 Chez cannot heap-cap a thread; `run-safe-eval` rightly refuses
 `max-memory-size`. The worker (P0-02) is the answer — make it ergonomic.
 
-- **Do:** (a) Ship `worker-run-eval` convenience: one call that execs a
-  confined worker with rlimit AS + CPU, runs the restricted eval, returns
-  bounded output. (b) Document the hierarchy: in-process `run-safe-eval`
-  (semi-trusted) → confined worker eval (untrusted) → container/VM
-  (hostile). (c) Investigate Chez `collect-trip` / maximum-heap parameters
-  as an in-process tripwire (defense-in-depth, not a boundary) — document
-  findings either way in `chez-limits.md`.
-- **Accept:** eval-bomb test killed by worker rlimit; docs updated;
-  `run-safe-eval` docs cross-link the worker API.
+- **Status:** complete for the Scheme-facing worker API and fail-closed
+  memory-limit contract. `(std security worker)` ships `worker-run-eval`,
+  bounded stdout/stderr, parent deadline/process-group kill, and
+  `memory-limit-bytes:` policy intent. Until the native pre-exec rlimit backend
+  exists, requested memory limits refuse before launch by default with refused
+  axis `memory-limit`; degraded diagnostic launches explicitly report
+  `(memory-limit-installed? . #f)`.
+- **Accept:** partially satisfied. Worker memory-limit requests now fail closed
+  and are regression-tested, and `run-safe-eval` docs cross-link the worker API
+  plus `docs/chez-limits.md`. The eval-bomb killed by rlimit AS remains pending
+  with the native P0-02 pre-exec backend.
 
 ### K3-P1-08 — Platform sandbox parity
 **Serves:** G2. **Effort:** 2 weeks.
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 9a89cac..fd49978 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -379,6 +379,7 @@ child entrypoint `support/security-worker-main.ss`.
     "(+ 1 2)"
     (worker-policy
       'timeout-ms: 1000
+      'memory-limit-bytes: #f
       'stdout-cap-bytes: 65536
       'stderr-cap-bytes: 65536)))
 
@@ -394,12 +395,30 @@ are fail-closed: if a policy requires an unavailable sandbox axis, the worker
 returns `launched? = #f`, status `126`, and the refused axis list instead of
 silently running with weaker controls.
 
+`worker-policy` accepts `memory-limit-bytes:` as the memory-control intent for
+untrusted evaluation. On the current Scheme-only exec facade, a non-`#f` value
+is refused by default before launch because the native pre-exec rlimit backend
+is not installed yet:
+
+```scheme
+(worker-run-eval
+  "(let loop () (loop))"
+  (worker-policy
+    'timeout-ms: 1000
+    'memory-limit-bytes: (* 64 1024 1024)))
+;; => launched? #f, status 126, refused axes include memory-limit
+```
+
+Callers may set `'fail-closed?: #f` to launch anyway for diagnostics, but the
+result will report `(memory-limit-installed? . #f)`. Do not treat that mode as
+memory isolation.
+
 Current limitation: the worker has a real exec boundary, pure environment,
 parent deadline, process-group kill through `aproc`, returned output caps, and
 audit-log start/end records in `worker-result-diagnostics`. The remaining
-pre-exec kernel-control backend is not complete, so rlimit, Landlock/seccomp,
-Seatbelt/Capsicum installation, and deny-default egress proxy wiring remain
-tracked by the K3 handoff.
+pre-exec kernel-control backend is not complete, so rlimit installation,
+Landlock/seccomp, Seatbelt/Capsicum installation, and deny-default egress proxy
+wiring remain tracked by the K3 handoff.
 
 ---
 
@@ -474,6 +493,12 @@ that fresh process. A supervisor should own deadlines and bound the worker's
 stdin, stdout, and stderr. Never run Scheme in the child between `fork` and
 `exec`.
 
+For untrusted expressions, use `(std security worker)` `worker-run-eval`.
+`run-safe-eval` cannot heap-cap the current Chez thread; non-`#f`
+`max-memory-size` requests raise phase `'fork`. The worker API exposes
+`memory-limit-bytes:` for that intent and fails closed until the native
+pre-exec rlimit backend can install it.
+
 ### API
 
 ```scheme
diff --git a/lib/std/security/worker.ss b/lib/std/security/worker.ss
index 9c709f8..71e5bdc 100644
--- a/lib/std/security/worker.ss
+++ b/lib/std/security/worker.ss
@@ -19,6 +19,7 @@
     worker-policy-env-policy
     worker-policy-cwd
     worker-policy-timeout-ms
+    worker-policy-memory-limit-bytes
     worker-policy-stdout-cap-bytes
     worker-policy-stderr-cap-bytes
     worker-policy-require
@@ -57,15 +58,23 @@
                 env-policy-audit-summary))
 
   (defstruct %worker-policy
-    (command env-policy cwd timeout-ms stdout-cap-bytes stderr-cap-bytes
-     require fail-closed?))
+    (command env-policy cwd timeout-ms memory-limit-bytes
+     stdout-cap-bytes stderr-cap-bytes require fail-closed?))
 
-  (def make-worker-policy make-%worker-policy)
+  (def make-worker-policy
+    (case-lambda
+      [(command env-policy cwd timeout-ms stdout-cap-bytes stderr-cap-bytes require fail-closed?)
+       (make-%worker-policy command env-policy cwd timeout-ms #f
+                            stdout-cap-bytes stderr-cap-bytes require fail-closed?)]
+      [(command env-policy cwd timeout-ms memory-limit-bytes stdout-cap-bytes stderr-cap-bytes require fail-closed?)
+       (make-%worker-policy command env-policy cwd timeout-ms memory-limit-bytes
+                            stdout-cap-bytes stderr-cap-bytes require fail-closed?)]))
   (def worker-policy? %worker-policy?)
   (def worker-policy-command %worker-policy-command)
   (def worker-policy-env-policy %worker-policy-env-policy)
   (def worker-policy-cwd %worker-policy-cwd)
   (def worker-policy-timeout-ms %worker-policy-timeout-ms)
+  (def worker-policy-memory-limit-bytes %worker-policy-memory-limit-bytes)
   (def worker-policy-stdout-cap-bytes %worker-policy-stdout-cap-bytes)
   (def worker-policy-stderr-cap-bytes %worker-policy-stderr-cap-bytes)
   (def worker-policy-require %worker-policy-require)
@@ -108,6 +117,7 @@
           [env-pol (env-policy-default)]
           [cwd #f]
           [timeout-ms 5000]
+          [memory-limit-bytes #f]
           [stdout-cap-bytes (* 1024 1024)]
           [stderr-cap-bytes (* 256 1024)]
           [require '()]
@@ -116,6 +126,7 @@
         (cond
           [(null? xs)
            (make-worker-policy command env-pol cwd timeout-ms
+                               memory-limit-bytes
                                stdout-cap-bytes stderr-cap-bytes
                                require fail-closed?)]
           [(null? (cdr xs))
@@ -127,6 +138,10 @@
                [(env-policy:) (set! env-pol val)]
                [(cwd:) (set! cwd val)]
                [(timeout-ms:) (set! timeout-ms val)]
+               [(memory-limit-bytes:)
+                (unless (or (not val) (and (integer? val) (> val 0)))
+                  (error 'worker-policy "memory-limit-bytes must be #f or a positive integer" val))
+                (set! memory-limit-bytes val)]
                [(stdout-cap-bytes:) (set! stdout-cap-bytes val)]
                [(stderr-cap-bytes:) (set! stderr-cap-bytes val)]
                [(require:) (set! require val)]
@@ -158,6 +173,18 @@
         [(axis-installed? (car xs) caps) (lp (cdr xs) out)]
         [else (lp (cdr xs) (cons (car xs) out))])))
 
+  (def (memory-limit-refused-axes pol)
+    (if (worker-policy-memory-limit-bytes pol)
+        '(memory-limit)
+        '()))
+
+  (def (append-refused-axes a b)
+    (let lp ([xs b] [out a])
+      (cond
+        [(null? xs) out]
+        [(memq (car xs) out) (lp (cdr xs) out)]
+        [else (lp (cdr xs) (append out (list (car xs))))])))
+
   (def (truncate-string s cap)
     (cond
       [(not cap) s]
@@ -177,6 +204,7 @@
      missing
      `((backend . ,(sandbox-backend))
        (capabilities . ,caps)
+       (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol))
        (env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
        (reason . ,reason))
      command))
@@ -221,6 +249,8 @@
        '()
        `((backend . ,(sandbox-backend))
          (capabilities . ,caps)
+         (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol))
+         (memory-limit-installed? . #f)
          (stdout-truncated? . ,stdout-truncated?)
          (stderr-truncated? . ,stderr-truncated?)
          (env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
@@ -235,7 +265,9 @@
     (unless (env-policy? (worker-policy-env-policy pol))
       (error 'worker-run-command "expected env-policy" (worker-policy-env-policy pol)))
     (let* ([caps (sandbox-capabilities)]
-           [missing (missing-required-axes (worker-policy-require pol) caps)])
+           [required-missing (missing-required-axes (worker-policy-require pol) caps)]
+           [memory-missing (memory-limit-refused-axes pol)]
+           [missing (append-refused-axes required-missing memory-missing)])
       (cond
         [(and (worker-policy-fail-closed? pol) (pair? missing))
          (worker-refusal-result command pol caps missing 'required-axis-unavailable)]
@@ -279,6 +311,7 @@
                  (worker-policy-env-policy pol0)
                  (worker-policy-cwd pol0)
                  (worker-policy-timeout-ms pol0)
+                 (worker-policy-memory-limit-bytes pol0)
                  (worker-policy-stdout-cap-bytes pol0)
                  (worker-policy-stderr-cap-bytes pol0)
                  (worker-policy-require pol0)
diff --git a/tests/test-worker.ss b/tests/test-worker.ss
index be98cce..df56119 100644
--- a/tests/test-worker.ss
+++ b/tests/test-worker.ss
@@ -138,5 +138,42 @@
          (equal? (worker-result-refused-axes r)
                  '(definitely-unavailable-worker-axis)))))
 
+(define requested-memory-limit (* 64 1024 1024))
+
+(test-pred "memory limit refuses before launch by default"
+  (worker-run-eval
+   "(+ 1 1)"
+   (worker-policy
+    'command: worker-command
+    'timeout-ms: 3000
+    'memory-limit-bytes: requested-memory-limit))
+  (lambda (r)
+    (and (worker-result? r)
+         (not (worker-result-launched? r))
+         (equal? (worker-result-status r) 126)
+         (memq 'memory-limit (worker-result-refused-axes r))
+         (equal? (alist-ref/default (worker-result-diagnostics r)
+                                    'memory-limit-bytes #f)
+                 requested-memory-limit))))
+
+(test-pred "memory limit degraded launch reports unenforced axis"
+  (worker-run-eval
+   "(+ 1 1)"
+   (worker-policy
+    'command: worker-command
+    'timeout-ms: 3000
+    'memory-limit-bytes: requested-memory-limit
+    'fail-closed?: #f))
+  (lambda (r)
+    (and (worker-result? r)
+         (worker-result-launched? r)
+         (equal? (worker-result-status r) 0)
+         (equal? (alist-ref/default (worker-result-diagnostics r)
+                                    'memory-limit-bytes #f)
+                 requested-memory-limit)
+         (eq? (alist-ref/default (worker-result-diagnostics r)
+                                 'memory-limit-installed? #t)
+              #f))))
+
 (printf "worker tests: ~a passed, ~a failed~%" pass fail)
 (when (> fail 0) (exit 1))