security: harden antidebug ffi

ober

c99852d803e9367b6a16d74452a184623bca81e8

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index de5aabf..02b01e4 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 430)
-    (unsafe-sites-with-nearby-safety-comment 182)
-    (unsafe-sites-without-nearby-safety-comment 248)
-    (unsafe-comment-review-sites 248)
+    (unsafe-sites-with-nearby-safety-comment 184)
+    (unsafe-sites-without-nearby-safety-comment 246)
+    (unsafe-comment-review-sites 246)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -112,8 +112,10 @@ empty paths, oversized lengths, interior NUL path truncation, and null output
 pointers before crossing into libc. `epoll.rs` now documents epoll/eventfd
 syscall and caller-output invariants, bounds caller-controlled event counts,
 checks output byte-size derivation, and treats empty nonblocking eventfd drains
-as normal. The generated inventory now reports 182 annotated native unsafe
-sites and 248 remaining unsafe review sites.
+as normal. `antidebug.rs` now documents ptrace and breakpoint-probe invariants,
+reports ptrace OS errors, and saturates timing duration conversion instead of
+truncating. The generated inventory now reports 184 annotated native unsafe
+sites and 246 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 b959601..3061e11 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -656,8 +656,10 @@ not started."
   interior NUL path truncation, and null output pointers before crossing into
   libc. `epoll.rs` now documents epoll/eventfd syscall and caller-output
   invariants, bounds caller-controlled event counts, checks output byte-size
-  derivation, and treats empty nonblocking eventfd drains as normal. The
-  generated report now shows 182 annotated native unsafe sites and 248
+  derivation, and treats empty nonblocking eventfd drains as normal.
+  `antidebug.rs` now documents ptrace and breakpoint-probe invariants, reports
+  ptrace OS errors, and saturates timing duration conversion instead of
+  truncating. The generated report now shows 184 annotated native unsafe sites and 246
   remaining unsafe review sites.
   Remaining work: continue unsafe invariant comments across the rest of
   `jerboa-native-rs`.
@@ -1148,7 +1150,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 | 248 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 246 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 accf29a..0ea29e8 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -13,12 +13,12 @@
     (export (symbol "embed_read_passphrase") (file "jerboa-native-rs/src/embed_crypto.rs") (line 213) (scheme-callers 0 (none)))
     (export (symbol "jerboa_aead_open") (file "jerboa-native-rs/src/crypto.rs") (line 431) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 161))))
     (export (symbol "jerboa_aead_seal") (file "jerboa-native-rs/src/crypto.rs") (line 343) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 140))))
-    (export (symbol "jerboa_antidebug_check_all") (file "jerboa-native-rs/src/antidebug.rs") (line 147) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 46))))
-    (export (symbol "jerboa_antidebug_check_breakpoint") (file "jerboa-native-rs/src/antidebug.rs") (line 98) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 42))))
-    (export (symbol "jerboa_antidebug_check_ld_preload") (file "jerboa-native-rs/src/antidebug.rs") (line 60) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 40))))
-    (export (symbol "jerboa_antidebug_check_tracer") (file "jerboa-native-rs/src/antidebug.rs") (line 30) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 38))))
+    (export (symbol "jerboa_antidebug_check_all") (file "jerboa-native-rs/src/antidebug.rs") (line 158) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 46))))
+    (export (symbol "jerboa_antidebug_check_breakpoint") (file "jerboa-native-rs/src/antidebug.rs") (line 105) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 42))))
+    (export (symbol "jerboa_antidebug_check_ld_preload") (file "jerboa-native-rs/src/antidebug.rs") (line 67) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 40))))
+    (export (symbol "jerboa_antidebug_check_tracer") (file "jerboa-native-rs/src/antidebug.rs") (line 37) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 38))))
     (export (symbol "jerboa_antidebug_ptrace") (file "jerboa-native-rs/src/antidebug.rs") (line 8) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 36))))
