security: add worker landlock pre-exec setup

ober

6cf254822642aa00f36c31049d0361895a4766ee

diff --git a/docs/aproc.md b/docs/aproc.md
index 2c89bbf..d59a73b 100644
--- a/docs/aproc.md
+++ b/docs/aproc.md
@@ -67,6 +67,9 @@ 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
+landlock-rules: '((read-only "/path") …)
+landlock-net-connect?: #t          ; deny TCP connect except allowed ports
+landlock-net-connect-ports: '(18080 …)
 ```
 
 `rlimits:` is supported only on the argv/native path and is rejected with
@@ -79,6 +82,15 @@ and rlimit setup and before `exec`; it is rejected with `pty: #t`. Higher-level
 code should prefer `(std security worker)` `sandbox: '(syscalls ptrace)` so
 fail-closed diagnostics remain structured.
 
+`landlock-rules:` and `landlock-net-connect?:` are also argv/native-only and
+are rejected with `pty: #t`. On Linux, the native child creates a Landlock
+ruleset after fd and rlimit setup and before `exec`. Filesystem rules accept
+`read-only`, `read-write`, and `execute` entries. TCP connect rules are
+port-scoped because that is the Landlock ABI boundary; pair them with
+`(std net allow-proxy)` for host allowlists. Higher-level code should prefer
+`(std security worker)` `sandbox: '(fs exec net)` so missing platform support
+is reported as a structured fail-closed refusal.
+
 ### Low-level handle
 
 ```
diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 8a8ddc4..fc7fcb0 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -40,8 +40,8 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (rust-file-count 29)
     (c-file-count 5)
     (no-mangle-exports 196)
-    (unsafe-sites 369)
-    (unsafe-sites-with-nearby-safety-comment 369)
+    (unsafe-sites 385)
+    (unsafe-sites-with-nearby-safety-comment 385)
     (unsafe-sites-without-nearby-safety-comment 0)
     (unsafe-comment-review-sites 0)
     (export-review-sites 196))
@@ -80,8 +80,9 @@ function body no longer grants implicit permission for unsafe operations. Each
 unsafe operation still has to live inside an explicit `unsafe { ... }` block.
 On 2026-07-28, the `aproc.rs` and `seccomp.rs` unsafe sites covering the
 worker pre-exec launch path, fd handoff/cleanup, FFI buffer copies, rlimit
-decoding, wait4 outputs, and seccomp `prctl` calls gained nearby `SAFETY:`
-invariant comments. The `crypto.rs` digest, HMAC, random, constant-time
+decoding, wait4 outputs, seccomp `prctl` calls, and Linux Landlock pre-exec
+ruleset/path/net syscalls gained nearby `SAFETY:` invariant comments. The
+`crypto.rs` digest, HMAC, random, constant-time
 comparison, AEAD, scrypt, PBKDF2, and Argon2id FFI buffer sites also gained
 nearby `SAFETY:` invariant comments; the same pass added fail-closed
 nonzero-null input checks for HMAC and AEAD optional buffers and checked AEAD
@@ -140,8 +141,9 @@ handle-store locks into normal FFI errors, rejects null argument/result buffers
 with nonzero lengths, checks guest-memory offsets with overflow-safe helpers,
 documents socket-fd adoption and caller-buffer invariants adjacent to the
 audited unsafe sites, and has Rust regressions for invalid C ABI inputs.
-`aproc.rs` now documents inherited-fd slice, `pre_exec`, and `openpty`
-invariants adjacent to the audited worker-launch unsafe sites.
+`aproc.rs` now documents inherited-fd slice, `pre_exec`, `openpty`, and Linux
+Landlock pre-exec ruleset/path/net syscall invariants adjacent to the audited
+worker-launch unsafe sites.
 `duckdb_native.rs` now
 funnels path, SQL, text, and blob inputs through checked C ABI slice helpers,
 converts poisoned database/statement/result store locks into normal FFI errors,
@@ -171,7 +173,7 @@ funnels hostname, path, PEM, pin, and read/write buffers through checked C ABI
 helpers, rejects null/nonzero and oversized buffers before slicing, rejects
 negative accepted fds before adopting them as `TcpStream`, and documents socket
 shutdown invariants used to break blocked TLS I/O. The generated inventory now
-reports 369 annotated native unsafe sites, 0 remaining unsafe review sites, and
+reports 385 annotated native unsafe sites, 0 remaining unsafe review sites, and
 0 Scheme blocking calls missing `__collect_safe`.
 
 Remaining work before closing K3-P1-01:
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 43ee684..9c54547 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -146,7 +146,7 @@ never pattern-based:
   and the unsafe pattern unrepresentable — that's most of P0/P1 below.
 - **The FFI boundary is our `unsafe`.** 91 files under `lib/std/` mention
   `foreign-procedure`; the current [FFI audit](ffi-audit.md) tracks 196
-  native `#[no_mangle]` sites and 369 Rust unsafe sites. That boundary is
+  native `#[no_mangle]` sites and 385 Rust unsafe sites. That boundary is
   where a K3-class adversary will concentrate, because it is the only place
   memory-corruption patterns *exist* in a Jerboa application.
   [`Philosophy.md`](Philosophy.md) Principle 4 and open tension #5 both
@@ -158,9 +158,8 @@ never pattern-based:
   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 Landlock/seccomp, Seatbelt/Capsicum, and egress
-  proxy wiring.
+  `supervise-available?` returns `#f`, and the remaining P0-02 work is native
+  Seatbelt/Capsicum parity for the worker path.
 
 ---
 
@@ -217,7 +216,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | Self-confinement (pledge/unveil-style `cage!`) | `(std security cage)` | exists; documented in the security module inventory | security-reference §12 |
 | 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, explicit `sandbox:` axis requests, egress proxy env wiring, memory rlimits installed pre-exec on supported platforms, and Linux syscall/ptrace seccomp pre-exec setup for requested axes; Landlock path/net and Seatbelt/Capsicum parity 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, memory rlimits installed pre-exec on supported platforms, and Linux syscall/ptrace seccomp plus Landlock filesystem/TCP-connect setup for requested axes; Seatbelt/Capsicum parity 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 inventory has per-site provisional verdicts, with remediation rules still open | security-reference §7; [ffi-audit.md](ffi-audit.md) |
 | Safe deserialization (tagged-JSON envelope, no native FASL on untrusted paths) | `(std safe-fasl)`, `(std fasl)` (trusted-only) | exists; raw `read`/FASL/`load` sites are classified by manifest and gated by `make audit` | [safety-guide.md](safety-guide.md) §10 |
