security: harden epoll ffi

ober

36fa5ca3077afd21612b3eb96d207860ac723978

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 02d2f87..de5aabf 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 176)
-    (unsafe-sites-without-nearby-safety-comment 254)
-    (unsafe-comment-review-sites 254)
+    (unsafe-sites-with-nearby-safety-comment 182)
+    (unsafe-sites-without-nearby-safety-comment 248)
+    (unsafe-comment-review-sites 248)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -109,8 +109,11 @@ device-free regressions for null pointer rejection. `panic.rs` now has an
 buffer writes. `inotify_native.rs` now documents inotify syscall, path-buffer,
 read-buffer, event-count bytevector, and close invariants; it rejects null or
 empty paths, oversized lengths, interior NUL path truncation, and null output
-pointers before crossing into libc. The generated inventory now reports 176
-annotated native unsafe sites and 254 remaining unsafe review sites.
+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.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index df7b77b..b959601 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -654,8 +654,11 @@ not started."
   documents inotify syscall, path-buffer, read-buffer, event-count bytevector,
   and close invariants; it rejects null or empty paths, oversized lengths,
   interior NUL path truncation, and null output pointers before crossing into
-  libc. The generated report now shows 176 annotated native unsafe sites and
-  254 remaining unsafe review sites.
+  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
+  remaining unsafe review sites.
   Remaining work: continue unsafe invariant comments across the rest of
   `jerboa-native-rs`.
 
@@ -1145,7 +1148,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 | 254 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 248 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 fea3461..accf29a 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -54,13 +54,13 @@
     (export (symbol "jerboa_duckdb_value_is_null") (file "jerboa-native-rs/src/duckdb_native.rs") (line 725) (scheme-callers 1 (caller (file "lib/std/db/duckdb-native.ss") (line 81))))
     (export (symbol "jerboa_duckdb_value_text") (file "jerboa-native-rs/src/duckdb_native.rs") (line 822) (scheme-callers 1 (caller (file "lib/std/db/duckdb-native.ss") (line 89))))
     (export (symbol "jerboa_duckdb_version") (file "jerboa-native-rs/src/duckdb_native.rs") (line 1002) (scheme-callers 1 (caller (file "lib/std/db/duckdb-native.ss") (line 98))))
