security: add taint propagation benchmark

Jaime Fournier

5bcaf970089c5732a8c436e6a9362dcbd9110576

diff --git a/benchmarks/bench-taint.ss b/benchmarks/bench-taint.ss
new file mode 100644
index 0000000..4faf7ce
--- /dev/null
+++ b/benchmarks/bench-taint.ss
@@ -0,0 +1,68 @@
+#!/usr/bin/env scheme-script
+#!chezscheme
+;;; bench-taint.ss -- measure overhead of taint propagation wrappers.
+;;;
+;;; Run: bin/jerboa run benchmarks/bench-taint.ss [iterations]
+;;; Default iterations: 200000
+
+(import (scheme)
+        (std security taint))
+
+(define (monotonic-ms)
+  (let ([now (current-time 'time-monotonic)])
+    (+ (* (time-second now) 1000)
+       (quotient (time-nanosecond now) 1000000))))
+
+(define (decimal-positive-integer s default)
+  (let ([len (string-length s)])
+    (let loop ([i 0] [n 0])
+      (cond
+        [(= i len) (if (> n 0) n default)]
+        [else
+         (let ([ch (string-ref s i)])
+           (if (and (char>=? ch #\0) (char<=? ch #\9))
+               (loop (+ i 1) (+ (* n 10) (- (char->integer ch) 48)))
+               default))]))))
+
+(define (iterations-from-argv default)
+  (let ([args (command-line)])
+    (if (and (pair? args) (pair? (cdr args)))
+        (decimal-positive-integer (cadr args) default)
+        default)))
+
+(define (bench name iterations thunk)
+  (let ([started (monotonic-ms)]
+        [result (thunk iterations)])
+    (let ([elapsed (- (monotonic-ms) started)])
+      (printf "~a\n" name)
+      (printf "  iterations: ~a\n" iterations)
+      (printf "  elapsed-ms: ~a\n" elapsed)
+      (printf "  result: ~a\n\n" result)
+      elapsed)))
+
+(define (clean-string-work iterations)
+  (let loop ([i 0] [sum 0])
+    (if (= i iterations)
+        sum
+        (let ([s (string-append "prefix-" "payload" "-suffix")])
+          (loop (+ i 1) (+ sum (string-length s)))))))
+
+(define (tainted-string-work iterations)
+  (let ([payload (taint-http "payload")])
+    (let loop ([i 0] [sum 0])
+      (if (= i iterations)
+          sum
+          (let ([s (tainted-string-append "prefix-" payload "-suffix")])
+            (loop (+ i 1) (+ sum (tainted-string-length s))))))))
+
+(define (print-ratio clean-ms tainted-ms)
+  (if (> clean-ms 0)
+      (printf "taint-overhead-ratio: ~,3f\n"
+              (/ (exact->inexact tainted-ms)
+                 (exact->inexact clean-ms)))
+      (printf "taint-overhead-ratio: n/a (baseline below timer resolution)\n")))
+
+(let* ([iterations (iterations-from-argv 200000)]
+       [clean-ms (bench "clean string append" iterations clean-string-work)]
+       [tainted-ms (bench "tainted string append" iterations tainted-string-work)])
+  (print-ratio clean-ms tainted-ms))
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 3d675dc..47d74be 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -304,8 +304,8 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 
 | Goal | Where we stand | The gap |
 |---|---|---|
-| G1 shrink target | Managed core is memory-safe; parsers budgeted; safe prelude exists; FFI audit inventory and per-site provisional verdicts exist | FFI remediation remains: null/width/bounds/GC-safety scanner rules, unsafe invariant comments, native export shrinking/justification, and the `vendor/jsqlite` decision |
-| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, memory rlimit pre-exec path, egress proxy env wiring, and authenticated actor transport/envelopes all exist | **No native pre-exec worker backend yet for kernel sandbox controls before child input**; protocol/network taint source defaults and performance measurement remain open |
+| G1 shrink target | Managed core is memory-safe; parsers budgeted; safe prelude exists; FFI audit inventory, per-site provisional verdicts, and FFI hazard scanner rules exist | FFI remediation remains: unsafe invariant comments, native export shrinking/justification, and the `vendor/jsqlite` decision |
+| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, memory rlimit pre-exec path, egress proxy env wiring, and authenticated actor transport/envelopes all exist | **No native pre-exec worker backend yet for kernel sandbox controls before child input**; protocol/network taint source defaults remain open |
 | G3 find it first | 13 harnesses, scanner w/ rule DB, lint | No corpora, no crash regression, no scheduled fuzzing, no standing AI-red-team, no exploit-shaped regression suite |
 | G4 fail closed | Raw-fork launchers retired correctly; `allow-degraded?` explicit | New controls must keep the invariant; degraded-mode warnings must be test-locked |
 | G5 recover fast | SBOM/repro/signing gates exist | TCB accounting manual; doc drift (stale tables, undocumented modules); independent-builder reproducibility not yet routine |
@@ -603,11 +603,13 @@ not started."
   Rust sites. The reporter now skips comment-only mentions, emits per-site
   `hazards` and provisional `verdict` fields in `--full` output, and summarizes
   blocking review, pointer/width safety review, unsafe-comment review, and
-  export-review counts. `jerboa-native-rs` now denies
+  export-review counts. `data/security-rules.sexp` includes the P1-01 FFI
+  hazard rules for collect-safe bytevector pinning, pointer return null guards,
+  integer width ambiguity, and pointer arithmetic bounds review.
+  `jerboa-native-rs` now denies
   `unsafe_op_in_unsafe_fn`, so unsafe function bodies do not implicitly permit
-  unsafe operations. Remaining work: scanner rules for null/width/bounds/
-  GC-safety hazards, unsafe invariant comments, export shrinking/
-  justification, and the `vendor/jsqlite` CVE/replacement decision.
+  unsafe operations. Remaining work: unsafe invariant comments, export
+  shrinking/justification, and the `vendor/jsqlite` CVE/replacement decision.
 
 ### K3-P1-02 — TOCTOU-safe filesystem capability checks
 **Serves:** G2. **Effort:** 1 week.
@@ -647,16 +649,16 @@ Taint is opt-in; native sinks don't check it. In the safe prelude, the
   now cover trim/split/join, UTF-8 string/bytevector conversion, bytevector
   copy, and bytevector element reads. Safe-prelude `getenv`,
   `read-file-string`, and `read-file-lines` now taint present environment
-  variables and file contents by default.
+  variables and file contents by default. `benchmarks/bench-taint.ss` gives a
+  reproducible local overhead measurement for taint propagation wrappers.
 - **Remaining:** (b) Taint sources by default for HTTP request fields and
-  network frames — wrapped at the protocol boundary. (c) Performance: measure;
-  if overhead matters, document
-  `*taint-enforce* #f` as an explicit, warned de-opt — never silent.
+  network frames — wrapped at the protocol boundary.
 - **Accept:** partially satisfied: tainted values reaching standard sink names
   in safe-prelude code raise `&taint-violation`; untainted flows are covered by
   existing safe-prelude tests; env/file-content reads are tainted by default;
-  `safety-guide.md` has a taint section. Full completion still requires
-  source-default work at protocol/network boundaries.
+  `safety-guide.md` has a taint section; taint overhead has a reproducible
+  benchmark. Full completion still requires source-default work at
+  protocol/network boundaries.
 
 ### K3-P1-04 — Decide `define-syntax` in the sandbox allowlist
 **Serves:** G1. **Effort:** 2–3 days.
@@ -1161,9 +1163,9 @@ fake confidence happens.
 - No covert-channel analysis; Chez GC is a timing side channel (P3-07).
 - Seccomp tables cover x86_64/aarch64 only; Landlock needs Linux 5.13+;
   Seatbelt/Capsicum have thinner test coverage (P1-08).
-- Safe-prelude file/shell/delete sink names now check taint, and env/file
-  content reads are source-tainted by default; protocol/network source marking
-  and performance measurement remain P1-03 follow-up.
+- Safe-prelude file/shell/delete sink names now check taint, env/file content
+  reads are source-tainted by default, and taint overhead has a benchmark;
+  protocol/network source marking remains P1-03 follow-up.
 - Distributed actor authentication is available in `(std actor transport)` and
   `(std actor distributed)` authenticated envelopes; compatibility raw
   serialization remains unauthenticated.
@@ -1178,9 +1180,10 @@ fake confidence happens.
   HMAC/AEAD/KDF/password APIs route to Rust native crypto.
 - Secure memory has a checked `secure-bytevector` API; raw secure-region
   pointers remain for explicit FFI integration.
-- FFI audit phase 5 has a reproducible inventory and per-site provisional
-  verdicts; null/width/bounds/GC-safety remediation and the `vendor/jsqlite`
-  TCB decision remain open.
+- FFI audit phase 5 has a reproducible inventory, per-site provisional
+  verdicts, and committed hazard scanner rules; unsafe invariant comments,
+  export shrinking/justification, and the `vendor/jsqlite` TCB decision remain
+  open.
 - No independent red-team evaluation yet (P2-04 starts the practice).
 - The confined exec worker facade exists as `(std security worker)`, with
   audit-log lifecycle events and native pre-exec memory rlimits on supported
diff --git a/docs/safety-guide.md b/docs/safety-guide.md
index 51d128b..d9a6431 100644
--- a/docs/safety-guide.md
+++ b/docs/safety-guide.md
@@ -292,7 +292,9 @@ In `(jerboa prelude safe)`, `getenv`, `read-file-string`, and
 are tagged as `env-input`, file contents are tagged as `file-input`, and
 missing environment-variable defaults are returned unchanged. Protocol and
 network modules still need explicit source marking unless their API documents a
-tainted-by-default boundary.
+tainted-by-default boundary. Measure local taint wrapper overhead with
+`jerboa run benchmarks/bench-taint.ss 200000` before changing enforcement
+policy for performance reasons.
 
 ### What the Contracts Check
 
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 9cd11a5..dba3372 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -359,6 +359,13 @@ The safe prelude also treats environment and file contents as taint sources:
 returns `file-input` taint, and `read-file-lines` taints each returned line.
 Missing environment-variable defaults are returned unchanged.
 
+Use `benchmarks/bench-taint.ss` to measure taint-propagation overhead on the
+current runtime and hardware:
+
+```sh
+jerboa run benchmarks/bench-taint.ss 200000
+```
+
 ### Taint-propagating string operations
 
 `tainted-string-append`, `tainted-string-ref`, `tainted-substring`,
@@ -918,7 +925,9 @@ These are known gaps documented as current limitations, not implementation promi
   Safe-prelude `getenv`, `read-file-string`, and `read-file-lines` mark
   environment and file contents by default, but raw Chez operations and unsafe
   imports do not check taint. Protocol and network sources still need explicit
-  `taint-*` marking until those modules wrap inputs by default.
+  `taint-*` marking until those modules wrap inputs by default. Track local
+  taint overhead with `benchmarks/bench-taint.ss` before accepting any
+  performance-based de-optimization.
 - **Distributed actor authentication is opt-in at the serialization layer.** `(std actor transport)` authenticates TCP traffic, and `(std actor distributed)` exposes HMAC'd envelopes with timestamp and monotonic sequence replay checks. Compatibility callers that use only `serialize-message` / `deserialize-message` still get parsing limits but no peer authentication.
 - **Filesystem capabilities are not process sandboxes.** They validate paths at
   the capability API boundary. Code with raw Chez file primitives or