security: annotate secure memory unsafe sites

ober

df2f9c5b0ee16860b1b1fed50a687e331923547e

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index e7f43df..e098c37 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -41,9 +41,9 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (c-file-count 5)
     (no-mangle-exports 196)
     (unsafe-sites 438)
-    (unsafe-sites-with-nearby-safety-comment 82)
-    (unsafe-sites-without-nearby-safety-comment 356)
-    (unsafe-comment-review-sites 356)
+    (unsafe-sites-with-nearby-safety-comment 97)
+    (unsafe-sites-without-nearby-safety-comment 341)
+    (unsafe-comment-review-sites 341)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -79,8 +79,11 @@ 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
-seal output length addition for overflow. The generated inventory now reports
-82 annotated native unsafe sites and 356 remaining unsafe review sites.
+seal output length addition for overflow. `secure_mem.rs` now documents the
+`sysconf`, `mmap`, guard-page `mprotect`, pointer-offset, `mlock`/`munlock`,
+`madvise`, `munmap`, wipe, and random-fill invariants around the secure memory
+region lifecycle. The generated inventory now reports 97 annotated native
+unsafe sites and 341 remaining unsafe review sites.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 7b9ee63..ad87472 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -625,9 +625,11 @@ not started."
   invariant comments for the worker launch/seccomp unsafe sites. `crypto.rs`
   also gained invariant comments for digest/HMAC/random/compare/AEAD/KDF
   buffer sites plus fail-closed nonzero-null input checks and AEAD seal length
-  overflow checks. The generated report now shows 82 annotated native unsafe
-  sites and 356 remaining unsafe review sites. Remaining work: continue unsafe
-  invariant comments across the rest of `jerboa-native-rs`.
+  overflow checks. `secure_mem.rs` now documents the mmap/mlock/guard-page,
+  wipe, free, and random-fill unsafe invariants. The generated report now shows
+  97 annotated native unsafe sites and 341 remaining unsafe review sites.
+  Remaining work: continue unsafe invariant comments across the rest of
+  `jerboa-native-rs`.
 
 ### K3-P1-02 — TOCTOU-safe filesystem capability checks
 **Serves:** G2. **Effort:** 1 week.
@@ -1115,7 +1117,7 @@ Track these in `docs/status.md` per release:
 | 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 | 356 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 341 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
 | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test |
 | Exploit-shaped regression tests | ~0 | ≥ 1 per historical finding |
 | Sandboxed-by-default app entry | no | yes (P0-06) |
diff --git a/docs/reviews/2026-07-27-native-export-review.sexp b/docs/reviews/2026-07-27-native-export-review.sexp
index 4541e5e..b7b025d 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -112,7 +112,7 @@
     (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 285) (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 179) (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 196) (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_alloc") (file "jerboa-native-rs/src/secure_mem.rs") (line 30) (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))))
     (export (symbol "jerboa_secure_dir_mkdirs") (file "jerboa-native-rs/src/secure_fs.rs") (line 472) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 56))))
@@ -120,14 +120,14 @@
     (export (symbol "jerboa_secure_dir_open_strict") (file "jerboa-native-rs/src/secure_fs.rs") (line 432) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 51))))
     (export (symbol "jerboa_secure_entry_info") (file "jerboa-native-rs/src/secure_fs.rs") (line 502) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 59))))
     (export (symbol "jerboa_secure_fd_close") (file "jerboa-native-rs/src/secure_fs.rs") (line 916) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 77))))
-    (export (symbol "jerboa_secure_free") (file "jerboa-native-rs/src/secure_mem.rs") (line 113) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 38))))
+    (export (symbol "jerboa_secure_free") (file "jerboa-native-rs/src/secure_mem.rs") (line 132) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 38))))
     (export (symbol "jerboa_secure_output_abort") (file "jerboa-native-rs/src/secure_fs.rs") (line 907) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 75))))
     (export (symbol "jerboa_secure_output_begin") (file "jerboa-native-rs/src/secure_fs.rs") (line 760) (scheme-callers 2 (caller (file "lib/std/os/secure-output.ss") (line 68)) (caller (file "lib/std/os/secure-output.ss") (line 71))))
     (export (symbol "jerboa_secure_output_begin_checked") (file "jerboa-native-rs/src/secure_fs.rs") (line 816) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 71))))
     (export (symbol "jerboa_secure_output_commit") (file "jerboa-native-rs/src/secure_fs.rs") (line 867) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 73))))
-    (export (symbol "jerboa_secure_random_fill") (file "jerboa-native-rs/src/secure_mem.rs") (line 169) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 44))))
+    (export (symbol "jerboa_secure_random_fill") (file "jerboa-native-rs/src/secure_mem.rs") (line 198) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 44))))
     (export (symbol "jerboa_secure_read_file") (file "jerboa-native-rs/src/secure_fs.rs") (line 673) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 65))))
-    (export (symbol "jerboa_secure_wipe") (file "jerboa-native-rs/src/secure_mem.rs") (line 156) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 41))))
+    (export (symbol "jerboa_secure_wipe") (file "jerboa-native-rs/src/secure_mem.rs") (line 183) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 41))))
     (export (symbol "jerboa_sha1") (file "jerboa-native-rs/src/crypto.rs") (line 71) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 60))))
     (export (symbol "jerboa_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 87) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 69))))
     (export (symbol "jerboa_sha384") (file "jerboa-native-rs/src/crypto.rs") (line 184) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 78))))
diff --git a/docs/status.md b/docs/status.md
index 33b00ea..0fed171 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`,
 | Area | Current state | Remaining work |
 |---|---|---|
 | 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` and the crypto FFI buffer path in `crypto.rs` now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 356. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. |
