Add safe Rust target and harden native boundaries

ober

d33020a2cfeb33201606a1492a353f475bdb052b

diff --git a/Dockerfile b/Dockerfile
index 86e6ad3..dcf1fa1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -68,7 +68,7 @@ WORKDIR /build
 RUN git clone --depth 1 https://github.com/ober/ChezScheme.git && \
     cd ChezScheme && \
     git submodule update --init --depth 1 && \
-    ./configure --threads --disable-x11 --installprefix=/usr/local && \
+    ./configure --threads --enable-harden --disable-x11 --installprefix=/usr/local && \
     make -j$(nproc) && \
     make install && \
     cd /build && rm -rf ChezScheme
@@ -81,12 +81,12 @@ RUN git clone --depth 1 https://github.com/ober/ChezScheme.git && \
 RUN git clone https://github.com/ober/ChezScheme.git chez-musl-src && \
     cd chez-musl-src && \
     git submodule update --init && \
-    ./configure --threads --disable-x11 --installprefix=/build/chez-musl && \
+    ./configure --threads --enable-harden --disable-x11 --installprefix=/build/chez-musl && \
     make -j$(nproc) && \
     cp ta6le/boot/ta6le/petite.boot /tmp/petite.boot && \
     cp ta6le/boot/ta6le/scheme.boot /tmp/scheme.boot && \
     make clean && \
-    ./configure --threads --disable-x11 --static CC=musl-gcc --installprefix=/build/chez-musl && \
+    ./configure --threads --enable-harden --disable-x11 --static CC=musl-gcc --installprefix=/build/chez-musl && \
     mkdir -p ta6le/boot/ta6le && \
     cp /tmp/petite.boot ta6le/boot/ta6le/ && \
     cp /tmp/scheme.boot ta6le/boot/ta6le/ && \
diff --git a/Makefile b/Makefile
index 59591d8..1188589 100644
--- a/Makefile
+++ b/Makefile
@@ -10,9 +10,10 @@ SCHEME ?= $(CHEZ_PREFIX)/bin/scheme
 CHEZ_MACHINE_TYPE := $(shell $(JERBOA_HOME)/support/detect-chez-machine.sh)
 
 CHEZ_INSTALL_FLAGS = \
-	--installprefix=$(CHEZ_PREFIX) \
-	--installbin=$(CHEZ_PREFIX)/bin \
-	--installlib=$(CHEZ_PREFIX)/lib \
+		--enable-harden \
+		--installprefix=$(CHEZ_PREFIX) \
+		--installbin=$(CHEZ_PREFIX)/bin \
+		--installlib=$(CHEZ_PREFIX)/lib \
 	--installman=$(CHEZ_PREFIX)/share/man \
 	--installdoc=$(CHEZ_PREFIX)/share/doc \
 	--as-is
@@ -152,7 +153,7 @@ CHEZ_XPATCH = $(CHEZ_BUILD_DIR)/xc-$(CHEZ_TARGET_MACHINE)/s/xpatch
 # for fully-static musl builds. We default to disabling curses/x11/iconv
 # because (a) self-contained binaries don't need a REPL or X11, and (b) the
 # musl-cross sysroot on macOS typically lacks ncurses/x11 headers.
-CROSS_CHEZ_CONFIGURE_FLAGS ?= --threads --disable-x11 --disable-curses --disable-iconv
+CROSS_CHEZ_CONFIGURE_FLAGS ?= --threads --enable-harden --disable-x11 --disable-curses --disable-iconv
 
 CHEZ_CROSS_INSTALL_FLAGS = \
 	--installprefix=$(CHEZ_CROSS_PREFIX) \
diff --git a/docs/chez-hardening.md b/docs/chez-hardening.md
index dc2fa60..8b7c946 100644
--- a/docs/chez-hardening.md
+++ b/docs/chez-hardening.md
@@ -8,10 +8,9 @@ shim and to the final link line, but **not** to `libkernel.a` itself,
 which is the ~1 MB block of native code that contributes nearly all
 ROP gadgets in the binary.
 
-This document is the engineering plan to close that gap, broken into
-phases ranked by effort, with the hazards (notably `call/cc` ×
-shadow-stack interaction) called out as research items rather than
-buried.
+This document tracks the hardening work, broken into phases ranked by
+effort, with the hazards (notably `call/cc` × shadow-stack interaction)
+called out as research items rather than buried.
 
 ---
 
@@ -65,16 +64,19 @@ LDFLAGS += -Wl,-z,relro,-z,now
 # if not --static: CFLAGS += -fPIC ; mdlinkflags += -pie
 ```
 
-**The gap is in Jerboa, not Chez:**
+Jerboa now passes `--enable-harden` through the standard Chez build
+entry points:
 
 | Build path                         | Passes `--enable-harden`? |
 |------------------------------------|:-------------------------:|
-| `Makefile` `$(CHEZ_INSTALL_FLAGS)` | ✗                         |
-| `Makefile` `chez-cross` target     | ✗                         |
-| `Dockerfile` (musl pipeline)       | ✗                         |
-
-Net effect today: `libkernel.a` is built with `-O2` only. Every
-hardening claim in `secure.md` about Chez is technically aspirational.
+| `Makefile` `$(CHEZ_INSTALL_FLAGS)` | ✓                         |
+| `Makefile` `chez-cross` target     | ✓                         |
+| `Dockerfile` (musl pipeline)       | ✓                         |
+| `support/musl-chez-build*.sh`      | ✓                         |
+
+Net effect: newly built `libkernel.a` uses the Chez fork's hardening
+flags. Existing installed Chez artifacts still need to be rebuilt before
+the binary on disk reflects this configuration.
 
 ---
 
diff --git a/docs/native-rust.md b/docs/native-rust.md
index 9ec73b3..0bc04c3 100644
--- a/docs/native-rust.md
+++ b/docs/native-rust.md
@@ -295,6 +295,10 @@ pub fn ffi_wrap<F: FnOnce() -> i32 + panic::UnwindSafe>(f: F) -> i32 {
 }
 ```
 
