security: install worker memory rlimits pre-exec

Jaime Fournier <jaimef@linbsd.org>

708e7e7d3f2f76b14a0c5f38663cf0bda1eb3617

diff --git a/docs/aproc.md b/docs/aproc.md
index 9700c3b..1e10220 100644
--- a/docs/aproc.md
+++ b/docs/aproc.md
@@ -66,8 +66,13 @@ encoding:   'utf8 | 'utf8-lossy | 'bytes
 new-pgroup: #t                     ; child becomes its own process-group leader
 pty:        #t                     ; allocate a pseudo-TTY (stdin/stdout/stderr)
 inherit-fd: '((3 . src-fd) …)      ; dup arbitrary fds into the child
+rlimits:   '((resource soft hard) …) ; native pre-exec setrlimit triples
 ```
 
+`rlimits:` is supported only on the argv/native path and is rejected with
+`pty: #t`. Resource numbers are platform `RLIMIT_*` constants; callers should
+prefer `(std os limits)` `limit-policy->rlimits` over hard-coding them.
+
 ### Low-level handle
 
 ```
@@ -196,6 +201,25 @@ exactly once.
 The Rust shim `dup2`s `source` onto `target` and clears `FD_CLOEXEC` so
 the fd survives `execve(2)`.
 
+### Install resource limits before exec
+
+```scheme
+(import (jerboa prelude)
+        (std os aproc)
+        (std os limits))
+
+(def limits (limit-policy))
+(limit-policy-set! limits 'mem (* 256 1024 1024))
+(limit-policy-set! limits 'nofile 64)
+
+(aproc-run/status* '("./worker")
+  'rlimits: (limit-policy->rlimits limits))
+```
+
+The native launcher applies each limit after descriptor setup and before
+`execve(2)`. A failed `setrlimit` makes the spawn fail instead of launching a
+less-confined child.
+
 ## Migration from `(system cmd)`
 
 ```scheme
diff --git a/docs/chez-limits.md b/docs/chez-limits.md
index 8484e10..db93cad 100644
--- a/docs/chez-limits.md
+++ b/docs/chez-limits.md
@@ -34,10 +34,12 @@ thread is not a security boundary. `run-safe-eval` therefore refuses non-`#f`
 
 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.
+platforms with a usable address-space rlimit, the worker installs it in the
+native aproc pre-exec path and reports `(memory-limit-installed? . #t)`. On
+platforms without that control, 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 a result with
+`(memory-limit-installed? . #f)` 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
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 40f9928..7939769 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -151,14 +151,16 @@ never pattern-based:
   memory-corruption patterns *exist* in a Jerboa application.
   [`Philosophy.md`](Philosophy.md) Principle 4 and open tension #5 both
   name this.
-- **The process-isolation boundary now has a first exec facade, but not the
-  full kernel-control backend.** `(std security worker)` launches restricted
-  evaluation through argv `exec` with a pure environment, parent deadline,
-  process-group kill, output caps, and fail-closed refused axes.
+- **The process-isolation boundary now has an exec facade and a first native
+  pre-exec control.** `(std security worker)` launches restricted evaluation
+  through argv `exec` with a pure environment, parent deadline, process-group
+  kill, output caps, audit-log start/end records, and fail-closed refused axes.
+  Memory limits are installed through native pre-exec `setrlimit` on supported
+  platforms and refuse before launch where unavailable.
   `sandbox-launch` still returns status 126 `pre-exec-refused`,
   `supervise-available?` returns `#f`, and the remaining P0-02 work is the
-  native pre-exec backend for rlimits, Landlock/seccomp, Seatbelt/Capsicum,
-  egress proxy wiring, and audit-log start/end.
+  native pre-exec backend for Landlock/seccomp, Seatbelt/Capsicum, and egress
+  proxy wiring.
 
 ---
 
@@ -182,8 +184,8 @@ declared egress policy, and OS resource limits. Defaults must fail closed.
 **D3 — Process boundaries for hostile input (G2, G4).** In-process
 restriction (`run-safe-eval`) is for *semi-trusted* expressions only.
 Anything adversarial gets a fresh exec'd worker with irreversible controls
-installed post-exec. Never run Scheme between fork and exec (the reason
-the raw-fork launchers were retired).
+installed before untrusted Scheme starts. Never run Scheme between fork and
+exec (the reason the raw-fork launchers were retired).
 
 **D4 — Adversarial self-testing (G3).** The same model class that attacks
 us defends us: continuous fuzzing with seed corpora and crash regression,
@@ -214,8 +216,8 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | macOS / FreeBSD confinement | `(std security seatbelt)`, `(std security capsicum)` | exist | lib listing |
 | Self-confinement (pledge/unveil-style `cage!`) | `(std security cage)` | exists; **absent from security-reference.md** — doc gap | `lib/std/security/cage.ss` |
 | Privilege-separation pipe channels (no launcher) | `(std security privsep)` | channels only, by design | security-reference §5 |
-| Launch policy planner + egress policy objects (no native pre-exec backend) | `(std os limits sandbox)` | passive policy only, `pre-exec-refused`; `(std security worker)` handles argv exec facade | [limits.md](limits.md) |
-| 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 |
+| Launch policy planner + egress policy objects | `(std os limits sandbox)` | passive planner remains `pre-exec-refused`; `(std os limits)` can emit rlimit triples for the worker/aproc backend | [limits.md](limits.md) |
+| Exec restricted worker facade | `(std security worker)` | exists; pure env, deadline, process-group kill, output caps, audit records, fail-closed refused axes; memory rlimits installed pre-exec on supported platforms; kernel sandbox and egress 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; named raw-read/FASL paths have first triage; broader `load`/REPL/dev-surface classification remains | [safety-guide.md](safety-guide.md) §10 |
@@ -303,7 +305,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | Goal | Where we stand | The gap |
 |---|---|---|
 | G1 shrink target | Managed core is memory-safe; parsers budgeted; safe prelude exists | FFI surface (91 files / 225 symbols / 432 `unsafe`) never systematically audited (phase 5); `vendor/jsqlite` is C in the TCB; safe prelude not the default entry; import conflict undermines "safe symbol wins" confidence |
-| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, and authenticated actor transport/envelopes all exist | **No native pre-exec worker backend to install kernel controls before child input**; taint source marking/propagation still incomplete; no memory limit story; `define-syntax` in sandbox |
+| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, memory rlimit pre-exec path, and authenticated actor transport/envelopes all exist | **No native pre-exec worker backend yet for kernel sandbox controls before child input**; taint source marking/propagation still incomplete; egress proxy wiring pending; `define-syntax` in sandbox |
 | G3 find it first | 13 harnesses, scanner w/ rule DB, lint | No corpora, no crash regression, no scheduled fuzzing, no standing AI-red-team, no exploit-shaped regression suite |
 | G4 fail closed | Raw-fork launchers retired correctly; `allow-degraded?` explicit | New controls must keep the invariant; degraded-mode warnings must be test-locked |
 | G5 recover fast | SBOM/repro/signing gates exist | TCB accounting manual; doc drift (stale tables, undocumented modules); independent-builder reproducibility not yet routine |
@@ -342,8 +344,10 @@ the code that ships. This violates the repo's own pre-commit rule.
 **Serves:** G2, G3, G4. **Effort:** 1–2 weeks. **Status:** initial
 `(std security worker)` facade landed 2026-07-27 with `tests/test-worker.ss`;
 audit-log start/end records landed 2026-07-27 and are asserted by
-`tests/test-worker.ss`. Native pre-exec controls, egress proxy wiring, and
-memory rlimit remain open.
+`tests/test-worker.ss`. Native pre-exec memory rlimit installation landed via
+`(std os aproc)` `rlimits:` and `(std os limits)` `limit-policy->rlimits`.
+Native Landlock/seccomp, Seatbelt/Capsicum setup and egress proxy wiring remain
+open.
 
 Every security doc routes adversarial work to "a bounded, separately exec'd
 worker". The initial facade exists; finish it as the assembly point for
@@ -355,7 +359,8 @@ controls that already exist individually.
   `(std security landlock)`, `(std security seccomp)`, `(std security seatbelt)`,
   `(std security capsicum)`, `(std security audit-log)`. Optionally a tiny
   pre-exec helper in `jerboa-native-rs` (Rust) for rlimit/namespace setup
-  that must happen before Scheme boots.
+  that must happen before Scheme boots. The rlimit path exists; extend that
+  pattern for the remaining platform controls.
 - **Design (fail closed at every step):**
   - Worker = fresh `jerboa run worker-main.ss` process started via argv
     (never shell), with `env-pure:` from an `(std security env)` policy
@@ -702,17 +707,18 @@ 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.
 
-- **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
+- **Status:** complete for the Scheme-facing worker API, fail-closed
+  memory-limit contract, and native pre-exec address-space rlimit on supported
+  platforms. `(std security worker)` ships `worker-run-eval`, bounded
+  stdout/stderr, parent deadline/process-group kill, and
+  `memory-limit-bytes:` policy intent. Supported platforms launch with
+  `(memory-limit-installed? . #t)`; unavailable platforms refuse before launch
+  by default with refused axis `memory-limit`, or launch only when callers
+  explicitly set `'fail-closed?: #f` and observe
   `(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.
+- **Accept:** satisfied for portable fail-closed behavior and native pre-exec
+  rlimit installation smoke coverage. Tree-wide cgroup/container memory
+  accounting remains future hardening rather than this worker API contract.
 
 ### K3-P1-08 — Platform sandbox parity
 **Serves:** G2. **Effort:** 2 weeks.
@@ -1063,7 +1069,7 @@ Track these in `docs/status.md` per release:
 | Metric | Baseline (2026-07-27) | Target |
 |---|---|---|
 | 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) |
+| Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, and memory rlimit pre-exec path landed 2026-07-27; kernel sandbox and egress wiring pending | yes, tested (P0-02) |
 | 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 |
@@ -1160,9 +1166,10 @@ fake confidence happens.
 - FFI audit (phase 5) unstarted until P1-01; `vendor/jsqlite` is C in the
   TCB pending its decision.
 - No independent red-team evaluation yet (P2-04 starts the practice).
-- The confined exec worker facade exists as `(std security worker)`, but the
-  native pre-exec backend for rlimits, kernel sandboxes, egress proxy wiring,
-  and audit-log lifecycle events is still pending (P0-02/P1-07/P1-08).
+- The confined exec worker facade exists as `(std security worker)`, with
+  audit-log lifecycle events and native pre-exec memory rlimits on supported
+  platforms. Kernel sandbox installation and egress proxy wiring are still
+  pending (P0-02/P1-08).
 - The committed `pattern.ss` balance blocker named in P0-01 was repaired on
   2026-07-27. The `pipeline.ss` and `test-pipeline.ss` reports were traced to
   escaped-identifier false positives in the balance scanner; the scanner now
diff --git a/docs/limits.md b/docs/limits.md
index d0b2525..3bbb557 100644
--- a/docs/limits.md
+++ b/docs/limits.md
@@ -2,15 +2,15 @@
 
 Jerboa exposes portable limit and sandbox policy records, filesystem
 restrictions, network allowlists, redacted environments, and structured audit
-records. Its historical Scheme-side process launchers are currently
-fail-closed compatibility surfaces; production execution needs a native or
-otherwise exec-only worker launcher.
+records. Native argv execution through `(std os aproc)` can install rlimits
+before `exec`; historical Scheme-side process launchers remain fail-closed
+compatibility surfaces.
 
 ## Modules
 
 | Module | Purpose |
 |---|---|
-| `(std os limits)` | Portable limit records and rlimit helpers. |
+| `(std os limits)` | Portable limit records, rlimit helpers, and `limit-policy->rlimits`. |
 | `(std os supervise)` | Launch/result records and an explicitly unavailable compatibility launcher. |
 | `(std os limits sandbox)` | Filesystem and process sandbox profile construction. |
 | `(std os exec-id)` | Executable identity and provenance checks. |
@@ -35,8 +35,9 @@ Recommended application policy:
 - Refuse to continue when a requested sandbox cannot be installed.
 - Record setup status and runtime decisions through `(std security audit-log)`.
 - Prefer temporary homes and caches for untrusted tools.
-- Use a bounded exec-only worker launcher whose parent enforces deadlines,
-  process-group termination, and stdin/stdout/stderr caps.
+- Use `(std security worker)` for adversarial eval, or `(std os aproc)`
+  `rlimits:` for lower-level argv execution that must install resource limits
+  before `exec`.
 
 ## Retired Raw-Fork Launchers
 
@@ -104,6 +105,26 @@ Use the policy and diagnostics to configure and verify a separate exec-only
 worker backend. Do not interpret a well-formed `sandbox-result` as proof that a
 process was launched; check `sandbox-result-launched?` and refused axes.
 
+For direct native argv execution, convert installable limit-policy entries into
+`aproc` triples:
+
+```scheme
+(import (jerboa prelude)
+        (std os aproc)
+        (std os limits))
+
+(def limits (limit-policy))
+(limit-policy-set! limits 'mem (* 256 1024 1024))
+(limit-policy-set! limits 'core 0)
+
+(aproc-run/status* '("./worker")
+  'rlimits: (limit-policy->rlimits limits))
+```
+
+Unsupported limits are omitted from the triple list. If a requested worker
+policy requires an omitted limit, `(std security worker)` fails closed before
+launch; a lower-level `aproc` caller must enforce the same policy decision.
+
 ## Regression Checks
 
 ```bash
diff --git a/docs/safety-guide.md b/docs/safety-guide.md
index 9dd9e7d..aca462c 100644
--- a/docs/safety-guide.md
+++ b/docs/safety-guide.md
@@ -30,7 +30,8 @@ the script in a base environment with raw FFI, raw `system`, `eval`,
   reject tainted paths or commands under their standard names
 - **Restricted evaluator** — `run-safe-eval` for bounded, allowlisted evaluation
 - **Exec worker facade** — `worker-run-eval` for running restricted eval in a
-  fresh process with pure env, deadline, and output caps
+  fresh process with pure env, deadline, output caps, and supported memory
+  rlimits
 - **Error conditions** — structured hierarchy instead of bare `(error ...)`
 
 What you do NOT get (intentionally):
@@ -64,14 +65,14 @@ evaluator from the application.
 
 Production code that needs a process boundary should use `(std security
 worker)`. It starts a fresh process through argv execution, uses a pure
-environment policy, enforces a parent deadline, and caps returned stdout/stderr.
-Do not execute Scheme between `fork` and `exec`.
+environment policy, enforces a parent deadline, caps returned stdout/stderr,
+and installs a memory rlimit before `exec` on supported platforms. Do not
+execute Scheme between `fork` and `exec`.
 
 Landlock, seccomp, Seatbelt, Capsicum, capabilities, deny-default egress, and
-address-space limits are requested as worker policy axes. They still require the
-native pre-exec backend before they can be installed inside the child; until
-then, requiring an unavailable axis returns `launched? = #f` instead of silently
-running degraded.
+address-space limits are requested as worker policy axes. Address-space rlimits
+are installed by the native aproc backend where available; unavailable required
+axes return `launched? = #f` instead of silently running degraded.
 
 ### Basic Usage
 
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 67c2411..5d7315f 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -16,7 +16,7 @@ Jerboa's security model is layered defense-in-depth. No single layer is trusted 
 | **Taint tracking** | Mark untrusted data, reject at dangerous sinks | `(std security taint)` |
 | **Kernel enforcement** | Landlock filesystem rules, seccomp-BPF syscall filtering | `(std security landlock)`, `(std security seccomp)` |
 | **Privilege separation** | Pipe channels for separately exec'd supervisor/worker processes | `(std security privsep)` |
-| **Exec worker** | Run restricted eval in a fresh argv-exec'd process with pure env, deadline, and output caps | `(std security worker)` |
+| **Exec worker** | Run restricted eval in a fresh argv-exec'd process with pure env, deadline, output caps, and supported memory rlimits | `(std security worker)` |
 | **Parser hardening** | Depth limits, size limits, backtracking budgets | Various (see section 7) |
 | **Crypto** | AEAD, CSPRNG, HMAC, KDF, timing-safe comparison, secure memory | `(std crypto ...)` |
 | **Input sanitization** | Context-aware escaping for HTML, SQL, paths, headers, URLs | `(std security sanitize)` |
@@ -446,9 +446,10 @@ 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:
+untrusted evaluation. The worker converts that request to a native pre-exec
+`setrlimit` address-space limit where the platform exposes one. On platforms
+without an installable address-space rlimit, the same policy is refused before
+launch by default:
 
 ```scheme
 (worker-run-eval
@@ -456,19 +457,22 @@ is not installed yet:
   (worker-policy
     'timeout-ms: 1000
     'memory-limit-bytes: (* 64 1024 1024)))
-;; => launched? #f, status 126, refused axes include memory-limit
+;; => supported platform: launched? #t, diagnostics include
+;;    (memory-limit-installed? . #t)
+;; => unsupported platform: 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.
+result reports whether the limit was actually installed. Do not treat
+`(memory-limit-installed? . #f)` 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 installation,
-Landlock/seccomp, Seatbelt/Capsicum installation, and deny-default egress proxy
-wiring remain tracked by the K3 handoff.
+audit-log start/end records in `worker-result-diagnostics`. Native pre-exec
+memory rlimit installation exists; Landlock/seccomp, Seatbelt/Capsicum
+installation, and deny-default egress proxy wiring remain tracked by the K3
+handoff.
 
 ---
 
@@ -546,8 +550,8 @@ stdin, stdout, and stderr. Never run Scheme in the child between `fork` and
 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.
+`memory-limit-bytes:` for that intent, installs a native pre-exec rlimit where
+supported, and fails closed where it cannot.
 
 ### API
 
diff --git a/jerboa-native-rs/src/aproc.rs b/jerboa-native-rs/src/aproc.rs
index ccc5d74..4d91851 100644
--- a/jerboa-native-rs/src/aproc.rs
+++ b/jerboa-native-rs/src/aproc.rs
@@ -78,6 +78,45 @@ fn make_stdio(mode: i32, path: *const u8, path_len: usize) -> Result<Stdio, Stri
     }
 }
 
+fn parse_rlimits(
+    buf: *const u8,
+    len: usize,
+    count: usize,
+) -> Result<Vec<(libc::c_int, libc::rlim_t, libc::rlim_t)>, String> {
+    if count == 0 {
+        return Ok(Vec::new());
+    }
+    if buf.is_null() {
+        return Err("rlimit buffer is null".to_string());
+    }
+    let expected = count
+        .checked_mul(24)
+        .ok_or_else(|| "rlimit count overflow".to_string())?;
+    if len != expected {
+        return Err(format!(
+            "expected {} bytes for {} rlimit entries, got {}",
+            expected, count, len
+        ));
+    }
+    let slice = unsafe { std::slice::from_raw_parts(buf, len) };
+    let mut out = Vec::with_capacity(count);
+    for i in 0..count {
+        let off = i * 24;
+        let resource = u64::from_ne_bytes(slice[off..off + 8].try_into().unwrap());
+        let soft = u64::from_ne_bytes(slice[off + 8..off + 16].try_into().unwrap());
+        let hard = u64::from_ne_bytes(slice[off + 16..off + 24].try_into().unwrap());
+        if resource > libc::c_int::MAX as u64 {
+            return Err(format!("rlimit resource {} out of range", resource));
+        }
+        out.push((
+            resource as libc::c_int,
+            soft as libc::rlim_t,
+            hard as libc::rlim_t,
+        ));
+    }
+    Ok(out)
+}
+
 /// argv-style spawn via std::process::Command (posix_spawn fast path or
 /// fork+execvp). Avoids /bin/sh -c, so the caller does not need to escape
 /// args and there is no extra shell process in the tree.
@@ -108,6 +147,9 @@ pub extern "C" fn jerboa_aproc_spawn(
     stderr_path_len: usize,
     inherit_fds: *const i32,
     inherit_fd_count: usize,
+    rlimit_buf: *const u8,
+    rlimit_buf_len: usize,
+    rlimit_count: usize,
     flags: i32,
     result: *mut i32,
 ) -> i32 {
@@ -208,6 +250,13 @@ pub extern "C" fn jerboa_aproc_spawn(
         } else {
             Vec::new()
         };
+        let rlimits = match parse_rlimits(rlimit_buf, rlimit_buf_len, rlimit_count) {
+            Ok(v) => v,
+            Err(e) => {
+                set_last_error(format!("rlimits parse: {}", e));
+                return -1;
+            }
+        };
 
         unsafe {
             cmd.pre_exec(move || {
@@ -234,6 +283,15 @@ pub extern "C" fn jerboa_aproc_spawn(
                         let _ = libc::fcntl(target, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
                     }
                 }
+                for (resource, soft, hard) in rlimits.iter().copied() {
+                    let lim = libc::rlimit {
+                        rlim_cur: soft,
+                        rlim_max: hard,
+                    };
+                    if libc::setrlimit(resource as _, &lim) != 0 {
+                        return Err(std::io::Error::last_os_error());
+                    }
+                }
                 Ok(())
             });
         }
diff --git a/lib/std/os/aproc.ss b/lib/std/os/aproc.ss
index 94a5be3..89d39de 100644
--- a/lib/std/os/aproc.ss
+++ b/lib/std/os/aproc.ss
@@ -88,6 +88,7 @@
     (only (std native-loader) native-loader-ensure-libc-symbol!)
     (std os errno)
     (std misc channel)
+    (only (std error conditions) raise-parse-error)
           (only (jerboa core) def defstruct try catch finally))
 
   ;; ========== libc loading ==========
@@ -130,6 +131,7 @@
               u8* size_t          ; stdout path, len
               u8* size_t          ; stderr path, len
               u8* size_t          ; inherit-fd pairs (i32 pairs), count
+              u8* size_t size_t   ; rlimit triples (u64 resource soft hard), count
               int                 ; flags
               u8*)                ; result buf (5*int)
              int)
@@ -281,6 +283,32 @@
                 (bytevector-s32-native-set! buf (+ (* i 8) 4) src-fd)
                 (lp (+ i 1) (cdr ps)))])))]))
 
