security: gate vendored jsqlite advisory review

Jaime Fournier

0168b463396753c6b3143d2ac20c4f47ce614635

diff --git a/.jerboa-system b/.jerboa-system
index 1f84577..7538ae8 100644
--- a/.jerboa-system
+++ b/.jerboa-system
@@ -49,7 +49,7 @@
        "*.sls" "*.so" "*.wpo" "*.boot")))
 
   (dependencies
-    (vendored ("vendor/ChezScheme"))
+    (vendored ("vendor/ChezScheme" "vendor/jsqlite"))
     (native ("jerboa-native-rs" "browser-repl"))
     (external-tools ("cc" "cargo" "make" "tar" "ln" "rm" "mkdir" "find"
                      "shasum|sha256sum")))
@@ -106,6 +106,7 @@
     (signing planned)
     (sbom planned)
     (tcb ("Chez Scheme" "Jerboa stdlib" "jerboa-native-rs" "browser-repl"
+          "vendor/jsqlite"
           "support/multicall-main.c" "support/jerboa-embed.c"
           "support/jerboa-embed.h"
           "support/hardening-report.h"
diff --git a/Makefile b/Makefile
index 91a0dc4..b361ff4 100644
--- a/Makefile
+++ b/Makefile
@@ -76,7 +76,7 @@ TYPED_LLVMIR_PARITY_FN ?= sample_typed_llvmir_smoke::main
 LLVM_BIN ?= $(shell if command -v llvm-as >/dev/null 2>&1; then dirname "$$(command -v llvm-as)"; elif [ -x /opt/homebrew/opt/llvm/bin/llvm-as ]; then echo /opt/homebrew/opt/llvm/bin; elif [ -x /usr/local/opt/llvm/bin/llvm-as ]; then echo /usr/local/opt/llvm/bin; fi)
 TYPED_WRAPPER_DIR ?= build/typed/jerboa
 
-.PHONY: help chez static-supported-check chez-cross build binary binary-typed binary-typed-smoke binary-cross native-cross pure-audit typecheck typed-rust typed-llvmir typed-llvmir-check typed-llvmir-smoke typed-llvmir-parity typed-wrappers typed-build typed-wrapper-smoke typed-split-tree-smoke typed-test typed-clean test test-known-flaky test-reader test-core test-runtime test-try-debug test-stdlib test-ffi test-modules test-expanded test-contract test-ergo test-sqlite-robustness test-limits-primitives test-typed-core test-typed-parser test-typed-checker test-typed-rust test-typed-kotlin test-typed-llvmir test-typed-wrappers test-pure-audit test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-security-profile test-native test-gaps native clean-native audit audit-native jpkg-audit tcb-drift-check import-policy-check source-balance restrict-closure-check security-audit clean security security-production security-profile security-hardware-smoke sbom reproducibility-report reproducibility-compare verify release-evidence fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all check-docs check-docs-strict data-check docker-build docker-push lint
+.PHONY: help chez static-supported-check chez-cross build binary binary-typed binary-typed-smoke binary-cross native-cross pure-audit typecheck typed-rust typed-llvmir typed-llvmir-check typed-llvmir-smoke typed-llvmir-parity typed-wrappers typed-build typed-wrapper-smoke typed-split-tree-smoke typed-test typed-clean test test-known-flaky test-reader test-core test-runtime test-try-debug test-stdlib test-ffi test-modules test-expanded test-contract test-ergo test-sqlite-robustness test-limits-primitives test-typed-core test-typed-parser test-typed-checker test-typed-rust test-typed-kotlin test-typed-llvmir test-typed-wrappers test-pure-audit test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-security-profile test-native test-gaps native clean-native audit audit-native jpkg-audit vendor-jsqlite-security-check tcb-drift-check import-policy-check source-balance restrict-closure-check security-audit clean security security-production security-profile security-hardware-smoke sbom reproducibility-report reproducibility-compare verify release-evidence fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all check-docs check-docs-strict data-check docker-build docker-push lint
 .PHONY: check-cross-tools docker fuzz-websocket-fuzz jlsp jlsp-freebsd-amd64 \
         jlsp-install jlsp-linux-amd64 jlsp-portable jmcp-freebsd-amd64 \
         jmcp-freebsd-arm64 jmcp-linux-amd64 jmcp-linux-arm64 \
@@ -137,6 +137,7 @@ help:
 	@echo "  clean-native     Remove Rust build artifacts"
 	@echo "  audit            Run production security/release gate checks"
 	@echo "  audit-native     Run cargo audit on Rust native library"
+	@echo "  vendor-jsqlite-security-check  Verify vendored jsqlite TCB review freshness"
 	@echo "  pure-audit       Scan jerboa-* repos for non-pure runtime surfaces"
 	@echo "  sbom             Write local SBOM/toolchain evidence under dist/sbom"
 	@echo "  reproducibility-report  Compare two local multicall builds"
@@ -1742,6 +1743,9 @@ audit-native:
 jpkg-audit: build
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tools/jpkg-main.ss audit
 
+vendor-jsqlite-security-check:
+	@support/check-vendor-jsqlite-security.sh
+
 tcb-drift-check:
 	@set -eu; \
 	if git diff --quiet -- .jerboa-system support/container-dependencies.lock \
@@ -1775,7 +1779,7 @@ unsafe-deserialize-check:
 import-policy-check: build
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tools/check-import-policy.ss examples
 
-audit: source-balance restrict-closure-check unsafe-deserialize-check import-policy-check security-audit audit-native jpkg-audit tcb-drift-check data-check security-production unification-check
+audit: source-balance restrict-closure-check unsafe-deserialize-check import-policy-check security-audit audit-native jpkg-audit vendor-jsqlite-security-check tcb-drift-check data-check security-production unification-check
 
 test-gaps:
 	$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-gaps.ss
diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index f389bae..291a44b 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -23,7 +23,7 @@ Latest summary from `tools/ffi-audit-report.ss`:
 (ffi-audit-report
   (schema "jerboa.ffi-audit/1")
   (scheme
-    (file-count 1202)
+    (file-count 1204)
     (site-count 904)
     (foreign-procedure 810)
     (load-shared-object 80)
@@ -46,7 +46,7 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (unsafe-comment-review-sites 433)
     (export-review-sites 231))
   (vendor
-    (jsqlite (path "vendor/jsqlite") (status accepted-risk-pending-cve-gate)))
+    (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
   (verdict needs-per-binding-review))
 ```
 
@@ -79,4 +79,7 @@ Remaining work before closing K3-P1-01:
 - Reconcile the 231 Rust `#[no_mangle]` exports with live Scheme callers and
   unexport or document unused exports.
 - Keep `vendor/jsqlite` in the TCB until consumers move to the Rust sqlite
-  path or release gates include a dedicated CVE freshness check.
+  path. The accepted-risk decision is now recorded in
+  `docs/vendor-jsqlite-tcb.md`, and `make audit` runs
+  `vendor-jsqlite-security-check` to fail stale advisory reviews or drift
+  between the decision record and `vendor/jsqlite/README.md`.
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index e292319..8191c0f 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -304,7 +304,7 @@ 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, 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 |
+| G1 shrink target | Managed core is memory-safe; parsers budgeted; safe prelude exists; FFI audit inventory, per-site provisional verdicts, FFI hazard scanner rules, and a gated `vendor/jsqlite` accepted-risk decision exist | FFI remediation remains: unsafe invariant comments and native export shrinking/justification |
 | G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, memory rlimit pre-exec path, egress proxy env wiring, authenticated actor transport/envelopes, Ring HTTP source taint, and WebSocket/fiber WebSocket source taint all exist | **No native pre-exec worker backend yet for kernel sandbox controls before child input** |
 | 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 |
@@ -608,8 +608,11 @@ not started."
   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: unsafe invariant comments, export
-  shrinking/justification, and the `vendor/jsqlite` CVE/replacement decision.
+  unsafe operations. `vendor/jsqlite` remains in the TCB with an accepted-risk
+  decision in `docs/vendor-jsqlite-tcb.md`; `make audit` now runs
+  `vendor-jsqlite-security-check`, which fails stale advisory reviews or drift
+  between the decision record and the vendored README compatibility target.
+  Remaining work: unsafe invariant comments and export shrinking/justification.
 
 ### K3-P1-02 — TOCTOU-safe filesystem capability checks
 **Serves:** G2. **Effort:** 1 week.
@@ -1190,9 +1193,9 @@ fake confidence happens.
 - 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, per-site provisional
-  verdicts, and committed hazard scanner rules; unsafe invariant comments,
-  export shrinking/justification, and the `vendor/jsqlite` TCB decision remain
-  open.
+  verdicts, committed hazard scanner rules, and a `vendor/jsqlite` accepted-risk
+  decision gated by `make audit`; unsafe invariant comments and export
+  shrinking/justification 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/release-security.md b/docs/release-security.md
index 65c4133..c6780e6 100644
--- a/docs/release-security.md
+++ b/docs/release-security.md
@@ -38,8 +38,9 @@ make verify
 `make audit` is the pull-request gate. It checks required release/security
 files, Apache/NOTICE preservation for vendored Chez, high-confidence secret
 patterns, generated-artifact ignore policy, RustSec advisories with warnings
-denied, `data/*.sexp` schema validity, the security profile tests, and the
-non-artifact unification checks from `.jerboa-system`.
+denied, vendored `jsqlite` advisory-review freshness, `data/*.sexp` schema
+validity, the security profile tests, and the non-artifact unification checks
+from `.jerboa-system`.
 
 For reproducible-build investigations, `jerbuild binary` honors
 `JERBUILD_BINARY_OBJ_DIR` as an opt-in fixed object directory. The path must be
@@ -157,6 +158,10 @@ crates, C shims, external tools, and current FFI-site count. The audit gate
 also runs `tcb-drift-check`, which requires a `data/changelog.sexp` note when
 TCB-affecting manifests, C shims, native lockfiles, container locks, or
 vendored C inputs change.
+The same audit gate runs `vendor-jsqlite-security-check`, which requires
+`docs/vendor-jsqlite-tcb.md` to record a fresh 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 `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/vendor-jsqlite-tcb.md b/docs/vendor-jsqlite-tcb.md
new file mode 100644
index 0000000..87ee956
--- /dev/null
+++ b/docs/vendor-jsqlite-tcb.md
@@ -0,0 +1,39 @@
+# Vendored jsqlite TCB Decision
+
+Last advisory review: 2026-07-27
+Review max age: 90 days
+SQLite compatibility target: 3.54.0
+Decision: accepted-risk
+Runtime dependency: none on libsqlite3
+
+`vendor/jsqlite` remains in the Jerboa TCB for now. It is a pure Jerboa
+SQLite-compatible engine, not a dynamically linked copy of upstream
+`libsqlite3`, so `cargo audit` and system SQLite package scanners do not cover
+it. The release gate therefore treats it as an internal database engine with a
+SQLite compatibility target and requires a fresh manual advisory review.
+
+The review must check at least:
+
+- SQLite's official CVE page: <https://www.sqlite.org/cves.html>
+- NVD/OpenCVE-style entries for SQLite CVEs affecting versions at or below the
+  compatibility target.
+- Whether each advisory depends on upstream C implementation memory safety,
+  optional extensions absent from jsqlite, SQL semantics that jsqlite implements,
+  or file-format behavior that jsqlite parses or writes.
+
+Current review notes:
+
+- `vendor/jsqlite/README.md` pins compatibility to SQLite 3.54.0.
+- CVE-2025-6965 affects SQLite versions before 3.50.2 according to NVD, so the
+  pinned compatibility target is beyond the fixed upstream version. The jsqlite
+  review still needs semantic applicability checks because jsqlite reimplements
+  query planning/execution rather than linking upstream C.
+- CVE-2025-70873 and similar zipfile/extension reports must be checked for
+  extension applicability; jsqlite does not currently expose SQLite's zipfile
+  extension as a runtime dependency.
+
+Release rule:
+
+`make audit` runs `support/check-vendor-jsqlite-security.sh`. The check fails
+when this decision record is missing, the README compatibility target drifts,
+or the advisory review is older than `Review max age`.
diff --git a/support/check-vendor-jsqlite-security.sh b/support/check-vendor-jsqlite-security.sh
new file mode 100755
index 0000000..3223a42
--- /dev/null
+++ b/support/check-vendor-jsqlite-security.sh
@@ -0,0 +1,85 @@
+#!/bin/sh
+set -eu
+
+doc="${1:-docs/vendor-jsqlite-tcb.md}"
+readme="${2:-vendor/jsqlite/README.md}"
+
+python3 - "$doc" "$readme" <<'PY'
+import datetime as dt
+import re
+import sys
+from pathlib import Path
+
+doc_path = Path(sys.argv[1])
+readme_path = Path(sys.argv[2])
+
+def fail(msg):
+    print(f"vendor-jsqlite-security: {msg}", file=sys.stderr)
+    sys.exit(1)
+
+def field(text, name):
+    match = re.search(rf"^{re.escape(name)}:\s*(.+)$", text, re.MULTILINE)
+    if not match:
+        fail(f"missing {name!r} field in {doc_path}")
+    return match.group(1).strip()
+
+if not doc_path.exists():
+    fail(f"missing decision record {doc_path}")
+if not readme_path.exists():
+    fail(f"missing vendored README {readme_path}")
+
+doc = doc_path.read_text(encoding="utf-8")
+readme = readme_path.read_text(encoding="utf-8")
+
+target = field(doc, "SQLite compatibility target")
+decision = field(doc, "Decision")
+runtime_dependency = field(doc, "Runtime dependency")
+review_date_text = field(doc, "Last advisory review")
+max_age_text = field(doc, "Review max age")
+
+if decision != "accepted-risk":
+    fail(f"unsupported decision {decision!r}; expected accepted-risk")
+if runtime_dependency != "none on libsqlite3":
+    fail("runtime dependency field must state that libsqlite3 is not linked")
+
+readme_target = re.search(r"Pinned compatibility target:\s*\*\*SQLite\s+([0-9]+(?:\.[0-9]+)+)\*\*", readme)
+if not readme_target:
+    fail("could not find pinned SQLite compatibility target in vendor/jsqlite/README.md")
+if readme_target.group(1) != target:
+    fail(f"decision target {target} does not match README target {readme_target.group(1)}")
+
+for needle in (
+    "https://www.sqlite.org/cves.html",
+    "CVE-2025-6965",
+    "CVE-2025-70873",
+):
+    if needle not in doc:
+        fail(f"decision record must mention {needle}")
+
+try:
+    review_date = dt.date.fromisoformat(review_date_text)
+except ValueError:
+    fail(f"invalid Last advisory review date {review_date_text!r}; expected YYYY-MM-DD")
+
+max_age_match = re.fullmatch(r"([0-9]+)\s+days", max_age_text)
+if not max_age_match:
+    fail(f"invalid Review max age {max_age_text!r}; expected '<n> days'")
+max_age_days = int(max_age_match.group(1))
+if max_age_days <= 0:
+    fail("Review max age must be positive")
+
+today = dt.date.today()
+if review_date > today:
+    fail(f"Last advisory review {review_date} is in the future relative to {today}")
+age_days = (today - review_date).days
+if age_days > max_age_days:
+    fail(
+        f"Last advisory review is {age_days} days old; "
+        f"refresh docs/vendor-jsqlite-tcb.md before release"
+    )
+
+print(
+    "vendor-jsqlite-security: OK "
+    f"(target SQLite {target}, review age {age_days} days, max {max_age_days})"
+)
+PY
diff --git a/tools/ffi-audit-report.ss b/tools/ffi-audit-report.ss
index a587df0..cd259f0 100644
--- a/tools/ffi-audit-report.ss
+++ b/tools/ffi-audit-report.ss
@@ -329,7 +329,7 @@
                           (eq? (rust-site-verdict s) 'needs-export-review))
                         rust)))
          (vendor
-           (jsqlite (path "vendor/jsqlite") (status accepted-risk-pending-cve-gate)))
+           (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
          (verdict needs-per-binding-review)
          ,@(if full?
                `((scheme-sites ,scheme*) (rust-sites ,rust*))