+Release builds use `panic = "unwind"` so these wrappers remain effective
+outside debug/test builds. Rust panics should be converted to error
+sentinels plus `jerboa_last_error()`, not abort the Scheme process.
+
 ### Example: Crypto Module
 
 ```rust
@@ -527,92 +531,12 @@ The secure region allocator from `vs-rust.md`, implemented in Rust:
 
 ```rust
 // src/secure_mem.rs
-use libc::{mmap, munmap, mlock, munlock, madvise, mprotect};
-use libc::{MAP_PRIVATE, MAP_ANONYMOUS, PROT_READ, PROT_WRITE, PROT_NONE};
-use libc::{MADV_DONTDUMP, MADV_DONTFORK};
-use std::ptr;
-use crate::panic::ffi_wrap;
-
-const GUARD_PAGE_SIZE: usize = 4096;
-
-#[no_mangle]
-pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
-    ffi_wrap_ptr(|| {
-        // Allocate: guard page + data + guard page
-        let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE;
-        let base = unsafe {
-            mmap(ptr::null_mut(), total, PROT_READ | PROT_WRITE,
-                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)
-        };
-        if base == libc::MAP_FAILED { return ptr::null_mut(); }
-
-        // Protect guard pages (PROT_NONE — any access = SIGSEGV)
-        unsafe {
-            mprotect(base, GUARD_PAGE_SIZE, PROT_NONE);
-            mprotect(base.add(GUARD_PAGE_SIZE + size), GUARD_PAGE_SIZE, PROT_NONE);
-        }
-
-        let data = unsafe { base.add(GUARD_PAGE_SIZE) as *mut u8 };
-
-        // Lock into RAM — never swapped to disk
-        unsafe { mlock(data as *mut _, size); }
-
-        // Exclude from core dumps
-        unsafe { madvise(data as *mut _, size, MADV_DONTDUMP); }
-
-        // Don't inherit in child processes
-        unsafe { madvise(data as *mut _, size, MADV_DONTFORK); }
-
-        data
-    })
-}
-
-#[no_mangle]
-pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 {
-    ffi_wrap(|| {
-        if ptr.is_null() { return -1; }
-
-        // Wipe — explicit_bzero is guaranteed not to be optimized away
-        unsafe { libc::explicit_bzero(ptr as *mut _, size); }
-
-        // Unlock
-        unsafe { munlock(ptr as *mut _, size); }
-
-        // Unmap entire region including guard pages
-        let base = unsafe { ptr.sub(GUARD_PAGE_SIZE) };
-        let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE;
-        unsafe { munmap(base as *mut _, total); }
-
-        0
-    })
-}
-
-#[no_mangle]
-pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 {
-    ffi_wrap(|| {
-        if ptr.is_null() { return -1; }
-        unsafe { libc::explicit_bzero(ptr as *mut _, size); }
-        0
-    })
-}
-
-#[no_mangle]
-pub extern "C" fn jerboa_secure_random_fill(ptr: *mut u8, size: usize) -> i32 {
-    ffi_wrap(|| {
-        if ptr.is_null() { return -1; }
-        let rng = ring::rand::SystemRandom::new();
-        let buf = unsafe { std::slice::from_raw_parts_mut(ptr, size) };
-        ring::rand::SecureRandom::fill(&rng, buf).map(|_| 0).unwrap_or(-1)
-    })
-}
-
-// Helper: ffi_wrap for pointer-returning functions
-fn ffi_wrap_ptr<F: FnOnce() -> *mut u8 + std::panic::UnwindSafe>(f: F) -> *mut u8 {
-    match std::panic::catch_unwind(f) {
-        Ok(ptr) => ptr,
-        Err(_) => ptr::null_mut(),
-    }
-}
+// Implementation details:
+// - mmap a page-rounded region with guard pages on both sides.
+// - Place the returned pointer so a one-byte overflow hits the right guard.
+// - Fail closed if mprotect, mlock, MADV_DONTDUMP, or MADV_DONTFORK fails.
+// - Wipe with volatile stores before munlock/munmap.
+// - Return null / -1 on any failed hardening step.
 ```
 
 Jerboa side:
diff --git a/docs/rust-target.md b/docs/rust-target.md
new file mode 100644
index 0000000..1bc6fb1
--- /dev/null
+++ b/docs/rust-target.md
@@ -0,0 +1,59 @@
+# Safe Rust Target
+
+Jerboa can generate Rust for a deliberately small safe subset:
+
+- typed, pure functions
+- integer and boolean values
+- `if`, `let`, `begin`, function calls
+- checked integer `+`, `-`, `*`, `/`, `mod`
+- comparisons and boolean operators
+- `#![forbid(unsafe_code)]` in the generated Rust
+
+This is not a full Jerboa-to-Rust compiler. It intentionally rejects mutation,
+FFI, `eval`, `read`, `lambda`, `quote`, and dynamic data structures. The goal is
+to write small security-sensitive kernels, validators, parsers, and arithmetic
+helpers in Jerboa syntax while producing boring safe Rust as the delivery
+artifact.
+
+```scheme
+(import (jerboa rust codegen))
+
+(write-safe-rust-program
+  '((module calc)
+    (define (add-one (x i32)) -> i32
+      (+ x 1))
+    (define (abs-i32 (x i32)) -> i32
+      (if (< x 0) (- 0 x) x))
+    (define (square-sum (x i32) (y i32)) -> i32
+      (let ([sum (+ x y)])
+        (* sum sum))))
+  "calc.rs")
+```
+
+Output:
+
+```rust
+#![forbid(unsafe_code)]
+
+// Generated by Jerboa's safe Rust target.
+// Safe subset: typed pure functions, checked integer arithmetic, no FFI.
+// source module: calc
+
+pub fn add_one(x: i32) -> i32 {
+    (x).checked_add(1_i32).expect("checked integer operation failed")
+}
+```
+
+The current API is `(jerboa rust codegen)`:
+
+- `safe-rust-program->string`
+- `safe-rust-form->string`
+- `safe-rust-expression->string`
+- `write-safe-rust-program`
+- `safe-rust-type?`
+- `safe-rust-form?`
+- `safe-rust-expression?`
+
+Near-term extensions should stay narrow: typed byte slices, explicit `Result`
+returns instead of panics on checked arithmetic failure, generated Rust tests,
+and FFI wrapper generation from declarative Jerboa specs.
diff --git a/docs/security-reference.md b/docs/security-reference.md
index d6e35ab..e950622 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -290,7 +290,7 @@ Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers
 | 3 | Capability intersection checked type only, not permissions | HIGH | `intersect-capabilities` now ANDs boolean permissions and set-intersects list permissions. |
 | 4 | Empty network host list meant "all allowed" | HIGH | Empty list now means no hosts allowed. Explicit `"*"` required for wildcard. |
 | 5 | Path canonicalization didn't resolve symlinks | HIGH | `canonicalize-path` now uses `realpath(3)` via FFI. |
