Harden browser isolation and native dependencies

ober

22205e28ae2b59888f5543e925de092b4d881734

diff --git a/.build.yml b/.build.yml
index 19f3e3e..a0c4fdf 100644
--- a/.build.yml
+++ b/.build.yml
@@ -3,6 +3,7 @@ oauth: pages.sr.ht/PAGES:RW
 packages:
   - cmake
   - g++
+  - git
   - ninja-build
   - qt6-base-dev
   - qt6-webengine-dev
@@ -24,11 +25,10 @@ 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
 sources:
   - https://git.sr.ht/~lisp/jerboa-browser
-  # Chez Scheme is vendored in the jerboa repo (vendor/ChezScheme); cloned so CI
-  # can build it and the Jerboa stdlib closure into the native binary.
-  - https://git.sr.ht/~lisp/jerboa
 artifacts:
   # Saved + downloadable from the build job page. Path is relative to ~ (home);
   # the repo is cloned to ~/jerboa-browser and build-tarball.sh writes here.
@@ -37,6 +37,16 @@ artifacts:
   # Offline Qt snapshot PNGs from the test-gui task (the harness output).
   - jerboa-browser/gui-snapshots.tar.gz
 tasks:
+  - fetch-jerboa: |
+      # Dependency source is fetched at the consumer-owned commit/tree lock;
+      # SourceHut's sources list otherwise follows a mutable repository head.
+      mkdir jerboa
+      git -C jerboa init -q
+      git -C jerboa remote add origin https://git.sr.ht/~lisp/jerboa
+      git -C jerboa fetch -q --depth 1 origin "$JERBOA_COMMIT"
+      test "$(git -C jerboa rev-parse FETCH_HEAD)" = "$JERBOA_COMMIT"
+      test "$(git -C jerboa show -s --format=%T FETCH_HEAD)" = "$JERBOA_TREE"
+      git -C jerboa checkout -q --detach "$JERBOA_COMMIT"
   - chez: |
       # Build Chez from the source vendored in the jerboa repo, so the binary
       # embeds the in-repo Chez (no external/stock Chez). Install to /usr/local
@@ -66,12 +76,8 @@ tasks:
       SCHEME="$(command -v scheme)" "$J" pkg verify "$PWD/jerboa-browser-0.1.0.jpkg"
       SCHEME="$(command -v scheme)" "$J" pkg verify --reproduce
   - setup-rust: |
-      # Build tasks need a current Rust toolchain for vendored crates using
-      # language features newer than Debian bookworm's packaged rustc.
-      if [ ! -x "$HOME/.cargo/bin/cargo" ]; then
-        curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
-          | sh -s -- -y --profile minimal --default-toolchain stable
-      fi
+      cd jerboa-browser
+      sh support/ensure-rust.sh
       echo 'source "$HOME/.cargo/env"' >> ~/.buildenv
   - setup-hut: |
       # bookworm has Go but not hut. Install hut locally so the final task can
@@ -91,16 +97,12 @@ tasks:
       # cdylib once into jerbuild's cache so they can dlopen it. The compiled
       # binary links these symbols statically (built above) and needs none. The
       # bundled crate's Cargo.lock is v4, so keep using the rustup toolchain.
-      if [ ! -x "$HOME/.cargo/bin/cargo" ]; then
-        curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
-          | sh -s -- -y --profile minimal --default-toolchain stable
-      fi
       export PATH="$HOME/.cargo/bin:$PATH"
       JH="$(jerbuild --jerboa-home)"
       # The crypto FFI exports (jerboa_sha*, _aead_*, _hmac_*, …) are behind the
       # crate's "crypto" Cargo feature; a default build omits them. crypto's deps
       # are pure-Rust, so no system libs are needed.
-      cargo build --release --no-default-features --features crypto \
+      cargo build --locked --release --no-default-features --features crypto \
         --manifest-path "$JH/jerboa-native-rs/Cargo.toml"
       ls -l "$JH"/jerboa-native-rs/target/release/libjerboa_native.* || true
   - verify: |
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..24e115f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.patch -whitespace
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 74848ef..e690158 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,8 @@ permissions:
   contents: read
 
 env:
-  JERBOA_VERSION: v0.2.3
+  JERBOA_VERSION: v0.2.4
+  RUST_TOOLCHAIN: "1.94.1"
   JERBUILD: ${{ github.workspace }}/.jerboa/bin/jerbuild
   QT_QPA_PLATFORM: offscreen
   QTWEBENGINE_CHROMIUM_FLAGS: --disable-gpu
@@ -18,9 +19,11 @@ env:
 
 jobs:
   verify:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+        with:
+          persist-credentials: false
 
       - name: Install system tools
         run: |
@@ -29,14 +32,17 @@ jobs:
           sudo apt-get install -y --no-install-recommends \
             build-essential git curl ca-certificates cmake pkg-config \
             qt6-base-dev qt6-webengine-dev xvfb
-          command -v cargo
-          cargo --version
+
+      - name: Install pinned Rust toolchain
+        run: |
+          sh support/ensure-rust.sh
+          echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
 
       - name: Install Jerboa toolchain
         run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin
 
       - name: Install cargo-audit
-        run: cargo install cargo-audit --locked
+        run: cargo install cargo-audit --version 0.22.2 --locked
 
       - name: Build Qt WebEngine backend
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..6ee1c3f 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -11,9 +11,11 @@ permissions:
 
 jobs:
   baseline:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+        with:
+          persist-credentials: false
 
       - name: Required release files
         run: |
diff --git a/.jerboa/security.json b/.jerboa/security.json
index 9c77ed8..4530617 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -10,7 +10,7 @@
     "docs": ["README.md", "SECURITY.md", "docs/**", "*.md"]
   },
   "policy": {
-    "failOn": ["critical", "high"],
+    "failOn": ["critical", "high", "medium"],
     "imports": { "directChezscheme": "deny" },
     "ffi": { "allowed": true, "requireLazyLoading": true, "requireBoundaryDocs": true },
     "browser": {
@@ -18,11 +18,15 @@
       "targetSandboxProof": "marker-validated-fail-closed",
       "defaultProfile": "off-record",
       "persistentProfileRequiresEncryptedVault": true,
-      "envPassphrase": "deny"
+      "envPassphrase": "deny",
+      "schemeCapabilities": "independent-allowlist",
+      "filesystemRoot": "absolute-canonical-required",
+      "vaultHostPaths": "descriptor-relative-no-follow-private"
     },
     "process": { "shellInterpolation": "deny" },
     "network": { "denyByDefaultCapabilities": true },
-    "secrets": { "forbidLogging": true, "ramOnlyPasswordVault": true, "strictStateFilePermissions": true }
+    "secrets": { "forbidLogging": true, "ramOnlyPasswordVault": true, "strictStateFilePermissions": true },
+    "supplyChain": { "lock": "supply-chain.lock", "mutableRefs": "deny", "cohostedChecksums": "deny" }
   },
   "suppressions": []
 }
diff --git a/.jerbuild b/.jerbuild
index 945cf2f..0905543 100644
--- a/.jerbuild
+++ b/.jerbuild
@@ -7,4 +7,11 @@
 (entry "scheme/browser-main.ss")
 (output "jerboa-browser")
 (libdirs "vendor/jerboa-yubikey/lib" "scheme" "vendor/jerboa-fuse/lib")
