Finish browser daily-driver hardening

ober

95ec1033a777d1305e96f477781804f1066a34d4

diff --git a/.build.yml b/.build.yml
index 0efdde1..8df3795 100644
--- a/.build.yml
+++ b/.build.yml
@@ -25,8 +25,8 @@ environment:
   LATEST_SITE: lisp.srht.site
   LATEST_SUBDIR: /jerboa-browser/latest
   LATEST_TARBALL: jerboa-browser-linux-amd64.tar.gz
-  JERBOA_COMMIT: a18b57415c011b66091749cb382d73346f163365
-  JERBOA_TREE: 2272f217b3d3af8d39cd12358c509cb3a1b0f92c
+  JERBOA_COMMIT: 73176e945f656334b8b1cf0d6b11c6c41f8ba427
+  JERBOA_TREE: 5e2ce0ac497d4b93d3a5859a13856fdbb0987ba3
 sources:
   - https://git.sr.ht/~lisp/jerboa-browser
 artifacts:
diff --git a/.gitignore b/.gitignore
index 5a2abed..a098cb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,5 +33,7 @@ lib/libyubikey_native.*
 # support/ensure-vendor.sh; do not commit repository copies here.
 /vendor/jerboa-fuse/
 /vendor/jerboa-yubikey/
+/vendor/adblock-rust-ffi/
 /vendor/.jerboa-fuse.tmp.*/
 /vendor/.jerboa-yubikey.tmp.*/
+/vendor/.adblock-rust-ffi.tmp.*/
diff --git a/Makefile b/Makefile
index 46c5820..8791562 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,10 @@
 # jerbuild bundles Chez Scheme + the jerboa stdlib, so building the browser
 # entry needs only `jerbuild` + a C compiler. CI uses a pinned project-local
 # release toolchain; developer machines can still override JERBUILD explicitly.
-JERBOA_VERSION ?= v0.2.4
-JERBOA_BUNDLE_SHA256 ?= 64efc7750859ac555905204cec289454c83642e4ea9b8f673985cf0fcead6194
+JERBOA_VERSION ?= v0.2.7
+JERBOA_COMMIT ?= 73176e945f656334b8b1cf0d6b11c6c41f8ba427
+JERBOA_TREE ?= 5e2ce0ac497d4b93d3a5859a13856fdbb0987ba3
+JERBOA_BUNDLE_SHA256 ?= ac8587f3b466f7d6d3047b4cafefd9985a08983503a5ad0764d4947cef25e828
 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin
 JERBUILD ?= $(JERBOA_TOOL_DIR)/jerbuild
 # Recursive on purpose: check-jerbuild may install the pinned local tool before
@@ -21,7 +23,11 @@ JERBOA_YUBIKEY_COMMIT ?= e448afca926ca9ad40fd98effa8d6264cb1019f5
 JERBOA_YUBIKEY_TREE ?= 67e4417511b63c0b41b1b9c5dc1c7d897df51f08
 JERBOA_YUBIKEY_DIR := vendor/jerboa-yubikey
 JERBOA_YUBIKEY_LIB := $(JERBOA_YUBIKEY_DIR)/lib
-JEXEC       = JERBOA_BROWSER_DEV_NATIVE=1 JERBOA_BROWSER_LIB=$(CURDIR)/qt-webengine/build/libjerboa_browser.$(SOEXT) $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/$(JERBOA_FUSE_LIB):$(JH)/lib
+ADBLOCK_RUST_FFI_URL ?= https://github.com/brave/adblock-rust-ffi
+ADBLOCK_RUST_FFI_COMMIT ?= 5382a0669bdbebaa9db740d74510c6e347fac27f
+ADBLOCK_RUST_FFI_TREE ?= d5e602ccb210678eabcf44fd1fc629db3f3581d0
+ADBLOCK_RUST_FFI_DIR := vendor/adblock-rust-ffi
+JEXEC       = JERBOA_DEV_NATIVE=1 JERBOA_NATIVE_LIB=$(CURDIR)/lib/libjerboa_native.$(SOEXT) JERBOA_BROWSER_DEV_NATIVE=1 JERBOA_BROWSER_LIB=$(CURDIR)/qt-webengine/build/libjerboa_browser.$(SOEXT) $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/$(JERBOA_FUSE_LIB):$(JH)/lib
 JERBUILD_ERROR := "ERROR: jerbuild unavailable (or '$(JERBUILD) --jerboa-home' failed). Run make ensure-jerboa-tools, install jerbuild, or set JERBUILD=/path/to/jerbuild."
 BIN_DIR ?= $(HOME)/.local/bin
 BIN ?= jerboa-browser
@@ -74,7 +80,9 @@ lib/libjerboa_native.$(SOEXT): FORCE | check-jerbuild
 	   cargo build --locked --release --no-default-features --features crypto \
 	     --manifest-path "$(JH)/jerboa-native-rs/Cargo.toml"; \
 	 fi; \
-	 if [ -f "$$src" ]; then ln -sf "$$src" "$@" && echo "staged $@ -> $$src"; \
+	 if [ -f "$$src" ]; then \
+	   rm -f "$@" && cp -f "$$src" "$@" && echo "staged $@ from $$src"; \
+	   if [ "$(SOEXT)" != "so" ]; then rm -f lib/libjerboa_native.so && cp -f "$$src" lib/libjerboa_native.so; fi; \
 	 else echo "note: libjerboa_native.$(SOEXT) unavailable; crypto tests may fail"; fi
 
 $(FUSE_SHIM): vendor-fuse $(JERBOA_FUSE_DIR)/src/mount_helper.c support/browser_secure_fs.c support/browser_secure_fs.h
@@ -99,6 +107,15 @@ vendor-yubikey:
 	  "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.lock" || \
 	  { echo "ERROR: YubiKey Cargo lock overlay mismatch" >&2; exit 1; }
 