-| 6 | Distributed actors used `read` for deserialization (remote code exec via `#.`) | CRITICAL | `deserialize-message` wraps `read` in `(parameterize ([read-eval #f]) ...)`. Message size limit enforced. |
+| 6 | Distributed actors used `read` for deserialization (remote code exec via `#.`) | CRITICAL | `deserialize-message` uses `jerboa-read`. Message size limit enforced. |
 | 7 | Seccomp/Landlock were stubs | HIGH | Both now have real implementations with actual syscalls (BPF bytecode generation, Landlock ABI detection). |
 | 8 | Taint tracking had no automatic sink enforcement | MEDIUM | Added `safe-open-input-file`, `safe-open-output-file`, `safe-system`, `safe-delete-file` that auto-reject tainted args. |
 | 9 | Restricted environment allowlist included `read` and `gensym` | MEDIUM | `read` removed (replaced by `jerboa-read`). `gensym` removed. |
@@ -445,10 +445,10 @@ These are known gaps. They are not on any roadmap in this document -- just hones
 - **No FIPS 140-3 validation.** The crypto uses ring (recommended) or OpenSSL (legacy), which can be FIPS-validated, but Jerboa itself has not undergone FIPS evaluation.
 - **No covert channel analysis.** Chez Scheme's GC is a timing side channel. No mitigation exists for timing, storage, or resource-exhaustion covert channels.
 - **No Common Criteria evaluation.** No Protection Profile, Security Target, or EAL evaluation has been performed.
-- **Seccomp is x86_64 only.** The BPF bytecode generator hardcodes `AUDIT_ARCH_X86_64` and x86_64 syscall numbers.
+- **Seccomp architecture coverage is limited.** The BPF bytecode generator supports x86_64 and aarch64 syscall numbers.
 - **Landlock requires Linux 5.13+.** No equivalent on macOS, BSDs, or older Linux kernels. `landlock-available?` returns `#f` on unsupported systems.
 - **Taint tracking is opt-in.** Only the `safe-*` wrappers enforce taint checks. Native Chez operations (`open-input-file`, `system`, etc.) do not check taint. No static analysis enforcement exists.
-- **No message authentication for distributed actors.** `deserialize-message` disables `#.` read-eval but messages are still plaintext with no HMAC.
+- **No message authentication for distributed actors.** `deserialize-message` uses `jerboa-read`, but messages are still plaintext with no HMAC.
 - **No TOCTOU-safe path checking.** `canonicalize-path` uses `realpath(3)` before access, not `O_NOFOLLOW` + `/proc/self/fd/N` after open.
 - **`define-syntax` remains in the sandbox allowlist.** Macro definition in sandboxed code is possible. Whether this is a risk depends on the use case.
 - **No max-output-size for sandboxes.** A sandboxed expression can produce unbounded output via `display`/`write`.
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index 5ce063b..dea55be 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -70,4 +70,4 @@ inotify = { version = "0.11", default-features = false }
 lto = true
 codegen-units = 1
 strip = true
