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"