@@ -268,7 +267,7 @@ Keep this section to baseline facts only.
 | Files under `lib/std/` mentioning `foreign-procedure` | **91** | `grep -rl foreign-procedure lib/std \| wc -l` |
 | Native `#[no_mangle]` sites in `jerboa-native-rs/src` | **196** | `make ffi-audit-report` |
 | Actual native C ABI exports after cfg filtering | **190** | `make native-export-review-check` |
-| Rust unsafe sites in `jerboa-native-rs/src` | **369** | `make ffi-audit-report` |
+| Rust unsafe sites in `jerboa-native-rs/src` | **385** | `make ffi-audit-report` |
 | Fuzz harnesses | 13 | `ls tests/fuzz/harness` |
 | Checked-in fuzz seed corpora | **0** | `tests/fuzz/` contains only `harness/` |
 | Security test files | 21 | Appendix A |
@@ -354,8 +353,10 @@ audit-log start/end records landed 2026-07-27 and are asserted by
 Explicit worker `sandbox:` axis requests and `egress-policy:` allow-proxy env
 wiring landed 2026-07-27. Native pre-exec syscall/ptrace seccomp setup for
 requested worker axes landed 2026-07-28 through `(std os aproc)`
-`seccomp-lock?:`. Native Landlock path/net setup and Seatbelt/Capsicum parity
-remain open.
+`seccomp-lock?:`. Native pre-exec Landlock filesystem/TCP-connect setup for
+requested worker axes landed 2026-07-28 through `(std os aproc)`
+`landlock-rules:` and `landlock-net-connect?:`. Seatbelt/Capsicum parity
+remains 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
@@ -708,7 +709,7 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit
   publication, and test-owned fd conversion invariants. `tls.rs` now validates
   hostname, path, PEM, pin, and read/write buffers through shared helpers,
   rejects invalid accepted fds before ownership transfer, and documents socket
-  shutdown invariants for blocked TLS I/O. The generated report now shows 369
+  shutdown invariants for blocked TLS I/O. The generated report now shows 385
   annotated native unsafe sites, 0 remaining unsafe review sites, and 0 Scheme
   blocking calls missing `__collect_safe`.
   Remaining work: finish per-binding Scheme FFI review and targeted scanner
@@ -868,7 +869,9 @@ Chez cannot heap-cap a thread; `run-safe-eval` rightly refuses
   and configured/available/installed state for seccomp, Landlock, Seatbelt,
   Capsicum, and memory limits. `(std security worker)` now routes requested
   `syscalls`/`ptrace` sandbox axes through native aproc pre-exec seccomp on
-  Linux. Native Landlock path/net setup and per-platform CI parity remain open.
+  Linux, and routes requested `fs`/`exec`/`net` axes through native aproc
+  pre-exec Landlock filesystem and TCP-connect rules on Linux. Seatbelt,
+  Capsicum, and per-platform CI parity remain open.
 
 ### K3-P1-09 — Confused-deputy defenses: capability plans enforced at runtime
 **Serves:** G2. **Effort:** 1 week.
@@ -1199,7 +1202,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, memory rlimit pre-exec path, explicit sandbox-axis refusal, egress proxy env wiring, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and Seatbelt/Capsicum parity 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, egress proxy env wiring, Linux syscall/ptrace seccomp pre-exec setup, and Linux Landlock filesystem/TCP-connect setup landed; Seatbelt/Capsicum parity pending | yes, tested (P0-02) |
 | Unclassified raw `read`/FASL/`load` sites | scanner-driven manifest classification gate closed 2026-07-27; trusted build, compiler, REPL/dev, and test surfaces are centrally justified by exact source line | 0 |
 | FFI bindings audited | scanner inventory and provisional verdicts in [ffi-audit.md](ffi-audit.md); native exports gated and justified | 100% with final verdicts |
 | Un-annotated Rust `unsafe` blocks | 0 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