-panic = "abort"
+panic = "unwind"
diff --git a/jerboa-native-rs/src/crypto.rs b/jerboa-native-rs/src/crypto.rs
index ecf3e1b..520da13 100644
--- a/jerboa-native-rs/src/crypto.rs
+++ b/jerboa-native-rs/src/crypto.rs
@@ -4,6 +4,13 @@ use argon2::{Argon2, Algorithm, Version, Params};
 use crate::panic::{ffi_wrap, set_last_error};
 use std::num::NonZeroU32;
 
+fn secure_zero_bytes(buf: &mut [u8]) {
+    for byte in buf {
+        unsafe { std::ptr::write_volatile(byte as *mut u8, 0); }
+    }
+    std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
+}
+
 // --- Digest ---
 
 fn digest_impl(algorithm: &'static digest::Algorithm, input: *const u8, input_len: usize,
@@ -244,19 +251,21 @@ pub extern "C" fn jerboa_aead_open(
             Err(_) => return -1,
         };
 
-        // Copy ciphertext+tag to output, open in place
-        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max.max(ct_len)) };
-        out[..ct_len].copy_from_slice(ct);
+        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
+        let mut in_out = ct.to_vec();
 
         let aad_obj = aead::Aad::from(ad);
-        match opening_key.open_in_place(nonce_val, aad_obj, &mut out[..ct_len]) {
+        let rc = match opening_key.open_in_place(nonce_val, aad_obj, &mut in_out) {
             Ok(plaintext) => {
                 let plen = plaintext.len();
+                out[..plen].copy_from_slice(plaintext);
                 unsafe { *output_len = plen; }
                 0
             }
             Err(_) => -1,
-        }
+        };
+        secure_zero_bytes(&mut in_out);
+        rc
     })
 }
 
@@ -351,18 +360,21 @@ pub extern "C" fn jerboa_chacha20_open(
             Err(_) => return -1,
         };
 
-        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max.max(ct_len)) };
-        out[..ct_len].copy_from_slice(ct);
+        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
+        let mut in_out = ct.to_vec();
 
         let aad_obj = aead::Aad::from(ad);
-        match opening_key.open_in_place(nonce_val, aad_obj, &mut out[..ct_len]) {
+        let rc = match opening_key.open_in_place(nonce_val, aad_obj, &mut in_out) {
             Ok(plaintext) => {
                 let plen = plaintext.len();
+                out[..plen].copy_from_slice(plaintext);
                 unsafe { *output_len = plen; }
                 0
             }
             Err(_) => -1,
-        }
+        };
+        secure_zero_bytes(&mut in_out);
+        rc
     })
 }
 
@@ -526,3 +538,74 @@ pub extern "C" fn jerboa_argon2id_verify(
         }
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn aes_gcm_open_accepts_plaintext_sized_output_buffer() {
+        let key = [7u8; 32];
+        let nonce = [3u8; 12];
+        let plaintext = b"secret message";
+        let aad = b"context";
+        let mut ciphertext = vec![0u8; plaintext.len() + 16];
+        let mut ciphertext_len = 0usize;
+
+        assert_eq!(jerboa_aead_seal(
+            key.as_ptr(), key.len(),
+            nonce.as_ptr(), nonce.len(),
+            plaintext.as_ptr(), plaintext.len(),
+            aad.as_ptr(), aad.len(),
+            ciphertext.as_mut_ptr(), ciphertext.len(),
+            &mut ciphertext_len,
+        ), 0);
+        ciphertext.truncate(ciphertext_len);
+
+        let mut output = vec![0u8; plaintext.len()];
+        let mut output_len = 0usize;
+        assert_eq!(jerboa_aead_open(
+            key.as_ptr(), key.len(),
+            nonce.as_ptr(), nonce.len(),
+            ciphertext.as_ptr(), ciphertext.len(),
+            aad.as_ptr(), aad.len(),
+            output.as_mut_ptr(), output.len(),
+            &mut output_len,
+        ), 0);
+        output.truncate(output_len);
+        assert_eq!(output, plaintext);
+    }
+
+    #[test]
+    fn chacha20_open_accepts_plaintext_sized_output_buffer() {
+        let key = [9u8; 32];
+        let nonce = [4u8; 12];
+        let plaintext = b"another secret";
+        let aad = b"context";
+        let mut ciphertext = vec![0u8; plaintext.len() + 16];
+        let mut ciphertext_len = 0usize;
+
+        assert_eq!(jerboa_chacha20_seal(
+            key.as_ptr(), key.len(),
+            nonce.as_ptr(), nonce.len(),
+            plaintext.as_ptr(), plaintext.len(),
+            aad.as_ptr(), aad.len(),
+            ciphertext.as_mut_ptr(), ciphertext.len(),
+            &mut ciphertext_len,
+        ), 0);
+        ciphertext.truncate(ciphertext_len);
+
+        let mut output = vec![0u8; plaintext.len()];
+        let mut output_len = 0usize;
+        assert_eq!(jerboa_chacha20_open(
+            key.as_ptr(), key.len(),
+            nonce.as_ptr(), nonce.len(),
+            ciphertext.as_ptr(), ciphertext.len(),
+            aad.as_ptr(), aad.len(),
+            output.as_mut_ptr(), output.len(),
+            &mut output_len,
+        ), 0);
+        output.truncate(output_len);
+        assert_eq!(output, plaintext);
+    }
+}
diff --git a/jerboa-native-rs/src/seccomp.rs b/jerboa-native-rs/src/seccomp.rs
index 73397af..eb51c9f 100644
--- a/jerboa-native-rs/src/seccomp.rs
+++ b/jerboa-native-rs/src/seccomp.rs
@@ -14,19 +14,40 @@ const SECCOMP_MODE_FILTER: libc::c_ulong = 2;
 const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
 const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000;
 
