security: harden spidermonkey wasm ffi

ober

0d6ee9f3473b86604a4580d69d79f192c0036db8

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 311b209..60d81d6 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -40,10 +40,10 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (rust-file-count 29)
     (c-file-count 5)
     (no-mangle-exports 196)
-    (unsafe-sites 400)
-    (unsafe-sites-with-nearby-safety-comment 211)
-    (unsafe-sites-without-nearby-safety-comment 189)
-    (unsafe-comment-review-sites 189)
+    (unsafe-sites 409)
+    (unsafe-sites-with-nearby-safety-comment 221)
+    (unsafe-sites-without-nearby-safety-comment 188)
+    (unsafe-comment-review-sites 188)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -135,9 +135,13 @@ signed values to `usize`, and centralizes output-buffer writes with bounded
 copy invariants. `x509.rs` now funnels certificate SAN/path/PEM inputs through
 checked C ABI UTF-8 helpers, centralizes PEM/fingerprint output-buffer writes,
 documents atomic file-publication syscall invariants, and has regressions for
-null/nonzero input and output-slot failures. The generated inventory now
-reports 211 annotated native unsafe sites and 189 remaining unsafe review
-sites.
+null/nonzero input and output-slot failures. `wasm_sm.rs` now compiles under
+the crate-wide `unsafe_op_in_unsafe_fn` denial for the SpiderMonkey feature,
+checks host-memory offset arithmetic, bounds C ABI module/function/argument/log
+buffers, converts poisoned handle-store locks into normal FFI errors, and has
+helper regressions for null/nonzero inputs and signed offset conversion. The
+generated inventory now reports 221 annotated native unsafe sites and 188
+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 a3aebe8..3f6295f 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -678,7 +678,10 @@ not started."
   poisoned handle-store locks into normal FFI errors. `x509.rs` now validates
   SAN/path/PEM inputs through checked UTF-8 helpers, centralizes PEM and
   fingerprint output writes, and documents atomic file-publication syscalls.