@@ -1218,7 +1221,7 @@ Track these in `docs/status.md` per release:
 ```bash
 # Posture facts used in this document (re-run to refresh):
 grep -rl 'foreign-procedure' lib/std | wc -l        # 91
-make ffi-audit-report                              # native no_mangle 196; unsafe 369
+make ffi-audit-report                              # native no_mangle 196; unsafe 385
 make native-export-review-check                    # actual exports 190
 ls tests/fuzz/harness                                # 13 harnesses
 ls vendor/                                           # ChezScheme, ChezScheme-patches, jsqlite
@@ -1288,15 +1291,17 @@ fake confidence happens.
   pointers remain for explicit FFI integration.
 - FFI audit phase 5 has a reproducible inventory, per-site provisional
   verdicts, committed hazard scanner rules, a `vendor/jsqlite` accepted-risk
-  decision gated by `make audit`, and a native export review gate; unsafe
-  invariant comments remain open.
-- No independent red-team evaluation yet (P2-04 starts the practice).
+  decision gated by `make audit`, a native export review gate, and native
+  unsafe invariant comments closed; Scheme per-binding safety review remains
+  open.
+- No external independent red-team evaluation is recorded yet; the
+  repository-local standing protocol is in place (P2-04).
 - The confined exec worker facade exists as `(std security worker)`, with
   audit-log lifecycle events and native pre-exec memory rlimits on supported
   platforms. It now exposes explicit `sandbox:` fail-closed axis requests and
-  `egress-policy:` proxy env wiring. Native kernel sandbox installation is
-  still pending for Landlock path/net rules and Seatbelt/Capsicum parity
-  (P0-02/P1-08).
+  `egress-policy:` proxy env wiring, and installs Linux seccomp plus Landlock
+  filesystem/TCP-connect controls before `exec` for requested axes. Native
+  Seatbelt/Capsicum worker parity remains 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/reviews/2026-07-27-native-export-review.sexp b/docs/reviews/2026-07-27-native-export-review.sexp
index 2825223..1c27eed 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -19,11 +19,11 @@
     (export (symbol "jerboa_antidebug_check_tracer") (file "jerboa-native-rs/src/antidebug.rs") (line 37) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 38))))
     (export (symbol "jerboa_antidebug_ptrace") (file "jerboa-native-rs/src/antidebug.rs") (line 8) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 36))))
     (export (symbol "jerboa_antidebug_timing_check") (file "jerboa-native-rs/src/antidebug.rs") (line 129) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 44))))
-    (export (symbol "jerboa_aproc_killpg") (file "jerboa-native-rs/src/aproc.rs") (line 674) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 155))))
-    (export (symbol "jerboa_aproc_set_nonblock") (file "jerboa-native-rs/src/aproc.rs") (line 644) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 151))))
-    (export (symbol "jerboa_aproc_spawn") (file "jerboa-native-rs/src/aproc.rs") (line 152) (scheme-callers 2 (caller (file "lib/std/os/aproc.ss") (line 127)) (caller (file "lib/std/os/aproc.ss") (line 142))))
-    (export (symbol "jerboa_aproc_spawn_pty") (file "jerboa-native-rs/src/aproc.rs") (line 374) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 142))))
-    (export (symbol "jerboa_aproc_wait4") (file "jerboa-native-rs/src/aproc.rs") (line 690) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 159))))
+    (export (symbol "jerboa_aproc_killpg") (file "jerboa-native-rs/src/aproc.rs") (line 987) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 158))))
+    (export (symbol "jerboa_aproc_set_nonblock") (file "jerboa-native-rs/src/aproc.rs") (line 957) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 154))))
+    (export (symbol "jerboa_aproc_spawn") (file "jerboa-native-rs/src/aproc.rs") (line 431) (scheme-callers 2 (caller (file "lib/std/os/aproc.ss") (line 127)) (caller (file "lib/std/os/aproc.ss") (line 145))))
+    (export (symbol "jerboa_aproc_spawn_pty") (file "jerboa-native-rs/src/aproc.rs") (line 687) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 145))))
+    (export (symbol "jerboa_aproc_wait4") (file "jerboa-native-rs/src/aproc.rs") (line 1003) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 162))))
     (export (symbol "jerboa_argon2id_hash") (file "jerboa-native-rs/src/crypto.rs") (line 814) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 276)) (caller (file "lib/std/crypto/password.ss") (line 35))))
     (export (symbol "jerboa_argon2id_verify") (file "jerboa-native-rs/src/crypto.rs") (line 867) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 296)) (caller (file "lib/std/crypto/password.ss") (line 42))))
     (export (symbol "jerboa_chacha20_open") (file "jerboa-native-rs/src/crypto.rs") (line 606) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 204))))
@@ -82,7 +82,7 @@
     (export (symbol "jerboa_landlock_add_path_rule") (file "jerboa-native-rs/src/landlock.rs") (line 129) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 83))))
     (export (symbol "jerboa_landlock_create_ruleset") (file "jerboa-native-rs/src/landlock.rs") (line 97) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 80))))
     (export (symbol "jerboa_landlock_enforce") (file "jerboa-native-rs/src/landlock.rs") (line 276) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 89))))
-    (export (symbol "jerboa_last_error") (file "jerboa-native-rs/src/panic.rs") (line 9) (scheme-callers 11 (caller (file "lib/std/compress/native-rust.ss") (line 42)) (caller (file "lib/std/crypto/native-rust.ss") (line 48)) (caller (file "lib/std/crypto/x509.ss") (line 24)) (caller (file "lib/std/native.ss") (line 56)) (caller (file "lib/std/net/tls-rustls.ss") (line 167)) (caller (file "lib/std/os/aproc.ss") (line 164)) (caller (file "lib/std/os/integrity.ss") (line 42)) (caller (file "lib/std/os/secure-output.ss") (line 47)) (caller (file "lib/std/pcap.ss") (line 66)) (caller (file "lib/std/regex-native.ss") (line 51)) (caller (file "lib/std/wasm/sandbox.ss") (line 196))))
+    (export (symbol "jerboa_last_error") (file "jerboa-native-rs/src/panic.rs") (line 9) (scheme-callers 11 (caller (file "lib/std/compress/native-rust.ss") (line 42)) (caller (file "lib/std/crypto/native-rust.ss") (line 48)) (caller (file "lib/std/crypto/x509.ss") (line 24)) (caller (file "lib/std/native.ss") (line 56)) (caller (file "lib/std/net/tls-rustls.ss") (line 167)) (caller (file "lib/std/os/aproc.ss") (line 167)) (caller (file "lib/std/os/integrity.ss") (line 42)) (caller (file "lib/std/os/secure-output.ss") (line 47)) (caller (file "lib/std/pcap.ss") (line 66)) (caller (file "lib/std/regex-native.ss") (line 51)) (caller (file "lib/std/wasm/sandbox.ss") (line 196))))
     (export (symbol "jerboa_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 740) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 246))))
     (export (symbol "jerboa_pbkdf2_verify") (file "jerboa-native-rs/src/crypto.rs") (line 773) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 261))))
     (export (symbol "jerboa_pcap_close") (file "jerboa-native-rs/src/pcap_capture.rs") (line 341) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 58))))
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 06d5f82..48dd8d0 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, output caps, and supported memory rlimits | `(std security worker)` |
+| **Exec worker** | Run restricted eval in a fresh argv-exec'd process with pure env, deadline, output caps, supported memory rlimits, Linux seccomp, and Linux Landlock | `(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)` |
@@ -491,6 +491,15 @@ debug/syscall seccomp blocklist after fd and rlimit setup and before `exec`.
 Diagnostics include `native-seccomp-requested?` and
 `native-seccomp-installed?`.
 
+When `sandbox:` includes `fs`, `exec`, `net`, or `landlock` on Linux, the
+worker passes native Landlock policy into `(std os aproc)`. The child installs
+filesystem rules for the runtime paths needed to start the worker and, when
+`net` is requested, enables Landlock TCP connect mediation before `exec`.
+If an `egress-policy:` proxy is configured, the proxy port is the only allowed
+Landlock TCP connect port; host allowlisting still belongs to the proxy because
+Landlock's network ABI is port-scoped. Diagnostics include
+`native-landlock-requested?` and `native-landlock-installed?`.
+
 Use `egress-policy:` with a `(std net allow-proxy)` policy when the worker
 should route HTTP-capable clients through the deny-default allow proxy:
 
@@ -536,10 +545,9 @@ result reports whether the limit was actually installed. Do not treat
 Current limitation: the worker has a real exec boundary, pure environment,
 parent deadline, process-group kill through `aproc`, returned output caps,
 audit-log start/end records, memory rlimits on supported platforms, Linux
-syscall/ptrace seccomp setup for requested axes, explicit fail-closed
-sandbox-axis requests, and allow-proxy environment wiring. Native pre-exec
-Landlock path/net setup and Seatbelt/Capsicum parity remain tracked by the K3
-handoff.
+syscall/ptrace seccomp setup, Linux Landlock filesystem and TCP-connect setup
+for requested axes, explicit fail-closed sandbox-axis requests, and allow-proxy
+environment wiring. Seatbelt/Capsicum parity remains tracked by the K3 handoff.
 
 ---
 
diff --git a/docs/status.md b/docs/status.md
index 5d10d84..d4bf667 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -14,7 +14,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`,
 | CLI | `jerboa`, `jerboa run`, `jerboa eval`, `jerboa test`, `jerboa build`, and `jerboa pkg` are the documented entry points. |
 | Language | `(jerboa prelude)` is the default import for user code and resolves to the safe surface for direct scripts; use `--unsafe-prelude` or `(jerboa prelude unsafe)` only for explicit raw access. `.sls` files remain implementation internals. |
 | Standard library | `lib/std` and `lib/jerboa` contain 647 `.ss` modules at this snapshot. See [libraries.md](libraries.md) and [api-index.md](api-index.md). |