-// Audit arch for x86_64
-const AUDIT_ARCH_X86_64: u32 = 0xC000_003E;
+#[cfg(target_arch = "x86_64")]
+const AUDIT_ARCH_CURRENT: u32 = 0xC000_003E;
+#[cfg(target_arch = "aarch64")]
+const AUDIT_ARCH_CURRENT: u32 = 0xC000_00B7;
 
-// x86_64 syscall numbers to block in default mode
-const NR_PTRACE: u32 = 101;
-const NR_PROCESS_VM_READV: u32 = 310;
-const NR_PROCESS_VM_WRITEV: u32 = 311;
-const NR_PERSONALITY: u32 = 135;
-const NR_MEMFD_CREATE: u32 = 319;  // prevents code injection via memfd
+#[cfg(target_arch = "x86_64")]
+const BLOCKED_DEBUG_SYSCALLS: [u32; 5] = [101, 310, 311, 135, 319];
+#[cfg(target_arch = "aarch64")]
+const BLOCKED_DEBUG_SYSCALLS: [u32; 5] = [117, 270, 271, 92, 279];
 
 // seccomp_data field offsets (for BPF_ABS loads)
 const OFFSET_NR: u32 = 0;   // offsetof(struct seccomp_data, nr)
 const OFFSET_ARCH: u32 = 4;  // offsetof(struct seccomp_data, arch)
+const MAX_STRICT_SYSCALLS: usize = 254;
+
+#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
+fn current_audit_arch() -> Option<u32> {
+    Some(AUDIT_ARCH_CURRENT)
+}
+
+#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
+fn current_audit_arch() -> Option<u32> {
+    None
+}
+
+#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
+fn blocked_debug_syscalls() -> Option<&'static [u32; 5]> {
+    Some(&BLOCKED_DEBUG_SYSCALLS)
+}
+
+#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
+fn blocked_debug_syscalls() -> Option<&'static [u32; 5]> {
+    None
+}
 
 #[repr(C)]
 #[derive(Clone, Copy)]
@@ -92,6 +113,21 @@ fn install_filter(filter: &[SockFilter]) -> Result<(), String> {
 #[no_mangle]
 pub extern "C" fn jerboa_seccomp_lock() -> i32 {
     ffi_wrap(|| {
+        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;
+            }
+        };
+        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;
+            }
+        };
+
         if let Err(e) = set_no_new_privs() {
             set_last_error(e);
             return -1;
@@ -99,32 +135,28 @@ pub extern "C" fn jerboa_seccomp_lock() -> i32 {
 
         // BPF program:
         //   0: load arch
-        //   1: if arch != x86_64, kill
+        //   1: if arch != current architecture, kill
         //   2: load syscall nr
-        //   3: if nr == ptrace, kill
-        //   4: if nr == process_vm_readv, kill
-        //   5: if nr == process_vm_writev, kill
-        //   6: if nr == personality, kill
-        //   7: if nr == memfd_create, kill
+        //   3..7: block ptrace/process_vm_readv/process_vm_writev/personality/memfd_create
         //   8: allow
         //   9: kill
         let filter = [
             // 0: Load architecture
             bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH),
-            // 1: Verify x86_64 — if not, jump to kill (offset +7 -> instruction 9)
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, 7),
+            // 1: Verify architecture — if not, jump to kill (offset +7 -> instruction 9)
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, audit_arch, 0, 7),
             // 2: Load syscall number
             bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR),
             // 3: Check ptrace — if match, jump to kill (+5 -> instruction 9)
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PTRACE, 5, 0),
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[0], 5, 0),
             // 4: Check process_vm_readv
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PROCESS_VM_READV, 4, 0),
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[1], 4, 0),
             // 5: Check process_vm_writev
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PROCESS_VM_WRITEV, 3, 0),
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[2], 3, 0),
             // 6: Check personality
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_PERSONALITY, 2, 0),
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[3], 2, 0),
             // 7: Check memfd_create — prevents code injection via memfd
-            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, NR_MEMFD_CREATE, 1, 0),
+            bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, blocked[4], 1, 0),
             // 8: Allow
             bpf_stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
             // 9: Kill process