-    (export (symbol "jerboa_epoll_close") (file "jerboa-native-rs/src/epoll.rs") (line 81) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 59))))
-    (export (symbol "jerboa_epoll_create") (file "jerboa-native-rs/src/epoll.rs") (line 6) (scheme-callers 2 (caller (file "lib/std/os/epoll-native.ss") (line 30)) (caller (file "lib/std/os/epoll-native.ss") (line 50))))
-    (export (symbol "jerboa_epoll_ctl") (file "jerboa-native-rs/src/epoll.rs") (line 22) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 53))))
-    (export (symbol "jerboa_epoll_wait") (file "jerboa-native-rs/src/epoll.rs") (line 42) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 56))))
-    (export (symbol "jerboa_eventfd_create") (file "jerboa-native-rs/src/epoll.rs") (line 94) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 293))))
-    (export (symbol "jerboa_eventfd_drain") (file "jerboa-native-rs/src/epoll.rs") (line 120) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 299))))
-    (export (symbol "jerboa_eventfd_signal") (file "jerboa-native-rs/src/epoll.rs") (line 106) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 296))))
+    (export (symbol "jerboa_epoll_close") (file "jerboa-native-rs/src/epoll.rs") (line 107) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 59))))
+    (export (symbol "jerboa_epoll_create") (file "jerboa-native-rs/src/epoll.rs") (line 7) (scheme-callers 2 (caller (file "lib/std/os/epoll-native.ss") (line 30)) (caller (file "lib/std/os/epoll-native.ss") (line 50))))
+    (export (symbol "jerboa_epoll_ctl") (file "jerboa-native-rs/src/epoll.rs") (line 25) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 53))))
+    (export (symbol "jerboa_epoll_wait") (file "jerboa-native-rs/src/epoll.rs") (line 47) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 56))))
+    (export (symbol "jerboa_eventfd_create") (file "jerboa-native-rs/src/epoll.rs") (line 122) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 293))))
+    (export (symbol "jerboa_eventfd_drain") (file "jerboa-native-rs/src/epoll.rs") (line 156) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 299))))
+    (export (symbol "jerboa_eventfd_signal") (file "jerboa-native-rs/src/epoll.rs") (line 136) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 296))))
     (export (symbol "jerboa_gunzip") (file "jerboa-native-rs/src/compress.rs") (line 162) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 39))))
     (export (symbol "jerboa_gzip") (file "jerboa-native-rs/src/compress.rs") (line 116) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 36))))
     (export (symbol "jerboa_hmac_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 206) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 109)) (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
diff --git a/docs/status.md b/docs/status.md
index 0038482..685d93c 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, and inotify ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 254. | 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, 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. |
 | 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/epoll.rs b/jerboa-native-rs/src/epoll.rs
index 79ad9e9..470f543 100644
--- a/jerboa-native-rs/src/epoll.rs
+++ b/jerboa-native-rs/src/epoll.rs
@@ -1,10 +1,13 @@
 use crate::panic::ffi_wrap;
 
 // epoll via libc syscalls — no external crate needed
+const MAX_EPOLL_EVENTS: i32 = 4096;
 
 #[no_mangle]
 pub extern "C" fn jerboa_epoll_create() -> i32 {
     ffi_wrap(|| {
+        // SAFETY: epoll_create1 has no pointer arguments; the flag is a libc
+        // constant and the returned fd is checked for failure before use.
         let fd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
         if fd < 0 {
             crate::panic::set_last_error(format!(
@@ -25,6 +28,8 @@ pub extern "C" fn jerboa_epoll_ctl(epfd: i32, op: i32, fd: i32, events: u32) -> 
             events,
             u64: fd as u64,
         };
+        // SAFETY: event is a valid, initialized epoll_event for the duration
+        // of the call. epoll_ctl does not retain the pointer after return.
         let rc = unsafe { libc::epoll_ctl(epfd, op, fd, &mut event) };
         if rc < 0 {
             crate::panic::set_last_error(format!("epoll_ctl: {}", std::io::Error::last_os_error()));
@@ -47,11 +52,29 @@ pub extern "C" fn jerboa_epoll_wait(
 ) -> i32 {
     ffi_wrap(|| {
         if events_out.is_null() || max_events <= 0 {
+            crate::panic::set_last_error(
+                "null epoll output pointer or nonpositive event count".into(),
+            );
             return -1;
         }
+        if max_events > MAX_EPOLL_EVENTS {
+            crate::panic::set_last_error("epoll event count exceeds native cap".into());
+            return -1;
+        }
+        let max_events_usize = max_events as usize;
+        let output_len = match max_events_usize.checked_mul(8) {
+            Some(len) => len,
+            None => {
+                crate::panic::set_last_error("epoll output length overflow".into());
+                return -1;
+            }
+        };
         let mut events: Vec<libc::epoll_event> =
-            vec![libc::epoll_event { events: 0, u64: 0 }; max_events as usize];
+            vec![libc::epoll_event { events: 0, u64: 0 }; max_events_usize];
         let n = loop {
+            // SAFETY: events is an initialized Vec<epoll_event> with capacity
+            // for max_events entries. epoll_wait writes at most max_events
+            // entries and does not retain the pointer.
             let r = unsafe { libc::epoll_wait(epfd, events.as_mut_ptr(), max_events, timeout_ms) };
             if r < 0 {
                 let err = std::io::Error::last_os_error();
@@ -65,7 +88,10 @@ pub extern "C" fn jerboa_epoll_wait(
             break r;
         };
         // Copy results to output buffer: each entry is (fd:i32, events:u32) = 8 bytes
-        let out = unsafe { std::slice::from_raw_parts_mut(events_out, (max_events as usize) * 8) };
+        // SAFETY: events_out is non-null, output_len is derived by checked
+        // multiplication from the bounded max_events count, and the Scheme
+        // caller allocates exactly max_events * 8 writable bytes.
+        let out = unsafe { std::slice::from_raw_parts_mut(events_out, output_len) };
         for i in 0..n as usize {
             let fd = events[i].u64 as i32;
             let ev = events[i].events;
@@ -80,6 +106,8 @@ pub extern "C" fn jerboa_epoll_wait(
 #[no_mangle]
 pub extern "C" fn jerboa_epoll_close(epfd: i32) -> i32 {
     ffi_wrap(|| {
+        // SAFETY: close takes only the integer file descriptor. libc reports
+        // invalid or already-closed descriptors via the return code.
         let rc = unsafe { libc::close(epfd) };
         if rc < 0 {
             crate::panic::set_last_error(format!("close: {}", std::io::Error::last_os_error()));
@@ -93,6 +121,8 @@ pub extern "C" fn jerboa_epoll_close(epfd: i32) -> i32 {
 #[no_mangle]
 pub extern "C" fn jerboa_eventfd_create() -> i32 {
     ffi_wrap(|| {
+        // SAFETY: eventfd has no pointer arguments; the flags are libc
+        // constants and the returned fd is checked for failure before use.
         let fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
         if fd < 0 {
             crate::panic::set_last_error(format!("eventfd: {}", std::io::Error::last_os_error()));
@@ -106,8 +136,14 @@ pub extern "C" fn jerboa_eventfd_create() -> i32 {
 pub extern "C" fn jerboa_eventfd_signal(fd: i32) -> i32 {
     ffi_wrap(|| {
         let val: u64 = 1;
+        // SAFETY: val is a live u64 stack slot and the write length is exactly
+        // its size. libc::write does not retain the pointer.
         let rc = unsafe { libc::write(fd, &val as *const u64 as *const libc::c_void, 8) };
         if rc < 0 {
+            crate::panic::set_last_error(format!(
+                "eventfd write: {}",
+                std::io::Error::last_os_error()
+            ));
             -1
         } else {
             0
@@ -120,11 +156,43 @@ pub extern "C" fn jerboa_eventfd_signal(fd: i32) -> i32 {
 pub extern "C" fn jerboa_eventfd_drain(fd: i32) -> i32 {
     ffi_wrap(|| {
         let mut val: u64 = 0;
+        // SAFETY: val is a live u64 stack slot and the read length is exactly
+        // its size. libc::read does not retain the pointer.
         let rc = unsafe { libc::read(fd, &mut val as *mut u64 as *mut libc::c_void, 8) };
         if rc < 0 {
+            let err = std::io::Error::last_os_error();
+            if err.raw_os_error() == Some(libc::EAGAIN) {
+                return 0;
+            }
+            crate::panic::set_last_error(format!("eventfd read: {}", err));
             -1
         } else {
             0
         }
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn ffi_wait_rejects_null_output_before_syscall() {
+        assert_eq!(jerboa_epoll_wait(-1, std::ptr::null_mut(), 1, 0), -1);
+    }
+
+    #[test]
+    fn ffi_wait_rejects_nonpositive_event_count_before_syscall() {
+        let mut output = [0u8; 8];
+        assert_eq!(jerboa_epoll_wait(-1, output.as_mut_ptr(), 0, 0), -1);
+    }
+
+    #[test]
+    fn ffi_wait_rejects_event_count_above_cap_before_allocation() {
+        let mut output = [0u8; 8];
+        assert_eq!(
+            jerboa_epoll_wait(-1, output.as_mut_ptr(), MAX_EPOLL_EVENTS + 1, 0),
+            -1
+        );
+    }
+}