-| Security | Landlock, seccomp, capability plans, taint-aware safe-prelude sinks, restricted evaluation, safe FASL, the exec worker facade, memory rlimit pre-exec setup, worker syscall/ptrace seccomp setup on Linux, security regression tests, and release evidence are implemented. Native worker Landlock path/net setup and Seatbelt/Capsicum parity remain open hardening work. |
+| Security | Landlock, seccomp, capability plans, taint-aware safe-prelude sinks, restricted evaluation, safe FASL, the exec worker facade, memory rlimit pre-exec setup, worker syscall/ptrace seccomp setup on Linux, worker Landlock filesystem/TCP-connect setup on Linux, security regression tests, and release evidence are implemented. Seatbelt/Capsicum parity remains open hardening work. |
 | Native Rust | `libjerboa_native` backs crypto, secure memory, compression, regex, selected databases, OS integrations, packet capture, and rustls TLS. |
 | Packaging | `jpkg` is shipped through the Jerboa multicall binary and documented in [jpkg-guide.md](jpkg-guide.md). |
 | Build and release | `make binary` is the local non-Linux release build, `make docker-build` is the Linux release build, and `make release-evidence` collects audit evidence. |
@@ -27,7 +27,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`,
 | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. |
 | FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, and SQLite native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. |
 | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. |
-| Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. |
+| Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup, Linux Landlock filesystem/TCP-connect setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Keep Linux/macOS/FreeBSD parity tests current and wire native Seatbelt/Capsicum worker paths. |
 | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. |
 
 ## Compatibility Notes
diff --git a/jerboa-native-rs/src/aproc.rs b/jerboa-native-rs/src/aproc.rs
index d4c9bd2..bbb123d 100644
--- a/jerboa-native-rs/src/aproc.rs
+++ b/jerboa-native-rs/src/aproc.rs
@@ -18,6 +18,8 @@ const FLAG_NEW_PGROUP: i32 = 1 << 0;
 const FLAG_USE_ENVP: i32 = 1 << 1; // apply envp entries
 const FLAG_ENV_CLEAR: i32 = 1 << 2; // env_clear() before applying envp (replace mode)
 const FLAG_SECCOMP_LOCK: i32 = 1 << 3; // install debug/syscall seccomp blocklist pre-exec
+const FLAG_LANDLOCK: i32 = 1 << 4; // install Landlock FS policy pre-exec
+const FLAG_LANDLOCK_NET_CONNECT: i32 = 1 << 5; // handle TCP connect with Landlock pre-exec
 
 // Result buffer layout (5 * i32, little-endian, native order):
 //   [0] = pid
@@ -124,6 +126,80 @@ fn parse_rlimits(
     Ok(out)
 }
 
+fn parse_u64_list(
+    buf: *const u8,
+    len: usize,
+    count: usize,
+    what: &str,
+) -> Result<Vec<u64>, String> {
+    if count == 0 {
+        return Ok(Vec::new());
+    }
+    if buf.is_null() {
+        return Err(format!("{} buffer is null", what));
+    }
+    let expected = count
+        .checked_mul(8)
+        .ok_or_else(|| format!("{} count overflow", what))?;
+    if len != expected {
+        return Err(format!(
+            "expected {} bytes for {} {} entries, got {}",
+            expected, count, what, len
+        ));
+    }
+    // SAFETY: non-null was checked above, and `len == count * 8` bounds every
+    // fixed-width decode. Values are copied into an owned Vec.
+    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 * 8;
+        out.push(u64::from_ne_bytes(slice[off..off + 8].try_into().unwrap()));
+    }
+    Ok(out)
+}
+
+#[derive(Debug)]
+#[allow(dead_code)]
+struct LandlockPolicy {
+    fs_rules: Vec<(CString, u64)>,
+    net_connect_ports: Vec<u64>,
+    handle_net_connect: bool,
+}
+
+fn parse_landlock_policy(
+    path_buf: *const u8,
+    path_buf_len: usize,
+    path_count: usize,
+    access_buf: *const u8,
+    access_buf_len: usize,
+    net_port_buf: *const u8,
+    net_port_buf_len: usize,
+    net_port_count: usize,
+    handle_net_connect: bool,
+) -> Result<LandlockPolicy, String> {
+    let paths = parse_nul_separated(path_buf, path_buf_len, path_count)?;
+    let access = parse_u64_list(access_buf, access_buf_len, path_count, "landlock access")?;
+    if paths.len() != access.len() {
+        return Err("landlock path/access count mismatch".to_string());
+    }
+    let net_connect_ports = parse_u64_list(
+        net_port_buf,
+        net_port_buf_len,
+        net_port_count,
+        "landlock TCP port",
+    )?;
+    for port in net_connect_ports.iter().copied() {
+        if port > u16::MAX as u64 {
+            return Err(format!("landlock TCP port {} out of range", port));
+        }
+    }
+    Ok(LandlockPolicy {
+        fs_rules: paths.into_iter().zip(access).collect(),
+        net_connect_ports,
+        handle_net_connect,
+    })
+}
+
 #[cfg(target_os = "linux")]
 fn install_seccomp_blocklist_pre_exec() -> std::io::Result<()> {
     crate::seccomp::install_debug_blocklist().map_err(std::io::Error::other)
@@ -137,6 +213,209 @@ fn install_seccomp_blocklist_pre_exec() -> std::io::Result<()> {
     ))
 }
 
+#[cfg(target_os = "linux")]
+fn install_landlock_pre_exec(policy: &LandlockPolicy) -> std::io::Result<()> {
+    const SYS_LANDLOCK_CREATE_RULESET: libc::c_long = 444;
+    const SYS_LANDLOCK_ADD_RULE: libc::c_long = 445;
+    const SYS_LANDLOCK_RESTRICT_SELF: libc::c_long = 446;
+    const LANDLOCK_RULE_PATH_BENEATH: u32 = 1;
+    const LANDLOCK_RULE_NET_PORT: u32 = 2;
+    const LANDLOCK_ACCESS_FS_EXECUTE: u64 = 1 << 0;
+    const LANDLOCK_ACCESS_FS_WRITE_FILE: u64 = 1 << 1;
+    const LANDLOCK_ACCESS_FS_READ_FILE: u64 = 1 << 2;
+    const LANDLOCK_ACCESS_FS_READ_DIR: u64 = 1 << 3;
+    const LANDLOCK_ACCESS_FS_REMOVE_DIR: u64 = 1 << 4;
+    const LANDLOCK_ACCESS_FS_REMOVE_FILE: u64 = 1 << 5;
+    const LANDLOCK_ACCESS_FS_MAKE_CHAR: u64 = 1 << 6;
+    const LANDLOCK_ACCESS_FS_MAKE_DIR: u64 = 1 << 7;
+    const LANDLOCK_ACCESS_FS_MAKE_REG: u64 = 1 << 8;
+    const LANDLOCK_ACCESS_FS_MAKE_SOCK: u64 = 1 << 9;
+    const LANDLOCK_ACCESS_FS_MAKE_FIFO: u64 = 1 << 10;
+    const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u64 = 1 << 11;
+    const LANDLOCK_ACCESS_FS_MAKE_SYM: u64 = 1 << 12;
+    const LANDLOCK_ACCESS_FS_REFER: u64 = 1 << 13;
+    const LANDLOCK_ACCESS_FS_TRUNCATE: u64 = 1 << 14;
+    const LANDLOCK_ACCESS_NET_CONNECT_TCP: u64 = 1 << 1;
+
+    #[repr(C)]
+    struct LandlockRulesetAttr {
+        handled_access_fs: u64,
+        handled_access_net: u64,
+    }
+
+    #[repr(C)]
+    struct LandlockPathBeneathAttr {
+        allowed_access: u64,
+        parent_fd: i32,
+    }
+
+    #[repr(C)]
+    struct LandlockNetPortAttr {
+        allowed_access: u64,
+        port: u64,
+    }
+
+    let handled_access_fs = if policy.fs_rules.is_empty() {
+        0
+    } else {
+        LANDLOCK_ACCESS_FS_EXECUTE
+            | LANDLOCK_ACCESS_FS_WRITE_FILE
+            | LANDLOCK_ACCESS_FS_READ_FILE
+            | LANDLOCK_ACCESS_FS_READ_DIR
+            | LANDLOCK_ACCESS_FS_REMOVE_DIR
+            | LANDLOCK_ACCESS_FS_REMOVE_FILE
+            | LANDLOCK_ACCESS_FS_MAKE_CHAR
+            | LANDLOCK_ACCESS_FS_MAKE_DIR
+            | LANDLOCK_ACCESS_FS_MAKE_REG
+            | LANDLOCK_ACCESS_FS_MAKE_SOCK
+            | LANDLOCK_ACCESS_FS_MAKE_FIFO
+            | LANDLOCK_ACCESS_FS_MAKE_BLOCK
+            | LANDLOCK_ACCESS_FS_MAKE_SYM
+            | LANDLOCK_ACCESS_FS_REFER
+            | LANDLOCK_ACCESS_FS_TRUNCATE
+    };
+    let handled_access_net = if policy.handle_net_connect {
+        LANDLOCK_ACCESS_NET_CONNECT_TCP
+    } else {
+        0
+    };
+    let attr = LandlockRulesetAttr {
+        handled_access_fs,
+        handled_access_net,
+    };
+    // SAFETY: `attr` is a C-compatible stack value that remains live for the
+    // duration of this syscall; the kernel copies it before returning.
+    let ruleset_fd = unsafe {
+        libc::syscall(
+            SYS_LANDLOCK_CREATE_RULESET,
+            &attr as *const _ as *const u8,
+            std::mem::size_of::<LandlockRulesetAttr>(),
+            0u32,
+        )
+    } as i32;
+    if ruleset_fd < 0 {
+        return Err(std::io::Error::last_os_error());
+    }
+
+    for (path, access_mask) in policy.fs_rules.iter() {
+        // SAFETY: `path` is an owned CString moved into the pre-exec closure and
+        // remains NUL-terminated and live while open(2) reads it.
+        let parent_fd =
+            unsafe { libc::open(path.as_ptr() as *const _, libc::O_PATH | libc::O_CLOEXEC) };
+        if parent_fd < 0 {
+            // SAFETY: ruleset_fd is owned by this child pre-exec path.
+            unsafe {
+                libc::close(ruleset_fd);
+            }
+            return Err(std::io::Error::last_os_error());
+        }
+        let mut effective_access = *access_mask;
+        // SAFETY: `st` is zero-initialized before fstat fills it, and
+        // `parent_fd` is the live O_PATH descriptor returned above.
+        let mut st: libc::stat = unsafe { std::mem::zeroed() };
+        // SAFETY: `st` points to valid writable storage and `parent_fd`
+        // remains open across the syscall.
+        if unsafe { libc::fstat(parent_fd, &mut st) } == 0
+            && (st.st_mode & libc::S_IFMT) != libc::S_IFDIR
+        {
+            const FILE_BITS: u64 = LANDLOCK_ACCESS_FS_EXECUTE
+                | LANDLOCK_ACCESS_FS_WRITE_FILE
+                | LANDLOCK_ACCESS_FS_READ_FILE
+                | LANDLOCK_ACCESS_FS_TRUNCATE;
+            effective_access &= FILE_BITS;
+        }
+        if effective_access == 0 {
+            // SAFETY: the O_PATH fd is owned by this child pre-exec path.
+            unsafe {
+                libc::close(parent_fd);
+            }
+            continue;
+        }
+
+        let attr = LandlockPathBeneathAttr {
+            allowed_access: effective_access,
+            parent_fd,
+        };
+        // SAFETY: `attr` and `parent_fd` remain live for the duration of the
+        // syscall; Landlock copies the rule data into the ruleset.
+        let rc = unsafe {
+            libc::syscall(
+                SYS_LANDLOCK_ADD_RULE,
+                ruleset_fd,
+                LANDLOCK_RULE_PATH_BENEATH,
+                &attr as *const _ as *const u8,
+                0u32,
+            )
+        };
+        // SAFETY: the O_PATH fd is no longer needed once Landlock has copied
+        // the rule or rejected it.
+        unsafe {
+            libc::close(parent_fd);
+        }
+        if rc < 0 {
+            // SAFETY: ruleset_fd is owned by this child pre-exec path.
+            unsafe {
+                libc::close(ruleset_fd);
+            }
+            return Err(std::io::Error::last_os_error());
+        }
+    }
+
+    for port in policy.net_connect_ports.iter().copied() {
+        let attr = LandlockNetPortAttr {
+            allowed_access: LANDLOCK_ACCESS_NET_CONNECT_TCP,
+            port,
+        };
+        // SAFETY: `attr` is C-compatible and live for the syscall; Landlock
+        // copies the port rule into the ruleset.
+        let rc = unsafe {
+            libc::syscall(
+                SYS_LANDLOCK_ADD_RULE,
+                ruleset_fd,
+                LANDLOCK_RULE_NET_PORT,
+                &attr as *const _ as *const u8,
+                0u32,
+            )
+        };
+        if rc < 0 {
+            // SAFETY: ruleset_fd is owned by this child pre-exec path.
+            unsafe {
+                libc::close(ruleset_fd);
+            }
+            return Err(std::io::Error::last_os_error());
+        }
+    }
+
+    // SAFETY: PR_SET_NO_NEW_PRIVS with integer arguments does not dereference
+    // user memory and is required before landlock_restrict_self.
+    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } < 0 {
+        // SAFETY: ruleset_fd is owned by this child pre-exec path.
+        unsafe {
+            libc::close(ruleset_fd);
+        }
+        return Err(std::io::Error::last_os_error());
+    }
+    // SAFETY: `ruleset_fd` names a ruleset created in this child; the kernel
+    // validates it and installs irreversible restrictions before exec.
+    let rc = unsafe { libc::syscall(SYS_LANDLOCK_RESTRICT_SELF, ruleset_fd, 0u32) };
+    // SAFETY: after restrict_self returns, the ruleset fd is no longer needed.
+    unsafe {
+        libc::close(ruleset_fd);
+    }
+    if rc < 0 {
+        return Err(std::io::Error::last_os_error());
+    }
+    Ok(())
+}
+
+#[cfg(not(target_os = "linux"))]
+fn install_landlock_pre_exec(_policy: &LandlockPolicy) -> std::io::Result<()> {
+    Err(std::io::Error::new(
+        std::io::ErrorKind::Unsupported,
+        "Landlock is only available on Linux",
+    ))
+}
+
 /// 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.
@@ -170,6 +449,14 @@ pub extern "C" fn jerboa_aproc_spawn(
     rlimit_buf: *const u8,
     rlimit_buf_len: usize,
     rlimit_count: usize,
+    landlock_path_buf: *const u8,
+    landlock_path_buf_len: usize,
+    landlock_path_count: usize,
+    landlock_access_buf: *const u8,
+    landlock_access_buf_len: usize,
+    landlock_net_port_buf: *const u8,
+    landlock_net_port_buf_len: usize,
+    landlock_net_port_count: usize,
     flags: i32,
     result: *mut i32,
 ) -> i32 {
@@ -260,6 +547,8 @@ pub extern "C" fn jerboa_aproc_spawn(
         let new_pgroup = (flags & FLAG_NEW_PGROUP) != 0;
         let merge_stderr = stderr_mode == STDIO_MERGE_STDOUT;
         let seccomp_lock = (flags & FLAG_SECCOMP_LOCK) != 0;
+        let landlock_enabled = (flags & FLAG_LANDLOCK) != 0;
+        let landlock_net_connect = (flags & FLAG_LANDLOCK_NET_CONNECT) != 0;
 
         // Copy inherit-fd pairs into a Vec we move into the pre_exec closure
         // — the raw pointer + count would not satisfy `'static` bounds.