@@ -152,12 +184,19 @@ pub extern "C" fn jerboa_seccomp_lock_strict(
     allowed_count: usize,
 ) -> i32 {
     ffi_wrap(|| {
+        let audit_arch = match current_audit_arch() {
+            Some(arch) => arch,
+            None => {
+                set_last_error("strict seccomp is not implemented for this CPU architecture".to_string());
+                return -1;
+            }
+        };
         if allowed.is_null() && allowed_count > 0 {
             set_last_error("null pointer with nonzero count".to_string());
             return -1;
         }
-        if allowed_count > 1024 {
-            set_last_error("too many allowed syscalls (max 1024)".to_string());
+        if allowed_count > MAX_STRICT_SYSCALLS {
+            set_last_error(format!("too many allowed syscalls (max {})", MAX_STRICT_SYSCALLS));
             return -1;
         }
 
@@ -174,7 +213,7 @@ pub extern "C" fn jerboa_seccomp_lock_strict(
 
         // Build BPF program:
         //   0: load arch
-        //   1: verify x86_64 (jf -> kill)
+        //   1: verify architecture (jf -> kill)
         //   2: load syscall nr
         //   3..3+N-1: check each allowed syscall (jt -> allow)
         //   3+N: kill (default)
@@ -184,9 +223,9 @@ pub extern "C" fn jerboa_seccomp_lock_strict(
 
         // 0: Load arch
         filter.push(bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARCH));
-        // 1: Verify x86_64 — if not, jump to kill (at index 3+N)
+        // 1: Verify architecture — if not, jump to kill (at index 3+N)
         let kill_offset = (allowed_count + 1) as u8; // skip load_nr + N checks
-        filter.push(bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, kill_offset));
+        filter.push(bpf_jump(BPF_JMP | BPF_JEQ | BPF_K, audit_arch, 0, kill_offset));
         // 2: Load syscall number
         filter.push(bpf_stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR));
 
diff --git a/jerboa-native-rs/src/secure_mem.rs b/jerboa-native-rs/src/secure_mem.rs
index 595fbc4..97becf6 100644
--- a/jerboa-native-rs/src/secure_mem.rs
+++ b/jerboa-native-rs/src/secure_mem.rs
@@ -3,12 +3,34 @@ use std::ptr;
 
 const GUARD_PAGE_SIZE: usize = 4096;
 
+fn round_up_to_page(size: usize) -> Option<usize> {
+    size.checked_add(GUARD_PAGE_SIZE - 1)
+        .map(|n| (n / GUARD_PAGE_SIZE) * GUARD_PAGE_SIZE)
+}
+
+unsafe fn secure_zero(ptr: *mut u8, size: usize) {
+    for i in 0..size {
+        ptr.add(i).write_volatile(0);
+    }
+    std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
+}
+
 #[no_mangle]
 pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
     ffi_wrap_ptr(|| {
         if size == 0 { return ptr::null_mut(); }
 
-        let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE;
+        let rounded = match round_up_to_page(size) {
+            Some(n) => n,
+            None => return ptr::null_mut(),
+        };
+        let total = match GUARD_PAGE_SIZE
+            .checked_add(rounded)
+            .and_then(|n| n.checked_add(GUARD_PAGE_SIZE))
+        {
+            Some(n) if n <= isize::MAX as usize => n,
+            _ => return ptr::null_mut(),
+        };
         let base = unsafe {
             libc::mmap(
                 ptr::null_mut(),
@@ -21,28 +43,46 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
         };
         if base == libc::MAP_FAILED { return ptr::null_mut(); }
 
-        // Protect guard pages (PROT_NONE — any access = SIGSEGV)
-        unsafe {
-            libc::mprotect(base, GUARD_PAGE_SIZE, libc::PROT_NONE);
-            libc::mprotect(
-                (base as *mut u8).add(GUARD_PAGE_SIZE + size) as *mut _,
-                GUARD_PAGE_SIZE,
-                libc::PROT_NONE,
-            );
+        let cleanup = || unsafe {
+            libc::munmap(base, total);
+        };
+
+        let protect_ok = unsafe {
+            libc::mprotect(base, GUARD_PAGE_SIZE, libc::PROT_NONE) == 0
+                && libc::mprotect(
+                    (base as *mut u8).add(GUARD_PAGE_SIZE + rounded) as *mut _,
+                    GUARD_PAGE_SIZE,
+                    libc::PROT_NONE,
+                ) == 0
+        };
+        if !protect_ok {
+            cleanup();
+            return ptr::null_mut();
         }
 
-        let data = unsafe { (base as *mut u8).add(GUARD_PAGE_SIZE) };
+        // Put the usable range flush against the right guard page.
+        let data = unsafe { (base as *mut u8).add(GUARD_PAGE_SIZE + (rounded - size)) };
 
-        // Lock into RAM — never swapped to disk
-        unsafe { libc::mlock(data as *const _, size); }
+        if unsafe { libc::mlock(data as *const _, size) } != 0 {
+            cleanup();
+            return ptr::null_mut();
+        }
 
         // Exclude from core dumps (MADV_DONTDUMP is Linux-specific)
         #[cfg(target_os = "linux")]
-        unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP); }
+        if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP) } != 0 {
+            unsafe { libc::munlock(data as *const _, size); }
+            cleanup();
+            return ptr::null_mut();
+        }
 
         // Don't inherit in child processes (MADV_DONTFORK is Linux-specific)
         #[cfg(target_os = "linux")]
-        unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK); }
+        if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK) } != 0 {
+            unsafe { libc::munlock(data as *const _, size); }
+            cleanup();
+            return ptr::null_mut();
+        }
 
         data
     })