+vendor-adblock:
+	sh support/ensure-vendor.sh adblock-rust-ffi "$(ADBLOCK_RUST_FFI_URL)" \
+	  "$(ADBLOCK_RUST_FFI_COMMIT)" "$(ADBLOCK_RUST_FFI_TREE)"
+	@test -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" || \
+	  { echo "ERROR: missing $(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" >&2; exit 1; }
+	@cmp -s support/adblock-rust-ffi-Cargo.lock \
+	  "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" || \
+	  { echo "ERROR: adblock Cargo lock overlay mismatch" >&2; exit 1; }
+
 lib/libyubikey_native.$(SOEXT): vendor-yubikey
 	$(CARGO) build --locked --manifest-path $(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml --release --no-default-features
 	@mkdir -p lib
@@ -106,7 +123,7 @@ lib/libyubikey_native.$(SOEXT): vendor-yubikey
 
 yubikey-native: lib/libyubikey_native.$(SOEXT)
 
-.PHONY: all build binary install run test test-all test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-gui test-buffers test-native test-supply-chain test-native-loader repl security audit sbom reproducibility-report binary-smoke webengine-sandbox-evidence verify release-evidence clean help check-jerbuild check-podman ensure-jerboa-tools static-qt podman-static-qt native-static vendor-fuse vendor-yubikey yubikey-native lint
+.PHONY: all build binary install run test test-all test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-fuzz test-gui test-buffers test-native test-supply-chain test-native-loader repl security audit sbom reproducibility-report binary-smoke webengine-sandbox-evidence verify release-evidence clean help check-jerbuild check-podman ensure-jerboa-tools static-qt podman-static-qt native-static vendor-fuse vendor-yubikey vendor-adblock yubikey-native lint
 .DEFAULT_GOAL := help
 
 all: binary
@@ -114,7 +131,8 @@ all: binary
 check-jerbuild:
 	@if [ "$(JERBUILD)" = "$(JERBOA_TOOL_DIR)/jerbuild" ] && \
 	   { [ ! -x "$(JERBUILD)" ] || [ "$$(cat "$(JERBOA_TOOL_DIR)/.jerboa-version" 2>/dev/null || true)" != "$(JERBOA_VERSION)" ]; }; then \
-	  sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \
+	  JERBOA_SOURCE_COMMIT="$(JERBOA_COMMIT)" JERBOA_SOURCE_TREE="$(JERBOA_TREE)" \
+	    sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \
 	fi
 	@"$(JERBUILD)" --jerboa-home >/dev/null 2>&1 || { echo $(JERBUILD_ERROR) >&2; exit 1; }
 	@if [ "$(JERBUILD)" = "$(JERBOA_TOOL_DIR)/jerbuild" ]; then \
@@ -129,7 +147,8 @@ ensure-jerboa-tools:
 	  echo "=== Using explicit Jerboa toolchain: $(JERBUILD) ==="; \
 	else \
 	  echo "=== Fetching Jerboa $(JERBOA_VERSION) release tools into $(JERBOA_TOOL_DIR) ==="; \
-	  sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \
+	  JERBOA_SOURCE_COMMIT="$(JERBOA_COMMIT)" JERBOA_SOURCE_TREE="$(JERBOA_TREE)" \
+	    sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \
 	fi
 	@"$(JERBUILD)" --jerboa-home >/dev/null || { \
 	  echo "ERROR: Jerboa toolchain is unavailable; set JERBUILD=/path/to/jerbuild or JERBOA_VERSION=<tag>"; \
@@ -152,7 +171,7 @@ native-static: check-jerbuild
 	    --manifest-path "$(JERBOA_NATIVE_CRATE_DIR)/Cargo.toml"; \
 	fi
 
-binary: check-jerbuild native-static vendor-yubikey $(FUSE_SHIM)
+binary: check-jerbuild native-static vendor-yubikey vendor-adblock $(FUSE_SHIM)
 	$(JERBUILD) build
 	@echo "" && ls -lh jerboa-browser && file jerboa-browser
 
@@ -168,7 +187,7 @@ install: binary
 
 # The crypto-using interpreter suites load (std crypto native-rust), so stage the
 # Rust crypto lib (rule above) as a prerequisite of each.
-test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock: lib/libjerboa_native.$(SOEXT) vendor-yubikey
+test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-fuzz: lib/libjerboa_native.$(SOEXT) vendor-yubikey
 test test-keymap test-minibuffer test-gui test-buffers repl: check-jerbuild vendor-fuse vendor-yubikey
 
 # Run the (browser) test suite in the interpreter (no binary needed). Depends on
@@ -177,7 +196,7 @@ test: test-securestore
 	QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \
 	  $(JEXEC) scheme/browser-test.ss
 
-test-all: test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-gui test-buffers test-native test-supply-chain
+test-all: test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-fuzz test-gui test-buffers test-native test-supply-chain
 
 lint: test-supply-chain
 
@@ -243,6 +262,10 @@ test-adblock:
 	QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \
 	  $(JEXEC) scheme/browser-adblock-test.ss
 
+test-fuzz:
+	QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \
+	  $(JEXEC) scheme/browser-fuzz-test.ss
+
 # Offline Qt GUI / snapshot harness (headless). Writes PNGs to ./test-artifacts.
 test-gui:
 	QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \
@@ -264,25 +287,25 @@ security:
 	sh scripts/security-check.sh
 	JERBUILD="$(JERBUILD)" bash test/native-loader-security-test.sh
 
-audit: vendor-yubikey
+audit: vendor-adblock vendor-yubikey
 	@mkdir -p "$(DIST_DIR)"
 	@test -x "$(CARGO_AUDIT)" || { echo "cargo-audit is required" >&2; exit 1; }
 	@"$(CARGO_AUDIT)" --version | grep -q " $(CARGO_AUDIT_VERSION)$$" || \
 	  { echo "cargo-audit $(CARGO_AUDIT_VERSION) is required" >&2; exit 1; }
-	@test -f qt-webengine/vendor/adblock-rust-ffi/Cargo.lock
-	@cd qt-webengine/vendor/adblock-rust-ffi && \
-	  "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"
+	@test -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock"
+	@cd "$(ADBLOCK_RUST_FFI_DIR)" && \
+	  "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"
 	@test -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.lock"
 	@cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && \
-	  "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"
+	  "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"
 	@test -f "$(JH)/jerboa-native-rs/Cargo.lock"
 	@cd "$(JH)/jerboa-native-rs" && \
-	  "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt"
+	  "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt"
 	@if [ -d "$$HOME/.cargo/advisory-db/.git" ]; then \
 	  git -C "$$HOME/.cargo/advisory-db" rev-parse HEAD > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; \
 	else echo unavailable > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; fi
 
-sbom: vendor-yubikey lib/libjerboa_native.$(SOEXT)
+sbom: vendor-adblock vendor-yubikey lib/libjerboa_native.$(SOEXT)
 	JBROWSER_SBOM_DIR="$(SBOM_DIR)" \
 	JERBUILD="$(JERBUILD)" \
 	BIN="$(BIN)" \
@@ -291,7 +314,7 @@ sbom: vendor-yubikey lib/libjerboa_native.$(SOEXT)
 	FUSE_SHIM="$(FUSE_SHIM)" \
 	sh scripts/sbom.sh
 
-reproducibility-report:
+reproducibility-report: vendor-adblock vendor-yubikey
 	JBROWSER_REPRO_DIR="$(REPRO_DIR)" \
 	JERBUILD="$(JERBUILD)" \
 	BIN="$(BIN)" \
@@ -333,7 +356,7 @@ release-evidence: verify
 	} > "$(DIST_DIR)/build-environment.txt"
 	@QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu ./jerboa-browser version > "$(DIST_DIR)/browser-version.txt" 2>&1 || true
 	@sh scripts/source-manifest.sh > "$(DIST_DIR)/source-sha256.txt"
-	@shasum -a 256 qt-webengine/vendor/adblock-rust-ffi/Cargo.toml qt-webengine/vendor/adblock-rust-ffi/Cargo.lock "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" support/yubikey-native-Cargo.lock > "$(DIST_DIR)/native-cargo-sha256.txt"
+	@shasum -a 256 "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" support/yubikey-native-Cargo.lock > "$(DIST_DIR)/native-cargo-sha256.txt"
 	@shasum -a 256 Makefile .jerbuild .jerboa/security.json supply-chain.lock rust-toolchain.toml SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/webengine-sandbox.md docs/release-evidence.md scripts/security-check.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/binary-smoke.sh scripts/webengine-sandbox-evidence.sh scripts/sanitize-evidence.sh support/jerboa-release-sha256.txt support/ensure-rust.sh > "$(DIST_DIR)/release-inputs-sha256.txt"
 	@shasum -a 256 "$(BIN)" > "$(DIST_DIR)/binary-sha256.txt"
 	@if command -v otool >/dev/null 2>&1; then \
@@ -343,16 +366,16 @@ release-evidence: verify
 	else \
 	  echo "no otool/ldd available" > "$(DIST_DIR)/native-linkage.txt"; \
 	fi
-	@if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f qt-webengine/vendor/adblock-rust-ffi/Cargo.toml ]; then \
-	  "$(CARGO)" metadata --locked --format-version 1 --manifest-path qt-webengine/vendor/adblock-rust-ffi/Cargo.toml > "$(DIST_DIR)/cargo-metadata-adblock-ffi.json"; \
+	@if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" ]; then \
+	  "$(CARGO)" metadata --locked --format-version 1 --manifest-path "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" > "$(DIST_DIR)/cargo-metadata-adblock-ffi.json"; \
 	fi
 	@if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" ]; then \
 	  "$(CARGO)" metadata --locked --format-version 1 --manifest-path "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" > "$(DIST_DIR)/cargo-metadata-yubikey-native.json"; \
 	fi
 	@test -x "$(CARGO_AUDIT)"
-	@cd qt-webengine/vendor/adblock-rust-ffi && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"
-	@cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"
-	@cd "$(JH)/jerboa-native-rs" && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt"
+	@cd "$(ADBLOCK_RUST_FFI_DIR)" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"
+	@cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"
+	@cd "$(JH)/jerboa-native-rs" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt"
 	@if [ -d "$$HOME/.cargo/advisory-db/.git" ]; then git -C "$$HOME/.cargo/advisory-db" rev-parse HEAD > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; else echo unavailable > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; fi
 	@rm -rf "$(DIST_DIR)/sbom" "$(DIST_DIR)/reproducibility" "$(DIST_DIR)/binary-smoke" "$(DIST_DIR)/webengine-sandbox"
 	@cp -R "$(SBOM_DIR)" "$(DIST_DIR)/sbom"
@@ -425,7 +448,7 @@ podman-static-qt: check-podman
 clean:
 	rm -f jerboa-browser jb-main.c jb-main.o jb_*.h jerboa-browser.boot
 	rm -f $(FUSE_SHIM)
-	rm -f lib/libyubikey_native.$(SOEXT)
+	rm -f lib/libyubikey_native.$(SOEXT) lib/libjerboa_native.$(SOEXT) lib/libjerboa_native.so
 	rm -f $(STATIC_QT_TARBALL)
 	rm -rf .bcache test-artifacts dist
 	find scheme -name '*.so' -delete 2>/dev/null || true
@@ -443,6 +466,7 @@ help:
 	@echo "  make binary-smoke start the binary through offscreen version"
 	@echo "  make webengine-sandbox-evidence record WebEngine sandbox status"
 	@echo "  make release-evidence write release evidence under dist/release-evidence"
+	@echo "  make vendor-adblock verify/fetch locked adblock-rust-ffi into vendor/"
 	@echo "  make vendor-yubikey verify/fetch locked jerboa-yubikey into vendor/"
 	@echo "  make yubikey-native build lib/libyubikey_native for direct USB"
 	@echo "  make test     run the (browser) test suite (interpreter)"
diff --git a/build-binary.ss b/build-binary.ss
index f3082a2..af1b1b1 100644
--- a/build-binary.ss
+++ b/build-binary.ss
@@ -31,9 +31,9 @@
 ;; --- locate Jerboa + Chez ------------------------------------------------
 (define jerboa-dir
   (or (getenv "JERBOA_HOME")
-      (let ((p (format "~a/mine/jerboa" (getenv "HOME"))))
-        (and (file-exists? p) p))
-      (begin (display "Error: cannot find Jerboa; set JERBOA_HOME\n") (exit 1))))
+      (begin
+        (display "Error: cannot find Jerboa; set JERBOA_HOME or run the pinned Makefile jerbuild flow\n")
+        (exit 1))))
 
 (define (find-csv-dir lib-dir mt)
   (let ((csv (let lp ((dirs (guard (e (#t '())) (directory-list lib-dir))))
diff --git a/docs/credential-handling.md b/docs/credential-handling.md
index 5bbb768..09bbffe 100644
--- a/docs/credential-handling.md
+++ b/docs/credential-handling.md
@@ -4,8 +4,8 @@
 
 The password manager in `(browser passwords)` is intentionally RAM-only. It does
 not write credentials to config files, environment variables, command lines, or
-the browser profile. Stored secrets are XOR-masked with a per-session pad and
-wiped after each `vault-with-secret` use.
+the browser profile. Stored secrets are copied into locked secure-memory regions
+and wiped after each `vault-with-secret` use.
 
 Autofill reconstitutes plaintext only long enough to inject into the selected
 page form. A compromised page that receives autofill can read the filled fields,
@@ -19,7 +19,21 @@ slot public key with RSA-OAEP-SHA256; unlock requires the YubiKey, PIN, and
 touch when configured by the slot policy.
 
 The in-RAM data-key session mirrors the password vault: the data key is masked
-with a random pad and wiped when locked.
+with a random pad and wiped when locked. PIV PIN prompts are wrapped as
+one-use bytevector secrets before the card operation and wiped after use.
+
+## History
+
+History persistence is opt-in with `JERBOA_BROWSER_HISTORY=1`. When enabled,
+visited URL/title pairs are kept in RAM and, after the YubiKey store is
+initialized or unlocked, sealed to `history.enc` with the same AES-256-GCM and
+PIV-wrapped data-key format used for bookmarks. When disabled, history commands
+report that history is disabled and no history entries are recorded.
+
+Encrypted bookmark and history writes go through `(std os secure-output)`.
+The pinned v0.2.7 toolchain ships the upstream module; the browser keeps the
+same fail-closed write path when release archives are unavailable by building
+that exact v0.2.7 source tree into the project-local `.jerboa/bin` toolchain.
 
 ## Browser Profile Vault
 
@@ -44,3 +58,8 @@ release checkout must not contain `scheme/browser/build-vault-secret.ss`.
 Diagnostics may record URLs, page titles, dependency versions, and linkage, but
 must not record passwords, PIV PINs, data keys, passphrase file contents,
 cookies, local storage, or bookmark plaintext.
+
+`show-metrics` exposes in-process counters and histograms in Prometheus text
+format. Tamper-evident audit logging is opt-in with
+`JERBOA_BROWSER_AUDIT_LOG=/path/to/audit.jsonl`; audit events record security
+event categories and non-secret metadata only.
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 35ba334..f22e666 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -14,6 +14,8 @@ remotes, or `uname -a` host fingerprints remain.
 
 `make release-evidence` also runs:
 
+- `make test-all`, including bounded fuzz smoke for keymap token parsing and
+  Scheme-to-native browser string ABI calls.
 - `make reproducibility-report`, which rebuilds `./jerboa-browser` twice using a
   fixed Jerbuild object directory without invoking the destructive `make clean`
   target after an unmeasured warmup and stabilization build. It compares the
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 655e667..4b5926f 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -41,13 +41,23 @@ vendored YubiKey/FUSE dependencies.
 - Network, application-local content, downloads, popups, canonical-rooted
   local-file access, clipboard access, and persistent storage are independently
   capability-gated per context.
+- Web camera/microphone-style permission requests are denied by default. The
+  native layer records the request and the Scheme controller surfaces an
+  allow-once/reject prompt; grants remain RAM-only and are audited when audit
+  logging is enabled.
 - Vault runtime paths are random owner-only directories. Host path traversal is
   descriptor-relative/no-follow; vault files are exclusive 0600 single-link
   files owned by the effective user.
-- Passwords are RAM-only, masked in heap storage, and wiped after transient
-  reconstitution.
+- Passwords are RAM-only, stored in locked secure-memory regions, and wiped
+  after transient reconstitution.
 - Bookmark persistence uses AES-256-GCM with a random data key wrapped to a
   YubiKey PIV public key.
+- History persistence is disabled by default; when enabled, history is sealed
+  with the same encrypted store format as bookmarks.
+- Metrics are in-process debug data exposed through an explicit browser command.
+  Security audit logging is disabled by default and writes only when
+  `JERBOA_BROWSER_AUDIT_LOG` names a log file; audit events must not include
+  passwords, PINs, data keys, cookies, local storage, or full page contents.
 - Browser profile vault unlock defaults to YubiKey PIV. It accepts a protected
   passphrase file or prompt for recovery/testing, but not a passphrase
   environment variable and not a build-local embedded secret.
diff --git a/final-kimi3.md b/final-kimi3.md
new file mode 100644
index 0000000..d544ce0
--- /dev/null
+++ b/final-kimi3.md
@@ -0,0 +1,303 @@
+# Jerboa Browser — Final Handoff (last mile to daily-driver)
+
+**Author**: kimi-k3 review pass, 2026-07-29
+**Repo state at review**: branch `main`, HEAD `29225ad`, working tree clean
+**Sibling repo surveyed**: `~/mine/jerboa` @ HEAD `1e43b3df`, VERSION 0.2.7
+**Audience**: whoever picks this up next (including future-me) to finish the last
+mile and run this browser on a work laptop.
+
+This document is a verified state assessment plus a concrete adoption map of
+`~/mine/jerboa` features for robustness, security, performance, and correctness.
+Every claim below was checked against the source (grep/read, not recollection);
+std-lib module availability was checked **per release tag** against the repo's
+pinned toolchain (`JERBOA_VERSION ?= v0.2.4` in the Makefile).
+
+---
+
+## 1. Where things stand
+
+### 1.1 What exists (all verified)
+
+- **Architecture**: Jerboa Scheme frontend (~3,700 lines under `scheme/`) driving
+  a Qt WebEngine C++ backend (~1,700 lines under `qt-webengine/src/`) through a
+  backend-neutral `jwb_*` C ABI (`include/jerboa_browser.h`). Rust components
+  (Brave `adblock-rust`, YubiKey CCID/FIDO2, crypto) are vendored under `vendor/`
+  and linked into `libjerboa_browser`.
+- **Feature completeness**: ROADMAP stages 0–7 all DONE; WISHLIST 8/8 checked.
+  Buffers (not tabs), emacs keymap + chords, fuzzy minibuffer, link hints,
+  RAM-only XOR-masked password vault, YubiKey-PIV-encrypted bookmarks,
+  encrypted FUSE profile vault, adblock on-by-default.
+- **Tests**: ~200 hermetic offscreen cases across 13 Scheme suites + 4 ctest
+  suites + 2 shell suites, all wired into `make test-all` and SourceHut CI
+  (`.build.yml`). Snapshot PNGs committed under `test-artifacts/`.
+- **Assurance pipeline**: `make verify` = tests + `scripts/security-check.sh`
+  (greps release sources for `QTWEBENGINE_DISABLE_SANDBOX` / `--no-sandbox`)
+  + cargo-audit on 3 Rust graphs + double-build reproducibility + SBOM +
+  WebEngine sandbox evidence + binary smoke. This is unusually thorough; keep it.
+- **Code hygiene**: a grep for `TODO|FIXME|XXX|HACK|unimplemented|stub|placeholder`
+  across `scheme/`, `support/`, `qt-webengine/src/`, `include/` finds **zero real
+  markers** (3 hits, all false positives or intentional test notes). The real
+  gaps are the deliberately *deferred* roadmap items, listed in §2.
+
+### 1.2 Stdlib baseline
+
+The browser today imports **only** `(jerboa prelude)` / `(scheme)` plus its own
+modules. **No `(std *)` module is used anywhere.** Everything in §3 is greenfield
+adoption, not replacement.
+
+### 1.3 Toolchain skew
+
+- Pinned build toolchain: **Jerboa v0.2.4** (`Makefile:4`).
+- Current Jerboa release: **v0.2.7**; `main` has more (e.g. `(std security worker)`
+  exists only on unreleased `main`).
+- Recommendation availability below is tagged `[0.2.4]` (usable now),
+  `[0.2.5+]` (bump pin), or `[unreleased]` (wait or track main).
+
+---
+
+## 2. Last-mile blockers (ranked for a work laptop)
+
+These are the things that will actually hurt within the first hour of daily use,
+in order. Each has a short design sketch; §3 maps the Jerboa features to build
+them with.
+
+### B1 — Form-field passthrough / focus mode  *(the blocker)*
+
+**Status**: ROADMAP Stage 4 deferred it: "form-field passthrough (when an input
+is focused, route keys to the page instead of the keymap)". Today the app-wide
+`KeyFilter` in `browser_window.cpp` feeds *every* key to the Scheme keymap, so
+**typing into web inputs is impossible**. This is the single daily-use blocker.
+
+**Sketch**:
+1. C++: on focus change, run a tiny JS probe
+   (`document.activeElement` → editable? `input/textarea/[contenteditable]`)
+   and cache a per-view `editable-focused` flag. Cheap signal: also hook
+   `focusInEvent`/`focusOutEvent` won't work for page-internal focus, so the JS
+   probe on `jwb_pump_wait` heartbeat (already 250 ms) or on key-press-before-
+   filtering is simplest.
+2. KeyFilter: when flag is set, pass keys through except a single escape chord
+   (`C-g` and/or `ESC`) which clears focus (blur via JS) and returns to the
+   keymap. This is nyxt's "insert mode" — name it that in the mode indicator.
+3. Scheme: add `insert` to the mode list shown in the status line
+   (`[modes]` slot already exists); command `insert-mode` / `leave-insert-mode`.
+4. Tests: `browser-keys-test.ss` pattern — inject keys into a `data:`/`qrc:`
+   page with an `<input>`, assert the input's value grew and no command fired.
+
+### B2 — TLS certificate errors & the corporate-MITM story  *(work-laptop critical)*
+
+**Status**: verified zero hits for `certificateError`/`featurePermission`/ssl in
+`qt-webengine/src` and `include/`. TLS errors and permission requests get silent
+Qt defaults. On a work laptop behind a corporate TLS-intercepting proxy this
+means: either everything fails mysteriously, or you get no chance to
+(deliberately, auditably) trust the corporate CA.
+
+**Sketch**:
+1. C++: override `QWebEnginePage::certificateError`, route to Scheme callback
+   with (host, error string, cert chain DER, overridable flag). Default deny;
+   interactive allow-once/allow-for-host stored in RAM only.
+2. Surface in minibuffer: "TLS error on host: … [a]ccept-once [r]eject" — never
+   auto-accept.
+3. Persist nothing by default; if persistence is wanted later it belongs in the
+   encrypted store (`secure-store.ss` pattern), not plaintext.
+4. Corporate CA: document that `SSL_CERT_FILE`/system store covers WebEngine on
+   Linux; on macOS the login keychain covers it. The interactive path above is
+   the fallback for everything else.
+
+### B3 — Permission mediation
+
+**Status**: no `featurePermissionRequested` handling; Qt defaults (deny) apply.
+Mostly fine (deny-by-default matches the capability model), but video calls on a
+work laptop need camera/mic. Add a `JWB_CAP_MEDIA`-style grant + Scheme prompt,
+same shape as B2's callback. Low priority until B1/B2 land.
+
+### B4 — Real adblock lists
+
+**Status**: ROADMAP Stage 7 deferred "load a full EasyList/EasyPrivacy file at
+startup (the starter list is only ~30 built-in `||host^` rules)" and cosmetic
+filtering. The vendored `adblock-rust` engine fully supports it — this is a
+loading problem, not an engine problem.
+
+**Sketch**: fetch EasyList+EasyPrivacy once (manual `curl`, pinned URL+sha256 in
+`supply-chain.lock`), store under `$XDG_DATA_HOME/jerboa-browser/lists/`, load
+at engine init, reload lazily. PSL-aware resolver is a separate deferred item;
+the engine's built-in handling is likely sufficient — measure before building.
+
+### B5 — History persistence (opt-in, encrypted)
+
+**Status**: WISHLIST "later/maybe". For daily use, session-history loss is
+annoying; full history is a privacy decision. Reuse the `secure-store.ss`
+AES-256-GCM + RSA-OAEP-to-PIV-9D format verbatim — the crypto and the test
+harness already exist (`browser-securestore-test.ss`). Atomic writes via
+`(std os secure-output)` `[0.2.5+]`.
+
+### B6 — Download manager
+
+**Status**: WISHLIST "later/maybe"; the ABI already has `JWB_CAP_DOWNLOADS`.
+Minimum viable: a `*Downloads*` buffer listing items from
+`QWebEngineDownloadRequest` signals (name, state, bytes), `C-g` to cancel. Pure
+plumbing, no design risk.
+
+### B7 — Hygiene items (quick wins, do first)
+
+1. **`build-binary.ss:32-34` falls back to `~/mine/jerboa`** for JERBOA_HOME.
+   This violates this repo's own AGENTS.md rule ("Never reference sibling
+   checkouts in build files") and the repo-boundary rule. On a work laptop that
+   path won't exist; fail closed instead: require `JERBOA_HOME` env or the
+   installed toolchain, error with instructions.
+2. **`jpkg.sexp` says `(license "UNLICENSED")`** but the repo is MIT
+   (`LICENSE`, commit `993793d`). One-word fix.
+3. **Toolchain pin v0.2.4 → v0.2.7**: unlocks `(std os secure-output)` and three
+   releases of stdlib fixes. Verify `make binary && make verify` after bump;
+   the pin exists to keep CI hermetic, so bump deliberately, not casually.
+4. **Hide internal Rust symbols in the adblock staticlib** (Stage 7 note) —
+   symbol hygiene for the shipped binary.
+
+---
+
+## 3. Adoption map: `~/mine/jerboa` → this repo
+
+Verified against `data/api-signatures.sexp` (738 modules; everything below is
+tier **stable** unless noted). Availability tags refer to the pinned toolchain.
+
+### 3.1 Robustness
+
+| Feature | Use here | Avail |
+|---|---|---|
+| `(std misc custodian)` | **Per-buffer custodian**: tie each buffer's timers, pending async Qt callbacks, and any future socket/port to a custodian; `session-close-buffer!` (`scheme/browser/buffers.ss`) shuts it down wholesale. Prevents callback-into-dead-view bugs as the buffer model grows. | 0.2.4 |
+| `(std result)` (prelude) | Already the convention (`browser.ss` returns `(ok/err)`). Extend to *all* new code paths; no new bare `error` calls in command handlers — a command failure should echo, not crash the run loop. | now |
+| `(std misc retry)` + `(std circuit)` | Adblock-list refresh, any future network fetch (update checks, EasyList): `retry/backoff` with jitter + circuit breaker so a down origin doesn't stall startup. | 0.2.4 |
+| `(std concur structured)` | `with-task-scope` for the hint-mode JS overlay pipeline and any future parallel fetch (favicon, prefetch): scope lifetime = buffer lifetime; cancel on buffer close. Composes with custodians. | 0.2.4 |
+| `(std errdefer)` / prelude `unwind-protect` | PIV unlock flows (`yubikey-unlock.ss`) and vault mount (`vault.ss`) — guarantee lock/wipe/unmount on every exit path. The current code is careful; make it structurally careful. | 0.2.4 |
+| `(std os secure-output)` | `call-with-secure-replacement-file` for bookmarks.enc, history, session — atomic replace, no half-written crypto blobs on crash. Pairs with `secure-directory-open-strict` (no-symlink-race) — same threat model as `support/browser_secure_fs.c`, so port that philosophy to the Scheme side. | 0.2.5+ |
+| `(std safe-fasl)` | If session restore ever serializes Scheme values: `safe-fasl-read` with record-type allowlists + size caps. Do **not** use raw `fasl-read` on session files. | 0.2.4 |
+| `(std health)` | `register-check!`/`run-checks` for the vault mount, WebEngine process liveness — feed the status line. | 0.2.4 |
+
+### 3.2 Security
+
+The posture is already strong (deny-by-default caps, secure-fs C shim, vaults,
+supply-chain evidence). These add depth where there are currently seams:
+
+| Feature | Use here | Avail |
+|---|---|---|
+| `(std crypto secure-mem)` | The password vault XOR-masks secrets in the heap (`passwords.ss`) — a decent obfuscation but not protection against memory disclosure. `secure-mem` gives **mlock'd, guard-paged, wiping buffers**. Migrate vault entries; keep the zero-after-use discipline (`wipe-bytevector!` already used). | 0.2.4 |
+| `(std security secret)` | `make-secret` (use-once) for the PIV PIN between prompt and card operation — the PIN currently lives as a normal string/bytevector through the minibuffer → `yubikey-unlock.ss` path. | 0.2.4 |
+| `(std security taint)` | Label page-derived strings (`html-label`) and password-vault secrets; `define-sink` on (a) JS evaluation (`jwb_view_run_js`), (b) clipboard set, (c) autofill. Catches the class of bug where vault data or page HTML flows somewhere unintended — precisely the trust boundaries `docs/credential-handling.md` describes. | 0.2.4 |
+| `(std security env)` | `make-env-policy` before spawning any helper (FUSE mount helper already; future crash reporter/downloader): allow-list env, redact secrets. The browser already refuses vault passphrases via env — this generalizes the rule. | 0.2.4 |
+| `(std net allow-proxy)` | Future extension/userscript or prefetch model: sandboxed child with *no* network except a 127.0.0.1 CONNECT proxy with host:port glob allowlist. This is the clean way to ever run untrusted code alongside the browser. | 0.2.4 |
+| `(std security worker)` | **The renderer-grade primitive**: confined child (seccomp+Landlock / Seatbelt / Capsicum), memory limits, egress policy, output caps, fail-closed. Overkill while Chromium does the sandboxing; bookmark this for a future Servo backend or for userscript/extension isolation. | unreleased |
+| TLS patterns from `(std net tls)` / `(std net tls-rustls)` | Not directly usable (Qt owns the browser's TLS), but steal the **policy shape**: min-TLS floor, verify-always default, and `make-pin-set`-style host pinning as the model for B2's Scheme-side cert store. | 0.2.4 |
+| `(std net request)` SSRF guard | Same story: the browser's URL authority is `browser_url_policy.cpp`. When prefetch/extensions fetch URLs *from Scheme*, parameterize `*http-ssrf-guard* #t` (it's **off by default**) to block 169.254.169.254/RFC1918/loopback, and reuse `http-framing-conflict?` for desync defense. | 0.2.4 |
+| `(std security audit)` | Tamper-evident hash-chained log of security events: cert overrides (B2), capability grants, vault unlocks. On a work laptop this doubles as your personal compliance evidence. | 0.2.4 |
+
+### 3.3 Performance
+
+No benchmark suite exists today; idle CPU was already fixed (`39027d9`,
+`jwb_pump_wait` uses `processEvents(WaitForMoreEvents)` correctly). Targets now
+are allocation churn and startup:
+
+| Feature | Use here | Avail |
+|---|---|---|
+| `(std arena)` | **Per-page-load arena** for hint-mode label computation, fuzzy-match scratch, JS-result strings; `arena-checkpoint`/`arena-rollback!` around transient work. `arena-intern!` for repeated URL/host strings. | 0.2.4 |
+| `(std misc lru-cache)` | Fuzzy-match result cache in the minibuffer (re-score on every keystroke is fine at 100 bookmarks, less fine at 10k history entries once B5 lands). Capacity-bounded, with stats. | 0.2.4 |
+| `(std misc memoize)` | `define-memoized` on URL canonicalization and key-token parsing — both pure, both hot during typing. | 0.2.4 |
+| `(std metrics)` | Prometheus-format counters/gauges: adblock blocks (already counted ad hoc), command latency, hint-mode JS round-trip time. Expose on a debug buffer; zero deps. | 0.2.4 |
+| Build flags | `build-binary.ss` already does whole-program compilation. Confirm `release:` semantics from jerbuild (Chez `optimize-level 3` + stripped inspector info) for the shipped binary, and `tree-shake-imports` once `(std *)` deps land — otherwise each adoption above grows the binary. CI's `JERBOA_CC_OPT=-O0` is an OOM workaround, keep it scoped to CI. | now |
+| `(std profile)` | `with-profile`/`profile-report` around startup and hint-mode on a heavy page before optimizing anything else. Measure first; the codebase is small enough that guesses are usually wrong. | 0.2.4 |
+
+### 3.4 Correctness & testing
+
+| Feature | Use here | Avail |
+|---|---|---|
+| `(std test fuzz)` | **Fuzz the FFI string boundary** (`browser_utf8.cpp` ↔ Scheme bytevector conversion) and the keymap token parser (`keymap.ss`). The repo itself knows this gap: "no fuzzing of the FFI string boundary". jerboa's own CI gate (`fuzz-smoke` with corpus + regression inputs) is the template. | 0.2.4 |
+| `(std proptest)` | Model-test buffer-list invariants (open/switch/close against a reference list model — extends `browser-buffers-test.ss`'s 27 cases to generated interleavings) and minibuffer state machine. Shrinking gives minimal failing sequences. | 0.2.4 |
+| `(std contract2)` | **Temporal contracts** on the PIV unlock protocol (must verify PIN before RSA unwrap; must wipe data key on lock) and the minibuffer lifecycle. Protocol-order bugs are exactly what unit tests miss. | 0.2.4 |
+| `(std contract)` | `define/contract` on the `jwb_*` wrapper boundary (`browser.ss`): handle validity, capability flag ranges, URL strings non-empty. Cheap, self-documenting. | 0.2.4 |
+| prelude ergo typing | `(: expr pred?)` checked casts where network/page data becomes Scheme values (JS results, titles, URLs) — the trust boundary `docs/ffi-boundary.md` already polices manually. | now |
+| `(std spec)` / `(std config)` | If prefs become a file (they will, eventually): `load-config` + `config-schema` + `watch-config!` beats ad-hoc parsing; `s-valid?` for shapes. | 0.2.4 |
+
+### 3.5 Deliberately *not* recommended (avoid scope creep)
+
+- `(std actor *)` / cluster/CRDTs — the browser is single-process driving a Qt
+  event loop; actor infrastructure buys nothing until userscripts/extensions.
+  Revisit then, starting with bounded mailboxes for backpressure.
+- `(std fiber)` — same reason: `jwb_pump_wait` heartbeat + Qt's loop already
+  solves the concurrency model. Don't run two schedulers.
+- `(std net http2)` — frame/HPACK only, no client state machine; Qt handles
+  HTTP/2 anyway.
+- `(std db leveldb)` — compat tier; if history needs a DB, use
+  `(std db sqlite)` (native rusqlite backend, encrypted-at-rest possible via
+  `sqlite-db->bytevector` + the existing GCM store).
+- SpiderMonkey (`mozjs`) / WASM (`wasmi`) features in `jerboa-native-rs` —
+  fascinating, but Qt WebEngine *is* the JS engine. Irrelevant unless a Servo
+  backend happens, and `notes/servo-watchpoint.md` lists 6 preconditions first.
+
+---
+
+## 4. Suggested sequencing
+
+Two-pass plan: **Pass 1 makes it usable, Pass 2 makes it deep.** Everything in
+Pass 1 is small, testable, and independent.
+
+**Pass 1 — daily-driver (this week)**
+
+1. B7 hygiene (3 one-line-ish fixes; 30 min, do first so nothing rots).
+2. B1 form-field passthrough (the blocker) + `browser-keys-test`-style test.
+3. B4 EasyList loading (unblocks real ad/tracker coverage).
+4. B2 cert-error surfacing (work-laptop MITM story).
+5. Toolchain bump v0.2.4 → v0.2.7, then `make binary && make verify`.
+
+**Pass 2 — depth (following weeks, any order)**
+
+6. `(std crypto secure-mem)` migration for the vault; `(std security secret)` for
+   PIV PIN handling. (Small, high-value.)
+7. `(std os secure-output)` atomic writes for bookmarks.enc (prereq for B5).
+8. B5 history persistence (encrypted store reuse) + `(std misc lru-cache)` on
+   the now-larger fuzzy corpus.
+9. `(std test fuzz)` on the FFI string boundary + keymap parser (this is the
+   repo's own acknowledged hole).
+10. B6 download manager; B3 permission prompts.
+11. `(std metrics)` + `(std security audit)` observability pass.
+12. Custodian-per-buffer + temporal contracts (structural insurance).
+
+**Verification protocol per change** (per AGENTS.md): macOS → `make binary`
+must succeed before any commit; run `make test-all` for functional changes,
+`make verify` before tagging anything. Use the jerboa-mcp tools
+(`jerboa_verify`, `jerboa_balanced_insert`, `jerboa_check_balance`) for all
+`.ss` edits — never raw `edit`/`sed`.
+
+---
+
+## 5. Risks & watchpoints
+
+- **Focus-mode edge cases (B1)**: JS-heavy pages that move focus programmatically
+  (search boxes that steal focus on load) will fight the keymap. Mitigation:
+  only enter insert-mode on *user-initiated* focus (click/key into field), or
+  show the mode loudly so `C-g` is discoverable. Test on a SPA (GitHub, Gmail).
+- **Cert overrides are a loaded gun (B2)**: keep allow-once RAM-only, log to the
+  audit trail, never add "always allow for all hosts". The threat model
+  (`docs/threat-model.md`) should gain a section when this lands.
+- **EasyList size (B4)**: full lists are ~50k rules; the adblock-rust engine
+  handles this fine, but startup cost should be measured (`(std profile)`) and
+  the engine is lazily built today (Stage 7 note) — keep it lazy.
+- **Binary size**: each `(std *)` adoption grows the WPO closure. After Pass 2,
+  compare `ls -l jerboa-browser` and enable `tree-shake-imports` if it balloons.
+- **Version pin**: `(std os secure-output)` needs ≥ v0.2.5; `(std security worker)`
+  is unreleased — don't code against it yet, just design for it.
+- **macOS-only assumption**: dev happens on macOS arm64 with Homebrew Qt; the
+  work laptop target should be confirmed (Linux tarball via `make static-qt`
+  exists and is CI-published). Test the actual target early in Pass 1.
+
+---
+
+## 6. Appendix — quick reference
+
+- Build: `make binary` · Test: `make test-all` · Full assurance: `make verify`
+- Key sources: `scheme/browser/commands.ss` (controller/run-loop),
+  `scheme/browser/keymap.ss` (chords), `qt-webengine/src/browser_window.cpp`
+  (KeyFilter — B1 lives here), `browser_page.cpp:76-95` (pump loop),
+  `browser_url_policy.cpp` (scheme authority), `scheme/browser/secure-store.ss`
+  (crypto format to reuse for B5)
+- Jerboa stdlib survey: `~/mine/jerboa/data/api-signatures.sexp` (738 modules,
+  stability tiers), `~/mine/jerboa/docs/status.md` (honest production status)
+- Deferred upstream: full `(std security worker)` release; PSL resolver and
+  cosmetic filtering are adblock follow-ups already noted in ROADMAP Stage 7.
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 731c8dd..1e1b4f4 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -54,7 +54,8 @@ typedef enum {
   JWB_CAP_POPUPS = 1u << 6,
   /* In-process content schemes (data/blob/qrc and inert about:blank).  This is
    * deliberately separate from NETWORK and FILESYSTEM authority. */
-  JWB_CAP_LOCAL_CONTENT = 1u << 7
+  JWB_CAP_LOCAL_CONTENT = 1u << 7,
+  JWB_CAP_MEDIA = 1u << 8
 } JwbCapabilityFlags;
 
 typedef void (*JwbStringCallback)(const uint8_t *ptr,
@@ -209,6 +210,35 @@ JWB_API int jwb_view_find(JwbHandle view, const char *text, int flags,
 JWB_API int jwb_clipboard_set(const char *utf8_text);
 JWB_API const char *jwb_clipboard_get(void);
 
+/* TLS certificate errors are fail-closed. The last denied certificate error is
+ * surfaced as "HOST\tDESCRIPTION\tOVERRIDABLE\tURL\tDER_CHAIN_BASE64". Chain
+ * entries are DER-encoded certificates joined by commas. Host grants are
+ * RAM-only; pass once=1 to accept the next overridable error for that host only. */
+JWB_API const char *jwb_tls_last_error(void);
+JWB_API int jwb_tls_allow_host(const char *host, int once);
+JWB_API int jwb_tls_clear_host(const char *host);
+
+/* Web capability prompts (camera/microphone/etc.) are denied by default. The
+ * last denied request is surfaced as "ORIGIN\tTYPE". One-shot grants are
+ * RAM-only and are consumed by the next matching request; they only grant if the
+ * context also has the corresponding capability bit (currently MEDIA for
+ * microphone/camera capture, CLIPBOARD for clipboard read/write). */
+JWB_API const char *jwb_permission_last_request(void);
+JWB_API int jwb_permission_allow_once(const char *origin, const char *type);
+JWB_API int jwb_permission_clear(const char *origin, const char *type);
+
+/* Helpers for Scheme secure-memory regions. Allocation uses guard pages,
+ * mlock/munlock, and volatile wipe. Store/load do raw byte copies; callers own
+ * their logical length and must pass the same allocation size to free. */
+JWB_API uint64_t jwb_secure_alloc(uintptr_t size);
+JWB_API int jwb_secure_free(uint64_t region_ptr, uintptr_t size);
+JWB_API int jwb_secure_wipe(uint64_t region_ptr, uintptr_t size);
+JWB_API int jwb_secure_random_fill(uint64_t region_ptr, uintptr_t size);
+JWB_API int jwb_secure_region_store(uint64_t region_ptr, const uint8_t *src,
+                                    uintptr_t len);
+JWB_API int jwb_secure_region_load(uint8_t *dst, uint64_t region_ptr,
+                                   uintptr_t len);
+
 /* === Window / buffer chrome (Stage 1) ==================================
  * A BrowserWindow is a QMainWindow hosting a QStackedWidget of views (the
  * nyxt "buffers" model: one window, many buffers, one visible at a time) plus
@@ -248,6 +278,11 @@ JWB_API int jwb_window_remove_key_hook(JwbHandle window);
  * empty). Returns "" when the queue is empty. The returned pointer is a
  * thread-local buffer valid until the next call. */
 JWB_API const char *jwb_window_poll_key(JwbHandle window);
+/* Cached insert-mode flag. The key filter refreshes this before routing real
+ * keys; status rendering reads this cached value without running JavaScript. */
+JWB_API int jwb_window_insert_mode(JwbHandle window);
+/* Refresh and return insert mode by probing the visible page's activeElement. */
+JWB_API int jwb_window_refresh_insert_mode(JwbHandle window);
 /* 1 while the window is mapped/visible; 0 once the user closes it. Lets the
  * Scheme run-loop exit when the window is dismissed. */
 JWB_API int jwb_window_is_visible(JwbHandle window);
@@ -262,8 +297,17 @@ JWB_API int jwb_window_is_visible(JwbHandle window);
 JWB_API int jwb_adblock_set_enabled(int on); /* returns the new state (1/0) */
 JWB_API int jwb_adblock_is_enabled(void);
 JWB_API int jwb_adblock_block_count(void);   /* requests blocked since start */
+JWB_API int jwb_adblock_external_rule_bytes(void); /* bytes loaded from local lists */
 JWB_API int jwb_adblock_available(void);      /* 1 if compiled in, else 0 */
 
+/* Download manager snapshot. Rows are:
+ * ID<TAB>STATE<TAB>RECEIVED<TAB>TOTAL<TAB>FILENAME<TAB>URL
+ * with one row per known request. Denied downloads are recorded as cancelled.
+ * jwb_download_cancel returns 1 if a live matching request was cancelled. */
+JWB_API const char *jwb_downloads_snapshot(void);
+JWB_API int jwb_download_cancel(uint32_t id);
+JWB_API int jwb_download_count(void);
+
 #ifdef __cplusplus
 } /* extern "C" */
 #endif
diff --git a/jpkg.sexp b/jpkg.sexp
index 5f10ee9..45e1690 100644
--- a/jpkg.sexp
+++ b/jpkg.sexp
@@ -2,7 +2,7 @@
   (name "@lisp/jerboa-browser")
   (version "0.1.0")
   (description "Programmable Qt WebEngine browser controlled from Jerboa")
-  (license "UNLICENSED")
+  (license "MIT")
   (source "https://git.sr.ht/~lisp/jerboa-browser")
   (jerboa ">=0.2.0")
   (modules ((root "scheme")))
diff --git a/qt-webengine/CMakeLists.txt b/qt-webengine/CMakeLists.txt
index c5a741a..54d5f4d 100644
--- a/qt-webengine/CMakeLists.txt
+++ b/qt-webengine/CMakeLists.txt
@@ -31,16 +31,25 @@ target_include_directories(jerboa_browser
 target_link_libraries(jerboa_browser
   PRIVATE Qt6::Core Qt6::Widgets Qt6::WebEngineWidgets
 )
+if(APPLE)
+  target_link_options(jerboa_browser PRIVATE
+    "LINKER:-exported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/jerboa_browser.exports")
+endif()
 
 # --- ad / tracker blocking (Stage 7) ---------------------------------------
-# Brave's adblock-rust via its C FFI, vendored under vendor/adblock-rust-ffi.
+# Brave's adblock-rust via its C FFI, fetched at build time into
+# ../vendor/adblock-rust-ffi by `make vendor-adblock`.
 # cargo builds a staticlib (libadblock.a) that links into the jerboa_browser
 # shared lib; the interceptor in browser_policy.cpp calls engine_match(). Turn
 # off (-DJWB_ADBLOCK=OFF) to build without a Rust toolchain — blocking then
 # compiles out to no-ops.
-option(JWB_ADBLOCK "Ad/tracker blocking via vendored adblock-rust (needs cargo)" ON)
+option(JWB_ADBLOCK "Ad/tracker blocking via pinned adblock-rust checkout (needs cargo)" ON)
 if(JWB_ADBLOCK)
-  set(ADBLOCK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/adblock-rust-ffi)
+  set(ADBLOCK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vendor/adblock-rust-ffi"
+      CACHE PATH "Pinned adblock-rust-ffi checkout")
+  if(NOT EXISTS "${ADBLOCK_DIR}/Cargo.toml")
+    message(FATAL_ERROR "missing ${ADBLOCK_DIR}/Cargo.toml; run `make vendor-adblock` first")
+  endif()
   set(ADBLOCK_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/adblock-rust-target)
   set(ADBLOCK_LIB ${ADBLOCK_TARGET_DIR}/release/libadblock.a)
   find_program(CARGO_EXE cargo REQUIRED)
@@ -51,6 +60,7 @@ if(JWB_ADBLOCK)
   add_custom_command(
     OUTPUT ${ADBLOCK_LIB}
     COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${ADBLOCK_TARGET_DIR}
+            RUSTFLAGS=-Csymbol-mangling-version=v0
             ${CARGO_EXE} build --locked --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml
     ${ADBLOCK_RANLIB_COMMAND}
     COMMENT "Building vendored adblock-rust staticlib (cargo build --release)"
@@ -61,6 +71,9 @@ if(JWB_ADBLOCK)
   target_include_directories(jerboa_browser PRIVATE ${ADBLOCK_DIR}/src)
   target_link_libraries(jerboa_browser PRIVATE ${ADBLOCK_LIB})
   if(NOT APPLE)
+    target_link_options(jerboa_browser PRIVATE "LINKER:--exclude-libs,ALL")
+  endif()
+  if(NOT APPLE)
     target_link_libraries(jerboa_browser PRIVATE pthread dl)
   endif()
 endif()
diff --git a/qt-webengine/jerboa_browser.exports b/qt-webengine/jerboa_browser.exports
new file mode 100644
index 0000000..e69a982
--- /dev/null
+++ b/qt-webengine/jerboa_browser.exports
@@ -0,0 +1,83 @@
+_jwb_adblock_available
+_jwb_adblock_block_count
+_jwb_adblock_external_rule_bytes
+_jwb_adblock_is_enabled
+_jwb_adblock_set_enabled
+_jwb_back
+_jwb_clipboard_get
+_jwb_clipboard_set
+_jwb_context_free
+_jwb_context_new
+_jwb_context_open
+_jwb_current_url
+_jwb_current_url_sync
+_jwb_download_cancel
+_jwb_download_count
+_jwb_downloads_snapshot
+_jwb_eval
+_jwb_eval_js
+_jwb_eval_js_sync
+_jwb_exec
+_jwb_forward
+_jwb_get_title
+_jwb_get_url
+_jwb_last_error
+_jwb_last_status
+_jwb_load
+_jwb_load_url
+_jwb_load_url_sync
+_jwb_permission_allow_once
+_jwb_permission_clear
+_jwb_permission_last_request
+_jwb_pump_events
+_jwb_pump_wait
+_jwb_quit
+_jwb_reload
+_jwb_runtime_init
+_jwb_send_key
+_jwb_secure_alloc
+_jwb_secure_free
+_jwb_secure_random_fill
+_jwb_secure_region_load
+_jwb_secure_region_store
+_jwb_secure_wipe
+_jwb_stop
+_jwb_string_free
+_jwb_title
+_jwb_title_sync
+_jwb_tls_allow_host
+_jwb_tls_clear_host
+_jwb_tls_last_error
+_jwb_version
+_jwb_view_find
+_jwb_view_focus
+_jwb_view_free
+_jwb_view_get_zoom
+_jwb_view_grab_png
+_jwb_view_hide
+_jwb_view_new
+_jwb_view_open
+_jwb_view_resize
+_jwb_view_set_title
+_jwb_view_set_zoom
+_jwb_view_show
+_jwb_window_add_view
+_jwb_window_close
+_jwb_window_current_index
+_jwb_window_grab_png
+_jwb_window_hide
+_jwb_window_install_key_hook
+_jwb_window_insert_mode
+_jwb_window_is_visible
+_jwb_window_open
+_jwb_window_poll_key
+_jwb_window_refresh_insert_mode
+_jwb_window_remove_key_hook
+_jwb_window_remove_view
+_jwb_window_resize
+_jwb_window_set_minibuffer
+_jwb_window_set_status
+_jwb_window_set_title
+_jwb_window_show
+_jwb_window_show_view
+_jwb_window_view_count
diff --git a/qt-webengine/src/browser_ffi.cpp b/qt-webengine/src/browser_ffi.cpp
index d6f891d..b7e0a15 100644
--- a/qt-webengine/src/browser_ffi.cpp
+++ b/qt-webengine/src/browser_ffi.cpp
@@ -9,9 +9,15 @@
 // It adds NO Qt dependency — it only composes the existing extern "C" entry
 // points — and targets the synchronous, single-threaded Jerboa REPL embedding.
 #include "jerboa_browser.h"
+#include "browser_internal.h"
 
+#include <algorithm>
 #include <cstring>
+#include <fcntl.h>
+#include <limits>
+#include <sys/mman.h>
 #include <string>
+#include <unistd.h>
 
 namespace {
 
@@ -33,6 +39,41 @@ const char *capture(char *owned) {
   return g_strbuf.c_str();
 }
 
+uintptr_t page_size() {
+  const long value = ::sysconf(_SC_PAGESIZE);
+  return value > 0 ? static_cast<uintptr_t>(value) : 4096;
+}
+
+bool round_up_to_page(uintptr_t size, uintptr_t *out) {
+  const uintptr_t page = page_size();
+  if (size > std::numeric_limits<uintptr_t>::max() - (page - 1)) return false;
+  *out = ((size + page - 1) / page) * page;
+  return true;
+}
+
+void volatile_wipe(void *ptr, uintptr_t size) {
+  volatile uint8_t *p = static_cast<volatile uint8_t *>(ptr);
+  for (uintptr_t i = 0; i < size; ++i) p[i] = 0;
+}
+
+bool fill_random(void *ptr, uintptr_t size) {
+  uint8_t *out = static_cast<uint8_t *>(ptr);
+  int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC);
+  if (fd < 0) return false;
+  uintptr_t off = 0;
+  while (off < size) {
+    const ssize_t n =
+        ::read(fd, out + off, static_cast<size_t>(size - off));
+    if (n <= 0) {
+      ::close(fd);
+      return false;
+    }
+    off += static_cast<uintptr_t>(n);
+  }
+  ::close(fd);
+  return true;
+}
+
 } // namespace
 
 extern "C" {
@@ -81,4 +122,130 @@ JWB_API const char *jwb_get_url(JwbHandle view) {
   return capture(jwb_current_url_sync(view, &g_status));
 }
 
+JWB_API uint64_t jwb_secure_alloc(uintptr_t size) {
+  jwb::clear_last_error();
+  if (size == 0) {
+    jwb::set_last_error("secure_alloc: size is zero");
+    return 0;
+  }
+  uintptr_t rounded = 0;
+  if (!round_up_to_page(size, &rounded)) {
+    jwb::set_last_error("secure_alloc: size overflow");
+    return 0;
+  }
+  const uintptr_t page = page_size();
+  if (rounded > std::numeric_limits<uintptr_t>::max() - (2 * page)) {
+    jwb::set_last_error("secure_alloc: total size overflow");
+    return 0;
+  }
+  const uintptr_t total = rounded + (2 * page);
+  void *base = ::mmap(nullptr, total, PROT_READ | PROT_WRITE,
+#if defined(MAP_ANONYMOUS)
+                      MAP_PRIVATE | MAP_ANONYMOUS,
+#else
+                      MAP_PRIVATE | MAP_ANON,
+#endif
+                      -1, 0);
+  if (base == MAP_FAILED) {
+    jwb::set_last_error("secure_alloc: mmap failed");
+    return 0;
+  }
+  auto cleanup = [&]() { ::munmap(base, total); };
+  uint8_t *data =
+      static_cast<uint8_t *>(base) + page + (rounded - size);
+  if (::mprotect(base, page, PROT_NONE) != 0 ||
+      ::mprotect(static_cast<uint8_t *>(base) + page + rounded, page,
+                 PROT_NONE) != 0 ||
+      ::mlock(data, size) != 0) {
+    cleanup();
+    jwb::set_last_error("secure_alloc: guard page or mlock failed");
+    return 0;
+  }
+#if defined(MADV_DONTDUMP)
+  (void)::madvise(data, size, MADV_DONTDUMP);
+#endif
+#if defined(MADV_DONTFORK)
+  (void)::madvise(data, size, MADV_DONTFORK);
+#endif
+  return static_cast<uint64_t>(reinterpret_cast<uintptr_t>(data));
+}
+
+JWB_API int jwb_secure_free(uint64_t region_ptr, uintptr_t size) {
+  jwb::clear_last_error();
+  if (region_ptr == 0 || size == 0) {
+    jwb::set_last_error("secure_free: invalid pointer or size");
+    return 0;
+  }
+  uintptr_t rounded = 0;
+  if (!round_up_to_page(size, &rounded)) {
+    jwb::set_last_error("secure_free: size overflow");
+    return 0;
+  }
+  const uintptr_t page = page_size();
+  uint8_t *data = reinterpret_cast<uint8_t *>(static_cast<uintptr_t>(region_ptr));
+  uint8_t *base = data - page - (rounded - size);
+  volatile_wipe(data, size);
+  const int unlock_rc = ::munlock(data, size);
+  const int unmap_rc = ::munmap(base, rounded + (2 * page));
+  if (unlock_rc != 0 || unmap_rc != 0) {
+    jwb::set_last_error("secure_free: munlock or munmap failed");
+    return 0;
+  }
+  return 1;
+}
+
+JWB_API int jwb_secure_wipe(uint64_t region_ptr, uintptr_t size) {
+  jwb::clear_last_error();
+  if (region_ptr == 0) {
+    jwb::set_last_error("secure_wipe: invalid pointer");
+    return 0;
+  }
+  volatile_wipe(reinterpret_cast<void *>(static_cast<uintptr_t>(region_ptr)),
+                size);
+  return 1;
+}
+
+JWB_API int jwb_secure_random_fill(uint64_t region_ptr, uintptr_t size) {