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))