@@ -52,20 +92,28 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
 pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 {
     ffi_wrap(|| {
         if ptr.is_null() { return -1; }
+        if size == 0 { return -1; }
+        let rounded = match round_up_to_page(size) {
+            Some(n) => n,
+            None => return -1,
+        };
 
-        // Wipe — volatile write guaranteed not to be optimized away
-        unsafe { std::ptr::write_bytes(ptr, 0, size); }
-        std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
+        unsafe { secure_zero(ptr, size); }
 
-        // Unlock
-        unsafe { libc::munlock(ptr as *const _, size); }
+        let unlock_rc = unsafe { libc::munlock(ptr as *const _, size) };
 
         // Unmap entire region including guard pages
-        let base = unsafe { ptr.sub(GUARD_PAGE_SIZE) };
-        let total = GUARD_PAGE_SIZE + size + GUARD_PAGE_SIZE;
-        unsafe { libc::munmap(base as *mut _, total); }
+        let base = unsafe { ptr.sub(GUARD_PAGE_SIZE + (rounded - size)) };
+        let total = match GUARD_PAGE_SIZE
+            .checked_add(rounded)
+            .and_then(|n| n.checked_add(GUARD_PAGE_SIZE))
+        {
+            Some(n) => n,
+            None => return -1,
+        };
+        let unmap_rc = unsafe { libc::munmap(base as *mut _, total) };
 
-        0
+        if unlock_rc == 0 && unmap_rc == 0 { 0 } else { -1 }
     })
 }
 
@@ -73,8 +121,7 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 {
 pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 {
     ffi_wrap(|| {
         if ptr.is_null() { return -1; }
-        unsafe { std::ptr::write_bytes(ptr, 0, size); }
-        std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
+        unsafe { secure_zero(ptr, size); }
         0
     })
 }
diff --git a/jerboa-native-rs/src/tls.rs b/jerboa-native-rs/src/tls.rs
index 93fff16..5a16315 100644
--- a/jerboa-native-rs/src/tls.rs
+++ b/jerboa-native-rs/src/tls.rs
@@ -175,11 +175,11 @@ pub extern "C" fn jerboa_tls_connect_pinned(
             }
         };
 
-        let expected_pin = if !pin_sha256.is_null() && pin_len == 32 {
-            Some(unsafe { std::slice::from_raw_parts(pin_sha256, pin_len) }.to_vec())
-        } else {
-            None
-        };
+        if pin_sha256.is_null() || pin_len != 32 {
+            set_last_error("certificate pin must be exactly 32 bytes".to_string());
+            return 0;
+        }
+        let expected_pin = unsafe { std::slice::from_raw_parts(pin_sha256, pin_len) }.to_vec();
 
         // Build config that skips CA verification (we verify via pin)
         let config = ClientConfig::builder()
@@ -231,10 +231,11 @@ pub extern "C" fn jerboa_tls_connect_pinned(
     }
 }
 
-// Certificate pin verifier — accepts any cert whose SHA-256 matches
+// Certificate pin verifier — verifies the presented cert hash and delegates
+// TLS handshake signature verification to rustls/webpki.
 #[derive(Debug)]
 struct PinVerifier {
-    expected_sha256: Option<Vec<u8>>,
+    expected_sha256: Vec<u8>,
 }
 
 impl rustls::client::danger::ServerCertVerifier for PinVerifier {
@@ -246,35 +247,42 @@ impl rustls::client::danger::ServerCertVerifier for PinVerifier {
         _ocsp_response: &[u8],
         _now: rustls::pki_types::UnixTime,
     ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
-        if let Some(ref expected) = self.expected_sha256 {
-            let digest = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref());
-            if digest.as_ref() == expected.as_slice() {
-                Ok(rustls::client::danger::ServerCertVerified::assertion())
-            } else {
-                Err(rustls::Error::General("certificate pin mismatch".to_string()))
-            }
-        } else {
-            // No pin — accept anything (insecure, for testing only)
+        let digest = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref());
+        if digest.as_ref() == self.expected_sha256.as_slice() {
             Ok(rustls::client::danger::ServerCertVerified::assertion())
+        } else {
+            Err(rustls::Error::General("certificate pin mismatch".to_string()))
         }
     }
 
     fn verify_tls12_signature(
         &self,
-        _message: &[u8],
-        _cert: &CertificateDer<'_>,
-        _dss: &rustls::DigitallySignedStruct,
+        message: &[u8],
+        cert: &CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
     ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
-        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+        let provider = rustls::crypto::ring::default_provider();
+        rustls::crypto::verify_tls12_signature(
+            message,
+            cert,
+            dss,
+            &provider.signature_verification_algorithms,
+        )
     }
 
     fn verify_tls13_signature(
         &self,
-        _message: &[u8],
-        _cert: &CertificateDer<'_>,
-        _dss: &rustls::DigitallySignedStruct,
+        message: &[u8],
+        cert: &CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
     ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
-        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+        let provider = rustls::crypto::ring::default_provider();
+        rustls::crypto::verify_tls13_signature(
+            message,
+            cert,
+            dss,
+            &provider.signature_verification_algorithms,
+        )
     }
 
     fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