+| 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`, and secure-memory region lifecycle in `secure_mem.rs` now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 341. | Continue adding `SAFETY:` invariant comments near the remaining 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, 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. |
diff --git a/jerboa-native-rs/src/secure_mem.rs b/jerboa-native-rs/src/secure_mem.rs
index cc8439d..4355c16 100644
--- a/jerboa-native-rs/src/secure_mem.rs
+++ b/jerboa-native-rs/src/secure_mem.rs
@@ -2,6 +2,8 @@ use crate::panic::{ffi_wrap, ffi_wrap_ptr};
 use std::ptr;
 
 fn system_page_size() -> Option<usize> {
+    // SAFETY: sysconf(_SC_PAGESIZE) takes no pointers and returns a scalar page
+    // size or an error indicator.
     let value = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
     if value <= 0 {
         None
@@ -46,6 +48,8 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
             Some(n) if n <= isize::MAX as usize => n,
             _ => return ptr::null_mut(),
         };
+        // SAFETY: mmap is asked for a fresh anonymous private mapping with no
+        // caller-supplied address. `total` is checked not to exceed isize::MAX.
         let base = unsafe {
             libc::mmap(
                 ptr::null_mut(),
@@ -60,10 +64,17 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
             return ptr::null_mut();
         }
 
-        let cleanup = || unsafe {
-            libc::munmap(base, total);
+        let cleanup = || {
+            // SAFETY: base/total describe the live mapping returned by mmap;
+            // cleanup is called only before ownership is returned to Scheme.
+            unsafe {
+                libc::munmap(base, total);
+            }
         };
 
+        // SAFETY: base is the start of a `total`-byte mmap region. The second
+        // guard starts at page_size + rounded, which remains inside the mapping
+        // because total == page_size + rounded + page_size.
         let protect_ok = unsafe {
             libc::mprotect(base, page_size, libc::PROT_NONE) == 0
                 && libc::mprotect(
@@ -78,8 +89,12 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
         }
 
         // Put the usable range flush against the right guard page.
+        // SAFETY: rounded >= size and page_size + rounded is within the mmap
+        // region; this computes the start of the user-visible writable range.
         let data = unsafe { (base as *mut u8).add(page_size + (rounded - size)) };
 
+        // SAFETY: data points to the writable range of length `size` inside the
+        // mapping and is not exposed to callers until after mlock succeeds.
         if unsafe { libc::mlock(data as *const _, size) } != 0 {
             cleanup();
             return ptr::null_mut();
@@ -87,7 +102,9 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
 
         // Exclude from core dumps (MADV_DONTDUMP is Linux-specific)
         #[cfg(target_os = "linux")]
+        // SAFETY: data/size is the live user-visible subrange of the mapping.
         if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTDUMP) } != 0 {
+            // SAFETY: data/size was successfully mlock'd above and is still live.
             unsafe {
                 libc::munlock(data as *const _, size);
             }
@@ -97,7 +114,9 @@ pub extern "C" fn jerboa_secure_alloc(size: usize) -> *mut u8 {
 
         // Don't inherit in child processes (MADV_DONTFORK is Linux-specific)
         #[cfg(target_os = "linux")]
+        // SAFETY: data/size is the live user-visible subrange of the mapping.
         if unsafe { libc::madvise(data as *mut _, size, libc::MADV_DONTFORK) } != 0 {
+            // SAFETY: data/size was successfully mlock'd above and is still live.
             unsafe {
                 libc::munlock(data as *const _, size);
             }
@@ -127,13 +146,19 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 {
             None => return -1,
         };
 
+        // SAFETY: caller must pass a pointer returned by jerboa_secure_alloc
+        // with the same `size`, so the user-visible range is writable.
         unsafe {
             secure_zero(ptr, size);
         }
 
+        // SAFETY: ptr/size is the user-visible range previously mlock'd by
+        // jerboa_secure_alloc.
         let unlock_rc = unsafe { libc::munlock(ptr as *const _, size) };
 
         // Unmap entire region including guard pages
+        // SAFETY: ptr came from jerboa_secure_alloc(size), so subtracting the
+        // same front guard and padding offset recovers the mmap base address.
         let base = unsafe { ptr.sub(page_size + (rounded - size)) };
         let total = match page_size
             .checked_add(rounded)
@@ -142,6 +167,8 @@ pub extern "C" fn jerboa_secure_free(ptr: *mut u8, size: usize) -> i32 {
             Some(n) => n,
             None => return -1,
         };
+        // SAFETY: base/total reconstruct the full mapping allocated by
+        // jerboa_secure_alloc for this size.
         let unmap_rc = unsafe { libc::munmap(base as *mut _, total) };
 
         if unlock_rc == 0 && unmap_rc == 0 {
@@ -158,6 +185,8 @@ pub extern "C" fn jerboa_secure_wipe(ptr: *mut u8, size: usize) -> i32 {
         if ptr.is_null() {
             return -1;
         }
+        // SAFETY: caller guarantees ptr points to a writable buffer of `size`
+        // bytes; secure_zero bounds every write by 0..size.
         unsafe {
             secure_zero(ptr, size);
         }
@@ -175,6 +204,8 @@ pub extern "C" fn jerboa_secure_random_fill(ptr: *mut u8, size: usize) -> i32 {
             return 0;
         }
         let rng = ring::rand::SystemRandom::new();
+        // SAFETY: ptr is non-null and `size > 0`; the caller provides a
+        // writable buffer for ring to fill.
         let buf = unsafe { std::slice::from_raw_parts_mut(ptr, size) };
         match ring::rand::SecureRandom::fill(&rng, buf) {
             Ok(()) => 0,