security: make worker sandbox and egress policy explicit
Jaime Fournier <jaimef@linbsd.org>
9c0188284e256f0c91ea5e6cec592a08edd22af7
diff --git a/docs/ai-threat.md b/docs/ai-threat.md
index 686879e..9c07042 100644
--- a/docs/ai-threat.md
+++ b/docs/ai-threat.md
@@ -187,7 +187,7 @@ resolution is tested so application scripts do not depend on ambiguous
| Eval injection | Allowlist-blocked | Ahead | Far ahead | Ahead |
| FFI boundary bugs | Risk (manual) | Comparable | Comparable | N/A (JNI) |
| Logic bugs | No advantage | No advantage | No advantage | No advantage |
-| DoS / resource exhaustion | Worker deadline/output caps; memory rlimit still pending | Behind | Behind | Behind (JVM has memlimits) |
+| DoS / resource exhaustion | Worker deadline/output caps; memory rlimits on supported platforms | Behind | Behind | Behind (JVM has memlimits) |
| Deserialization attacks | Depth-limited | Comparable | Ahead (limits) | Comparable |
| Supply chain attacks | TUF metadata, Ed25519 signatures, transparency log, SBOM, OSV audit, content-addressed artifacts | Comparable | Comparable | Comparable |
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index d5a513f..3cbab3d 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -217,7 +217,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
| 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 | `(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 |
+| Exec restricted worker facade | `(std security worker)` | exists; pure env, deadline, process-group kill, output caps, audit records, fail-closed refused axes, explicit `sandbox:` axis requests, egress proxy env wiring, and memory rlimits installed pre-exec on supported platforms; native kernel sandbox installation 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 |
@@ -346,8 +346,9 @@ the code that ships. This violates the repo's own pre-commit rule.
audit-log start/end records landed 2026-07-27 and are asserted by
`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.
+Explicit worker `sandbox:` axis requests and `egress-policy:` allow-proxy env
+wiring landed 2026-07-27. Native Landlock/seccomp and Seatbelt/Capsicum setup
+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
@@ -377,8 +378,9 @@ controls that already exist individually.
record — the same shapes `(std os limits sandbox)` already defines, so
`sandbox-launch` can be re-pointed at the real backend while keeping
its fail-closed diagnostics contract.
- - Wire `make-egress-policy` enforcement via `(std net allow-proxy)` as
- the worker's only network route when a deny-default policy is set.
+ - Wire `(std net allow-proxy)` egress policy into the worker environment;
+ callers still start/supervise the proxy, and kernel network denial remains
+ a sandbox backend responsibility.
- **Do NOT:** resurrect any Scheme-callback-after-fork API. The retirement
of `run-safe` thunks was correct ([security-reference.md](security-reference.md) §6); the worker
boundary is `exec`.
@@ -389,7 +391,8 @@ controls that already exist individually.
checklist); (c) memory bomb killed by rlimit AS; (d) FS escape blocked by
Landlock on Linux; (e) oversized stdout truncated at cap; (f) requested
control that can't install ⇒ structured refusal, never silent
- degradation; (g) audit-log records start/end. Docs: new section in
+ degradation; (g) audit-log records start/end; (h) `egress-policy:` injects
+ proxy environment into an argv child. Docs: new section in
`security-reference.md` §5/§6 and rewrite of the "use an exec worker"
passages in `safety-guide.md` §2 and `ai-threat.md` to point at the real
API.
@@ -1072,7 +1075,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 | 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) |
+| Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, memory rlimit pre-exec path, explicit sandbox-axis refusal, and egress proxy env wiring landed 2026-07-27; native kernel sandbox installation 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 |
@@ -1171,8 +1174,9 @@ fake confidence happens.
- No independent red-team evaluation yet (P2-04 starts the practice).
- 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).
+ platforms. It now exposes explicit `sandbox:` fail-closed axis requests and
+ `egress-policy:` proxy env wiring. Native kernel sandbox installation is
+ 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/safety-guide.md b/docs/safety-guide.md
index 4a398ed..10555d7 100644
--- a/docs/safety-guide.md
+++ b/docs/safety-guide.md
@@ -72,7 +72,10 @@ execute Scheme between `fork` and `exec`.
Landlock, seccomp, Seatbelt, Capsicum, capabilities, deny-default egress, and
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.
+axes return `launched? = #f` instead of silently running degraded. Use
+`sandbox:` for required sandbox axes and `egress-policy:` with a `(std net
+allow-proxy)` policy to inject proxy env into the worker; start and supervise
+the proxy separately.
### Basic Usage
@@ -112,6 +115,7 @@ axes return `launched? = #f` instead of silently running degraded.
"(map (lambda (x) (* x x)) '(1 2 3))"
(worker-policy
'timeout-ms: 1000
+ 'sandbox: '()
'stdout-cap-bytes: 65536
'stderr-cap-bytes: 65536)))
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 5d54ae8..15dbb99 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -433,6 +433,7 @@ child entrypoint `support/security-worker-main.ss`.
(worker-policy
'timeout-ms: 1000
'memory-limit-bytes: #f
+ 'sandbox: '()
'stdout-cap-bytes: 65536
'stderr-cap-bytes: 65536)))
@@ -448,6 +449,33 @@ 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.
+Use `sandbox:` to declare the kernel/control axes the worker must have before
+launch, such as `'(fs syscalls net)`. Axes are compared against
+`(std os limits sandbox)` capabilities. With the default `fail-closed?: #t`,
+any unavailable or degraded requested axis refuses before `exec`; with
+`fail-closed?: #f`, the launch continues and diagnostics record the requested
+axes.
+
+Use `egress-policy:` with a `(std net allow-proxy)` policy when the worker
+should route HTTP-capable clients through the deny-default allow proxy:
+
+```scheme
+(import (std net allow-proxy))
+(import (std security worker))
+
+(worker-run-command
+ '("/usr/bin/env")
+ (worker-policy
+ 'egress-policy:
+ (make-allow-proxy-policy '("api.example.com:443") 'port: 18080)))
+;; child env includes HTTP_PROXY/HTTPS_PROXY/ALL_PROXY for the proxy URL
+```
+
+The worker injects proxy environment variables and records the proxy URL and
+allowlist in diagnostics. The caller is still responsible for starting and
+supervising the proxy process with `allow-proxy-start!`; proxy env alone is not
+a kernel network deny.
+
`worker-policy` accepts `memory-limit-bytes:` as the memory-control intent for
untrusted evaluation. The worker converts that request to a native pre-exec
`setrlimit` address-space limit where the platform exposes one. On platforms
@@ -471,11 +499,11 @@ 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`. Native pre-exec
-memory rlimit installation exists; Landlock/seccomp, Seatbelt/Capsicum
-installation, and deny-default egress proxy wiring remain tracked by the K3
-handoff.
+parent deadline, process-group kill through `aproc`, returned output caps,
+audit-log start/end records, memory rlimits on supported platforms, explicit
+fail-closed sandbox-axis requests, and allow-proxy environment wiring. Native
+pre-exec Landlock/seccomp and Seatbelt/Capsicum installation remain tracked by
+the K3 handoff.
---
diff --git a/lib/std/security/worker.ss b/lib/std/security/worker.ss
index 5b45ddc..ed8ddfb 100644
--- a/lib/std/security/worker.ss
+++ b/lib/std/security/worker.ss
@@ -20,6 +20,8 @@
worker-policy-cwd
worker-policy-timeout-ms
worker-policy-memory-limit-bytes
+ worker-policy-sandbox
+ worker-policy-egress-policy
worker-policy-stdout-cap-bytes
worker-policy-stderr-cap-bytes
worker-policy-require
@@ -48,6 +50,11 @@
limit-policy-plan
limit-policy->rlimits)
(only (std os limits sandbox) sandbox-capabilities sandbox-backend)
+ (only (std net allow-proxy)
+ allow-proxy?
+ allow-proxy-env
+ allow-proxy-allowlist
+ allow-proxy-url)
(only (std security audit-log)
audit-log
audit-log-now
@@ -65,15 +72,23 @@
(defstruct %worker-policy
(command env-policy cwd timeout-ms memory-limit-bytes
+ sandbox egress-policy
stdout-cap-bytes stderr-cap-bytes require fail-closed?))
(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
+ '() #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
+ '() #f
+ stdout-cap-bytes stderr-cap-bytes require fail-closed?)]
+ [(command env-policy cwd timeout-ms memory-limit-bytes sandbox egress-policy
+ stdout-cap-bytes stderr-cap-bytes require fail-closed?)
+ (make-%worker-policy command env-policy cwd timeout-ms memory-limit-bytes
+ sandbox egress-policy
stdout-cap-bytes stderr-cap-bytes require fail-closed?)]))
(def worker-policy? %worker-policy?)
(def worker-policy-command %worker-policy-command)
@@ -81,6 +96,8 @@
(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-sandbox %worker-policy-sandbox)
+ (def worker-policy-egress-policy %worker-policy-egress-policy)
(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)
@@ -124,6 +141,8 @@
[cwd #f]
[timeout-ms 5000]
[memory-limit-bytes #f]
+ [sandbox '()]
+ [egress-policy #f]
[stdout-cap-bytes (* 1024 1024)]
[stderr-cap-bytes (* 256 1024)]
[require '()]
@@ -133,6 +152,7 @@
[(null? xs)
(make-worker-policy command env-pol cwd timeout-ms
memory-limit-bytes
+ sandbox egress-policy
stdout-cap-bytes stderr-cap-bytes
require fail-closed?)]
[(null? (cdr xs))
@@ -148,6 +168,19 @@
(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)]
+ [(sandbox:)
+ (unless (and (list? val)
+ (let lp ([ys val])
+ (cond
+ [(null? ys) #t]
+ [(symbol? (car ys)) (lp (cdr ys))]
+ [else #f])))
+ (error 'worker-policy "sandbox must be a list of axis symbols" val))
+ (set! sandbox val)]
+ [(egress-policy:)
+ (unless (or (not val) (allow-proxy? val))
+ (error 'worker-policy "egress-policy must be #f or an allow-proxy policy" val))
+ (set! egress-policy val)]
[(stdout-cap-bytes:) (set! stdout-cap-bytes val)]
[(stderr-cap-bytes:) (set! stderr-cap-bytes val)]
[(require:) (set! require val)]
@@ -205,6 +238,38 @@
'(memory-limit)
'()))
+ (def (worker-sandbox-refused-axes pol caps)
+ (missing-required-axes (worker-policy-sandbox pol) caps))
+
+ (def (egress-policy-summary pol)
+ (let ([egress (worker-policy-egress-policy pol)])
+ (if egress
+ `((proxy-url . ,(allow-proxy-url egress))
+ (allowlist . ,(allow-proxy-allowlist egress)))
+ 'disabled)))
+
+ (def (remove-env-keys env keys)
+ (let lp ([xs env] [out '()])
+ (cond
+ [(null? xs) (reverse out)]
+ [(and (pair? (car xs))
+ (let ([name (caar xs)])
+ (let kp ([ks keys])
+ (cond
+ [(null? ks) #f]
+ [(string=? name (car ks)) #t]
+ [else (kp (cdr ks))]))))
+ (lp (cdr xs) out)]
+ [else (lp (cdr xs) (cons (car xs) out))])))
+
+ (def (worker-merge-egress-env env pol)
+ (let ([egress (worker-policy-egress-policy pol)])
+ (if egress
+ (let* ([proxy-env (allow-proxy-env egress)]
+ [keys (map car proxy-env)])
+ (append (remove-env-keys env keys) proxy-env))
+ env)))
+
(def (append-refused-axes a b)
(let lp ([xs b] [out a])
(cond
@@ -232,6 +297,8 @@
`((backend . ,(sandbox-backend))
(capabilities . ,caps)
(memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol))
+ (sandbox . ,(worker-policy-sandbox pol))
+ (egress . ,(egress-policy-summary pol))
(env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
(reason . ,reason))
command))
@@ -285,6 +352,8 @@
(memory-limit-installed? . ,(and (worker-policy-memory-limit-bytes pol)
(memory-limit-installable? pol)))
(memory-limit-report . ,(worker-memory-limit-report pol))
+ (sandbox . ,(worker-policy-sandbox pol))
+ (egress . ,(egress-policy-summary pol))
(stdout-truncated? . ,stdout-truncated?)
(stderr-truncated? . ,stderr-truncated?)
(env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
@@ -300,13 +369,18 @@
(error 'worker-run-command "expected env-policy" (worker-policy-env-policy pol)))
(let* ([caps (sandbox-capabilities)]
[required-missing (missing-required-axes (worker-policy-require pol) caps)]
+ [sandbox-missing (worker-sandbox-refused-axes pol caps)]
[memory-missing (memory-limit-refused-axes pol)]
- [missing (append-refused-axes required-missing memory-missing)])
+ [missing (append-refused-axes
+ (append-refused-axes required-missing sandbox-missing)
+ memory-missing)])
(cond
[(and (worker-policy-fail-closed? pol) (pair? missing))
(worker-refusal-result command pol caps missing 'required-axis-unavailable)]
[else
- (let* ([env (env-policy-build (worker-policy-env-policy pol) getenv)]
+ (let* ([env (worker-merge-egress-env
+ (env-policy-build (worker-policy-env-policy pol) getenv)
+ pol)]
[validation (env-policy-validate-command
(worker-policy-env-policy pol) '() command)])
(cond
@@ -347,6 +421,8 @@
(worker-policy-cwd pol0)
(worker-policy-timeout-ms pol0)
(worker-policy-memory-limit-bytes pol0)
+ (worker-policy-sandbox pol0)
+ (worker-policy-egress-policy 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 466a397..06777e6 100644
--- a/tests/test-worker.ss
+++ b/tests/test-worker.ss
@@ -2,6 +2,7 @@
;;; Tests for (std security worker).
(import (scheme)
+ (std net allow-proxy)
(std security worker))
(define pass 0)
@@ -138,6 +139,55 @@
(equal? (worker-result-refused-axes r)
'(definitely-unavailable-worker-axis)))))
+(test-pred "requested sandbox axis refuses before launch"
+ (worker-run-eval
+ "(+ 1 1)"
+ (worker-policy
+ 'command: worker-command
+ 'timeout-ms: 3000
+ 'sandbox: '(definitely-unavailable-sandbox-axis)
+ 'fail-closed?: #t))
+ (lambda (r)
+ (and (worker-result? r)
+ (not (worker-result-launched? r))
+ (equal? (worker-result-status r) 126)
+ (equal? (worker-result-refused-axes r)
+ '(definitely-unavailable-sandbox-axis))
+ (equal? (alist-ref/default (worker-result-diagnostics r)
+ 'sandbox 'missing)
+ '(definitely-unavailable-sandbox-axis)))))
+
+(test-pred "degraded sandbox launch reports requested axes"
+ (worker-run-eval
+ "(+ 1 1)"
+ (worker-policy
+ 'command: worker-command
+ 'timeout-ms: 3000
+ 'sandbox: '(definitely-unavailable-sandbox-axis)
+ '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)
+ 'sandbox 'missing)
+ '(definitely-unavailable-sandbox-axis)))))
+
+(test-pred "egress proxy policy reaches worker env"
+ (worker-run-command
+ '("/usr/bin/env")
+ (worker-policy
+ 'timeout-ms: 3000
+ 'egress-policy: (make-allow-proxy-policy '("example.com:443") 'port: 19080)))
+ (lambda (r)
+ (and (worker-result? r)
+ (worker-result-launched? r)
+ (equal? (worker-result-status r) 0)
+ (string-contains? (worker-result-stdout r) "HTTPS_PROXY=http://127.0.0.1:19080")
+ (not (eq? (alist-ref/default (worker-result-diagnostics r)
+ 'egress 'missing)
+ 'disabled)))))
+
(define requested-memory-limit (* 512 1024 1024))
(test-pred "memory limit fail-closed or installed before launch"