-  The generated report now shows 211 annotated native unsafe sites and 189
+  `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the
+  SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets,
+  and converts poisoned handle-store locks into normal FFI errors. The
+  generated report now shows 221 annotated native unsafe sites and 188
   remaining unsafe review sites.
   Remaining work: continue unsafe invariant comments across the rest of
   `jerboa-native-rs`.
diff --git a/docs/reviews/2026-07-27-native-export-review.sexp b/docs/reviews/2026-07-27-native-export-review.sexp
index b4a3931..b72ba38 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -132,14 +132,14 @@
     (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))))
     (export (symbol "jerboa_sha512") (file "jerboa-native-rs/src/crypto.rs") (line 194) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 87))))
-    (export (symbol "jerboa_sm_add_fuel") (file "jerboa-native-rs/src/wasm_sm.rs") (line 964) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 186))))
-    (export (symbol "jerboa_sm_call") (file "jerboa-native-rs/src/wasm_sm.rs") (line 837) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 168))))
-    (export (symbol "jerboa_sm_get_log") (file "jerboa-native-rs/src/wasm_sm.rs") (line 977) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 180))))
-    (export (symbol "jerboa_sm_instance_free") (file "jerboa-native-rs/src/wasm_sm.rs") (line 828) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 162))))
-    (export (symbol "jerboa_sm_instance_new") (file "jerboa-native-rs/src/wasm_sm.rs") (line 818) (scheme-callers 2 (caller (file "lib/std/wasm/sandbox.ss") (line 156)) (caller (file "lib/std/wasm/sandbox.ss") (line 174))))
-    (export (symbol "jerboa_sm_instance_new_hosted") (file "jerboa-native-rs/src/wasm_sm.rs") (line 823) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 174))))
-    (export (symbol "jerboa_sm_module_free") (file "jerboa-native-rs/src/wasm_sm.rs") (line 777) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 150))))
-    (export (symbol "jerboa_sm_module_new") (file "jerboa-native-rs/src/wasm_sm.rs") (line 697) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 144))))
+    (export (symbol "jerboa_sm_add_fuel") (file "jerboa-native-rs/src/wasm_sm.rs") (line 1149) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 186))))
+    (export (symbol "jerboa_sm_call") (file "jerboa-native-rs/src/wasm_sm.rs") (line 1005) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 168))))
+    (export (symbol "jerboa_sm_get_log") (file "jerboa-native-rs/src/wasm_sm.rs") (line 1162) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 180))))
+    (export (symbol "jerboa_sm_instance_free") (file "jerboa-native-rs/src/wasm_sm.rs") (line 994) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 162))))
+    (export (symbol "jerboa_sm_instance_new") (file "jerboa-native-rs/src/wasm_sm.rs") (line 984) (scheme-callers 2 (caller (file "lib/std/wasm/sandbox.ss") (line 156)) (caller (file "lib/std/wasm/sandbox.ss") (line 174))))
+    (export (symbol "jerboa_sm_instance_new_hosted") (file "jerboa-native-rs/src/wasm_sm.rs") (line 989) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 174))))
+    (export (symbol "jerboa_sm_module_free") (file "jerboa-native-rs/src/wasm_sm.rs") (line 934) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 150))))
+    (export (symbol "jerboa_sm_module_new") (file "jerboa-native-rs/src/wasm_sm.rs") (line 847) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 144))))
     (export (symbol "jerboa_sqlite_bind_blob") (file "jerboa-native-rs/src/sqlite.rs") (line 318) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 63))))
     (export (symbol "jerboa_sqlite_bind_double") (file "jerboa-native-rs/src/sqlite.rs") (line 247) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 59))))
     (export (symbol "jerboa_sqlite_bind_int") (file "jerboa-native-rs/src/sqlite.rs") (line 227) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 57))))
diff --git a/docs/status.md b/docs/status.md
index 259cfe0..f106a9d 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`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, DuckDB native ABI, and X.509 native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 189. | 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`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, and X.509 native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 188. | 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/wasm_sm.rs b/jerboa-native-rs/src/wasm_sm.rs
index 3188853..b474264 100644
--- a/jerboa-native-rs/src/wasm_sm.rs
+++ b/jerboa-native-rs/src/wasm_sm.rs
@@ -8,8 +8,8 @@
 use ::std::collections::HashMap;
 use ::std::ptr;
 use ::std::sync::atomic::{AtomicU64, Ordering};
-use ::std::sync::Mutex;
 use ::std::sync::OnceLock;
+use ::std::sync::{Mutex, MutexGuard};
 
 use mozjs::jsapi::*;
 use mozjs::jsval::JSVal;
@@ -31,6 +31,9 @@ use crate::panic::set_last_error;
 // ============================================================
 
 const LOG_BUFFER_CAP: usize = 10_000;
+const MAX_C_ABI_SLICE_LEN: usize = isize::MAX as usize;
+const MAX_WASM_COPY_LEN: usize = 16 * 1024 * 1024;
+const MAX_WASI_IOVS: usize = 1024;
 
 // ============================================================
 // Thread-local host state for import callbacks
@@ -73,6 +76,102 @@ fn sm_instances() -> &'static Mutex<HashMap<u64, SmWasmInstance>> {
     I.get_or_init(|| Mutex::new(HashMap::new()))
 }
 
+fn lock_sm_modules() -> Option<MutexGuard<'static, HashMap<u64, SmWasmModule>>> {
+    match sm_modules().lock() {
+        Ok(guard) => Some(guard),
+        Err(_) => {
+            set_last_error("spidermonkey module store lock poisoned".into());
+            None
+        }
+    }
+}
+
+fn lock_sm_instances() -> Option<MutexGuard<'static, HashMap<u64, SmWasmInstance>>> {
+    match sm_instances().lock() {
+        Ok(guard) => Some(guard),
+        Err(_) => {
+            set_last_error("spidermonkey instance store lock poisoned".into());
+            None
+        }
+    }
+}
+
+fn ffi_bytes<'a>(ptr: *const u8, len: usize, label: &str) -> ::std::result::Result<&'a [u8], ()> {
+    if len > MAX_C_ABI_SLICE_LEN {
+        set_last_error(format!("{label} too large"));
+        return Err(());
+    }
+    if ptr.is_null() && len != 0 {
+        set_last_error(format!("null {label}"));
+        return Err(());
+    }
+    if len == 0 {
+        return Ok(&[]);
+    }
+    // SAFETY: `len` is bounded to `isize::MAX`, and null pointers paired with
+    // nonzero length were rejected above. The returned slice is copied or
+    // decoded before control returns across the C ABI.
+    Ok(unsafe { ::std::slice::from_raw_parts(ptr, len) })
+}
+
+fn ffi_utf8(ptr: *const u8, len: usize, label: &str) -> ::std::result::Result<String, ()> {
+    let bytes = ffi_bytes(ptr, len, label)?;
+    ::std::str::from_utf8(bytes)
+        .map(str::to_owned)
+        .map_err(|_| {
+            set_last_error(format!("invalid UTF-8 {label}"));
+        })
+}
+
+fn ffi_i64_args<'a>(
+    ptr: *const i64,
+    len: usize,
+    label: &str,
+) -> ::std::result::Result<&'a [i64], ()> {
+    if len > MAX_C_ABI_SLICE_LEN / ::std::mem::size_of::<i64>() {
+        set_last_error(format!("{label} too large"));
+        return Err(());
+    }
+    if ptr.is_null() && len != 0 {
+        set_last_error(format!("null {label}"));
+        return Err(());
+    }
+    if len == 0 {
+        return Ok(&[]);
+    }
+    // SAFETY: `len * size_of::<i64>()` is bounded below `isize::MAX`, and
+    // null pointers paired with nonzero length were rejected above.
+    Ok(unsafe { ::std::slice::from_raw_parts(ptr, len) })
+}
+
+fn js_i32_arg_to_usize(value: i32) -> Option<usize> {
+    usize::try_from(value).ok()
+}
+
+fn copy_log_prefix(
+    bytes: &[u8],
+    output: *mut u8,
+    output_max: usize,
+) -> ::std::result::Result<(), ()> {
+    if output_max > MAX_C_ABI_SLICE_LEN {
+        set_last_error("spidermonkey log output too large".into());
+        return Err(());
+    }
+    if output.is_null() && output_max != 0 {
+        set_last_error("null spidermonkey log output".into());
+        return Err(());
+    }
+    let n = bytes.len().min(output_max);
+    if n != 0 {
+        // SAFETY: null output with nonzero capacity was rejected, `output_max`
+        // is bounded to `isize::MAX`, and `n` is capped to both buffers.
+        unsafe {
+            ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), output, n);
+        }
+    }
+    Ok(())
+}
+
 /// Global JS engine — initialized once, never dropped.
 /// We leak it intentionally to avoid lifecycle issues with Runtime handles.
 fn sm_engine() -> &'static mozjs::rust::JSEngineHandle {
@@ -127,10 +226,14 @@ fn read_wasm_memory(offset: usize, len: usize) -> Option<Vec<u8>> {
     CALL_CTX.with(|ctx| {
         let borrow = ctx.borrow();
         let call_ctx = borrow.as_ref()?;
-        if call_ctx.memory_ptr.is_null() || offset + len > call_ctx.memory_len {
+        let end = offset.checked_add(len)?;
+        if call_ctx.memory_ptr.is_null() || len > MAX_WASM_COPY_LEN || end > call_ctx.memory_len {
             return None;
         }
         let mut buf = vec![0u8; len];
+        // SAFETY: guest-memory bounds are checked with overflow-safe addition,
+        // the source pointer is non-null, and the destination Vec owns `len`
+        // initialized bytes.
         unsafe {
             ::std::ptr::copy_nonoverlapping(call_ctx.memory_ptr.add(offset), buf.as_mut_ptr(), len);
         }
@@ -147,9 +250,18 @@ fn write_wasm_memory(offset: usize, data: &[u8]) -> bool {
             Some(c) => c,
             None => return false,
         };
-        if call_ctx.memory_ptr.is_null() || offset + data.len() > call_ctx.memory_len {
+        let Some(end) = offset.checked_add(data.len()) else {
+            return false;
+        };
+        if call_ctx.memory_ptr.is_null()
+            || data.len() > MAX_WASM_COPY_LEN
+            || end > call_ctx.memory_len
+        {
             return false;
         }
+        // SAFETY: guest-memory bounds are checked with overflow-safe addition,
+        // the destination pointer is non-null, and `data` provides at least
+        // `data.len()` initialized source bytes.
         unsafe {
             ::std::ptr::copy_nonoverlapping(
                 data.as_ptr(),
@@ -163,7 +275,9 @@ fn write_wasm_memory(offset: usize, data: &[u8]) -> bool {
 
 // ---- log_message(level, msg_ptr, msg_len) -> 0 ----
 unsafe extern "C" fn host_log_message(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     let level = if argc > 0 && args.get(0).get().is_int32() {
         args.get(0).get().to_int32()
     } else {
@@ -188,9 +302,12 @@ unsafe extern "C" fn host_log_message(_cx: *mut JSContext, argc: u32, vp: *mut V
     };
 
     let msg = if msg_len > 0 {
-        match read_wasm_memory(msg_ptr as usize, msg_len as usize) {
-            Some(bytes) => String::from_utf8_lossy(&bytes).to_string(),
-            None => format!("[ptr={},len={}]", msg_ptr, msg_len),
+        match (js_i32_arg_to_usize(msg_ptr), js_i32_arg_to_usize(msg_len)) {
+            (Some(ptr), Some(len)) => match read_wasm_memory(ptr, len) {
+                Some(bytes) => String::from_utf8_lossy(&bytes).to_string(),
+                None => format!("[ptr={},len={}]", msg_ptr, msg_len),
+            },
+            _ => format!("[ptr={},len={}]", msg_ptr, msg_len),
         }
     } else {
         String::new()
@@ -214,7 +331,9 @@ unsafe extern "C" fn host_log_message(_cx: *mut JSContext, argc: u32, vp: *mut V
 
 // ---- get_time_ms() -> i32 ----
 unsafe extern "C" fn host_get_time_ms(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     let ms = CALL_CTX.with(|ctx| {
         if let Some(ref call_ctx) = *ctx.borrow() {
             if let Ok(instances) = sm_instances().try_lock() {
@@ -231,19 +350,25 @@ unsafe extern "C" fn host_get_time_ms(_cx: *mut JSContext, argc: u32, vp: *mut V
 
 // ---- random_get(buf_ptr, buf_len) -> errno ----
 unsafe extern "C" fn host_random_get(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     let buf_ptr = if argc > 0 && args.get(0).get().is_int32() {
-        args.get(0).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(0).get().to_int32()).unwrap_or(0)
     } else {
         0
     };
     let buf_len = if argc > 1 && args.get(1).get().is_int32() {
-        args.get(1).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(1).get().to_int32()).unwrap_or(0)
     } else {
         0
     };
 
     if buf_len > 0 {
+        if buf_len > MAX_WASM_COPY_LEN {
+            args.rval().set(Int32Value(28));
+            return true;
+        }
         let mut rand_buf = vec![0u8; buf_len];
         // Simple PRNG: fill with pseudo-random bytes based on time
         let seed = ::std::time::SystemTime::now()
@@ -266,24 +391,28 @@ unsafe extern "C" fn host_random_get(_cx: *mut JSContext, argc: u32, vp: *mut Va
 
 // ---- fd_write(fd, iovs_ptr, iovs_len, nwritten_ptr) -> errno ----
 unsafe extern "C" fn host_fd_write(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     let _fd = if argc > 0 && args.get(0).get().is_int32() {
         args.get(0).get().to_int32()
     } else {
         1
     };
     let iovs_ptr = if argc > 1 && args.get(1).get().is_int32() {
-        args.get(1).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(1).get().to_int32()).unwrap_or(0)
     } else {
         0
     };
     let iovs_len = if argc > 2 && args.get(2).get().is_int32() {
-        args.get(2).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(2).get().to_int32())
+            .unwrap_or(0)
+            .min(MAX_WASI_IOVS)
     } else {
         0
     };
     let nwritten_ptr = if argc > 3 && args.get(3).get().is_int32() {
-        args.get(3).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(3).get().to_int32()).unwrap_or(0)
     } else {
         0
     };
@@ -292,7 +421,9 @@ unsafe extern "C" fn host_fd_write(_cx: *mut JSContext, argc: u32, vp: *mut Valu
 
     // Read iov entries: each is (ptr: i32, len: i32) = 8 bytes
     for i in 0..iovs_len {
-        let iov_offset = iovs_ptr + i * 8;
+        let Some(iov_offset) = i.checked_mul(8).and_then(|n| iovs_ptr.checked_add(n)) else {
+            break;
+        };
         if let Some(iov_bytes) = read_wasm_memory(iov_offset, 8) {
             let data_ptr =
                 u32::from_le_bytes([iov_bytes[0], iov_bytes[1], iov_bytes[2], iov_bytes[3]])
@@ -303,7 +434,8 @@ unsafe extern "C" fn host_fd_write(_cx: *mut JSContext, argc: u32, vp: *mut Valu
             if let Some(data) = read_wasm_memory(data_ptr, data_len) {
                 // Write to stderr (fd 1 = stdout, fd 2 = stderr)
                 let _ = ::std::io::Write::write_all(&mut ::std::io::stderr(), &data);
-                total_written += data_len as u32;
+                total_written =
+                    total_written.saturating_add(data_len.min(u32::MAX as usize) as u32);
             }
         }
     }
@@ -319,21 +451,25 @@ unsafe extern "C" fn host_fd_write(_cx: *mut JSContext, argc: u32, vp: *mut Valu
 
 // ---- fd_read(fd, iovs_ptr, iovs_len, nread_ptr) -> errno ----
 unsafe extern "C" fn host_fd_read(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     args.rval().set(Int32Value(8)); // EBADF — stdin blocked
     true
 }
 
 // ---- clock_time_get(clock_id, precision, time_ptr) -> errno ----
 unsafe extern "C" fn host_clock_time_get(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     let _clock_id = if argc > 0 && args.get(0).get().is_int32() {
         args.get(0).get().to_int32()
     } else {
         0
     };
     let time_ptr = if argc > 2 && args.get(2).get().is_int32() {
-        args.get(2).get().to_int32() as usize
+        js_i32_arg_to_usize(args.get(2).get().to_int32()).unwrap_or(0)
     } else {
         0
     };
@@ -352,14 +488,18 @@ unsafe extern "C" fn host_clock_time_get(_cx: *mut JSContext, argc: u32, vp: *mu
 
 // ---- proc_exit(code) ----
 unsafe extern "C" fn host_proc_exit(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     args.rval().set(UndefinedValue());
     true
 }
 
 // ---- recv_packet / send_packet / cdb_open / cdb_find / cdb_close stubs ----
 unsafe extern "C" fn host_stub_i32(_cx: *mut JSContext, argc: u32, vp: *mut Value) -> bool {
-    let args = CallArgs::from_vp(vp, argc);
+    // SAFETY: SpiderMonkey invokes native callbacks with a valid `(argc, vp)`
+    // pair for the duration of the callback.
+    let args = unsafe { CallArgs::from_vp(vp, argc) };
     args.rval().set(Int32Value(-1));
     true
 }
@@ -370,133 +510,138 @@ unsafe extern "C" fn host_stub_i32(_cx: *mut JSContext, argc: u32, vp: *mut Valu
 
 /// Build the JS import object: { wasi_snapshot_preview1: {...}, dns: {...} }
 unsafe fn build_hosted_imports(cx: &mut AutoRealm) -> *mut JSObject {
-    rooted!(&in(cx) let mut imports = JS_NewPlainObject(cx));
-    if imports.is_null() {
-        return ptr::null_mut();
-    }
+    // SAFETY: `cx` is an active SpiderMonkey realm supplied by the caller; all
+    // created objects and values are rooted for the duration of this helper,
+    // and native callback pointers have the ABI SpiderMonkey expects.
+    unsafe {
+        rooted!(&in(cx) let mut imports = JS_NewPlainObject(cx));
+        if imports.is_null() {
+            return ptr::null_mut();
+        }
 
-    // ---- wasi_snapshot_preview1 namespace ----
-    rooted!(&in(cx) let mut wasi = JS_NewPlainObject(cx));
-    if wasi.is_null() {
-        return ptr::null_mut();
-    }
+        // ---- wasi_snapshot_preview1 namespace ----
+        rooted!(&in(cx) let mut wasi = JS_NewPlainObject(cx));
+        if wasi.is_null() {
+            return ptr::null_mut();
+        }
 
-    JS_DefineFunction(
-        cx,
-        wasi.handle().into(),
-        c"fd_write".as_ptr(),
-        Some(host_fd_write),
-        4,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        wasi.handle().into(),
-        c"fd_read".as_ptr(),
-        Some(host_fd_read),
-        4,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        wasi.handle().into(),
-        c"clock_time_get".as_ptr(),
-        Some(host_clock_time_get),
-        3,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        wasi.handle().into(),
-        c"random_get".as_ptr(),
-        Some(host_random_get),
-        2,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        wasi.handle().into(),
-        c"proc_exit".as_ptr(),
-        Some(host_proc_exit),
-        1,
-        0,
-    );
-
-    rooted!(&in(cx) let mut wasi_val = ObjectValue(wasi.get()));
-    JS_SetProperty(
-        cx,
-        imports.handle(),
-        c"wasi_snapshot_preview1".as_ptr(),
-        wasi_val.handle(),
-    );
-
-    // ---- dns namespace ----
-    rooted!(&in(cx) let mut dns = JS_NewPlainObject(cx));
-    if dns.is_null() {
-        return ptr::null_mut();
-    }
+        JS_DefineFunction(
+            cx,
+            wasi.handle().into(),
+            c"fd_write".as_ptr(),
+            Some(host_fd_write),
+            4,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            wasi.handle().into(),
+            c"fd_read".as_ptr(),
+            Some(host_fd_read),
+            4,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            wasi.handle().into(),
+            c"clock_time_get".as_ptr(),
+            Some(host_clock_time_get),
+            3,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            wasi.handle().into(),
+            c"random_get".as_ptr(),
+            Some(host_random_get),
+            2,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            wasi.handle().into(),
+            c"proc_exit".as_ptr(),
+            Some(host_proc_exit),
+            1,
+            0,
+        );
+
+        rooted!(&in(cx) let mut wasi_val = ObjectValue(wasi.get()));
+        JS_SetProperty(
+            cx,
+            imports.handle(),
+            c"wasi_snapshot_preview1".as_ptr(),
+            wasi_val.handle(),
+        );
+
+        // ---- dns namespace ----
+        rooted!(&in(cx) let mut dns = JS_NewPlainObject(cx));
+        if dns.is_null() {
+            return ptr::null_mut();
+        }
+
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"log_message".as_ptr(),
+            Some(host_log_message),
+            3,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"get_time_ms".as_ptr(),
+            Some(host_get_time_ms),
+            0,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"recv_packet".as_ptr(),
+            Some(host_stub_i32),
+            2,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"send_packet".as_ptr(),
+            Some(host_stub_i32),
+            4,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"cdb_open".as_ptr(),
+            Some(host_stub_i32),
+            2,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"cdb_find".as_ptr(),
+            Some(host_stub_i32),
+            5,
+            0,
+        );
+        JS_DefineFunction(
+            cx,
+            dns.handle().into(),
+            c"cdb_close".as_ptr(),
+            Some(host_stub_i32),
+            1,
+            0,
+        );
+
+        rooted!(&in(cx) let mut dns_val = ObjectValue(dns.get()));
+        JS_SetProperty(cx, imports.handle(), c"dns".as_ptr(), dns_val.handle());
 
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"log_message".as_ptr(),
-        Some(host_log_message),
-        3,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"get_time_ms".as_ptr(),
-        Some(host_get_time_ms),
-        0,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"recv_packet".as_ptr(),
-        Some(host_stub_i32),
-        2,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"send_packet".as_ptr(),
-        Some(host_stub_i32),
-        4,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"cdb_open".as_ptr(),
-        Some(host_stub_i32),
-        2,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"cdb_find".as_ptr(),
-        Some(host_stub_i32),
-        5,
-        0,
-    );
-    JS_DefineFunction(
-        cx,
-        dns.handle().into(),
-        c"cdb_close".as_ptr(),
-        Some(host_stub_i32),
-        1,
-        0,
-    );
-
-    rooted!(&in(cx) let mut dns_val = ObjectValue(dns.get()));
-    JS_SetProperty(cx, imports.handle(), c"dns".as_ptr(), dns_val.handle());
-
-    imports.get()
+        imports.get()
+    }
 }
 
 // ============================================================
@@ -513,180 +658,185 @@ unsafe fn sm_compile_and_call(
     func_name: &str,
     args: &[i32],
 ) -> ::std::result::Result<i32, String> {
-    // Re-root the global in this scope
-    rooted!(&in(cx) let global = global_ptr);
-
-    // Get WebAssembly constructors
-    rooted!(&in(cx) let mut wasm_val = UndefinedValue());
-    if !JS_GetProperty(
-        cx,
-        global.handle(),
-        c"WebAssembly".as_ptr(),
-        wasm_val.handle_mut(),
-    ) {
-        return Err("WebAssembly not available".into());
-    }
-    rooted!(&in(cx) let wasm_obj = wasm_val.to_object());
-    rooted!(&in(cx) let mut module_ctor = UndefinedValue());
-    rooted!(&in(cx) let mut instance_ctor = UndefinedValue());
-    JS_GetProperty(
-        cx,
-        wasm_obj.handle(),
-        c"Module".as_ptr(),
-        module_ctor.handle_mut(),
-    );
-    JS_GetProperty(
-        cx,
-        wasm_obj.handle(),
-        c"Instance".as_ptr(),
-        instance_ctor.handle_mut(),
-    );
-
-    // Compile: aligned buffer → ArrayBuffer → WebAssembly.Module
-    let mut aligned = vec![0u8; wasm_bytes.len() + 8];
-    let off = aligned.as_ptr() as usize % 8;
-    let start = if off == 0 { 0 } else { 8 - off };
-    aligned[start..start + wasm_bytes.len()].copy_from_slice(wasm_bytes);
-
-    let ab = NewArrayBufferWithUserOwnedContents(
-        cx,
-        wasm_bytes.len(),
-        aligned[start..].as_ptr() as *mut _,
-    );
-    if ab.is_null() {
-        return Err("ArrayBuffer creation failed".into());
-    }
+    // SAFETY: `cx` is an active SpiderMonkey realm, `global_ptr` belongs to
+    // that realm, all JS values are rooted, and borrowed slices live through
+    // the synchronous SpiderMonkey compile/instantiate/call sequence.
+    unsafe {
+        // Re-root the global in this scope
+        rooted!(&in(cx) let global = global_ptr);
+
+        // Get WebAssembly constructors
+        rooted!(&in(cx) let mut wasm_val = UndefinedValue());
+        if !JS_GetProperty(
+            cx,
+            global.handle(),
+            c"WebAssembly".as_ptr(),
+            wasm_val.handle_mut(),
+        ) {
+            return Err("WebAssembly not available".into());
+        }
+        rooted!(&in(cx) let wasm_obj = wasm_val.to_object());
+        rooted!(&in(cx) let mut module_ctor = UndefinedValue());
+        rooted!(&in(cx) let mut instance_ctor = UndefinedValue());
+        JS_GetProperty(
+            cx,
+            wasm_obj.handle(),
+            c"Module".as_ptr(),
+            module_ctor.handle_mut(),
+        );
+        JS_GetProperty(
+            cx,
+            wasm_obj.handle(),
+            c"Instance".as_ptr(),
+            instance_ctor.handle_mut(),
+        );
 
-    rooted!(&in(cx) let buf_val = ObjectValue(ab));
-    let compile_args = HandleValueArray::from(buf_val.handle().into_handle());
-    rooted!(&in(cx) let mut module_obj = ptr::null_mut::<JSObject>());
-    if !Construct1(
-        cx,
-        module_ctor.handle(),
-        &compile_args,
-        module_obj.handle_mut(),
-    ) {
-        return Err("WebAssembly.Module compilation failed".into());
-    }
+        // Compile: aligned buffer → ArrayBuffer → WebAssembly.Module
+        let mut aligned = vec![0u8; wasm_bytes.len() + 8];
+        let off = aligned.as_ptr() as usize % 8;
+        let start = if off == 0 { 0 } else { 8 - off };
+        aligned[start..start + wasm_bytes.len()].copy_from_slice(wasm_bytes);
 
-    // Build imports
-    let imports_obj = if hosted {
-        build_hosted_imports(cx)
-    } else {
-        JS_NewPlainObject(cx)
-    };
-    if imports_obj.is_null() {
-        return Err("failed to build imports".into());
-    }
-    rooted!(&in(cx) let imports = imports_obj);
-
-    // Instantiate: new WebAssembly.Instance(module, imports)
-    rooted!(&in(cx) let mut inst_args = ValueArray::new([
-        ObjectValue(module_obj.get()),
-        ObjectValue(imports.get()),
-    ]));
-    rooted!(&in(cx) let mut instance_obj = ptr::null_mut::<JSObject>());
-    if !Construct1(
-        cx,
-        instance_ctor.handle(),
-        &HandleValueArray::from(&inst_args),
-        instance_obj.handle_mut(),
-    ) {
-        return Err("WebAssembly.Instance creation failed".into());
-    }
+        let ab = NewArrayBufferWithUserOwnedContents(
+            cx,
+            wasm_bytes.len(),
+            aligned[start..].as_ptr() as *mut _,
+        );
+        if ab.is_null() {
+            return Err("ArrayBuffer creation failed".into());
+        }
+
+        rooted!(&in(cx) let buf_val = ObjectValue(ab));
+        let compile_args = HandleValueArray::from(buf_val.handle().into_handle());
+        rooted!(&in(cx) let mut module_obj = ptr::null_mut::<JSObject>());
+        if !Construct1(
+            cx,
+            module_ctor.handle(),
+            &compile_args,
+            module_obj.handle_mut(),
+        ) {
+            return Err("WebAssembly.Module compilation failed".into());
+        }
+
+        // Build imports
+        let imports_obj = if hosted {
+            build_hosted_imports(cx)
+        } else {
+            JS_NewPlainObject(cx)
+        };
+        if imports_obj.is_null() {
+            return Err("failed to build imports".into());
+        }
+        rooted!(&in(cx) let imports = imports_obj);
+
+        // Instantiate: new WebAssembly.Instance(module, imports)
+        rooted!(&in(cx) let mut inst_args = ValueArray::new([
+            ObjectValue(module_obj.get()),
+            ObjectValue(imports.get()),
+        ]));
+        rooted!(&in(cx) let mut instance_obj = ptr::null_mut::<JSObject>());
+        if !Construct1(
+            cx,
+            instance_ctor.handle(),
+            &HandleValueArray::from(&inst_args),
+            instance_obj.handle_mut(),
+        ) {
+            return Err("WebAssembly.Instance creation failed".into());
+        }
 
-    // Get exports object
-    rooted!(&in(cx) let mut exports_val = UndefinedValue());
-    JS_GetProperty(
-        cx,
-        instance_obj.handle(),
-        c"exports".as_ptr(),
-        exports_val.handle_mut(),
-    );
-    rooted!(&in(cx) let exports_obj = exports_val.to_object());
-
-    // Extract WASM linear memory pointer for host imports
-    {
-        rooted!(&in(cx) let mut mem_val = UndefinedValue());
+        // Get exports object
+        rooted!(&in(cx) let mut exports_val = UndefinedValue());
         JS_GetProperty(
             cx,
-            exports_obj.handle(),
-            c"memory".as_ptr(),
-            mem_val.handle_mut(),
+            instance_obj.handle(),
+            c"exports".as_ptr(),
+            exports_val.handle_mut(),
         );
-        if !mem_val.get().is_undefined() && mem_val.get().is_object() {
-            // memory is a WebAssembly.Memory — get its .buffer (ArrayBuffer)
-            rooted!(&in(cx) let mem_obj = mem_val.to_object());
-            rooted!(&in(cx) let mut buf_val = UndefinedValue());
+        rooted!(&in(cx) let exports_obj = exports_val.to_object());
+
+        // Extract WASM linear memory pointer for host imports
+        {
+            rooted!(&in(cx) let mut mem_val = UndefinedValue());
             JS_GetProperty(
                 cx,
-                mem_obj.handle(),
-                c"buffer".as_ptr(),
-                buf_val.handle_mut(),
+                exports_obj.handle(),
+                c"memory".as_ptr(),
+                mem_val.handle_mut(),
             );
-            if !buf_val.get().is_undefined() && buf_val.get().is_object() {
-                let ab = buf_val.to_object();
-                let mut len: usize = 0;
-                let mut is_shared = false;
-                let mut data: *mut u8 = ptr::null_mut();
-                GetArrayBufferLengthAndData(ab, &mut len, &mut is_shared, &mut data);
-                if !data.is_null() {
-                    CALL_CTX.with(|ctx| {
-                        if let Some(ref mut call_ctx) = *ctx.borrow_mut() {
-                            call_ctx.memory_ptr = data;
-                            call_ctx.memory_len = len;
-                        }
-                    });
+            if !mem_val.get().is_undefined() && mem_val.get().is_object() {
+                // memory is a WebAssembly.Memory — get its .buffer (ArrayBuffer)
+                rooted!(&in(cx) let mem_obj = mem_val.to_object());
+                rooted!(&in(cx) let mut buf_val = UndefinedValue());
+                JS_GetProperty(
+                    cx,
+                    mem_obj.handle(),
+                    c"buffer".as_ptr(),
+                    buf_val.handle_mut(),
+                );
+                if !buf_val.get().is_undefined() && buf_val.get().is_object() {
+                    let ab = buf_val.to_object();
+                    let mut len: usize = 0;
+                    let mut is_shared = false;
+                    let mut data: *mut u8 = ptr::null_mut();
+                    GetArrayBufferLengthAndData(ab, &mut len, &mut is_shared, &mut data);
+                    if !data.is_null() {
+                        CALL_CTX.with(|ctx| {
+                            if let Some(ref mut call_ctx) = *ctx.borrow_mut() {
+                                call_ctx.memory_ptr = data;
+                                call_ctx.memory_len = len;
+                            }
+                        });
+                    }
                 }
             }
         }
-    }
 
-    // Get exported function
-    let c_name =
-        ::std::ffi::CString::new(func_name).map_err(|_| "invalid function name".to_string())?;
-    rooted!(&in(cx) let mut func_val = UndefinedValue());
-    JS_GetProperty(
-        cx,
-        exports_obj.handle(),
-        c_name.as_ptr(),
-        func_val.handle_mut(),
-    );
-    if func_val.get().is_undefined() {
-        return Err(format!("export '{}' not found", func_name));
-    }
+        // Get exported function
+        let c_name =
+            ::std::ffi::CString::new(func_name).map_err(|_| "invalid function name".to_string())?;
+        rooted!(&in(cx) let mut func_val = UndefinedValue());
+        JS_GetProperty(
+            cx,
+            exports_obj.handle(),
+            c_name.as_ptr(),
+            func_val.handle_mut(),
+        );
+        if func_val.get().is_undefined() {
+            return Err(format!("export '{}' not found", func_name));
+        }
 
-    // Build arguments
-    let js_args: Vec<JSVal> = args.iter().map(|a: &i32| Int32Value(*a)).collect();
-    let call_args = HandleValueArray {
-        length_: js_args.len(),
-        elements_: if js_args.is_empty() {
-            ptr::null()
-        } else {
-            js_args.as_ptr()
-        },
-    };
+        // Build arguments
+        let js_args: Vec<JSVal> = args.iter().map(|a: &i32| Int32Value(*a)).collect();
+        let call_args = HandleValueArray {
+            length_: js_args.len(),
+            elements_: if js_args.is_empty() {
+                ptr::null()
+            } else {
+                js_args.as_ptr()
+            },
+        };
 
-    // Call
-    rooted!(&in(cx) let mut rval = UndefinedValue());
-    if !Call(
-        cx,
-        HandleValue::undefined(),
-        func_val.handle().into(),
-        &call_args,
-        rval.handle_mut().into(),
-    ) {
-        return Err("WASM function call failed (exception in WASM)".into());
-    }
+        // Call
+        rooted!(&in(cx) let mut rval = UndefinedValue());
+        if !Call(
+            cx,
+            HandleValue::undefined(),
+            func_val.handle().into(),
+            &call_args,
+            rval.handle_mut().into(),
+        ) {
+            return Err("WASM function call failed (exception in WASM)".into());
+        }
 
-    // Extract i32 result