@@ -283,6 +572,27 @@ pub extern "C" fn jerboa_aproc_spawn(
                 return -1;
             }
         };
+        let landlock_policy = if landlock_enabled || landlock_net_connect {
+            match parse_landlock_policy(
+                landlock_path_buf,
+                landlock_path_buf_len,
+                landlock_path_count,
+                landlock_access_buf,
+                landlock_access_buf_len,
+                landlock_net_port_buf,
+                landlock_net_port_buf_len,
+                landlock_net_port_count,
+                landlock_net_connect,
+            ) {
+                Ok(policy) => Some(policy),
+                Err(e) => {
+                    set_last_error(format!("landlock parse: {}", e));
+                    return -1;
+                }
+            }
+        } else {
+            None
+        };
 
         // CommandExt::pre_exec runs this closure in the child after fork and
         // before exec.
@@ -321,6 +631,9 @@ pub extern "C" fn jerboa_aproc_spawn(
                         return Err(std::io::Error::last_os_error());
                     }
                 }
+                if let Some(policy) = landlock_policy.as_ref() {
+                    install_landlock_pre_exec(policy)?;
+                }
                 if seccomp_lock {
                     install_seccomp_blocklist_pre_exec()?;
                 }
@@ -756,3 +1069,43 @@ fn _unused_owned_from_raw(fd: i32) -> Option<OwnedFd> {
 fn _unused_as_raw<T: AsRawFd>(f: &T) -> i32 {
     f.as_raw_fd()
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn landlock_policy_rejects_mismatched_access_count() {
+        let paths = b"/tmp\0";
+        let one_port = 443u64.to_ne_bytes();
+        assert!(parse_landlock_policy(
+            paths.as_ptr(),
+            paths.len(),
+            1,
+            std::ptr::null(),
+            0,
+            one_port.as_ptr(),
+            one_port.len(),
+            1,
+            true,
+        )
+        .is_err());
+    }
+
+    #[test]
+    fn landlock_policy_rejects_out_of_range_port() {
+        let port = ((u16::MAX as u64) + 1).to_ne_bytes();
+        assert!(parse_landlock_policy(
+            std::ptr::null(),
+            0,
+            0,
+            std::ptr::null(),
+            0,
+            port.as_ptr(),
+            port.len(),
+            1,
+            true,
+        )
+        .is_err());
+    }
+}
diff --git a/lib/std/os/aproc.ss b/lib/std/os/aproc.ss
index a9c3bf1..e3e93d7 100644
--- a/lib/std/os/aproc.ss
+++ b/lib/std/os/aproc.ss
@@ -133,7 +133,10 @@
               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 (pgroup/env/seccomp)
+              u8* size_t size_t   ; Landlock path strings, len, count
+              u8* size_t          ; Landlock access masks (u64 per path), len
+              u8* size_t size_t   ; Landlock TCP connect ports (u64), len, count
+              int                 ; flags (pgroup/env/seccomp/landlock)
               u8*)                ; result buf (5*int)
              int)
          (catch (e) #f))))
@@ -316,11 +319,118 @@
   (def STDIO_DEVNULL 2)
   (def STDIO_FILE 3)
   (def STDIO_MERGE_STDOUT 4)
-  (def FLAG_NEW_PGROUP   1)
-  (def FLAG_USE_ENVP     2)
-  (def FLAG_ENV_CLEAR    4)
+  (def FLAG_NEW_PGROUP 1)
+  (def FLAG_USE_ENVP 2)
+  (def FLAG_ENV_CLEAR 4)
   (def FLAG_SECCOMP_LOCK 8)
+  (def FLAG_LANDLOCK 16)
+  (def FLAG_LANDLOCK_NET_CONNECT 32)
+
+  (def LANDLOCK_ACCESS_FS_EXECUTE #x1)
+  (def LANDLOCK_ACCESS_FS_WRITE_FILE #x2)
+  (def LANDLOCK_ACCESS_FS_READ_FILE #x4)
+  (def LANDLOCK_ACCESS_FS_READ_DIR #x8)
+  (def LANDLOCK_ACCESS_FS_REMOVE_DIR #x10)
+  (def LANDLOCK_ACCESS_FS_REMOVE_FILE #x20)
+  (def LANDLOCK_ACCESS_FS_MAKE_DIR #x80)
+  (def LANDLOCK_ACCESS_FS_MAKE_REG #x100)
+  (def LANDLOCK_ACCESS_FS_REFER #x2000)
+  (def LANDLOCK_ACCESS_FS_TRUNCATE #x4000)
+
+  (def LANDLOCK_READ_ONLY_ACCESS
+    (bitwise-ior LANDLOCK_ACCESS_FS_READ_FILE
+                 LANDLOCK_ACCESS_FS_READ_DIR))
+  (def LANDLOCK_READ_WRITE_ACCESS
+    (bitwise-ior LANDLOCK_READ_ONLY_ACCESS
+                 LANDLOCK_ACCESS_FS_WRITE_FILE
+                 LANDLOCK_ACCESS_FS_MAKE_REG
+                 LANDLOCK_ACCESS_FS_MAKE_DIR
+                 LANDLOCK_ACCESS_FS_REMOVE_FILE
+                 LANDLOCK_ACCESS_FS_REMOVE_DIR
+                 LANDLOCK_ACCESS_FS_REFER
+                 LANDLOCK_ACCESS_FS_TRUNCATE))
+  (def LANDLOCK_EXECUTE_ACCESS
+    (bitwise-ior LANDLOCK_ACCESS_FS_EXECUTE
+                 LANDLOCK_ACCESS_FS_READ_FILE))
+
+  (def (landlock-rule-access kind)
+    (case kind
+      [(read-only read) LANDLOCK_READ_ONLY_ACCESS]
+      [(read-write write) LANDLOCK_READ_WRITE_ACCESS]
+      [(execute exec) LANDLOCK_EXECUTE_ACCESS]
+      [else (raise-parse-error 'aproc
+              "landlock-rules: expected read-only, read-write, or execute rule, got ~s"
+              kind)]))
+
+  (def (landlock-rule-entries rule)
+    (unless (and (list? rule) (pair? rule) (symbol? (car rule)) (pair? (cdr rule)))
+      (raise-parse-error 'aproc
+        "landlock-rules: expected (kind path ...) rule, got ~s"
+        rule))
+    (let ([access (landlock-rule-access (car rule))])
+      (let lp ([paths (cdr rule)] [out '()])
+        (cond
+          [(null? paths) (reverse out)]
+          [(and (string? (car paths)) (> (string-length (car paths)) 0))
+           (lp (cdr paths) (cons (cons (car paths) access) out))]
+          [else
+           (raise-parse-error 'aproc
+             "landlock-rules: path must be a non-empty string, got ~s"
+             (car paths))]))))
+
+  (def (normalize-landlock-rules rules)
+    (cond
+      [(or (not rules) (null? rules)) '()]
+      [(list? rules)
+       (apply append (map landlock-rule-entries rules))]
+      [else
+       (raise-parse-error 'aproc
+         "landlock-rules: expected a list of rules, got ~s"
+         rules)]))
 
+  (def (pack-u64-list who entries)
+    (cond
+      [(or (not entries) (null? entries)) (make-bytevector 0)]
+      [else
+       (let* ([n (length entries)]
+              [buf (make-bytevector (* 8 n) 0)])
+         (let lp ([i 0] [xs entries])
+           (cond
+             [(null? xs) buf]
+             [else
+              (let ([value (car xs)])
+                (unless (and (integer? value) (>= value 0))
+                  (raise-parse-error 'aproc
+                    "~a: expected nonnegative integer, got ~s"
+                    who value))
+                (bytevector-u64-native-set! buf (* i 8) value)
+                (lp (+ i 1) (cdr xs)))])))]))
+
+  (def (validate-landlock-ports ports)
+    (cond
+      [(or (not ports) (null? ports)) '()]
+      [(list? ports)
+       (let lp ([xs ports] [out '()])
+         (cond
+           [(null? xs) (reverse out)]
+           [(and (integer? (car xs)) (>= (car xs) 0) (<= (car xs) 65535))
+            (lp (cdr xs) (cons (car xs) out))]
+           [else
+            (raise-parse-error 'aproc
+              "landlock-net-connect-ports: expected TCP port 0..65535, got ~s"
+              (car xs))]))]
+      [else
+       (raise-parse-error 'aproc
+         "landlock-net-connect-ports: expected a list, got ~s"
+         ports)]))
+
+  (def (pack-landlock-rules rules)
+    (let* ([entries (normalize-landlock-rules rules)]
+           [paths (map car entries)]
+           [access (map cdr entries)])
+      (let-values ([(path-buf path-len path-count) (pack-strings paths)])
+        (values path-buf path-len path-count
+                (pack-u64-list 'landlock-rules access)))))
   (def (aproc-native-spawn-available?)
     (and c-jerboa-aproc-spawn #t))
 
@@ -357,7 +467,10 @@
           [pty? #f]
           [inherit-fds '()]
           [rlimits '()]
-          [seccomp-lock? #f])
+          [seccomp-lock? #f]
+          [landlock-rules '()]
+          [landlock-net-connect? #f]
+          [landlock-net-connect-ports '()])
       (let loop ([kw kwargs])
         (cond
           [(null? kw) (void)]
@@ -377,12 +490,19 @@
                [(inherit-fd:) (set! inherit-fds v)]
                [(rlimits:) (set! rlimits v)]
                [(seccomp-lock?:) (set! seccomp-lock? (and v #t))]
+               [(landlock-rules:) (set! landlock-rules v)]
+               [(landlock-net-connect?:) (set! landlock-net-connect? (and v #t))]
+               [(landlock-net-connect-ports:)
+                (set! landlock-net-connect-ports (validate-landlock-ports 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"))
       (when (and pty? seccomp-lock?)
         (error 'aproc-spawn* "seccomp-lock?: #t is not supported with pty: #t"))
+      (when (and pty? (or (pair? (normalize-landlock-rules landlock-rules))
+                          landlock-net-connect?))
+        (error 'aproc-spawn* "Landlock options are not supported with pty: #t"))
       ;; Pack argv
       (let*-values
         ([(argv-buf argv-len argv-count) (pack-strings argv)]
@@ -399,7 +519,9 @@
           (cond
             [dir (let ((bv (string->utf8 dir)))
                    (values bv (bytevector-length bv)))]
-            [else (values (make-bytevector 0) 0)])])
+            [else (values (make-bytevector 0) 0)])]
+         [(landlock-path-buf landlock-path-len landlock-path-count landlock-access-buf)
+          (pack-landlock-rules landlock-rules)])
         (let* ([s-mode (stdio-symbol->mode stdin-mode #f)]
                [o-mode (if (string? stdout-mode) STDIO_FILE
                          (stdio-symbol->mode stdout-mode #f))]
@@ -415,11 +537,15 @@
                [inh-count (length inherit-fds)]
                [rlimit-buf (pack-rlimits rlimits)]
                [rlimit-count (length rlimits)]
+               [landlock-port-buf (pack-u64-list 'landlock-net-connect-ports
+                                                  landlock-net-connect-ports)]
                [flags (bitwise-ior
                         (if new-pgroup? FLAG_NEW_PGROUP 0)
                         (if use-envp FLAG_USE_ENVP 0)
                         (if env-clear? FLAG_ENV_CLEAR 0)
-                        (if seccomp-lock? FLAG_SECCOMP_LOCK 0))]
+                        (if seccomp-lock? FLAG_SECCOMP_LOCK 0)
+                        (if (> landlock-path-count 0) FLAG_LANDLOCK 0)
+                        (if landlock-net-connect? FLAG_LANDLOCK_NET_CONNECT 0))]
                [result-buf (make-bytevector 20 0)])
           (let ([rc (if pty?
                       (call-pty-spawn argv-buf argv-len argv-count
@@ -435,6 +561,10 @@
                         err-path-bv err-path-len
                         inh-buf inh-count
                         rlimit-buf (bytevector-length rlimit-buf) rlimit-count
+                        landlock-path-buf landlock-path-len landlock-path-count
+                        landlock-access-buf (bytevector-length landlock-access-buf)
+                        landlock-port-buf (bytevector-length landlock-port-buf)
+                        (length landlock-net-connect-ports)
                         flags result-buf))])
             (when (< rc 0)
               (error 'aproc-spawn*
@@ -873,7 +1003,10 @@
           [pty? (kw-ref kwargs 'pty:)]
           [inherit-fd (kw-ref kwargs 'inherit-fd:)]
           [rlimits (kw-ref kwargs 'rlimits:)]
-          [seccomp-lock? (kw-ref kwargs 'seccomp-lock?:)])
+          [seccomp-lock? (kw-ref kwargs 'seccomp-lock?:)]
+          [landlock-rules (kw-ref kwargs 'landlock-rules:)]
+          [landlock-net-connect? (kw-ref kwargs 'landlock-net-connect?:)]
+          [landlock-net-connect-ports (kw-ref kwargs 'landlock-net-connect-ports:)])
       (when (and seccomp-lock? (not c-jerboa-aproc-spawn))
         (error 'aproc-run/status* "seccomp-lock?: #t requires libjerboa_native"))
       ;; Build spawn kwargs
@@ -888,7 +1021,14 @@
                          (if pty? (list 'pty: #t) '())
                          (if inherit-fd (list 'inherit-fd: inherit-fd) '())
                          (if rlimits (list 'rlimits: rlimits) '())
-                         (if seccomp-lock? (list 'seccomp-lock?: #t) '()))]
+                         (if seccomp-lock? (list 'seccomp-lock?: #t) '())
+                         (if landlock-rules (list 'landlock-rules: landlock-rules) '())
+                         (if landlock-net-connect?
+                             (list 'landlock-net-connect?: #t)
+                             '())
+                         (if landlock-net-connect-ports
+                             (list 'landlock-net-connect-ports: landlock-net-connect-ports)
+                             '()))]
              [h (cond
                   [c-jerboa-aproc-spawn
                    (apply aproc-spawn* argv spawn-kw)]
diff --git a/lib/std/security/worker.ss b/lib/std/security/worker.ss
index e23b955..e79540b 100644
--- a/lib/std/security/worker.ss
+++ b/lib/std/security/worker.ss
@@ -256,6 +256,115 @@
             [else (lp (cdr xs) out)]))
         '()))
 
+  (def (worker-native-landlock-requested? pol)
+    (let ([axes (worker-policy-sandbox pol)])
+      (or (memq 'fs axes)
+          (memq 'exec axes)
+          (memq 'net axes)
+          (memq 'landlock axes))))
+
+  (def (worker-native-landlock-available?)
+    (and (aproc-native-spawn-available?)
+         (eq? (sandbox-backend) 'landlock)))
+
+  (def (worker-native-landlock-refused-axes pol)
+    (if (and (worker-native-landlock-requested? pol)
+             (not (worker-native-landlock-available?)))
+        (let lp ([xs '(fs exec net landlock)] [out '()])
+          (cond
+            [(null? xs) (reverse out)]
+            [(memq (car xs) (worker-policy-sandbox pol))
+             (lp (cdr xs) (cons (car xs) out))]
+            [else (lp (cdr xs) out)]))
+        '()))
+
+  (def (path-dirname path)
+    (let ([n (string-length path)])
+      (let lp ([i (- n 1)])
+        (cond
+          [(< i 0) "."]
+          [(char=? (string-ref path i) #\/)
+           (cond
+             [(= i 0) "/"]
+             [else (substring path 0 i)])]
+          [else (lp (- i 1))]))))
+
+  (def (existing-paths paths)
+    (let lp ([xs paths] [out '()])
+      (cond
+        [(null? xs) (reverse out)]
+        [(and (string? (car xs))
+              (> (string-length (car xs)) 0)
+              (file-exists? (car xs))
+              (not (member (car xs) out)))
+         (lp (cdr xs) (cons (car xs) out))]
+        [else (lp (cdr xs) out)])))
+
+  (def (landlock-rule-or-empty kind paths)
+    (let ([paths* (existing-paths paths)])
+      (if (null? paths*)
+          '()
+          (list (cons kind paths*)))))
+
+  (def (worker-landlock-rules pol command)
+    (let* ([axes (worker-policy-sandbox pol)]
+           [cwd (or (worker-policy-cwd pol) (current-directory))]
+           [cmd0 (and (pair? command) (car command))]
+           [cmd-dir (and (string? cmd0) (path-dirname cmd0))]
+           [repo-root (absolute-path ".")]
+           [runtime-read (list cwd repo-root
+                               (absolute-path "lib")
+                               (absolute-path "vendor/jsqlite/src")
+                               (absolute-path "support/security-worker-main.ss")
+                               "/usr/lib" "/usr/share" "/lib" "/lib64"
+                               "/etc/ssl" "/etc/resolv.conf" "/dev/urandom")]
+           [runtime-exec (list cmd-dir (absolute-path ".chez/bin")
+                               "/bin" "/usr/bin")])
+      (if (worker-native-landlock-requested? pol)
+          (append (if (or (memq 'fs axes) (memq 'landlock axes))
+                      (landlock-rule-or-empty 'read-only runtime-read)
+                      '())
+                  (if (or (memq 'exec axes) (memq 'landlock axes))
+                      (landlock-rule-or-empty 'execute runtime-exec)
+                      '()))
+          '())))
+
+  (def (last-colon-index s)
+    (let ([n (string-length s)])
+      (let lp ([i (- n 1)])
+        (cond
+          [(< i 0) #f]
+          [(char=? (string-ref s i) #\:) i]
+          [else (lp (- i 1))]))))