-(pre-build "sh support/ensure-vendor.sh jerboa-yubikey https://git.sr.ht/~lisp/jerboa-yubikey")
+(static-native #t)
+(extra-sources "support/browser_executable_identity.c")
+(ffi-symbols "support/browser-ffi-symbols.list")
+(os-libs
+  (Darwin "-lm -lpthread -lncurses -liconv -lc++")
+  (Linux "-lm -ldl -lpthread -lncurses -lstdc++")
+  (FreeBSD "-lm -lpthread -lncurses -lstdc++"))
+(pre-build "sh support/ensure-vendor.sh jerboa-yubikey https://git.sr.ht/~lisp/jerboa-yubikey e448afca926ca9ad40fd98effa8d6264cb1019f5 67e4417511b63c0b41b1b9c5dc1c7d897df51f08")
diff --git a/Dockerfile.qt b/Dockerfile.qt
index 6e270d2..d3372fa 100644
--- a/Dockerfile.qt
+++ b/Dockerfile.qt
@@ -12,15 +12,22 @@
 #   id=$(podman create jerboa-browser-qt-builder)
 #   podman cp $id:/out/jerboa-browser-static-qt.tar.gz . && podman rm $id
 
-ARG BASE_IMAGE=docker.io/library/ubuntu:24.04
+ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
 FROM ${BASE_IMAGE} AS builder
 
-ARG JERBOA_VERSION=v0.2.3
+ARG JERBOA_VERSION=v0.2.4
+ARG JBROWSER_SOURCE_COMMIT
+ARG JBROWSER_SOURCE_TREE
+ARG APT_SNAPSHOT=20260701T000000Z
 
 ENV DEBIAN_FRONTEND=noninteractive
 ENV PATH=/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
 
-RUN apt-get update && apt-get install -y --no-install-recommends \
+RUN sed -Ei \
+      -e "s#https?://(archive|security)\\.ubuntu\\.com/ubuntu/?#https://snapshot.ubuntu.com/ubuntu/${APT_SNAPSHOT}/#g" \
+      -e "s#https?://ports\\.ubuntu\\.com/ubuntu-ports/?#https://snapshot.ubuntu.com/ubuntu/${APT_SNAPSHOT}/#g" \
+      /etc/apt/sources.list.d/ubuntu.sources && \
+    apt-get update && apt-get install -y --no-install-recommends \
     bash \
     ca-certificates \
     cmake \
@@ -40,12 +47,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     xz-utils \
     && rm -rf /var/lib/apt/lists/*
 
-RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal && \
-    rustup default stable && \
+COPY support/ensure-rust.sh /tmp/ensure-rust.sh
+RUN sh /tmp/ensure-rust.sh && \
     cargo --version && \
     rustc -vV
 
-COPY support/ensure-jerboa.sh /tmp/ensure-jerboa.sh
+COPY support/ensure-jerboa.sh support/jerboa-release-sha256.txt /tmp/
 RUN sh /tmp/ensure-jerboa.sh "$JERBOA_VERSION" /usr/local/bin && \
     command -v jerbuild && \
     jerbuild --version && \
@@ -54,7 +61,9 @@ RUN sh /tmp/ensure-jerboa.sh "$JERBOA_VERSION" /usr/local/bin && \
 COPY . /src
 WORKDIR /src
 
-RUN BUNDLE_QT=1 bash packaging/linux/build-tarball.sh && \
+RUN BUNDLE_QT=1 JBROWSER_SOURCE_COMMIT="$JBROWSER_SOURCE_COMMIT" \
+    JBROWSER_SOURCE_TREE="$JBROWSER_SOURCE_TREE" \
+    bash packaging/linux/build-tarball.sh && \
     mkdir -p /out && \
     cp qt-webengine/build/jerboa-browser-*-linux-*.tar.gz \
       /out/jerboa-browser-static-qt.tar.gz && \
@@ -70,6 +79,8 @@ RUN BUNDLE_QT=1 bash packaging/linux/build-tarball.sh && \
     tar tzf "$tb" | grep -E '/libexec/QtWebEngineProcess$' >/dev/null && \
     tar tzf "$tb" | grep -E '/libexec/qt.conf$'           >/dev/null && \
     tar tzf "$tb" | grep -E '/resources/qtwebengine_resources\.pak$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/PROVENANCE\.txt$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/SOURCE-SHA256\.txt$' >/dev/null && \
     bin_path="$(tar tzf "$tb" | grep -E '/bin/jerboa-browser\.bin$' | head -n 1)" && \
     tar xzf "$tb" -O "$bin_path" > /tmp/jerboa-browser && \
     file /tmp/jerboa-browser
diff --git a/Makefile b/Makefile
index 4e3ca26..500fe1c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,23 +1,19 @@
 # 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.3
+JERBOA_VERSION ?= v0.2.4
+JERBOA_BUNDLE_SHA256 ?= 64efc7750859ac555905204cec289454c83642e4ea9b8f673985cf0fcead6194
 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin
-JERBOA_SIBLING_TOOL_DIR ?= $(CURDIR)/../jerboa/dist
-JERBUILD ?= $(shell if [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ] && [ -x "$(JERBOA_TOOL_DIR)/jerboa" ]; then \
-	printf '%s\n' "$(JERBOA_TOOL_DIR)/jerbuild"; \
-elif [ -x "$(JERBOA_SIBLING_TOOL_DIR)/jerbuild" ] && [ -x "$(JERBOA_SIBLING_TOOL_DIR)/jerboa" ]; then \
-	printf '%s\n' "$(JERBOA_SIBLING_TOOL_DIR)/jerbuild"; \
-elif command -v jerbuild >/dev/null 2>&1 && command -v jerboa >/dev/null 2>&1; then \
-	command -v jerbuild; \
-else \
-	printf '%s\n' "$(JERBOA_TOOL_DIR)/jerbuild"; \
-fi)
-JH := $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
+JERBUILD ?= $(JERBOA_TOOL_DIR)/jerbuild
+# Recursive on purpose: check-jerbuild may install the pinned local tool before
+# recipes expand this value.
+JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
 JERBOA_YUBIKEY_URL ?= https://git.sr.ht/~lisp/jerboa-yubikey
+JERBOA_YUBIKEY_COMMIT ?= e448afca926ca9ad40fd98effa8d6264cb1019f5
+JERBOA_YUBIKEY_TREE ?= 67e4417511b63c0b41b1b9c5dc1c7d897df51f08
 JERBOA_YUBIKEY_DIR := vendor/jerboa-yubikey
 JERBOA_YUBIKEY_LIB := $(JERBOA_YUBIKEY_DIR)/lib
-JEXEC       := $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/vendor/jerboa-fuse/lib:$(JH)/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)/vendor/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
@@ -30,70 +26,92 @@ SANDBOX_DIR ?= dist/webengine-sandbox
 CC ?= cc
 CARGO ?= cargo
 CARGO_AUDIT ?= $(shell command -v cargo-audit 2>/dev/null || printf '%s/.cargo/bin/cargo-audit' "$$HOME")
+CARGO_AUDIT_VERSION ?= 0.22.2
 PODMAN ?= podman
 PODMAN_PLATFORM ?= linux/arm64
 PODMAN_BUILD_FLAGS ?=
-STATIC_QT_BASE_IMAGE ?= docker.io/library/ubuntu:24.04
+STATIC_QT_BASE_IMAGE ?= docker.io/library/ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
 STATIC_QT_IMAGE ?= jerboa-browser-qt-builder
 STATIC_QT_TARBALL ?= jerboa-browser-static-qt-linux-arm64.tar.gz
+STATIC_QT_APT_SNAPSHOT ?= 20260701T000000Z
 
 # Modules that use (std crypto native-rust) need libjerboa_native dlopen-able at
 # runtime — in the interpreter (jerbuild exec: tests, repl) AND in the compiled
-# binary: jerbuild does not statically register the Rust crypto symbols, so the
-# binary dlopens the lib too (else it aborts with "no entry for
-# jerboa_last_error"). native-rust probes ./lib/libjerboa_native.* (CWD-relative)
-# and the dynamic loader path, so stage a symlink under ./lib from where jerbuild
-# built it (its cache, after `make binary`) or a sibling jerboa checkout. (The
-# static-qt tarball bundles a copy in lib/; its launcher adds lib/ to
-# LD_LIBRARY_PATH so the binary finds it from any CWD.)
+# binary. Stage a symlink under ./lib from jerbuild's cache; release project
+# DSOs are located from the registered canonical executable path, never CWD or
+# ambient dynamic-loader variables.
 SOEXT := $(if $(filter Darwin,$(shell uname -s)),dylib,so)
 FUSE_SHARED_FLAG := $(if $(filter Darwin,$(shell uname -s)),-dynamiclib,-shared)
 FUSE_SHIM := libjerboa_fuse_mount.so
+ifeq ($(shell uname -s),Darwin)
+FUSE_OPENSSL_PREFIX ?= $(shell brew --prefix openssl@4 2>/dev/null || brew --prefix openssl@3 2>/dev/null || echo /opt/homebrew/opt/openssl@4)
+FUSE_OPENSSL_CFLAGS ?= -I$(FUSE_OPENSSL_PREFIX)/include
+FUSE_OPENSSL_LIBS ?= -L$(FUSE_OPENSSL_PREFIX)/lib -lcrypto
+else
+FUSE_OPENSSL_CFLAGS ?= $(shell pkg-config --cflags openssl 2>/dev/null)
+FUSE_OPENSSL_LIBS ?= $(shell pkg-config --libs openssl 2>/dev/null || printf '%s\n' -lcrypto)
+endif
 YUBIKEY_NATIVE_SHARED := $(JERBOA_YUBIKEY_DIR)/yubikey-native/target/release/libyubikey_native.$(SOEXT)
 # `make binary` links the static archive from a temp dir and leaves no shared
 # lib behind, so build the cdylib from jerbuild's bundled crate if it isn't
 # already cached, then symlink it where native-rust probes (./lib, CWD-relative).
-lib/libjerboa_native.$(SOEXT): | check-jerbuild
+.PHONY: FORCE
+FORCE:
+
+lib/libjerboa_native.$(SOEXT): FORCE | check-jerbuild
 	@mkdir -p lib
 	@src="$(JH)/jerboa-native-rs/target/release/libjerboa_native.$(SOEXT)"; \
 	 if [ ! -f "$$src" ] && [ -f "$(JH)/jerboa-native-rs/Cargo.toml" ]; then \
 	   echo "building libjerboa_native via cargo (one-time)..."; \
-	   cargo build --release --no-default-features --features crypto \
+	   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"; \
 	 else echo "note: libjerboa_native.$(SOEXT) unavailable; crypto tests may fail"; fi
 
-$(FUSE_SHIM): vendor/jerboa-fuse/src/mount_helper.c
-	$(CC) $(FUSE_SHARED_FLAG) -fPIC -O2 -o $@ $<
+$(FUSE_SHIM): vendor/jerboa-fuse/src/mount_helper.c support/browser_secure_fs.c support/browser_secure_fs.h
+	$(CC) $(FUSE_SHARED_FLAG) -fPIC -O2 -Wall -Wextra -Werror \
+	  $(FUSE_OPENSSL_CFLAGS) -I support -o $@ \
+	  vendor/jerboa-fuse/src/mount_helper.c support/browser_secure_fs.c \
+	  $(FUSE_OPENSSL_LIBS)
 
 vendor-yubikey:
-	sh support/ensure-vendor.sh jerboa-yubikey "$(JERBOA_YUBIKEY_URL)"
+	sh support/ensure-vendor.sh jerboa-yubikey "$(JERBOA_YUBIKEY_URL)" \
+	  "$(JERBOA_YUBIKEY_COMMIT)" "$(JERBOA_YUBIKEY_TREE)"
 	@test -f "$(JERBOA_YUBIKEY_LIB)/yubikey/auth.sls" || \
 	  { echo "ERROR: missing $(JERBOA_YUBIKEY_LIB)/yubikey/auth.sls" >&2; exit 1; }
+	@cmp -s support/yubikey-native-Cargo.lock \
+	  "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.lock" || \
+	  { echo "ERROR: YubiKey Cargo lock overlay mismatch" >&2; exit 1; }
 
 lib/libyubikey_native.$(SOEXT): vendor-yubikey
-	$(CARGO) build --manifest-path $(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml --release --no-default-features
+	$(CARGO) build --locked --manifest-path $(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml --release --no-default-features
 	@mkdir -p lib
 	cp -f $(YUBIKEY_NATIVE_SHARED) $@
 
 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 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 vendor-yubikey yubikey-native
+.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 vendor-yubikey yubikey-native
 .DEFAULT_GOAL := help
 
 all: binary
 
 check-jerbuild:
-	@test -n "$(JH)" || { echo $(JERBUILD_ERROR) >&2; exit 1; }
+	@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)"; \
+	fi
+	@"$(JERBUILD)" --jerboa-home >/dev/null 2>&1 || { echo $(JERBUILD_ERROR) >&2; exit 1; }
+	@if [ "$(JERBUILD)" = "$(JERBOA_TOOL_DIR)/jerbuild" ]; then \
+	  "$(JERBUILD)" --version | grep -q 'bundled-lib sha256=$(JERBOA_BUNDLE_SHA256)' || \
+	    { echo "ERROR: selected Jerboa bundle digest does not match supply-chain.lock" >&2; exit 1; }; \
+	fi
 
 ensure-jerboa-tools:
 	@if [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ] && [ -x "$(JERBOA_TOOL_DIR)/jerboa" ]; then \
 	  echo "=== Using downloaded Jerboa toolchain: $(JERBOA_TOOL_DIR) ==="; \
-	elif [ -x "$(JERBOA_SIBLING_TOOL_DIR)/jerbuild" ] && [ -x "$(JERBOA_SIBLING_TOOL_DIR)/jerboa" ]; then \
-	  echo "=== Using sibling Jerboa release toolchain: $(JERBOA_SIBLING_TOOL_DIR) ==="; \
-	elif command -v jerbuild >/dev/null 2>&1 && command -v jerboa >/dev/null 2>&1; then \
-	  echo "=== Using Jerboa toolchain from PATH: $$(command -v jerbuild) ==="; \
+	elif [ -x "$(JERBUILD)" ]; then \
+	  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)"; \
@@ -135,7 +153,13 @@ 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-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-supply-chain:
+	sh test/supply-chain-test.sh
+
+test-native-loader:
+	JERBUILD="$(JERBUILD)" bash test/native-loader-security-test.sh
 
 # Stage 8 secure-store unit tests: bookmark (de)serialize, AES-256-GCM seal/open,
 # RSA-OAEP wrap + simulated card unwrap, tamper detection, masked-DK zeroing.
@@ -212,21 +236,27 @@ test-native:
 
 security:
 	sh scripts/security-check.sh
+	JERBUILD="$(JERBUILD)" bash test/native-loader-security-test.sh
 
-audit:
+audit: vendor-yubikey
 	@mkdir -p "$(DIST_DIR)"
-	@if [ -x "$(CARGO_AUDIT)" ] && [ -f qt-webengine/vendor/adblock-rust-ffi/Cargo.toml ]; then \
-	  cd qt-webengine/vendor/adblock-rust-ffi && "$(CARGO_AUDIT)" audit > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"; \
-	else \
-	  echo "cargo-audit not installed or adblock Cargo.toml missing; release owner must review before publication." > "$(DIST_DIR)/rustsec-adblock-ffi.txt"; \
-	fi
-	@if [ -x "$(CARGO_AUDIT)" ] && [ -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" ]; then \
-	  cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"; \
-	else \
-	  echo "cargo-audit not installed or YubiKey native Cargo.toml missing; release owner must review before publication." > "$(DIST_DIR)/rustsec-yubikey-native.txt"; \
-	fi
-
-sbom:
+	@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 "$(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"
+	@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"
+	@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)
 	JBROWSER_SBOM_DIR="$(SBOM_DIR)" \
 	JERBUILD="$(JERBUILD)" \
 	BIN="$(BIN)" \
@@ -276,9 +306,9 @@ release-evidence: verify
 	  command -v "$(CARGO)" >/dev/null 2>&1 && "$(CARGO)" --version || true; \
 	} > "$(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
-	@find scheme qt-webengine/src qt-webengine/tests include support packaging -type f \( -name '*.ss' -o -name '*.sls' -o -name '*.c' -o -name '*.cc' -o -name '*.cpp' -o -name '*.h' -o -name '*.sh' -o -name '*.md' -o -name 'CMakeLists.txt' \) -print | sort | xargs shasum -a 256 > "$(DIST_DIR)/source-sha256.txt"
-	@find qt-webengine/vendor/adblock-rust-ffi "$(JERBOA_YUBIKEY_DIR)/yubikey-native" -maxdepth 2 -type f \( -name Cargo.toml -o -name Cargo.lock \) -print 2>/dev/null | sort | xargs shasum -a 256 > "$(DIST_DIR)/native-cargo-sha256.txt"
-	@shasum -a 256 Makefile .jerbuild .jerboa/security.json 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 > "$(DIST_DIR)/release-inputs-sha256.txt"
+	@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 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 \
 	  { otool -L "$(BIN)" 2>/dev/null || true; otool -L qt-webengine/build/libjerboa_browser.* 2>/dev/null || true; otool -L lib/libyubikey_native.* 2>/dev/null || true; } > "$(DIST_DIR)/native-linkage.txt"; \
@@ -288,21 +318,16 @@ release-evidence: verify
 	  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 --format-version 1 --manifest-path qt-webengine/vendor/adblock-rust-ffi/Cargo.toml > "$(DIST_DIR)/cargo-metadata-adblock-ffi.json"; \
+	  "$(CARGO)" metadata --locked --format-version 1 --manifest-path qt-webengine/vendor/adblock-rust-ffi/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 --format-version 1 --manifest-path "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" > "$(DIST_DIR)/cargo-metadata-yubikey-native.json"; \
-	fi
-	@if [ -x "$(CARGO_AUDIT)" ] && [ -f qt-webengine/vendor/adblock-rust-ffi/Cargo.toml ]; then \
-	  cd qt-webengine/vendor/adblock-rust-ffi && "$(CARGO_AUDIT)" audit > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt"; \
-	else \
-	  echo "cargo-audit not installed or adblock Cargo.toml missing; release owner must review before publication." > "$(DIST_DIR)/rustsec-adblock-ffi.txt"; \
-	fi
-	@if [ -x "$(CARGO_AUDIT)" ] && [ -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" ]; then \
-	  cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt"; \
-	else \
-	  echo "cargo-audit not installed or YubiKey native Cargo.toml missing; release owner must review before publication." > "$(DIST_DIR)/rustsec-yubikey-native.txt"; \
+	  "$(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"
+	@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"
 	@cp -R "$(REPRO_DIR)" "$(DIST_DIR)/reproducibility"
@@ -329,10 +354,15 @@ static-qt: podman-static-qt
 
 podman-static-qt: check-podman
 	@echo "=== Building jerboa-browser Linux Qt artifact with Podman ==="
+	@test -z "$$(git status --porcelain --untracked-files=all)" || \
+	  { echo "static release packaging requires a clean tree" >&2; exit 1; }
 	$(PODMAN) pull --platform $(PODMAN_PLATFORM) $(STATIC_QT_BASE_IMAGE)
 	$(PODMAN) build $(PODMAN_BUILD_FLAGS) --platform $(PODMAN_PLATFORM) \
 	  --build-arg BASE_IMAGE="$(STATIC_QT_BASE_IMAGE)" \
 	  --build-arg JERBOA_VERSION="$(JERBOA_VERSION)" \
+	  --build-arg APT_SNAPSHOT="$(STATIC_QT_APT_SNAPSHOT)" \
+	  --build-arg JBROWSER_SOURCE_COMMIT="$$(git rev-parse HEAD)" \
+	  --build-arg JBROWSER_SOURCE_TREE="$$(git rev-parse 'HEAD^{tree}')" \
 	  -f Dockerfile.qt -t $(STATIC_QT_IMAGE) .
 	@id=$$($(PODMAN) create $(STATIC_QT_IMAGE)) && \
 	  trap '$(PODMAN) rm $$id >/dev/null 2>&1 || true' EXIT INT TERM && \
@@ -387,11 +417,11 @@ 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-yubikey clone jerboa-yubikey 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)"
 	@echo "  make test-gui offline Qt GUI/snapshot harness (offscreen, headless)"
 	@echo "  make repl     REPL with (browser) preloaded (interpreter)"
 	@echo "  make clean    remove build artifacts"
 	@echo ""
-	@echo "  env: JERBOA_HOME (default ../jerboa), SCHEME, JERBOA_BROWSER_LIB"
+	@echo "  dev env: JERBOA_BROWSER_DEV_NATIVE=1 with canonical JERBOA_BROWSER_LIB"
diff --git a/README.md b/README.md
index b62c645..04b9140 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,8 @@ access controller trusts only the browser PID and descendants such as
 is not reused by the browser. Defaults:
 
 - Vault file: `$XDG_DATA_HOME/jerboa-browser/browser.vault`
-- Mountpoint: `$XDG_RUNTIME_DIR/jerboa-browser/vault-<browser-pid>` (or `/tmp`)
+- Mountpoint: a fresh 0700 CSPRNG-named directory directly under a validated
+  `$XDG_RUNTIME_DIR` (or the platform's canonical sticky temporary directory)
 - Size: `JERBOA_BROWSER_VAULT_BLOCKS` blocks, default 65536 (256 MiB)
 
 The production default is YubiKey PIV unlock. With `JERBOA_BROWSER_VAULT=1`, the
@@ -139,12 +140,23 @@ The browser intentionally does not accept a vault passphrase through environment
 variables and does not embed a build-local fallback secret. Use the YubiKey PIV
 default, a protected passphrase file, or the interactive prompt.
 
+An explicit `JERBOA_BROWSER_VAULT_MOUNT` must be an absolute, new path beneath
+a trusted parent; pre-existing paths and symlinks are rejected. The vault data
+directory is owner-only, and an existing vault file must be a 0600 regular file
+owned by the current user with exactly one link.
+
+URL authority is scheme-specific. `network` permits only `http`, `https`, `ws`,
+and `wss`; it never implies local content or files. Application-created `data`,
+`blob`, `qrc`, and `about:blank` pages require `local-content`. File URLs require
+`filesystem` plus an absolute `JERBOA_BROWSER_FILESYSTEM_ROOT`, and targets must
+canonicalize beneath that root.
+
 ## Build / test
 
 ```sh
 make binary           # the self-contained native ./jerboa-browser
 make static-qt        # bundled Linux Qt/WebEngine tarball via Podman (linux/arm64)
-make vendor-yubikey   # clone https://git.sr.ht/~lisp/jerboa-yubikey into vendor/
+make vendor-yubikey   # verify/fetch the locked YubiKey commit and tree
 make yubikey-native   # build lib/libyubikey_native for direct USB YubiKey access
 make test             # (browser) suite + secure-store unit tests; also test-{keymap,…,polish}
 make verify           # full test set, security checks, and dependency audit evidence
diff --git a/SECURITY.md b/SECURITY.md
index 0c3bee7..d686c3c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -20,7 +20,7 @@ requires:
 - The Qt WebEngine backend builds and its native tests pass on every supported
   target OS.
 - RustSec/native dependency audits are clean or documented as accepted release
-  risks.
+  risks; missing audit tooling and RustSec warnings fail closed.
 - Target-OS evidence confirms the Chromium/Qt WebEngine sandbox is enabled. Run
   WebEngine evidence with `JBROWSER_RUN_WEBENGINE_PROCESS_SMOKE=1`,
   `JBROWSER_REQUIRE_WEBENGINE_PROCESS_SMOKE=1`,
@@ -35,6 +35,9 @@ requires:
 
 - Web content is hostile. Do not grant network, downloads, clipboard, file, or
   persistent-storage capabilities unless the caller explicitly requested them.
+- Network authority permits only HTTP(S)/WS(S). Local synthetic content and
+  canonical-rooted file reads require their own capabilities; custom and
+  JavaScript navigation schemes remain denied.
 - The default WebEngine profile must remain off-record. Persistent cookies,
   cache, local storage, and downloads require the browser-owned encrypted vault.
 - Do not accept browser-profile vault secrets through process environment
@@ -47,6 +50,9 @@ requires:
   `(chezscheme)` imports are denied in production/test source.
 - Native boundaries must be documented, length-checked, and covered by release
   evidence.
+- Release inputs must match `supply-chain.lock`. Do not replace pinned commits,
+  trees, OCI digests, installer hashes, action SHAs, or compiler versions with
+  tags/default branches or co-hosted checksums.
 
 ## Reporting
 
diff --git a/build-binary.ss b/build-binary.ss
index dc18544..f3082a2 100644
--- a/build-binary.ss
+++ b/build-binary.ss
@@ -174,6 +174,9 @@
     (fprintf o "#include \"scheme.h\"\n")
     (fprintf o "#include \"jb_petite_boot.h\"\n#include \"jb_scheme_boot.h\"\n")
     (fprintf o "#include \"jb_boot.h\"\n#include \"jb_program.h\"\n\n")
+    (fprintf o "static char jwb_bootstrap_exe[PATH_MAX];\n")
+    (fprintf o "static const char *jwb_bootstrap_executable_path(void) {\n")
+    (fprintf o "  return jwb_bootstrap_exe[0] ? jwb_bootstrap_exe : NULL;\n}\n\n")
     (fprintf o "int main(int argc, char *argv[]) {\n")
     (fprintf o "  /* Record the real exe path so (browser) finds its sibling lib/:\n")
     (fprintf o "     the embedded program runs from a temp file below, so argv[0]\n")
@@ -181,10 +184,10 @@
     (fprintf o "  { char exe[PATH_MAX];\n")
     (fprintf o "#if defined(__linux__)\n")
     (fprintf o "    ssize_t en = readlink(\"/proc/self/exe\", exe, sizeof(exe)-1);\n")
-    (fprintf o "    if (en > 0) { exe[en] = 0; setenv(\"JERBOA_BROWSER_EXE\", exe, 1); }\n")
+    (fprintf o "    if (en > 0) { exe[en] = 0; if (!realpath(exe, jwb_bootstrap_exe)) return 1; }\n")
     (fprintf o "#elif defined(__APPLE__)\n")
     (fprintf o "    uint32_t esz = sizeof(exe);\n")
-    (fprintf o "    if (_NSGetExecutablePath(exe, &esz) == 0) setenv(\"JERBOA_BROWSER_EXE\", exe, 1);\n")
+    (fprintf o "    if (_NSGetExecutablePath(exe, &esz) == 0 && !realpath(exe, jwb_bootstrap_exe)) return 1;\n")
     (fprintf o "#endif\n")
     (fprintf o "  }\n")
     (fprintf o "  char prog_path[256];\n")
@@ -196,6 +199,7 @@
     (fprintf o "    perror(\"write\"); close(fd); unlink(prog_path); return 1; }\n")
     (fprintf o "  close(fd);\n")
     (fprintf o "  Sscheme_init(NULL);\n")
+    (fprintf o "  Sforeign_symbol(\"jwb_bootstrap_executable_path\", (void *)jwb_bootstrap_executable_path);\n")
     (fprintf o "  Sregister_boot_file_bytes(\"petite\", (void*)petite_boot_data, petite_boot_size);\n")
     (fprintf o "  Sregister_boot_file_bytes(\"scheme\", (void*)scheme_boot_data, scheme_boot_size);\n")
     (fprintf o "  Sregister_boot_file_bytes(\"jerboa-browser\", (void*)jb_boot_data, jb_boot_size);\n")
diff --git a/docs/ffi-boundary.md b/docs/ffi-boundary.md
index f58b62c..9e24c59 100644
--- a/docs/ffi-boundary.md
+++ b/docs/ffi-boundary.md
@@ -21,6 +21,9 @@ FFI expectations:
 - `libjerboa_browser` wraps Qt WebEngine and the adblock-rust FFI.
 - `libyubikey_native` handles direct USB CCID/FIDO2 support when present.
 - `libjerboa_fuse_mount.so` is the browser-owned encrypted vault mount helper.
+  It also exports fixed-signature descriptor-relative secure-filesystem wrappers
+  for private runtime directories and vault files; Scheme never calls variadic
+  `open(2)` for that boundary.
 - `libjerboa_native` provides Rust crypto used by secure-store tests and builds.
 
 All native dependencies must be represented in release evidence with source or
@@ -38,3 +41,9 @@ Release review should re-check:
 - UTF-8/NUL/length handling for URL, JavaScript, title, status, and path inputs.
 - Adblock FFI pointer cleanup for redirect buffers.
 - Context destruction order: views before profile/interceptor cleanup.
+  The native handle registry records the parent context and rejects context
+  release while any child view remains, preventing profile use-after-free even
+  when a caller violates the documented order.
+- Scheme-by-scheme URL policy at direct-load, navigation/redirect, request, and
+  download boundaries.
+- Vault path component, owner, permission, type, and link-count validation.
diff --git a/docs/finding-closure.md b/docs/finding-closure.md
new file mode 100644
index 0000000..6bd901d
--- /dev/null
+++ b/docs/finding-closure.md
@@ -0,0 +1,75 @@
+# 56sol Finding Closure
+
+This repository closes the applicable project security-audit findings as
+follows. Release evidence is valid only for the exact source and dependency
+hashes emitted by `make release-evidence`.
+
+## BRW-001 — network capability could load local files
+
+- `browser_url_policy.cpp` is the single scheme-authority decision point.
+  `NETWORK` permits only HTTP(S) and WS(S). `LOCAL_CONTENT`, `FILESYSTEM`, and
+  `DEVTOOLS` are independent bits; unknown, JavaScript, extension, and custom
+  schemes fail closed.
+- `FILESYSTEM` requires an existing absolute non-symlink
+  `JERBOA_BROWSER_FILESYSTEM_ROOT`. Every file target is canonicalized and
+  checked beneath that root. Remote file authorities are forbidden.
+- The policy is enforced before direct loads, for navigation/redirects in the
+  page subclass, for all profile requests (frames, workers, XHR, WebSockets,
+  and subresources), and again for downloads.
+- `jwb_url_policy_test` exercises the hostile scheme/capability matrix,
+  traversal, outside-root, remote-file, and symlink cases. `browser-test.ss`
+  dynamically proves a network-only renderer cannot load `/etc/hosts`, data,
+  qrc, or JavaScript URLs.
+
+## BRW-002 — predictable and untrusted vault paths
+
+- The fallback is a 128-bit CSPRNG directory created descriptor-relatively
+  beneath a verified sticky temporary directory; no username or PID appears in
+  its name. An explicit mount override is exclusive and must not pre-exist.
+- `browser_secure_fs.c` walks every component with held directory descriptors
+  and `O_NOFOLLOW`. Runtime directories are 0700. Vault files are created with
+  `O_EXCL|O_NOFOLLOW|O_CLOEXEC`, mode 0600, and existing files must be regular,
+  owned by the effective uid, and single-link.
+- The vendored blockstore now calls fixed-signature wrappers rather than
+  variadic libc `open(2)`. Mount directories are removed through a verified
+  parent descriptor after unmount.
+- `jwb_secure_fs_test` covers unpredictability, exact modes, pre-existing
+  paths, symlinked parents/finals, hard links, wrong modes, cleanup, and
+  fail-closed secret input without a controlling terminal. PIN/passphrase
+  prompts save, disable, and exactly restore termios through `/dev/tty`; they no
+  longer invoke a shell or accept redirected stdin.
+- The no-controlling-terminal probe uses `posix_spawn` to enter a fresh test
+  image before calling the secret reader; it does not resume C++ after raw
+  `fork`.
+- Passphrase files are opened through the same no-follow descriptor walk and
+  must be bounded 0600, owner-only, single-link regular files.
+
+## Supply, dependency, and assurance closure
+
+- `support/jerboa-release-sha256.txt` is a consumer-owned trust root for Jerboa
+  tools; a checksum downloaded beside an archive is never trusted.
+- The default build uses only that project-local pinned toolchain; sibling and
+  ambient PATH tool selection was removed.
+- YubiKey and SourceHut Jerboa source fetches require exact commits and Git tree
+  IDs. Existing vendor snapshots carry an exact lock. Default branches cannot
+  enter builds (`SUPPLY-01`, `SUPPLY-05`).
+- Ubuntu is pinned by OCI digest and an immutable signed apt snapshot;
+  Rust/rustup/cargo-audit are exact versions,
+  and rustup-init is verified against per-architecture consumer-owned hashes
+  (`SUPPLY-01`, `SUPPLY-03`, `ASSURANCE-01`).
+- GitHub actions use full commit IDs with persisted checkout credentials
+  disabled (`SUPPLY-04`).
+- The consumer-owned YubiKey lock upgrades `anyhow` to 1.0.103 and release audits deny
+  RustSec warnings (`DEP-01`).
+- `supply-chain.lock`, Cargo locks, native/source hashes, compiler and system
+  package versions, resolved Cargo metadata, and the RustSec database commit
+  are retained in the SBOM/evidence bundle (`ASSURANCE-01`). Secret scans are
+  explicitly a covered-pattern gate, not proof that arbitrary credentials are
+  absent (`ASSURANCE-02`).
+- Linux archives embed a complete source manifest and provenance record. SBOM
+  generation treats arbitrary or stale tarballs as unverified unless commit,
+  tree, source bytes, and supply lock all match the current release candidate.
+
+`test/supply-chain-test.sh` exercises hostile vendor-lock and transport cases;
+`scripts/security-check.sh` rejects mutable action refs, mutable Rust
+bootstraps, unpinned container bases, and missing lock material.
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 8046210..35ba334 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -66,8 +66,13 @@ Expected files include:
 - `binary-sha256.txt`
 - `native-linkage.txt`
 - Cargo metadata for adblock and YubiKey native crates when available
-- RustSec audit outputs for adblock and YubiKey native crates when
-  `cargo-audit` is installed
+- `supply-chain.lock`, its hash, toolchain versions, resolved system package
+  versions, and the RustSec advisory database commit
+- current and packaged source manifests plus packaged `PROVENANCE.txt`; a
+  pre-existing static Qt tarball is labeled `present-unverified` unless its
+  commit, tree, full source manifest, and supply lock match the current source
+- warning-denying RustSec audit outputs for adblock, YubiKey native, and the
+  resolved Jerboa native crypto crate
 - `sbom/`
 - `reproducibility/`
 - `binary-smoke/`
@@ -77,6 +82,10 @@ Expected files include:
   supplied
 - `result.txt` with `release_evidence_status=pass`
 
-Release owners must treat missing `cargo-audit`, skipped Qt native tests, or
-missing target-OS sandbox/process evidence as production blockers, even when the
-local gate exits successfully for developer convenience.
+Missing the pinned `cargo-audit` now fails the gate. Skipped Qt native tests or
+missing target-OS sandbox/process evidence remain production blockers; target
+sandbox proof is intentionally collected only on a matching release host.
+
+The high-confidence secret scan covers the formats named in the security gate;
+it is not evidence that arbitrary raw application credentials cannot exist.
+Review credential-bearing files and binary smoke evidence separately.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 275f8c8..655e667 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -38,8 +38,12 @@ vendored YubiKey/FUSE dependencies.
   `JERBOA_BROWSER_VAULT=1`, which mounts a browser-owned encrypted FUSE vault.
 - Chromium/Qt WebEngine sandbox must remain enabled. Release source must not
   set or document `QTWEBENGINE_DISABLE_SANDBOX`.
-- Network, downloads, popups, local-file access, clipboard access, and
-  persistent storage are capability-gated per context.
+- Network, application-local content, downloads, popups, canonical-rooted
+  local-file access, clipboard access, and persistent storage are independently
+  capability-gated per context.
+- 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.
 - Bookmark persistence uses AES-256-GCM with a random data key wrapped to a
@@ -78,3 +82,5 @@ vendored YubiKey/FUSE dependencies.
   `status=blocked-target-proof`.
 - No repeated binary reproducibility, SBOM/provenance, or no-secret binary
   startup evidence for the release candidate.
+- Any mismatch against `supply-chain.lock`, any mutable CI action/toolchain
+  bootstrap, or any RustSec warning.
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 2d19c57..731c8dd 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -51,7 +51,10 @@ typedef enum {
   JWB_CAP_DOWNLOADS = 1u << 3,
   JWB_CAP_PERSISTENT_STORAGE = 1u << 4,
   JWB_CAP_DEVTOOLS = 1u << 5,
-  JWB_CAP_POPUPS = 1u << 6
+  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
 } JwbCapabilityFlags;
 
 typedef void (*JwbStringCallback)(const uint8_t *ptr,
diff --git a/packaging/README.md b/packaging/README.md
index 44827b6..9f3209a 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -115,13 +115,14 @@ produce the executable.
 
 With `BUNDLE_QT=1`, the package also stages Qt libraries, platform plugins,
 `QtWebEngineProcess`, WebEngine `.pak` resources, ICU data, locales, and the
-shared-library dependency closure under the package root. The launcher
-`bin/jerboa-browser` sets `LD_LIBRARY_PATH`, `QT_PLUGIN_PATH`,
-`QML2_IMPORT_PATH`, `QT_TRANSLATIONS_PATH`, `QTWEBENGINEPROCESS_PATH`,
-`QTWEBENGINE_RESOURCES_PATH`, `QTWEBENGINE_LOCALES_PATH`, and
-`JERBOA_FUSE_MOUNT_LIB`, then execs `bin/jerboa-browser.bin`. `qt.conf` files
-next to the native binary and `QtWebEngineProcess` point Qt at the package root.
-The Chromium sandbox remains enabled.
+shared-library dependency closure under the package root. The launcher sets
+fixed bundle-local Qt plugin, QML, translation, helper, resource, and locale
+paths, then execs `bin/jerboa-browser.bin`. Project DSOs are selected from the
+native bootstrap's canonical executable path and validated before loading; the
+launcher does not set `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, or project-library
+override variables. `qt.conf` files next to the native binary and
+`QtWebEngineProcess` point Qt at the package root. The Chromium sandbox
+remains enabled.
 
 Browser profile persistence is opt-in with `JERBOA_BROWSER_VAULT=1`. The
 browser mounts its own encrypted vault and points Qt cache/storage paths inside
diff --git a/packaging/linux/build-tarball.sh b/packaging/linux/build-tarball.sh
index 1f8075c..9513976 100755
--- a/packaging/linux/build-tarball.sh
+++ b/packaging/linux/build-tarball.sh
@@ -21,6 +21,14 @@ JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}"
 SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
 BUNDLE_QT="${BUNDLE_QT:-0}"
 
+sha256_value() {
+    if command -v sha256sum >/dev/null 2>&1; then
+        sha256sum "$1" | awk '{print $1}'
+    else
+        shasum -a 256 "$1" | awk '{print $1}'
+    fi
+}
+
 copy_tree_if_exists() {
     local src="$1" dst="$2"
     if [ -d "$src" ]; then
@@ -158,17 +166,13 @@ bundle_qt_runtime() {
 #!/bin/sh
 set -eu
 APPDIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
-export LD_LIBRARY_PATH="$APPDIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
 export QT_PLUGIN_PATH="$APPDIR/plugins"
-export QML2_IMPORT_PATH="$APPDIR/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
-export QT_TRANSLATIONS_PATH="$APPDIR/translations${QT_TRANSLATIONS_PATH:+:$QT_TRANSLATIONS_PATH}"
-export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$APPDIR/lib/libjerboa_browser.so}"
-export QTWEBENGINEPROCESS_PATH="${QTWEBENGINEPROCESS_PATH:-$APPDIR/libexec/QtWebEngineProcess}"
-export QTWEBENGINE_RESOURCES_PATH="${QTWEBENGINE_RESOURCES_PATH:-$APPDIR/resources}"
-export QTWEBENGINE_LOCALES_PATH="${QTWEBENGINE_LOCALES_PATH:-$APPDIR/resources/qtwebengine_locales}"
-export QTWEBENGINE_DICTIONARIES_PATH="${QTWEBENGINE_DICTIONARIES_PATH:-$APPDIR/resources/qtwebengine_dictionaries}"
-export JERBOA_FUSE_MOUNT_LIB="${JERBOA_FUSE_MOUNT_LIB:-$APPDIR/lib/libjerboa_fuse_mount.so}"
-export JERBOA_BROWSER_EXE="${JERBOA_BROWSER_EXE:-$APPDIR/bin/jerboa-browser.bin}"
+export QML2_IMPORT_PATH="$APPDIR/qml"
+export QT_TRANSLATIONS_PATH="$APPDIR/translations"
+export QTWEBENGINEPROCESS_PATH="$APPDIR/libexec/QtWebEngineProcess"
+export QTWEBENGINE_RESOURCES_PATH="$APPDIR/resources"
+export QTWEBENGINE_LOCALES_PATH="$APPDIR/resources/qtwebengine_locales"
+export QTWEBENGINE_DICTIONARIES_PATH="$APPDIR/resources/qtwebengine_dictionaries"
 exec "$APPDIR/bin/jerboa-browser.bin" "$@"
 EOF
     chmod +x "$STAGE/bin/jerboa-browser" "$STAGE/bin/jerboa-browser.bin"
@@ -289,18 +293,17 @@ if ( cd "$REPO" && make check-jerbuild >/dev/null 2>&1 ); then
         # The binary re-execs its embedded program from a temp file and dlopens
         # the Qt backend (libjerboa_browser) + the Rust crypto lib
         # (libjerboa_native, for the secure store) at runtime. jerbuild's
-        # launcher does not export JERBOA_BROWSER_EXE, so the in-binary "find the
-        # lib next to me" probe lands in /tmp. Install the real ELF as
-        # bin/jerboa-browser.bin behind a small launcher that points the loader
-        # at the bundled lib/ regardless of the caller's CWD — so the tarball is
-        # runnable as-is (the whole point of `make static-qt`).
+        # embedded bootstrap registers the canonical executable path, so
+        # project DSOs are selected from the adjacent bundle without CWD or
+        # ambient loader-policy variables. Keep the real ELF behind a small
+        # relocatable launcher for symlink resolution.
         cp -f "$REPO/jerboa-browser" "$STAGE/bin/jerboa-browser.bin"
         chmod +x "$STAGE/bin/jerboa-browser.bin"
         cat > "$STAGE/bin/jerboa-browser" <<'WRAP'
 #!/bin/sh
 # Launcher for the relocatable Jerboa Browser tarball: resolve our install dir
-# (following symlinks) and point the native binary at the bundled lib/ before
-# exec'ing it. Override JERBOA_BROWSER_LIB to use a different Qt backend build.
+# (following symlinks) before exec'ing it. The native bootstrap locates and
+# validates the adjacent lib/ directory itself.
 set -eu
 self=$0
 while [ -L "$self" ]; do
@@ -311,8 +314,6 @@ while [ -L "$self" ]; do
   esac
 done
 here=$(CDPATH= cd -- "$(dirname -- "$self")/.." && pwd)
-export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$here/lib/libjerboa_browser.so}"
-export LD_LIBRARY_PATH="$here/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
 exec "$here/bin/jerboa-browser.bin" "$@"
 WRAP
         chmod +x "$STAGE/bin/jerboa-browser"
@@ -440,6 +441,40 @@ Encrypted browser vault:
   JERBOA_BROWSER_VAULT_YUBIKEY_PROVISION=1.
 EOF
 
+echo "==> recording source provenance"
+sh "$REPO/scripts/source-manifest.sh" "$REPO" > "$STAGE/SOURCE-SHA256.txt"
+source_manifest_sha256=$(sha256_value "$STAGE/SOURCE-SHA256.txt")
+supply_chain_lock_sha256=$(sha256_value "$REPO/supply-chain.lock")
+if git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
+    source_commit=$(git -C "$REPO" rev-parse HEAD)
+    source_tree=$(git -C "$REPO" rev-parse 'HEAD^{tree}')
+    if [ -n "$(git -C "$REPO" status --porcelain --untracked-files=all)" ]; then
+        source_state=dirty
+    else
+        source_state=clean
+    fi
+else
+    source_commit=${JBROWSER_SOURCE_COMMIT:-}
+    source_tree=${JBROWSER_SOURCE_TREE:-}
+    [ "${#source_commit}" -eq 40 ] && [ "${#source_tree}" -eq 40 ] || {
+        echo "container builds require JBROWSER_SOURCE_COMMIT and JBROWSER_SOURCE_TREE" >&2
+        exit 1
+    }
+    source_state=declared-container-context
+fi
+if [ "$source_state" = dirty ] && [ "${JBROWSER_ALLOW_DIRTY_PACKAGE:-0}" != 1 ]; then
+    echo "refusing release package from a dirty source tree" >&2
+    exit 1
+fi
+cat > "$STAGE/PROVENANCE.txt" <<EOF
+format=1
+source_commit=$source_commit
+source_tree=$source_tree
+source_state=$source_state
+source_manifest_sha256=$source_manifest_sha256
+supply_chain_lock_sha256=$supply_chain_lock_sha256
+EOF
+
 echo "==> creating $TARBALL"
 tar -C "$BUILD" -czf "$TARBALL" "$(basename "$STAGE")"
 echo "    $TARBALL  (native binary: $([ "$HAVE_BIN" = 1 ] && echo yes || echo no))"
diff --git a/qt-webengine/CMakeLists.txt b/qt-webengine/CMakeLists.txt
index 2d0e2a2..c5a741a 100644
--- a/qt-webengine/CMakeLists.txt
+++ b/qt-webengine/CMakeLists.txt
@@ -1,5 +1,5 @@
 cmake_minimum_required(VERSION 3.19)
-project(jerboa_browser LANGUAGES CXX)
+project(jerboa_browser LANGUAGES C CXX)
 
 set(CMAKE_CXX_STANDARD 17)
 set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -19,6 +19,7 @@ add_library(jerboa_browser SHARED
   src/browser_view.cpp
   src/browser_page.cpp
   src/browser_policy.cpp
+  src/browser_url_policy.cpp
   src/browser_ffi.cpp
   src/browser_automation.cpp
   src/browser_window.cpp
@@ -50,7 +51,7 @@ if(JWB_ADBLOCK)
   add_custom_command(
     OUTPUT ${ADBLOCK_LIB}
     COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${ADBLOCK_TARGET_DIR}
-            ${CARGO_EXE} build --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml
+            ${CARGO_EXE} build --locked --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml
     ${ADBLOCK_RANLIB_COMMAND}
     COMMENT "Building vendored adblock-rust staticlib (cargo build --release)"
     VERBATIM)
diff --git a/qt-webengine/src/browser_handles.cpp b/qt-webengine/src/browser_handles.cpp
index 250304d..d8150f8 100644
--- a/qt-webengine/src/browser_handles.cpp
+++ b/qt-webengine/src/browser_handles.cpp
@@ -13,6 +13,7 @@ namespace {
 struct HandleEntry {
   jwb::HandleKind kind;
   void *payload;
+  JwbHandle parent;
 };
 
 std::mutex g_mutex;
@@ -37,10 +38,10 @@ namespace jwb {
 void set_last_error(const std::string &msg) { g_last_error = msg; }
 void clear_last_error() { g_last_error.clear(); }
 
-JwbHandle handle_alloc(HandleKind kind, void *payload) {
+JwbHandle handle_alloc(HandleKind kind, void *payload, JwbHandle parent) {
   std::lock_guard<std::mutex> lock(g_mutex);
   JwbHandle h = g_next++;
-  g_registry.emplace(h, HandleEntry{kind, payload});
+  g_registry.emplace(h, HandleEntry{kind, payload, parent});
   return h;
 }
 
@@ -78,6 +79,15 @@ bool handle_release(JwbHandle h, HandleKind expected, void **out_payload,
     *err = JWB_ERR_INVALID_HANDLE;
     return false;
   }
+  if (expected == HandleKind::Context) {
+    for (const auto &entry : g_registry) {
+      if (entry.second.parent == h) {
+        set_last_error("context still has live views");
+        *err = JWB_ERR_INVALID_ARGUMENT;
+        return false;
+      }
+    }
+  }
   if (out_payload) *out_payload = it->second.payload;
   g_registry.erase(it);
   return true;
@@ -124,7 +134,7 @@ JWB_API JwbStatus jwb_view_new(JwbHandle context, JwbHandle *out) {
     return st;
   void *view_obj = jwb::backend_view_create(ctx_obj, &st);
   if (!view_obj) return st;
-  *out = jwb::handle_alloc(jwb::HandleKind::View, view_obj);
+  *out = jwb::handle_alloc(jwb::HandleKind::View, view_obj, context);
   return JWB_OK;
 }
 
diff --git a/qt-webengine/src/browser_internal.h b/qt-webengine/src/browser_internal.h
index d42159e..232988d 100644
--- a/qt-webengine/src/browser_internal.h
+++ b/qt-webengine/src/browser_internal.h
@@ -27,7 +27,9 @@ enum class HandleKind { Context, View, Window };
 // Allocate a handle owning `payload`. Handles are monotonic and never reused,
 // so a freed handle stays permanently invalid — stale/double-free is rejected
 // cleanly rather than aliasing a later object.
-JwbHandle handle_alloc(HandleKind kind, void *payload);
+// `parent` records ownership dependencies (views retain their context). A
+// parent handle cannot be released while a live child remains.
+JwbHandle handle_alloc(HandleKind kind, void *payload, JwbHandle parent = 0);