@@ -316,20 +324,32 @@ impl rustls::server::danger::ClientCertVerifier for PinnedClientVerifier {
 
     fn verify_tls12_signature(
         &self,
-        _message: &[u8],
-        _cert: &CertificateDer<'_>,
-        _dss: &rustls::DigitallySignedStruct,
+        message: &[u8],
+        cert: &CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
     ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
-        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+        let provider = rustls::crypto::ring::default_provider();
+        rustls::crypto::verify_tls12_signature(
+            message,
+            cert,
+            dss,
+            &provider.signature_verification_algorithms,
+        )
     }
 
     fn verify_tls13_signature(
         &self,
-        _message: &[u8],
-        _cert: &CertificateDer<'_>,
-        _dss: &rustls::DigitallySignedStruct,
+        message: &[u8],
+        cert: &CertificateDer<'_>,
+        dss: &rustls::DigitallySignedStruct,
     ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
-        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+        let provider = rustls::crypto::ring::default_provider();
+        rustls::crypto::verify_tls13_signature(
+            message,
+            cert,
+            dss,
+            &provider.signature_verification_algorithms,
+        )
     }
 
     fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
@@ -853,17 +873,24 @@ pub extern "C" fn jerboa_tls_connect_mtls(
             Ok(f) => f,
             Err(e) => { set_last_error(format!("open server CA: {}", e)); return 0; }
         };
-        let _ca_certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(ca_file))
+        let ca_certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(ca_file))
             .filter_map(|r| r.ok())
             .collect();
+        if ca_certs.is_empty() {
+            set_last_error("no CA certificates found in server CA file".to_string());
+            return 0;
+        }
+
+        let mut root_store = rustls::RootCertStore::empty();
+        for cert in ca_certs {
+            if let Err(e) = root_store.add(cert) {
+                set_last_error(format!("add server CA cert: {}", e));
+                return 0;
+            }
+        }
 
-        // Build client config: skip hostname verification (self-signed),
-        // but present client cert for mutual authentication.
         let config = match ClientConfig::builder()
-            .dangerous()
-            .with_custom_certificate_verifier(Arc::new(PinVerifier {
-                expected_sha256: None,
-            }))
+            .with_root_certificates(root_store)
             .with_client_auth_cert(client_certs, PrivateKeyDer::from(client_key))
         {
             Ok(c) => c,
@@ -1051,10 +1078,15 @@ pub extern "C" fn jerboa_tls_connect_mtls_mem(
             Err(e) => { set_last_error(format!("read client key PEM: {}", e)); return 0; }
         };
 
+        // Without a CA parameter this legacy entry point can only be secure for
+        // symmetric self-signed deployments where both peers use the same cert.
+        let server_cert_pin =
+            ring::digest::digest(&ring::digest::SHA256, client_certs[0].as_ref()).as_ref().to_vec();
+
         let config = match ClientConfig::builder()
             .dangerous()
             .with_custom_certificate_verifier(Arc::new(PinVerifier {
-                expected_sha256: None,
+                expected_sha256: server_cert_pin,
             }))
             .with_client_auth_cert(client_certs, PrivateKeyDer::from(client_key))
         {
@@ -1266,3 +1298,53 @@ pub extern "C" fn jerboa_tls_get_fd(handle: u64) -> i32 {
         None => -1,
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use rustls::client::danger::ServerCertVerifier;
+
+    #[test]
+    fn pinned_connect_rejects_missing_pin_before_network_io() {
+        let host = b"example.com";
+        let handle = jerboa_tls_connect_pinned(
+            host.as_ptr(),
+            host.len(),
+            443,
+            std::ptr::null(),
+            0,
+        );
+        assert_eq!(handle, 0);
+    }
+
+    #[test]
+    fn pin_verifier_matches_exact_certificate_digest() {
+        let cert = CertificateDer::from(vec![1u8, 2, 3, 4]);
+        let pin = ring::digest::digest(&ring::digest::SHA256, cert.as_ref()).as_ref().to_vec();
+        let verifier = PinVerifier { expected_sha256: pin };
+        let server_name = ServerName::try_from("example.com").unwrap();
+
+        assert!(verifier.verify_server_cert(
+            &cert,
+            &[],
+            &server_name,
+            &[],
+            rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)),
+        ).is_ok());
+    }
+
+    #[test]
+    fn pin_verifier_rejects_wrong_certificate_digest() {
+        let cert = CertificateDer::from(vec![1u8, 2, 3, 4]);
+        let verifier = PinVerifier { expected_sha256: vec![0u8; 32] };
+        let server_name = ServerName::try_from("example.com").unwrap();
+
+        assert!(verifier.verify_server_cert(
+            &cert,
+            &[],
+            &server_name,
+            &[],
+            rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)),
+        ).is_err());
+    }
+}
diff --git a/lib/jerboa/rust/codegen.ss b/lib/jerboa/rust/codegen.ss
new file mode 100644
index 0000000..76e8389
--- /dev/null
+++ b/lib/jerboa/rust/codegen.ss
@@ -0,0 +1,409 @@
+#!chezscheme
+;;; (jerboa rust codegen) -- Safe-subset Jerboa to Rust source generation
+;;;
+;;; This is not a general Scheme compiler. It is a deliberately small target
+;;; for pure, typed functions that can be emitted as safe Rust with no unsafe
+;;; blocks, no FFI, no mutation, no eval, and checked integer arithmetic.
+
+(library (jerboa rust codegen)
+  (export
+    safe-rust-program->string
+    safe-rust-form->string
+    safe-rust-expression->string
+    write-safe-rust-program
+    safe-rust-type?
+    safe-rust-form?
+    safe-rust-expression?)
+
+  (import (chezscheme)) ; jerboa-security: suppress direct-chezscheme-import-user-code -- trusted compiler backend module
+
+  (define safe-rust-types
+    '(i8 i16 i32 i64 i128 isize
+      u8 u16 u32 u64 u128 usize
+      bool))