security: install worker seccomp pre-exec

Jaime Fournier <jaimef@linbsd.org>

06936a274a6d0a466ea91479d4f89f3d54e777dd

diff --git a/docs/aproc.md b/docs/aproc.md
index 1e10220..2c89bbf 100644
--- a/docs/aproc.md
+++ b/docs/aproc.md
@@ -73,6 +73,12 @@ rlimits:   '((resource soft hard) …) ; native pre-exec setrlimit triples
 `pty: #t`. Resource numbers are platform `RLIMIT_*` constants; callers should
 prefer `(std os limits)` `limit-policy->rlimits` over hard-coding them.
 
+`seccomp-lock?: #t` is also supported only on the argv/native path. On Linux,
+the child installs Jerboa's default seccomp debug/syscall blocklist after fd
+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.
+
 ### Low-level handle
 
 ```
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index d1fb263..7f07dee 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; 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, and memory rlimits installed pre-exec on supported platforms; native kernel sandbox installation 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 pre-exec setup for requested axes; Landlock path/net and 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 |
@@ -347,7 +347,9 @@ 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`.
 Explicit worker `sandbox:` axis requests and `egress-policy:` allow-proxy env
-wiring landed 2026-07-27. Native Landlock/seccomp and Seatbelt/Capsicum setup
+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.
 
 Every security doc routes adversarial work to "a bounded, separately exec'd
@@ -360,8 +362,8 @@ controls that already exist individually.
   `(std security landlock)`, `(std security seccomp)`, `(std security seatbelt)`,
   `(std security capsicum)`, `(std security audit-log)`. Optionally a tiny
   pre-exec helper in `jerboa-native-rs` (Rust) for rlimit/namespace setup
-  that must happen before Scheme boots. The rlimit path exists; extend that
-  pattern for the remaining platform controls.
+  that must happen before Scheme boots. The rlimit and syscall/ptrace seccomp
+  paths exist; extend that pattern for the remaining platform controls.
 - **Design (fail closed at every step):**
   - Worker = fresh `jerboa run worker-main.ss` process started via argv
     (never shell), with `env-pure:` from an `(std security env)` policy
@@ -773,7 +775,9 @@ Chez cannot heap-cap a thread; `run-safe-eval` rightly refuses
   `sandbox-report`, an audit-friendly alist covering platform, requested
   controls, degraded `run-safe-eval` state, active sandbox-child reservations,
   and configured/available/installed state for seccomp, Landlock, Seatbelt,
-  Capsicum, and memory limits. Native per-platform CI parity remains open.
+  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.
 
 ### K3-P1-09 — Confused-deputy defenses: capability plans enforced at runtime
 **Serves:** G2. **Effort:** 1 week.
@@ -1102,7 +1106,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, and egress proxy env wiring landed 2026-07-27; native kernel sandbox installation 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, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and 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 | 433 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
@@ -1208,7 +1212,8 @@ fake confidence happens.
   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 (P0-02/P1-08).
+  still pending for Landlock path/net rules and Seatbelt/Capsicum parity
+  (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 bad6245..db6d4f8 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 30) (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 119) (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 614) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 154))))
-    (export (symbol "jerboa_aproc_set_nonblock") (file "jerboa-native-rs/src/aproc.rs") (line 586) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 150))))
-    (export (symbol "jerboa_aproc_spawn") (file "jerboa-native-rs/src/aproc.rs") (line 132) (scheme-callers 2 (caller (file "lib/std/os/aproc.ss") (line 126)) (caller (file "lib/std/os/aproc.ss") (line 141))))
-    (export (symbol "jerboa_aproc_spawn_pty") (file "jerboa-native-rs/src/aproc.rs") (line 340) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 141))))
-    (export (symbol "jerboa_aproc_wait4") (file "jerboa-native-rs/src/aproc.rs") (line 628) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 158))))
+    (export (symbol "jerboa_aproc_killpg") (file "jerboa-native-rs/src/aproc.rs") (line 632) (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 604) (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 146) (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 358) (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 646) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 159))))
     (export (symbol "jerboa_argon2id_hash") (file "jerboa-native-rs/src/crypto.rs") (line 709) (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 758) (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 526) (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 120) (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 91) (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 234) (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 163)) (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 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_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 642) (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 671) (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 324) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 58))))