-    (export (symbol "jerboa_antidebug_timing_check") (file "jerboa-native-rs/src/antidebug.rs") (line 119) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 44))))
+    (export (symbol "jerboa_antidebug_timing_check") (file "jerboa-native-rs/src/antidebug.rs") (line 130) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 44))))
     (export (symbol "jerboa_aproc_killpg") (file "jerboa-native-rs/src/aproc.rs") (line 675) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 155))))
     (export (symbol "jerboa_aproc_set_nonblock") (file "jerboa-native-rs/src/aproc.rs") (line 645) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 151))))
     (export (symbol "jerboa_aproc_spawn") (file "jerboa-native-rs/src/aproc.rs") (line 152) (scheme-callers 2 (caller (file "lib/std/os/aproc.ss") (line 127)) (caller (file "lib/std/os/aproc.ss") (line 142))))
diff --git a/docs/status.md b/docs/status.md
index 685d93c..58031eb 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, and epoll/eventfd ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 248. | 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, and antidebug ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 246. | 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/antidebug.rs b/jerboa-native-rs/src/antidebug.rs
index 271c62d..688c1f1 100644
--- a/jerboa-native-rs/src/antidebug.rs
+++ b/jerboa-native-rs/src/antidebug.rs
@@ -8,13 +8,20 @@ use crate::panic::{ffi_wrap, set_last_error};
 pub extern "C" fn jerboa_antidebug_ptrace() -> i32 {
     ffi_wrap(|| {
         #[cfg(target_os = "linux")]
+        // SAFETY: PTRACE_TRACEME does not dereference the addr/data arguments
+        // on Linux. The zero placeholders match libc's variadic ptrace ABI.
         let rc = unsafe { libc::ptrace(libc::PTRACE_TRACEME, 0, 0, 0) };
         #[cfg(target_os = "freebsd")]
+        // SAFETY: PT_TRACE_ME does not dereference addr for this request; the
+        // null pointer and zero data argument are the FreeBSD ABI convention.
         let rc = unsafe { libc::ptrace(libc::PT_TRACE_ME, 0, std::ptr::null_mut(), 0) };
         #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
         let rc: libc::c_long = -1;
         if rc == -1 {
-            set_last_error("PTRACE_TRACEME failed: process is already being traced".to_string());
+            set_last_error(format!(
+                "PTRACE_TRACEME failed: {}",
+                std::io::Error::last_os_error()
+            ));
             -1
         } else {
             0
@@ -101,6 +108,10 @@ pub extern "C" fn jerboa_antidebug_check_breakpoint(addr: *const u8) -> i32 {
             set_last_error("null address".to_string());
             return -1;
         }
+        // SAFETY: null is rejected above. The public FFI contract requires
+        // addr to point to a readable byte, typically a code address owned by
+        // the current process. Volatile read prevents the compiler from
+        // optimizing away the breakpoint probe.
         let byte = unsafe { std::ptr::read_volatile(addr) };
         if byte == 0xCC {
             1
@@ -127,7 +138,7 @@ pub extern "C" fn jerboa_antidebug_timing_check(max_ns: u64) -> i32 {
         }
         std::hint::black_box(acc);
 
-        let elapsed = start.elapsed().as_nanos() as u64;
+        let elapsed = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
         if elapsed > max_ns {
             1
         } else {
@@ -170,3 +181,25 @@ pub extern "C" fn jerboa_antidebug_check_all() -> i32 {
         flags
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn ffi_breakpoint_rejects_null_address() {
+        assert_eq!(jerboa_antidebug_check_breakpoint(std::ptr::null()), -1);
+    }
+
+    #[test]
+    fn ffi_breakpoint_detects_int3_byte() {
+        let byte = 0xCCu8;
+        assert_eq!(jerboa_antidebug_check_breakpoint(&byte), 1);
+    }
+
+    #[test]
+    fn ffi_breakpoint_accepts_non_int3_byte() {
+        let byte = 0x90u8;
+        assert_eq!(jerboa_antidebug_check_breakpoint(&byte), 0);
+    }
+}