security: require native export decisions
Jaime Fournier <jaimef@linbsd.org>
ce749ee25ef28121309b2e2dce5200aa043b6097
diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index c666754..2b37cbe 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -59,7 +59,9 @@ candidate calls, and unsafe Rust sites. `make native-export-review-check`
keeps the actual C ABI export/caller map in
`docs/reviews/2026-07-27-native-export-review.sexp` fresh; the current map has
225 actual exported functions, 183 tracked Scheme references, and 42 exports
-with no tracked Scheme reference. The full report appends a provisional
+with no tracked Scheme reference. The companion decision file marks 7 of those
+as retained standalone C/binary-tooling exports and 35 as removal candidates.
+The full report appends a provisional
`hazards` list and `verdict` to every Scheme and Rust site. Scheme verdicts
separate blocking calls missing `__collect_safe`, pointer/GC/ownership or
integer-width review, and lower-risk API review. Rust verdicts separate unsafe
@@ -80,8 +82,7 @@ Remaining work before closing K3-P1-01:
- Add targeted scanner rules for null-return checks, integer width confusion,
caller-supplied length trust, and bytevector pointer lifetime hazards.
- Add `SAFETY:` invariant comments near Rust unsafe sites.
-- Use the native export review's no-Scheme-reference set to unexport or
- explicitly retain each currently unused Rust C ABI export.
+- Remove or wrap the 35 native export-review removal candidates.
- Keep `vendor/jsqlite` in the TCB until consumers move to the Rust sqlite
path. The accepted-risk decision is now recorded in
`docs/reviews/2026-07-27-vendor-jsqlite-tcb.md`, and `make audit` runs
diff --git a/docs/index.md b/docs/index.md
index a9f79b5..2adbe13 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -76,7 +76,7 @@ work.
- [safety-guide.md](safety-guide.md) - practical secure-application guide for Jerboa programs
- [release-security.md](release-security.md) - release gates and security evidence
- [reviews/2026-07-27-vendor-jsqlite-tcb.md](reviews/2026-07-27-vendor-jsqlite-tcb.md) - accepted-risk review for the vendored SQLite-compatible engine
-- [reviews/2026-07-27-native-export-review.md](reviews/2026-07-27-native-export-review.md) - native Rust export/caller review and drift gate
+- [reviews/2026-07-27-native-export-review.md](reviews/2026-07-27-native-export-review.md) - native Rust export/caller review, zero-caller decisions, and drift gate
- [capability.md](capability.md) - object-capability model
- [limits.md](limits.md) - resource limits, sandboxing, and capability primitives
- [harden.md](harden.md) - binary hardening for release artifacts
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 5d9a3fd..f5206be 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -613,10 +613,11 @@ not started."
`vendor-jsqlite-security-check`, which fails stale advisory reviews or drift
between the decision record and the vendored README compatibility target.
`make audit` also runs `native-export-review-check`, backed by
- `docs/reviews/2026-07-27-native-export-review.sexp`, to keep the Rust C ABI
- export/caller map current. Remaining work: unsafe invariant comments and
- shrinking or explicitly retaining the 42 exports with no tracked Scheme
- reference.
+ `docs/reviews/2026-07-27-native-export-review.sexp` plus
+ `docs/reviews/2026-07-27-native-export-decisions.sexp`, to keep the Rust C
+ ABI export/caller map current and require a decision for each zero-caller
+ export. Remaining work: unsafe invariant comments and removing or wrapping
+ the 35 exports marked removal candidates.
### K3-P1-02 — TOCTOU-safe filesystem capability checks
**Serves:** G2. **Effort:** 1 week.
diff --git a/docs/release-security.md b/docs/release-security.md
index 041a4b7..96c4d25 100644
--- a/docs/release-security.md
+++ b/docs/release-security.md
@@ -164,7 +164,9 @@ accepted-risk review for the vendored pure-Jerboa SQLite-compatible engine and
to match the pinned SQLite compatibility target in `vendor/jsqlite/README.md`.
It also runs `native-export-review-check`, which compares
`docs/reviews/2026-07-27-native-export-review.sexp` against the current Rust
-C ABI export set and tracked Scheme caller map.
+C ABI export set and tracked Scheme caller map, and requires
+`docs/reviews/2026-07-27-native-export-decisions.sexp` to cover every export
+with no tracked Scheme caller.
It also runs `support/check-release-path-leaks.sh` against `dist/jerboa` and
requires `path_leak_status=pass` in `path-leaks.txt`. Release packaging runs
the same checker against the final `.tar.gz`, so shipped artifacts cannot
diff --git a/docs/reviews/2026-07-27-native-export-decisions.sexp b/docs/reviews/2026-07-27-native-export-decisions.sexp
new file mode 100644
index 0000000..7869f83
--- /dev/null
+++ b/docs/reviews/2026-07-27-native-export-decisions.sexp
@@ -0,0 +1,47 @@
+(native-export-decisions
+ (schema "jerboa.native-export-decisions/1")
+ (review-date "2026-07-27")
+ (policy zero-scheme-reference-requires-decision)
+ (exports
+ (export (symbol "ed25519_derive_pubkey_standalone") (decision retain-standalone-c-abi) (reason "standalone signing helper for release/binary tooling outside Scheme foreign-procedure callers"))
+ (export (symbol "ed25519_sign_standalone") (decision retain-standalone-c-abi) (reason "standalone signing helper for release/binary tooling outside Scheme foreign-procedure callers"))
+ (export (symbol "embed_decrypt") (decision retain-standalone-c-abi) (reason "binary embedding and encrypted-boot support API, intentionally consumed outside Scheme source"))
+ (export (symbol "embed_encrypt") (decision retain-standalone-c-abi) (reason "binary embedding and encrypted-boot support API, intentionally consumed outside Scheme source"))
+ (export (symbol "embed_pbkdf2_sha256") (decision retain-standalone-c-abi) (reason "binary embedding and encrypted-boot support API, intentionally consumed outside Scheme source"))
+ (export (symbol "embed_random_bytes") (decision retain-standalone-c-abi) (reason "binary embedding and encrypted-boot support API, intentionally consumed outside Scheme source"))
+ (export (symbol "embed_read_passphrase") (decision retain-standalone-c-abi) (reason "binary embedding and encrypted-boot support API, intentionally consumed outside Scheme source"))
+ (export (symbol "jerboa_aproc_close") (decision removal-candidate) (reason "no tracked Scheme caller; remove or add a wrapper before closing P1-01"))
+ (export (symbol "jerboa_aproc_dup") (decision removal-candidate) (reason "no tracked Scheme caller; remove or add a wrapper before closing P1-01"))
+ (export (symbol "jerboa_freebsd_is_traced") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if FreeBSD hardening wrapper lands"))
+ (export (symbol "jerboa_freebsd_process_count") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if FreeBSD hardening wrapper lands"))
+ (export (symbol "jerboa_hkdf_sha256") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if X25519 key-agreement wrapper lands"))
+ (export (symbol "jerboa_kill_probe") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if process-control wrapper lands"))
+ (export (symbol "jerboa_md5") (decision removal-candidate) (reason "legacy digest export with no tracked Scheme caller"))
+ (export (symbol "jerboa_mlockall") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if process-control wrapper lands"))
+ (export (symbol "jerboa_prctl_set_name") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if process-control wrapper lands"))
+ (export (symbol "jerboa_proc_self_exe") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if process-control wrapper lands"))
+ (export (symbol "jerboa_regex_compile_ex") (decision removal-candidate) (reason "no tracked Scheme caller; verify extended regex API need before retaining"))
+ (export (symbol "jerboa_setproctitle") (decision removal-candidate) (reason "no tracked Scheme caller; keep only if FreeBSD process-title wrapper lands"))
+ (export (symbol "jerboa_sha256_ctx_final") (decision removal-candidate) (reason "streaming SHA-256 context API has no tracked Scheme caller"))
+ (export (symbol "jerboa_sha256_ctx_free") (decision removal-candidate) (reason "streaming SHA-256 context API has no tracked Scheme caller"))
+ (export (symbol "jerboa_sha256_ctx_new") (decision removal-candidate) (reason "streaming SHA-256 context API has no tracked Scheme caller"))
+ (export (symbol "jerboa_sha256_ctx_update") (decision removal-candidate) (reason "streaming SHA-256 context API has no tracked Scheme caller"))
+ (export (symbol "jerboa_sm_fuel_remaining") (decision removal-candidate) (reason "SpiderMonkey/WASM support export has no tracked Scheme caller"))
+ (export (symbol "jerboa_sm_memory_read") (decision removal-candidate) (reason "SpiderMonkey/WASM support export has no tracked Scheme caller"))
+ (export (symbol "jerboa_sm_memory_size") (decision removal-candidate) (reason "SpiderMonkey/WASM support export has no tracked Scheme caller"))
+ (export (symbol "jerboa_sm_memory_write") (decision removal-candidate) (reason "SpiderMonkey/WASM support export has no tracked Scheme caller"))
+ (export (symbol "jerboa_socks5_server_port") (decision removal-candidate) (reason "SOCKS5 proxy export has no tracked Scheme caller"))
+ (export (symbol "jerboa_socks5_server_start") (decision removal-candidate) (reason "SOCKS5 proxy export has no tracked Scheme caller"))
+ (export (symbol "jerboa_socks5_server_stats") (decision removal-candidate) (reason "SOCKS5 proxy export has no tracked Scheme caller"))
+ (export (symbol "jerboa_socks5_server_stop") (decision removal-candidate) (reason "SOCKS5 proxy export has no tracked Scheme caller"))
+ (export (symbol "jerboa_tls_connect_mtls_mem") (decision removal-candidate) (reason "mTLS memory-buffer variant has no tracked Scheme caller"))
+ (export (symbol "jerboa_tls_connect_mtls_pem_ca") (decision removal-candidate) (reason "mTLS PEM-CA variant has no tracked Scheme caller"))
+ (export (symbol "jerboa_tls_server_new_mtls_pem") (decision removal-candidate) (reason "TLS server mTLS constructor has no tracked Scheme caller"))
+ (export (symbol "jerboa_tls_server_new_pem") (decision removal-candidate) (reason "TLS server PEM constructor has no tracked Scheme caller"))
+ (export (symbol "jerboa_wasm_allow_cdb_dir") (decision removal-candidate) (reason "WASM host permission export has no tracked Scheme caller"))
+ (export (symbol "jerboa_wasm_set_socket") (decision removal-candidate) (reason "WASM socket injection export has no tracked Scheme caller"))
+ (export (symbol "jerboa_x25519_diffie_hellman") (decision removal-candidate) (reason "X25519 export has no tracked Scheme caller"))
+ (export (symbol "jerboa_x25519_generate_keypair") (decision removal-candidate) (reason "X25519 export has no tracked Scheme caller"))
+ (export (symbol "jerboa_x25519_public_from_private") (decision removal-candidate) (reason "X25519 export has no tracked Scheme caller"))
+ (export (symbol "jerboa_x509_generate_self_signed_mem") (decision removal-candidate) (reason "memory-buffer X.509 variant has no tracked Scheme caller"))
+ (export (symbol "jerboa_x509_generate_signed_by_ca_mem") (decision removal-candidate) (reason "memory-buffer X.509 CA-signing variant has no tracked Scheme caller"))))
diff --git a/docs/reviews/2026-07-27-native-export-review.md b/docs/reviews/2026-07-27-native-export-review.md
index 7080f8a..0106e29 100644
--- a/docs/reviews/2026-07-27-native-export-review.md
+++ b/docs/reviews/2026-07-27-native-export-review.md
@@ -2,6 +2,7 @@
Review date: 2026-07-27
Review artifact: `docs/reviews/2026-07-27-native-export-review.sexp`
+Decision artifact: `docs/reviews/2026-07-27-native-export-decisions.sexp`
Gate: `make native-export-review-check`
This review reconciles Rust `#[no_mangle] extern "C"` exports against tracked
@@ -13,6 +14,8 @@ Current summary:
- Actual Rust C ABI exports: 225
- Exports referenced by tracked Scheme source: 183
- Exports with no tracked Scheme reference: 42
+- Zero-caller exports explicitly retained for standalone C/binary tooling: 7
+- Zero-caller exports marked as removal candidates: 35
The broader FFI audit still reports raw `no_mangle` marker sites separately;
this review counts only actual exported Rust functions.
@@ -21,6 +24,6 @@ Release rule:
`make audit` runs `support/check-native-export-review.sh --check`. The check
fails when the committed review artifact does not match the current Rust export
-set or the tracked Scheme caller map. New exports must therefore either gain a
-Scheme caller, be removed, or remain visible in the no-Scheme-reference set for
-an explicit follow-up decision.
+set, the tracked Scheme caller map, or the required decision manifest for every
+zero-caller export. New exports must therefore either gain a Scheme caller, be
+removed, or get an explicit retain/remove-candidate decision.
diff --git a/support/check-native-export-review.sh b/support/check-native-export-review.sh
index ee2a83e..818a42c 100755
--- a/support/check-native-export-review.sh
+++ b/support/check-native-export-review.sh
@@ -3,6 +3,7 @@ set -eu
mode="${1:---check}"
report="${2:-docs/reviews/2026-07-27-native-export-review.sexp}"
+decisions="${3:-docs/reviews/2026-07-27-native-export-decisions.sexp}"
case "$mode" in
--check|--write) ;;
@@ -12,7 +13,7 @@ case "$mode" in
;;
esac
-python3 - "$mode" "$report" <<'PY'
+python3 - "$mode" "$report" "$decisions" <<'PY'
import pathlib
import re
import subprocess
@@ -20,6 +21,7 @@ import sys
mode = sys.argv[1]
report_path = pathlib.Path(sys.argv[2])
+decisions_path = pathlib.Path(sys.argv[3])
repo = pathlib.Path(".")
def fail(msg):
@@ -89,6 +91,12 @@ def collect_callers(exports, scheme_paths):
by_symbol[symbol].append((str(path), line_no))
return by_symbol
+def collect_decisions():
+ if not decisions_path.exists():
+ fail(f"missing decisions {decisions_path}")
+ text = decisions_path.read_text(encoding="utf-8")
+ return set(re.findall(r'\(export\s+\(symbol\s+"([^"]+)"\)', text))
+
def render(exports, callers):
total = len(exports)
referenced = sum(1 for item in exports if callers[item["symbol"]])
@@ -135,5 +143,13 @@ else:
expected = report_path.read_text(encoding="utf-8")
if expected != current:
fail(f"{report_path} is stale; run support/check-native-export-review.sh --write")
+ decided = collect_decisions()
+ zero_caller = {item["symbol"] for item in exports if not callers[item["symbol"]]}
+ missing = sorted(zero_caller - decided)
+ unknown = sorted(decided - {item["symbol"] for item in exports})
+ if missing:
+ fail("missing decisions for no-Scheme-reference exports: " + ", ".join(missing))
+ if unknown:
+ fail("decisions mention non-exported symbols: " + ", ".join(unknown))
print(f"native-export-review: OK ({len(exports)} exports)")
PY