@@ -109,9 +109,9 @@
     (export (symbol "jerboa_regex_is_match") (file "jerboa-native-rs/src/regex_native.rs") (line 49) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 30))))
     (export (symbol "jerboa_regex_replace_all") (file "jerboa-native-rs/src/regex_native.rs") (line 112) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 44))))
     (export (symbol "jerboa_scrypt") (file "jerboa-native-rs/src/crypto.rs") (line 600) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 226))))
-    (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 283) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 32))))
-    (export (symbol "jerboa_seccomp_lock") (file "jerboa-native-rs/src/seccomp.rs") (line 125) (scheme-callers 2 (caller (file "lib/std/os/seccomp.ss") (line 34)) (caller (file "lib/std/os/seccomp.ss") (line 36))))
-    (export (symbol "jerboa_seccomp_lock_strict") (file "jerboa-native-rs/src/seccomp.rs") (line 197) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 36))))
+    (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 277) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 32))))
+    (export (symbol "jerboa_seccomp_lock") (file "jerboa-native-rs/src/seccomp.rs") (line 174) (scheme-callers 2 (caller (file "lib/std/os/seccomp.ss") (line 34)) (caller (file "lib/std/os/seccomp.ss") (line 36))))
+    (export (symbol "jerboa_seccomp_lock_strict") (file "jerboa-native-rs/src/seccomp.rs") (line 191) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 36))))
     (export (symbol "jerboa_secure_alloc") (file "jerboa-native-rs/src/secure_mem.rs") (line 28) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 35))))
     (export (symbol "jerboa_secure_dir_close") (file "jerboa-native-rs/src/secure_fs.rs") (line 463) (scheme-callers 2 (caller (file "lib/std/os/secure-output.ss") (line 33)) (caller (file "lib/std/os/secure-output.ss") (line 53))))
     (export (symbol "jerboa_secure_dir_list") (file "jerboa-native-rs/src/secure_fs.rs") (line 633) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 62))))
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 01c27a5..06d5f82 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -485,6 +485,12 @@ any unavailable or degraded requested axis refuses before `exec`; with
 `fail-closed?: #f`, the launch continues and diagnostics record the requested
 axes.
 
+When `sandbox:` includes `syscalls` or `ptrace` on Linux, the worker passes
+`seccomp-lock?: #t` to `(std os aproc)`. The native aproc child installs the
+debug/syscall seccomp blocklist after fd and rlimit setup and before `exec`.
+Diagnostics include `native-seccomp-requested?` and
+`native-seccomp-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:
 
@@ -529,10 +535,11 @@ 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, 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.
+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.
 
 ---
 
diff --git a/docs/status.md b/docs/status.md
index 1b23646..e267f1c 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, security regression tests, and release evidence are implemented. Native worker Landlock/seccomp and Seatbelt/Capsicum installation 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, security regression tests, and release evidence are implemented. Native worker Landlock path/net setup and Seatbelt/Capsicum parity remain 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. | Add `SAFETY:` invariant comments near Rust unsafe sites. |
 | 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, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native kernel sandboxes 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 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. |
 | 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 96f9da1..94d0259 100644
--- a/jerboa-native-rs/src/aproc.rs
+++ b/jerboa-native-rs/src/aproc.rs
@@ -17,6 +17,7 @@ const STDIO_MERGE_STDOUT: i32 = 4; // stderr only
 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
 
 // Result buffer layout (5 * i32, little-endian, native order):
 //   [0] = pid
@@ -117,6 +118,19 @@ fn parse_rlimits(
     Ok(out)
 }
 
+#[cfg(target_os = "linux")]
+fn install_seccomp_blocklist_pre_exec() -> std::io::Result<()> {
+    crate::seccomp::install_debug_blocklist().map_err(std::io::Error::other)
+}
+
+#[cfg(not(target_os = "linux"))]
+fn install_seccomp_blocklist_pre_exec() -> std::io::Result<()> {
+    Err(std::io::Error::new(
+        std::io::ErrorKind::Unsupported,
+        "seccomp lock 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.
@@ -237,6 +251,7 @@ 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;
 
         // Copy inherit-fd pairs into a Vec we move into the pre_exec closure
         // — the raw pointer + count would not satisfy `'static` bounds.