+  (def (pack-rlimits entries)
+    (cond
+      [(or (not entries) (null? entries)) (make-bytevector 0)]
+      [else
+       (let* ([n (length entries)]
+              [buf (make-bytevector (* 24 n) 0)])
+         (let lp ([i 0] [xs entries])
+           (cond
+             [(null? xs) buf]
+             [else
+              (let ([entry (car xs)])
+                (unless (and (list? entry)
+                             (= (length entry) 3)
+                             (integer? (car entry))
+                             (integer? (cadr entry))
+                             (integer? (caddr entry))
+                             (>= (car entry) 0)
+                             (>= (cadr entry) 0)
+                             (>= (caddr entry) 0))
+                  (raise-parse-error 'aproc "rlimits: expected (resource soft hard) nonnegative integer triple, got ~s" entry))
+                (let ([off (* i 24)])
+                  (bytevector-u64-native-set! buf off (car entry))
+                  (bytevector-u64-native-set! buf (+ off 8) (cadr entry))
+                  (bytevector-u64-native-set! buf (+ off 16) (caddr entry))
+                  (lp (+ i 1) (cdr xs))))])))]))
+
   ;; ========== Stdio modes ==========
   (def STDIO_PIPE 0)
   (def STDIO_INHERIT 1)
@@ -322,7 +350,8 @@
           [stderr-path #f]
           [new-pgroup? #f]
           [pty? #f]
-          [inherit-fds '()])
+          [inherit-fds '()]
+          [rlimits '()])
       (let loop ([kw kwargs])
         (cond
           [(null? kw) (void)]
@@ -340,8 +369,11 @@
                [(new-pgroup:) (set! new-pgroup? (and v #t))]
                [(pty:) (set! pty? (and v #t))]
                [(inherit-fd:) (set! inherit-fds v)]
+               [(rlimits:) (set! rlimits v)]
                [else (error 'aproc-spawn* "unknown keyword" k)])
              (loop (cddr kw)))]))
+      (when (and pty? (pair? rlimits))
+        (error 'aproc-spawn* "rlimits are not supported with pty: #t"))
       ;; Pack argv
       (let*-values
         ([(argv-buf argv-len argv-count) (pack-strings argv)]
@@ -372,6 +404,8 @@
                [err-path-len (bytevector-length err-path-bv)]
                [inh-buf (pack-inherit-fds inherit-fds)]
                [inh-count (length inherit-fds)]
+               [rlimit-buf (pack-rlimits rlimits)]
+               [rlimit-count (length rlimits)]
                [flags (bitwise-ior
                         (if new-pgroup? FLAG_NEW_PGROUP 0)
                         (if use-envp FLAG_USE_ENVP 0)
@@ -390,6 +424,7 @@
                         out-path-bv out-path-len
                         err-path-bv err-path-len
                         inh-buf inh-count
+                        rlimit-buf (bytevector-length rlimit-buf) rlimit-count
                         flags result-buf))])
             (when (< rc 0)
               (error 'aproc-spawn*
@@ -826,7 +861,8 @@
           [encoding (or (kw-ref kwargs 'encoding:) 'utf8)]
           [new-pgroup? (kw-ref kwargs 'new-pgroup:)]
           [pty? (kw-ref kwargs 'pty:)]
-          [inherit-fd (kw-ref kwargs 'inherit-fd:)])
+          [inherit-fd (kw-ref kwargs 'inherit-fd:)]
+          [rlimits (kw-ref kwargs 'rlimits:)])
       ;; Build spawn kwargs
       (let* ([spawn-kw (append
                          (if env (list 'env: env) '())
@@ -837,7 +873,8 @@
                          (list 'stderr: stderr-mode)
                          (if new-pgroup? (list 'new-pgroup: #t) '())
                          (if pty? (list 'pty: #t) '())
-                         (if inherit-fd (list 'inherit-fd: inherit-fd) '()))]
+                         (if inherit-fd (list 'inherit-fd: inherit-fd) '())
+                         (if rlimits (list 'rlimits: rlimits) '()))]
              [h (cond
                   [c-jerboa-aproc-spawn
                    (apply aproc-spawn* argv spawn-kw)]
diff --git a/lib/std/os/limits.ss b/lib/std/os/limits.ss
index 95a3217..593a417 100644
--- a/lib/std/os/limits.ss
+++ b/lib/std/os/limits.ss
@@ -54,6 +54,7 @@
     limit-parse-value
     limit-policy-install!
     limit-policy-plan
+    limit-policy->rlimits
     limit-policy-explain
 
     limits-capabilities)
@@ -345,6 +346,33 @@
       [(out-bytes) 'parent]
       [else 'unavailable]))
 
+  (def (limit-kind->rlimit-code kind)
+    (case kind
+      [(mem) (and (not (platform-macos?)) (mem-rlimit-code))]
+      [(cpu-sec) *rl-cpu*]
+      [(nofile) *rl-nofile*]
+      [(fsize) *rl-fsize*]
+      [(core) *rl-core*]
+      [(pids) *rl-nproc*]
+      [else #f]))
+
+  (def (limit-policy->rlimits pol)
+    ;; Convert setrlimit-shaped entries into native spawn triples:
+    ;; (resource soft hard). Parent-side and unavailable limits are omitted.
+    (let lp ([xs (limit-policy-entries pol)] [out '()])
+      (cond
+        [(null? xs) (reverse out)]
+        [else
+         (let* ([kind (car (car xs))]
+                [val (cdr (car xs))]
+                [code (limit-kind->rlimit-code kind)]
+                [plan (limit-plan-status kind val)])
+           (if (and code
+                    (or (eq? plan 'attempt-installed)
+                        (eq? plan 'attempt-degraded)))
+               (lp (cdr xs) (cons (list code val val) out))
+               (lp (cdr xs) out)))])))
+
   (def (set-rlim name code val)
     (try
       (begin
diff --git a/lib/std/security/worker.ss b/lib/std/security/worker.ss
index e9a019f..5b45ddc 100644
--- a/lib/std/security/worker.ss
+++ b/lib/std/security/worker.ss
@@ -42,6 +42,11 @@
   (import (chezscheme)
           (only (jerboa core) def defstruct)
           (only (std os aproc) aproc-run/status*)
+          (only (std os limits)
+                limit-policy
+                limit-policy-set!
+                limit-policy-plan
+                limit-policy->rlimits)
           (only (std os limits sandbox) sandbox-capabilities sandbox-backend)
           (only (std security audit-log)
                 audit-log
@@ -174,8 +179,29 @@
         [(axis-installed? (car xs) caps) (lp (cdr xs) out)]
         [else (lp (cdr xs) (cons (car xs) out))])))
 
+  (def (worker-memory-limit-policy pol)
+    (let ([bytes (worker-policy-memory-limit-bytes pol)])
+      (and bytes
+           (let ([limits (limit-policy)])
+             (limit-policy-set! limits 'mem bytes)
+             limits))))
+
+  (def (worker-memory-limit-report pol)
+    (let ([limits (worker-memory-limit-policy pol)])
+      (and limits (limit-policy-plan limits))))
+
+  (def (memory-limit-installable? pol)
+    (let ([report (worker-memory-limit-report pol)])
+      (and report
+           (cond
+             [(assq 'mem report)
+              => (lambda (entry)
+                   (eq? (cdr entry) 'attempt-installed))]
+             [else #f]))))
+
   (def (memory-limit-refused-axes pol)
-    (if (worker-policy-memory-limit-bytes pol)
+    (if (and (worker-policy-memory-limit-bytes pol)
+             (not (memory-limit-installable? pol)))
         '(memory-limit)
         '()))
 
@@ -217,6 +243,11 @@
                       (list 'dir: (worker-policy-cwd pol))
                       '())
                   (if stdin-data (list 'stdin: stdin-data) '())
+                  (let ([limits (worker-memory-limit-policy pol)])
+                    (if limits
+                        (let ([rlimits (limit-policy->rlimits limits)])
+                          (if (null? rlimits) '() (list 'rlimits: rlimits)))
+                        '()))
                   (list 'timeout-ms: (worker-policy-timeout-ms pol)
                         'encoding: 'utf8-lossy
                         'new-pgroup: #t))])
@@ -251,7 +282,9 @@
        `((backend . ,(sandbox-backend))
          (capabilities . ,caps)
          (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol))
-         (memory-limit-installed? . #f)
+         (memory-limit-installed? . ,(and (worker-policy-memory-limit-bytes pol)
+                                          (memory-limit-installable? pol)))
+         (memory-limit-report . ,(worker-memory-limit-report pol))
          (stdout-truncated? . ,stdout-truncated?)
          (stderr-truncated? . ,stderr-truncated?)
          (env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
diff --git a/tests/test-aproc.ss b/tests/test-aproc.ss
index 21898ee..69d69fe 100644
--- a/tests/test-aproc.ss
+++ b/tests/test-aproc.ss
@@ -89,6 +89,13 @@
     out)
   "ONLYVAR=yes\n")
 
+(test "rlimits: applies before exec"
+  (let-values (((out err code)
+                (aproc-run/status* '("sh" "-c" "ulimit -c")
+                  'rlimits: '((4 0 0)))))
+    (cons out code))
+  '("0\n" . 0))
+
 ;; ===== stdin from value =====
 (test "stdin: string"
   (let-values (((out err code)
diff --git a/tests/test-worker.ss b/tests/test-worker.ss
index df56119..466a397 100644
--- a/tests/test-worker.ss
+++ b/tests/test-worker.ss
@@ -138,9 +138,9 @@
          (equal? (worker-result-refused-axes r)
                  '(definitely-unavailable-worker-axis)))))
 
-(define requested-memory-limit (* 64 1024 1024))
+(define requested-memory-limit (* 512 1024 1024))
 
-(test-pred "memory limit refuses before launch by default"
+(test-pred "memory limit fail-closed or installed before launch"
   (worker-run-eval
    "(+ 1 1)"
    (worker-policy
@@ -149,14 +149,19 @@
     '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"
+                 requested-memory-limit)
+         (or (and (not (worker-result-launched? r))
+                  (equal? (worker-result-status r) 126)
+                  (memq 'memory-limit (worker-result-refused-axes r)))
+             (and (worker-result-launched? r)
+                  (equal? (worker-result-status r) 0)
+                  (eq? (alist-ref/default (worker-result-diagnostics r)
+                                          'memory-limit-installed? #f)
+                       #t))))))
+
+(test-pred "memory limit degraded launch reports enforcement state"
   (worker-run-eval
    "(+ 1 1)"
    (worker-policy
@@ -171,9 +176,8 @@
          (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))))
+         (boolean? (alist-ref/default (worker-result-diagnostics r)
+                                      'memory-limit-installed? 'missing)))))
 
 (printf "worker tests: ~a passed, ~a failed~%" pass fail)
 (when (> fail 0) (exit 1))