@@ -292,6 +307,9 @@ pub extern "C" fn jerboa_aproc_spawn(
                         return Err(std::io::Error::last_os_error());
                     }
                 }
+                if seccomp_lock {
+                    install_seccomp_blocklist_pre_exec()?;
+                }
                 Ok(())
             });
         }
diff --git a/jerboa-native-rs/src/seccomp.rs b/jerboa-native-rs/src/seccomp.rs
index 42467fa..502af30 100644
--- a/jerboa-native-rs/src/seccomp.rs
+++ b/jerboa-native-rs/src/seccomp.rs
@@ -113,40 +113,21 @@ fn install_filter(filter: &[SockFilter]) -> Result<(), String> {
     }
 }
 
-/// Install a seccomp-bpf filter that blocks debug-related syscalls:
-///   - ptrace (101)
-///   - process_vm_readv (310)
-///   - process_vm_writev (311)
-///   - personality (135)
-/// All other syscalls are allowed.
-/// This is IRREVERSIBLE. Returns 0 on success, -1 on error.
-/// Requires Linux 3.5+ with CONFIG_SECCOMP_FILTER.
-#[no_mangle]
-pub extern "C" fn jerboa_seccomp_lock() -> i32 {
-    ffi_wrap(|| {
+pub(crate) fn install_debug_blocklist() -> Result<(), String> {
         let audit_arch = match current_audit_arch() {
             Some(arch) => arch,
             None => {
-                set_last_error(
-                    "seccomp lock is not implemented for this CPU architecture".to_string(),
-                );
-                return -1;
+                return Err("seccomp lock is not implemented for this CPU architecture".to_string());
             }
         };
         let blocked = match blocked_debug_syscalls() {
             Some(syscalls) => syscalls,
             None => {
-                set_last_error(
-                    "seccomp lock is not implemented for this CPU architecture".to_string(),
-                );
-                return -1;
+                return Err("seccomp lock is not implemented for this CPU architecture".to_string());
             }
         };
 
-        if let Err(e) = set_no_new_privs() {
-            set_last_error(e);
-            return -1;
-        }
+        set_no_new_privs()?;
 
         // BPF program:
         //   0: load arch
@@ -178,11 +159,24 @@ pub extern "C" fn jerboa_seccomp_lock() -> i32 {
             bpf_stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
         ];
 
-        if let Err(e) = install_filter(&filter) {
+        install_filter(&filter)
+}
+
+/// Install a seccomp-bpf filter that blocks debug-related syscalls:
+///   - ptrace (101)
+///   - process_vm_readv (310)
+///   - process_vm_writev (311)
+///   - personality (135)
+/// All other syscalls are allowed.
+/// This is IRREVERSIBLE. Returns 0 on success, -1 on error.
+/// Requires Linux 3.5+ with CONFIG_SECCOMP_FILTER.
+#[no_mangle]
+pub extern "C" fn jerboa_seccomp_lock() -> i32 {
+    ffi_wrap(|| {
+        if let Err(e) = install_debug_blocklist() {
             set_last_error(e);
             return -1;
         }
-
         0
     })
 }
diff --git a/lib/std/os/aproc.ss b/lib/std/os/aproc.ss
index 89d39de..a9c3bf1 100644
--- a/lib/std/os/aproc.ss
+++ b/lib/std/os/aproc.ss
@@ -71,6 +71,7 @@
     aproc-run/status
     aproc-run/status*           ; argv-style
     aproc-system
+    aproc-native-spawn-available?
 
     ;; Multi-process (P2.3)
     aproc-pipeline
@@ -132,7 +133,7 @@
               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
+              int                 ; flags (pgroup/env/seccomp)
               u8*)                ; result buf (5*int)
              int)
          (catch (e) #f))))
@@ -315,9 +316,13 @@
   (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 (aproc-native-spawn-available?)
+    (and c-jerboa-aproc-spawn #t))
 
   (def (stdio-symbol->mode sym for-stderr?)
     (case sym
@@ -351,7 +356,8 @@
           [new-pgroup? #f]
           [pty? #f]
           [inherit-fds '()]
-          [rlimits '()])
+          [rlimits '()]
+          [seccomp-lock? #f])
       (let loop ([kw kwargs])
         (cond
           [(null? kw) (void)]
@@ -370,10 +376,13 @@
                [(pty:) (set! pty? (and v #t))]
                [(inherit-fd:) (set! inherit-fds v)]
                [(rlimits:) (set! rlimits v)]
+               [(seccomp-lock?:) (set! seccomp-lock? (and v #t))]
                [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"))
       ;; Pack argv
       (let*-values
         ([(argv-buf argv-len argv-count) (pack-strings argv)]
@@ -409,7 +418,8 @@
                [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 env-clear? FLAG_ENV_CLEAR 0)
+                        (if seccomp-lock? FLAG_SECCOMP_LOCK 0))]
                [result-buf (make-bytevector 20 0)])
           (let ([rc (if pty?
                       (call-pty-spawn argv-buf argv-len argv-count
@@ -862,7 +872,10 @@
           [new-pgroup? (kw-ref kwargs 'new-pgroup:)]
           [pty? (kw-ref kwargs 'pty:)]
           [inherit-fd (kw-ref kwargs 'inherit-fd:)]
-          [rlimits (kw-ref kwargs 'rlimits:)])
+          [rlimits (kw-ref kwargs 'rlimits:)]
+          [seccomp-lock? (kw-ref kwargs 'seccomp-lock?:)])
+      (when (and seccomp-lock? (not c-jerboa-aproc-spawn))
+        (error 'aproc-run/status* "seccomp-lock?: #t requires libjerboa_native"))
       ;; Build spawn kwargs
       (let* ([spawn-kw (append
                          (if env (list 'env: env) '())
@@ -874,7 +887,8 @@
                          (if new-pgroup? (list 'new-pgroup: #t) '())
                          (if pty? (list 'pty: #t) '())
                          (if inherit-fd (list 'inherit-fd: inherit-fd) '())
-                         (if rlimits (list 'rlimits: rlimits) '()))]
+                         (if rlimits (list 'rlimits: rlimits) '())
+                         (if seccomp-lock? (list 'seccomp-lock?: #t) '()))]
              [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 ed8ddfb..e23b955 100644
--- a/lib/std/security/worker.ss
+++ b/lib/std/security/worker.ss
@@ -43,7 +43,7 @@
 
   (import (chezscheme)
           (only (jerboa core) def defstruct)
-          (only (std os aproc) aproc-run/status*)
+          (only (std os aproc) aproc-run/status* aproc-native-spawn-available?)
           (only (std os limits)
                 limit-policy
                 limit-policy-set!
@@ -241,6 +241,21 @@
   (def (worker-sandbox-refused-axes pol caps)
     (missing-required-axes (worker-policy-sandbox pol) caps))
 
+  (def (worker-native-seccomp-requested? pol)
+    (let ([axes (worker-policy-sandbox pol)])
+      (or (memq 'syscalls axes) (memq 'ptrace axes))))
+
+  (def (worker-native-seccomp-refused-axes pol)
+    (if (and (worker-native-seccomp-requested? pol)
+             (not (aproc-native-spawn-available?)))
+        (let lp ([xs '(syscalls ptrace)] [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 (egress-policy-summary pol)
     (let ([egress (worker-policy-egress-policy pol)])
       (if egress
@@ -297,6 +312,8 @@
      `((backend . ,(sandbox-backend))
        (capabilities . ,caps)
        (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol))
+       (native-seccomp-requested? . ,(and (worker-native-seccomp-requested? pol) #t))
+       (native-seccomp-available? . ,(aproc-native-spawn-available?))
        (sandbox . ,(worker-policy-sandbox pol))
        (egress . ,(egress-policy-summary pol))
        (env . ,(env-policy-audit-summary (worker-policy-env-policy pol)))
@@ -315,6 +332,9 @@
                         (let ([rlimits (limit-policy->rlimits limits)])
                           (if (null? rlimits) '() (list 'rlimits: rlimits)))
                         '()))
+                  (if (worker-native-seccomp-requested? pol)
+                      (list 'seccomp-lock?: #t)
+                      '())
                   (list 'timeout-ms: (worker-policy-timeout-ms pol)
                         'encoding: 'utf8-lossy
                         'new-pgroup: #t))])
@@ -352,6 +372,9 @@
          (memory-limit-installed? . ,(and (worker-policy-memory-limit-bytes pol)
                                           (memory-limit-installable? pol)))
          (memory-limit-report . ,(worker-memory-limit-report pol))
+         (native-seccomp-requested? . ,(and (worker-native-seccomp-requested? pol) #t))
+         (native-seccomp-installed? . ,(and (worker-native-seccomp-requested? pol)
+                                            (aproc-native-spawn-available?)))
          (sandbox . ,(worker-policy-sandbox pol))
          (egress . ,(egress-policy-summary pol))
          (stdout-truncated? . ,stdout-truncated?)
@@ -371,9 +394,12 @@
            [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)]
+           [native-seccomp-missing (worker-native-seccomp-refused-axes pol)]
            [missing (append-refused-axes
-                     (append-refused-axes required-missing sandbox-missing)
-                     memory-missing)])
+                     (append-refused-axes
+                      (append-refused-axes required-missing sandbox-missing)
+                      memory-missing)
+                     native-seccomp-missing)])
       (cond
         [(and (worker-policy-fail-closed? pol) (pair? missing))
          (worker-refusal-result command pol caps missing 'required-axis-unavailable)]
diff --git a/tests/test-aproc.ss b/tests/test-aproc.ss
index 69d69fe..3b68af6 100644
--- a/tests/test-aproc.ss
+++ b/tests/test-aproc.ss
@@ -96,6 +96,14 @@
     (cons out code))
   '("0\n" . 0))
 
+(test-pred "seccomp-lock?: rejects pty"
+  (guard (exn [#t (and (message-condition? exn)
+                       (condition-message exn))])
+    (aproc-spawn* '("true") 'pty: #t 'seccomp-lock?: #t)
+    #f)
+  (lambda (msg)
+    (equal? msg "seccomp-lock?: #t is not supported with pty: #t")))
+
 ;; ===== stdin from value =====
 (test "stdin: string"
   (let-values (((out err code)
diff --git a/tests/test-security2-parsers.ss b/tests/test-security2-parsers.ss
index 1794663..bef35b0 100644
--- a/tests/test-security2-parsers.ss
+++ b/tests/test-security2-parsers.ss
@@ -13,6 +13,7 @@
         (std pregexp)
         (std net dns)
         (std net websocket)
+        (only (std security taint) tainted? untaint)
         (jerboa reader))
 
 (define test-count 0)
@@ -325,8 +326,11 @@
     (let* ([payload (string->utf8 "hello")]
            [frame (ws-text-frame payload)]
            [encoded (ws-frame-encode frame)]
-           [decoded (ws-frame-decode encoded)])
-      (assert-equal "payload" payload (ws-frame-payload decoded)))))
+           [decoded (ws-frame-decode encoded)]
+           [decoded-payload (ws-frame-payload decoded)])
+      (unless (tainted? decoded-payload)
+        (error 'assert-equal "decoded payload should be tainted" decoded-payload))
+      (assert-equal "payload" payload (untaint decoded-payload)))))
 
 (test "ws: too short for header"
   (lambda ()
diff --git a/tests/test-worker.ss b/tests/test-worker.ss
index 06777e6..bbbd745 100644
--- a/tests/test-worker.ss
+++ b/tests/test-worker.ss
@@ -173,6 +173,24 @@
                                     'sandbox 'missing)
                  '(definitely-unavailable-sandbox-axis)))))
 
+(test-pred "requested syscall sandbox installs native seccomp or refuses"
+  (worker-run-eval
+   "(+ 1 1)"
+   (worker-policy
+    'command: worker-command
+    'timeout-ms: 3000
+    'sandbox: '(syscalls ptrace)
+    'fail-closed?: #t))
+  (lambda (r)
+    (and (worker-result? r)
+         (let ([diag (worker-result-diagnostics r)])
+           (if (worker-result-launched? r)
+               (and (equal? (worker-result-status r) 0)
+                    (eq? (alist-ref/default diag 'native-seccomp-requested? #f) #t)
+                    (eq? (alist-ref/default diag 'native-seccomp-installed? #f) #t))
+               (and (equal? (worker-result-status r) 126)
+                    (pair? (worker-result-refused-axes r))))))))
+
 (test-pred "egress proxy policy reaches worker env"
   (worker-run-command
    '("/usr/bin/env")