Harden credential storage and release packaging

ober

025249de619de68b44092ddfbe2385079b50ec2b

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f4242bb..e24152e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,15 +12,6 @@ permissions:
 env:
   JERBOA_VERSION: v0.2.3
   JERBUILD: ${{ github.workspace }}/.jerboa/bin/jerbuild
-  JERBOA_AWS_REPO: https://git.sr.ht/~lisp/jerboa-aws
-  JERBOA_PROTON_BRIDGE_REPO: https://git.sr.ht/~lisp/jerboa-proton-bridge
-  JERBOA_YUBIKEY_REPO: https://git.sr.ht/~lisp/jerboa-yubikey
-  JERBOA_PGP_REPO: https://git.sr.ht/~lisp/jerboa-pgp
-  JERBOA_CRYPTO_REPO: https://git.sr.ht/~lisp/jerboa-crypto
-  JERBOA_FUSE_REPO: https://git.sr.ht/~lisp/jerboa-fuse
-  JERBOA_MAIL_REPO: https://git.sr.ht/~lisp/jerboa-mail
-  JERBOA_HTTPS_REPO: https://git.sr.ht/~lisp/jerboa-https
-  JERBOA_SSL_REPO: https://git.sr.ht/~lisp/jerboa-ssl
   JERBOA_AWS_DIR: ${{ github.workspace }}/.deps/jerboa-aws
   JERBOA_PROTON_BRIDGE_DIR: ${{ github.workspace }}/.deps/jerboa-proton-bridge
   JERBOA_YUBIKEY_DIR: ${{ github.workspace }}/.deps/jerboa-yubikey
@@ -35,7 +26,7 @@ jobs:
   build-test-audit:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
@@ -49,18 +40,7 @@ jobs:
         run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin
 
       - name: Fetch dependencies
-        run: |
-          set -eu
-          mkdir -p .deps
-          git clone --depth 1 "$JERBOA_AWS_REPO" "$JERBOA_AWS_DIR"
-          git clone --depth 1 "$JERBOA_PROTON_BRIDGE_REPO" "$JERBOA_PROTON_BRIDGE_DIR"
-          git clone --depth 1 "$JERBOA_YUBIKEY_REPO" "$JERBOA_YUBIKEY_DIR"
-          git clone --depth 1 "$JERBOA_PGP_REPO" "$JERBOA_PGP_DIR"
-          git clone --depth 1 "$JERBOA_CRYPTO_REPO" "$JERBOA_CRYPTO_DIR"
-          git clone --depth 1 "$JERBOA_FUSE_REPO" "$JERBOA_FUSE_DIR"
-          git clone --depth 1 "$JERBOA_MAIL_REPO" "$JERBOA_MAIL_DIR"
-          git clone --depth 1 "$JERBOA_HTTPS_REPO" "$JERBOA_HTTPS_DIR"
-          git clone --depth 1 "$JERBOA_SSL_REPO" "$JERBOA_SSL_DIR"
+        run: sh support/fetch-locked-deps.sh dependency-lock.tsv .deps
 
       - name: Test
         run: make test
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..5381c31 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Required release files
         run: |
diff --git a/.jerbuild.release-darwin b/.jerbuild.release-darwin
new file mode 100644
index 0000000..a2d0439
--- /dev/null
+++ b/.jerbuild.release-darwin
@@ -0,0 +1,25 @@
+(entry "support/binary-entry.ss")
+(output "protonstorage-bin")
+(libdirs
+  "support/vendor-overrides"
+  "."
+  "build/release-inputs/jerboa-aws/lib"
+  "build/release-inputs/jerboa-proton-bridge"
+  "build/release-inputs/jerboa-yubikey"
+  "build/release-inputs/jerboa-yubikey/lib"
+  "build/release-inputs/jerboa-pgp"
+  "build/release-inputs/jerboa-crypto/lib"
+  "build/release-inputs/jerboa-fuse/lib"
+  "build/release-inputs/jerboa-mail"
+  "build/release-inputs/jerboa-https/lib"
+  "build/release-inputs/jerboa-ssl/lib")
+(extra-sources "support/bundle-system-symbols.c")
+(extra-ldflags
+  "build/release-inputs/jerboa-ssl/jerboa_ssl_shim.dylib"
+  "build/release-inputs/jerboa-fuse/libjerboa_fuse_mount.so"
+  "build/release-inputs/jerboa-proton-bridge/proton-bridge-native/target/release/libproton_bridge_native.dylib"
+  "build/release-inputs/jerboa-yubikey/yubikey-native/target/release/libyubikey_native.dylib"
+  "build/release-inputs/jerboa-pgp/pgp-native/target/release/libjpgp_native.dylib")
+(main-c "support/bundle-main.c")
+(ffi-symbols "support/bundle-ffi-symbols.list")
+(static-native #t)
diff --git a/.jerbuild.release-elf b/.jerbuild.release-elf
new file mode 100644
index 0000000..4dc4741
--- /dev/null
+++ b/.jerbuild.release-elf
@@ -0,0 +1,25 @@
+(entry "support/binary-entry.ss")
+(output "protonstorage-bin")
+(libdirs
+  "support/vendor-overrides"
+  "."
+  "build/release-inputs/jerboa-aws/lib"
+  "build/release-inputs/jerboa-proton-bridge"
+  "build/release-inputs/jerboa-yubikey"
+  "build/release-inputs/jerboa-yubikey/lib"
+  "build/release-inputs/jerboa-pgp"
+  "build/release-inputs/jerboa-crypto/lib"
+  "build/release-inputs/jerboa-fuse/lib"
+  "build/release-inputs/jerboa-mail"
+  "build/release-inputs/jerboa-https/lib"
+  "build/release-inputs/jerboa-ssl/lib")
+(extra-sources "support/bundle-system-symbols.c")
+(extra-ldflags
+  "build/release-inputs/jerboa-ssl/jerboa_ssl_shim.so"
+  "build/release-inputs/jerboa-fuse/libjerboa_fuse_mount.so"
+  "build/release-inputs/jerboa-proton-bridge/proton-bridge-native/target/release/libproton_bridge_native.so"
+  "build/release-inputs/jerboa-yubikey/yubikey-native/target/release/libyubikey_native.so"
+  "build/release-inputs/jerboa-pgp/pgp-native/target/release/libjpgp_native.so")
+(main-c "support/bundle-main.c")
+(ffi-symbols "support/bundle-ffi-symbols.list")
+(static-native #t)
diff --git a/Makefile b/Makefile
index 767fc87..0b2f1e6 100644
--- a/Makefile
+++ b/Makefile
@@ -8,6 +8,7 @@ JERBUILD ?= $(shell if [ -x "$(CURDIR)/jerbuild" ]; then echo "$(CURDIR)/jerbuil
 	else echo "$(JERBOA_TOOL_DIR)/jerbuild"; fi)
 JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
 JERBOA_HOME ?= $(JH)
+NATIVE_LOADER_OVERRIDE_DIR := $(CURDIR)/support/vendor-overrides
 
 DEPS_DIR ?= $(CURDIR)/.deps
 LOCAL_PROTON_BRIDGE_DIR := $(CURDIR)/../jerboa-proton-bridge
@@ -28,7 +29,7 @@ JERBOA_CRYPTO_DIR ?= $(if $(wildcard $(LOCAL_CRYPTO_DIR)/.),$(realpath $(LOCAL_C
 JERBOA_FUSE_DIR ?= $(if $(wildcard $(LOCAL_FUSE_DIR)/.),$(realpath $(LOCAL_FUSE_DIR)),$(DEPS_DIR)/jerboa-fuse)
 JERBOA_MAIL_DIR ?= $(if $(wildcard $(LOCAL_MAIL_DIR)/.),$(realpath $(LOCAL_MAIL_DIR)),$(DEPS_DIR)/jerboa-mail)
 JERBOA_AWS_DIR ?= $(if $(wildcard $(LOCAL_AWS_DIR)/.),$(realpath $(LOCAL_AWS_DIR)),$(DEPS_DIR)/jerboa-aws)
-LIBDIRS = $(CURDIR):$(JERBOA_AWS_DIR)/lib:$(JERBOA_PROTON_BRIDGE_DIR):$(JERBOA_YUBIKEY_DIR):$(JERBOA_YUBIKEY_DIR)/lib:$(JERBOA_PGP_DIR):$(JERBOA_CRYPTO_DIR)/lib:$(JERBOA_FUSE_DIR)/lib:$(JERBOA_MAIL_DIR):$(JERBOA_HTTPS_DIR)/lib:$(JERBOA_SSL_DIR)/lib:$(JH)/lib
+LIBDIRS = $(NATIVE_LOADER_OVERRIDE_DIR):$(CURDIR):$(JERBOA_AWS_DIR)/lib:$(JERBOA_PROTON_BRIDGE_DIR):$(JERBOA_YUBIKEY_DIR):$(JERBOA_YUBIKEY_DIR)/lib:$(JERBOA_PGP_DIR):$(JERBOA_CRYPTO_DIR)/lib:$(JERBOA_FUSE_DIR)/lib:$(JERBOA_MAIL_DIR):$(JERBOA_HTTPS_DIR)/lib:$(JERBOA_SSL_DIR)/lib:$(JH)/lib
 XC_LIBDIRS = $(LIBDIRS):$(JH)/.chez-cross-ta6le/lib:$(JH)/.chez-cross-tarm64le/lib:$(JH)/.chez-cross-ta6fb/lib
 JERBOA_NATIVE_DIR ?= $(JH)/jerboa-native-rs/target/release
 PROTON_BRIDGE_NATIVE_DIR := $(JERBOA_PROTON_BRIDGE_DIR)/proton-bridge-native/target/release
@@ -42,10 +43,24 @@ else
   SO_EXT = .so
   FUSE_SO_EXT = .so
 endif
+YUBIKEY_NATIVE_LIB := $(JERBOA_YUBIKEY_DIR)/yubikey-native/target/release/libyubikey_native$(SO_EXT)
+PGP_NATIVE_LIB := $(JERBOA_PGP_DIR)/pgp-native/target/release/libjpgp_native$(SO_EXT)
+PROTON_BRIDGE_NATIVE_LIB := $(PROTON_BRIDGE_NATIVE_DIR)/libproton_bridge_native$(SO_EXT)
+SSL_NATIVE_LIB := $(JERBOA_SSL_DIR)/jerboa_ssl_shim$(SO_EXT)
+FUSE_NATIVE_LIB := $(JERBOA_FUSE_DIR)/libjerboa_fuse_mount$(FUSE_SO_EXT)
+BINARY_NATIVE_INPUT_DIR := $(CURDIR)/build/release-inputs
+BINARY_RELEASE_CONFIG := $(if $(filter Darwin,$(UNAME_S)),.jerbuild.release-darwin,.jerbuild.release-elf)
 RUN_ENV := JERBOA_HOME=$(JH) \
 	JERBOA_SSL_LIB=$(JERBOA_SSL_DIR) \
+	JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1 \
 	JERBOA_CRYPTO_LIB=$(JERBOA_CRYPTO_DIR) \
+	JERBOA_CRYPTO_ALLOW_DYNAMIC_NATIVE=1 \
 	JERBOA_FUSE_LIB=$(JERBOA_FUSE_DIR) \
+	JPGP_ALLOW_DYNAMIC_NATIVE=1 \
+	JPGP_NATIVE_LIB=$(PGP_NATIVE_LIB) \
+	JYUBIKEY_DEV_NATIVE=1 \
+	JYUBIKEY_NATIVE_LIB=$(YUBIKEY_NATIVE_LIB) \
+	PROTON_BRIDGE_NATIVE_LIB=$(PROTON_BRIDGE_NATIVE_LIB) \
 	DYLD_LIBRARY_PATH=$(JERBOA_NATIVE_DIR):$(JERBOA_CRYPTO_DIR):$(JERBOA_FUSE_DIR):$(PROTON_BRIDGE_NATIVE_DIR):$${DYLD_LIBRARY_PATH:-} \
 	DYLD_FALLBACK_LIBRARY_PATH=$(JERBOA_NATIVE_DIR):$${DYLD_FALLBACK_LIBRARY_PATH:-} \
 	LD_LIBRARY_PATH=$(JERBOA_NATIVE_DIR):$(JERBOA_CRYPTO_DIR):$(JERBOA_FUSE_DIR):$(PROTON_BRIDGE_NATIVE_DIR):$${LD_LIBRARY_PATH:-}
@@ -63,7 +78,7 @@ DIST_DIR ?= $(CURDIR)/dist
 RELEASE_EVIDENCE_DIR ?= $(DIST_DIR)/release-evidence
 TARGET_EVIDENCE_DIR ?= $(DIST_DIR)/target-evidence
 
-.PHONY: help ensure-jerboa-tools ensure-deps deps run test integration s3-integration doctor binary binary-doctor-smoke audit verify security dependency-evidence sbom reproducibility-report target-evidence release-evidence bundle install linux linux-amd64 linux-arm64 freebsd freebsd-amd64 clean
+.PHONY: help ensure-jerboa-tools ensure-deps native-runtime deps run test integration s3-integration doctor binary-release-inputs binary binary-doctor-smoke audit verify security dependency-evidence sbom reproducibility-report target-evidence release-evidence bundle install linux linux-amd64 linux-arm64 freebsd freebsd-amd64 clean
 .DEFAULT_GOAL := help
 
 lib/libjerboa_native$(SO_EXT): ensure-jerboa-tools
@@ -142,7 +157,11 @@ ensure-deps:
 	@test -d "$(JERBOA_HTTPS_DIR)" || { echo "ERROR: JERBOA_HTTPS_DIR missing: $(JERBOA_HTTPS_DIR)" >&2; exit 1; }
 	@test -d "$(JERBOA_SSL_DIR)" || { echo "ERROR: JERBOA_SSL_DIR missing: $(JERBOA_SSL_DIR)" >&2; exit 1; }
 
-deps: ensure-jerboa-tools ensure-deps lib/libjerboa_native$(SO_EXT)
+native-runtime: ensure-jerboa-tools
+	@command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo not found on PATH." >&2; exit 1; }
+	cargo build --release --manifest-path "$(JH)/jerboa-native-rs/Cargo.toml" --no-default-features --features tls,crypto
+
+deps: ensure-jerboa-tools ensure-deps native-runtime lib/libjerboa_native$(SO_EXT)
 	@if [ ! -f "$(JERBOA_SSL_DIR)/lib/jerboa-ssl.sls" ] || [ ! -f "$(JERBOA_SSL_DIR)/jerboa_ssl_shim$(SO_EXT)" ]; then \
 	  $(MAKE) -C "$(JERBOA_SSL_DIR)" JERBUILD="$(JERBUILD)" build; \
 	fi
@@ -158,6 +177,12 @@ deps: ensure-jerboa-tools ensure-deps lib/libjerboa_native$(SO_EXT)
 	@if [ ! -f "$(PROTON_BRIDGE_NATIVE_DIR)/libproton_bridge_native$(SO_EXT)" ]; then \
 	  $(MAKE) -C "$(JERBOA_PROTON_BRIDGE_DIR)" JERBUILD="$(JERBUILD)" native; \
 	fi
+	@if [ ! -f "$(YUBIKEY_NATIVE_LIB)" ]; then \
+	  $(MAKE) -C "$(JERBOA_YUBIKEY_DIR)" JERBUILD="$(JERBUILD)" native; \
+	fi
+	@if [ ! -f "$(PGP_NATIVE_LIB)" ]; then \
+	  $(MAKE) -C "$(JERBOA_PGP_DIR)" JERBUILD="$(JERBUILD)" build-native; \
+	fi
 
 run: deps
 	$(RUN_ENV) \
@@ -178,8 +203,25 @@ s3-integration: deps
 	$(RUN_ENV) \
 		"$(JERBUILD)" exec --libdirs "$(LIBDIRS)" test/integration-s3.ss
 
-binary: deps
-	$(RUN_ENV) "$(JERBUILD)" binary --libdirs "$(LIBDIRS)" support/binary-entry.ss $(BINARY_OUTPUT)
+binary-release-inputs: deps
+	rm -rf "$(BINARY_NATIVE_INPUT_DIR)"
+	mkdir -p "$(BINARY_NATIVE_INPUT_DIR)"
+	ln -s "$(JERBOA_AWS_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-aws"
+	ln -s "$(JERBOA_PROTON_BRIDGE_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-proton-bridge"
+	ln -s "$(JERBOA_YUBIKEY_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-yubikey"
+	ln -s "$(JERBOA_PGP_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-pgp"
+	ln -s "$(JERBOA_CRYPTO_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-crypto"
+	ln -s "$(JERBOA_FUSE_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-fuse"
+	ln -s "$(JERBOA_MAIL_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-mail"
+	ln -s "$(JERBOA_HTTPS_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-https"
+	ln -s "$(JERBOA_SSL_DIR)" "$(BINARY_NATIVE_INPUT_DIR)/jerboa-ssl"
+
+binary: binary-release-inputs
+	JERBOA_NATIVE_A="$(JERBOA_NATIVE_DIR)/libjerboa_native.a" \
+		"$(JERBUILD)" build --config "$(BINARY_RELEASE_CONFIG)"
+	@if [ "$(BINARY_OUTPUT)" != protonstorage-bin ]; then \
+	  cp protonstorage-bin "$(BINARY_OUTPUT)"; \
+	fi
 
 binary-doctor-smoke: binary
 	$(RUN_ENV) ./$(BINARY_OUTPUT) doctor
@@ -191,6 +233,12 @@ verify: security test doctor audit reproducibility-report sbom target-evidence
 
 security:
 	@test -f scripts/sanitize-evidence.sh || { echo "missing scripts/sanitize-evidence.sh" >&2; exit 1; }
+	@bash scripts/package-security-test.sh
+	@test -f dependency-lock.tsv || { echo "missing dependency-lock.tsv" >&2; exit 1; }
+	@test -f support/fetch-locked-deps.sh || { echo "missing locked dependency fetcher" >&2; exit 1; }
+	@! grep -R -E 'uses:[[:space:]]*[^[:space:]]+@(v[0-9]+|main|master|stable)([[:space:]]|$$)' .github/workflows
+	@! grep -R -F 'git clone --depth 1' .github support scripts
+	@awk -F '\t' 'NF != 4 || $$1 !~ /^[a-z0-9-]+$$/ || $$3 !~ /^[0-9a-f]{40}$$/ || $$4 !~ /^[0-9a-f]{40}$$/ { exit 1 } END { if (NR != 9) exit 1 }' dependency-lock.tsv
 	@grep -q 'sanitize-evidence' docs/release-evidence.md || { echo "missing release evidence sanitizer docs" >&2; exit 1; }
 	@grep -q 'host-private' docs/release-evidence.md || { echo "missing host-private proof docs" >&2; exit 1; }
 	@set -eu; \
@@ -269,7 +317,8 @@ release-evidence:
 
 bundle: binary
 	JERBOA_PROTON_BRIDGE_DIR="$(JERBOA_PROTON_BRIDGE_DIR)" \
-	JERBOA_CRYPTO_DIR="$(JERBOA_CRYPTO_DIR)" \
+	JERBOA_YUBIKEY_DIR="$(JERBOA_YUBIKEY_DIR)" \
+	JERBOA_PGP_DIR="$(JERBOA_PGP_DIR)" \
 	JERBOA_FUSE_DIR="$(JERBOA_FUSE_DIR)" \
 	JERBOA_SSL_DIR="$(JERBOA_SSL_DIR)" \
 		bash support/package-bundle.sh "$(BINARY_OUTPUT)" "$(BUNDLE_OUTPUT)"
@@ -277,17 +326,7 @@ bundle: binary
 install: bundle
 	mkdir -p "$(INSTALL_BINDIR)" "$(INSTALL_OPTDIR)"
 	rsync -a --delete "$(BUNDLE_OUTPUT)/" "$(INSTALL_OPTDIR)/"
-	{ \
-		echo '#!/usr/bin/env bash'; \
-		echo 'set -euo pipefail'; \
-		echo 'DIR="$(INSTALL_OPTDIR)"'; \
-		echo 'export DYLD_LIBRARY_PATH="$$DIR/lib:$${DYLD_LIBRARY_PATH:-}"'; \
-		echo 'export LD_LIBRARY_PATH="$$DIR/lib:$${LD_LIBRARY_PATH:-}"'; \
-		echo 'export JERBOA_SSL_LIB="$$DIR/lib"'; \
-		echo 'export JERBOA_CRYPTO_LIB="$$DIR/lib"'; \
-		echo 'exec "$$DIR/bin/protonstorage" "$$@"'; \
-	} > "$(INSTALL_BINDIR)/jdrive"
-	chmod +x "$(INSTALL_BINDIR)/jdrive"
+	ln -sfn "$(INSTALL_OPTDIR)/jdrive" "$(INSTALL_BINDIR)/jdrive"
 	@echo "Installed jdrive -> $(INSTALL_BINDIR)/jdrive"
 
 linux: linux-amd64
@@ -328,7 +367,7 @@ freebsd-amd64:
 		bash $(JERBOA_HOME)/support/build-binary.sh support/binary-entry.ss protonstorage-freebsd-amd64
 
 clean:
-	rm -rf cache tmp mount dist protonstorage-bin protonstorage-linux-amd64 protonstorage-linux-arm64 protonstorage-freebsd-amd64
+	rm -rf cache tmp mount dist build/release-inputs protonstorage-bin protonstorage-linux-amd64 protonstorage-linux-arm64 protonstorage-freebsd-amd64
 	rm -f lib/libjerboa_native$(SO_EXT)
 	rm -f protonstorage-bin-main.c protonstorage-bin.wp.so
 	rm -f protonstorage-linux-amd64-main.c protonstorage-linux-amd64.wp.so
diff --git a/README.md b/README.md
index 14d912a..138de39 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,8 @@ jdrive s3 profile-status
 jdrive s3 unlock-test
 ```
 
-For S3-compatible providers, add `--endpoint URL --path-style` when needed.
+For S3-compatible providers, add an HTTPS `--endpoint URL --path-style` when
+needed. Plaintext S3 endpoints are rejected.
 Use `--aws-profile NAME` to select an AWS profile.
 
 Add optional built-in YubiKey PIV unlock material:
@@ -92,6 +93,11 @@ remote-to-local, and remote-to-remote encrypted copies. `check` and `sync`
 support simple `*` wildcards through repeated `--include PATTERN` and
 `--exclude PATTERN`.
 
+Decrypted remote names are validated again when a manifest is loaded. Recursive
+downloads use a retained destination-directory descriptor, no-follow component
+walks, and atomic file publication, so traversal components and concurrent
+symlink replacement cannot redirect output outside the selected local root.
+
 ## Runtime Status
 
 Current runtime code is Scheme/Jerboa only:
@@ -329,7 +335,11 @@ provider multipart upload.
 
 `make bundle` builds the host binary and writes
 `dist/protonstorage-<os>-<arch>.tar.gz` with the binary, native Jerboa
-libraries, and a wrapper that sets the runtime library search path.
+libraries, and a native launcher. The release binary records only
+bundle-relative native-library paths. The launcher validates the bundle's
+ownership and modes and removes inherited loader and development-native
+selectors before starting it; development `make run` keeps its explicit local
+library workflow.
 
 Release dependency and provenance evidence is documented in
 [`docs/dependency-provenance.md`](docs/dependency-provenance.md) and
diff --git a/dependency-lock.tsv b/dependency-lock.tsv
new file mode 100644
index 0000000..cc80679
--- /dev/null
+++ b/dependency-lock.tsv
@@ -0,0 +1,9 @@
+jerboa-aws	https://git.sr.ht/~lisp/jerboa-aws	c1e6296c2ca17ec1e3f100876d433b12f613e22a	5c17cc5fc069c06b7a3f40510c3ae05e2b9911f7
+jerboa-proton-bridge	https://git.sr.ht/~lisp/jerboa-proton-bridge	43035ebf6b7e679d160699469f7e9c66f922478c	dc1b1d495eaae53475376004862dd28fd38dc2b3
+jerboa-yubikey	https://git.sr.ht/~lisp/jerboa-yubikey	0091e16bfe2f5d96e7fb18ce016e5b1ce010edae	a1fb427573692992cf68fea8eb0aea984cce3bce
+jerboa-pgp	https://git.sr.ht/~lisp/jerboa-pgp	38c681f41abde4d8ae05fa922c4383035c5e90e6	60300c5757b4bdfe15fb85e24baa9b2053b01a1b
+jerboa-crypto	https://git.sr.ht/~lisp/jerboa-crypto	74675676105775de23c55df65da27d8d2ef4a1d7	a49d8a0ce63731081f7fb140e13eff8d3efb850e
+jerboa-fuse	https://git.sr.ht/~lisp/jerboa-fuse	020ab5f8872261142b4f4c8d9379945db42f59fc	7184ad9a92719af4a08850a5f7a9ce4263ababdb
+jerboa-mail	https://git.sr.ht/~lisp/jerboa-mail	5d6382371decb5ce93016b46a1710d65255401b0	48b15dbdb9e18749b6195f1cbbd7d2804c54b6a6
+jerboa-https	https://git.sr.ht/~lisp/jerboa-https	c3a4be506edcdddeed37c4b7723bdd226a8511c1	f979837172aff40da3108c365afe45616579176d
+jerboa-ssl	https://git.sr.ht/~lisp/jerboa-ssl	270b858534941baad6420e4906a26c279f4a8678	4d629174bbff9b1cd2ff1aa696be0cd33bf462e4
diff --git a/docs/dependency-provenance.md b/docs/dependency-provenance.md
index d52dbba..1a8969e 100644
--- a/docs/dependency-provenance.md
+++ b/docs/dependency-provenance.md
@@ -10,7 +10,15 @@ the local source tree and the sibling Jerboa libraries it delegates to:
 - `jerboa-aws` for S3-compatible storage requests.
 - `jerboa-pgp`, `jerboa-crypto`, `jerboa-fuse`, `jerboa-mail`,
   `jerboa-https`, and `jerboa-ssl` for crypto, filesystem, mail/HTTP, and TLS
-  support.
+support.
+
+CI dependency inputs are declared in the checked-in `dependency-lock.tsv`.
+Each row fixes both a 40-hex Git commit and its expected tree hash. The CI
+fetcher initializes an empty repository, fetches only that commit, checks the
+detached `HEAD`, and rejects a tree mismatch before any dependency build code
+runs. Updating a dependency therefore requires a reviewed lock-file change;
+default branches are never build inputs. The lock and its SHA-256 are copied
+into the generated SBOM evidence.
 
 `make dependency-evidence` runs sibling `release-evidence` targets when they
 exist and fails closed when any required sibling gate fails or is missing.
diff --git a/docs/security-model.md b/docs/security-model.md
index 8b0d516..02e1e62 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -43,6 +43,16 @@ marker and vault writers only write known profile-owned files. S3 sync and cp
 refuse local symlinks to avoid following attacker-controlled paths during tree
 uploads.
 
+Encrypted manifest paths are validated both when a client creates a name and
+after manifest authentication/decryption. Empty, dot, dot-dot, repeated-slash,
+alternate-separator, reserved-device, control-character, and overlong
+components fail closed. Remote-to-local copies retain a descriptor for the
+selected destination root, open/create every descendant directory with
+no-follow semantics, and publish each `0600` file by an exclusive temporary
+inode plus `fsync` and descriptor-relative atomic rename. A concurrent symlink
+or directory replacement therefore cannot redirect the final write outside the
+retained root.
+
 Current daemon state is foreground-supervised. `drive daemon stop` clears local
 marker files; unmounting a live FUSE mount is still delegated to the platform
 unmount command.
@@ -61,6 +71,13 @@ Release builds must audit the Proton bridge Rust dependency graph with
 dependency provenance for the sibling Jerboa libraries listed in
 [`dependency-provenance.md`](dependency-provenance.md).
 
+Host release bundles link their native providers directly and rewrite those
+dependencies to bundle-relative RPATHs. The `jdrive` launcher resolves its own
+canonical location, rejects symlinked, foreign-owned, or group/world-writable
+bundle components, clears ambient dynamic-loader and development-native
+selectors, and then executes the bundled program. Pathname-based native
+overrides remain available only to the explicit `make run` development flow.
+
 ## Network And Remote State
 
 Network access is explicit through Proton Drive API calls and S3-compatible
diff --git a/protonstorage/cli.ss b/protonstorage/cli.ss
index c5df8c4..4aa3d5d 100644
--- a/protonstorage/cli.ss
+++ b/protonstorage/cli.ss
@@ -9,8 +9,7 @@
                   meta atom? partition sort sort! make-date make-time
                   read-file-string path-join)
           (only (jerboa core)
-                getenv directory-files file-info file-info-type
-                open-process process-status)
+                getenv directory-files file-info file-info-type)
           (protonstorage drive api)
           (protonstorage drive auth)
           (protonstorage drive client)
@@ -28,6 +27,7 @@
                 proton-session-uid
                 proton-session-access-token
                 proton-session-refresh-token)
+          (only (proton-bridge terminal) read-secret-from-tty)
           (only (std text json) json-object->string))
 
   (define version "0.1.0")
@@ -213,34 +213,8 @@
     (eprintln msg)
     (exit code))
 
-  (define (disable-tty-echo)
-    (guard (e [(condition? e) #f])
-      (let ([proc (open-process
-                    (list 'path: "sh"
-                          'arguments:
-                          (list "-c" "stty -echo </dev/tty 2>/dev/null")))])
-        (process-status proc)
-        #t)))
-
-  (define (enable-tty-echo)
-    (guard (e [(condition? e) #f])
-      (let ([proc (open-process
-                    (list 'path: "sh"
-                          'arguments:
-                          (list "-c" "stty echo </dev/tty 2>/dev/null")))])
-        (process-status proc)
-        #t)))
-
   (define (read-secret prompt)
-    (display prompt (current-error-port))
-    (let ([line #f])
-      (dynamic-wind
-        disable-tty-echo
-        (lambda () (set! line (get-line (current-input-port))))
-        (lambda ()
-          (enable-tty-echo)
-          (newline (current-error-port))))
-      (if (eof-object? line) "" line)))
+    (read-secret-from-tty prompt))
 
   (define (strip-script-separator args)
     (if (and (pair? args) (string=? (car args) "--"))
diff --git a/protonstorage/drive/credentials.ss b/protonstorage/drive/credentials.ss
index a644d2f..12d5b19 100644
--- a/protonstorage/drive/credentials.ss
+++ b/protonstorage/drive/credentials.ss
@@ -23,9 +23,9 @@
   (define vault-version 1)
   (define vault-cipher "chacha20-poly1305")
   (define vault-kdf "scrypt")
-  (define vault-scrypt-N 32768)
+  (define vault-scrypt-N 65536)
   (define vault-scrypt-r 8)
-  (define vault-scrypt-p 1)
+  (define vault-scrypt-p 2)
 
   (define (json-object . fields)
     (let ([ht (make-hashtable equal-hash equal?)])
diff --git a/protonstorage/drive/crypto.ss b/protonstorage/drive/crypto.ss
index 70b2e66..4a77d72 100644
--- a/protonstorage/drive/crypto.ss
+++ b/protonstorage/drive/crypto.ss
@@ -75,7 +75,8 @@
                 base64-string->u8vector
                 u8vector->base64-string)
           (jerboa-crypto)
-          (proton-bridge crypto))
+          (proton-bridge crypto)
+          (proton-bridge native-loader))
 
   (defstruct proton-drive-unlocked-key
     (id private-key passphrase source))
@@ -211,22 +212,6 @@
           [else
            (error who "native Drive crypto backend failed" rc)]))))
 
-  (define *native-paths*
-    '("libproton_bridge_native.so"
-      "libproton_bridge_native.dylib"
-      "proton-bridge-native/target/release/libproton_bridge_native.so"
-      "proton-bridge-native/target/release/libproton_bridge_native.dylib"
-      "./proton-bridge-native/target/release/libproton_bridge_native.so"
-      "./proton-bridge-native/target/release/libproton_bridge_native.dylib"
-      "../jerboa-proton-bridge/proton-bridge-native/target/release/libproton_bridge_native.so"
-      "../jerboa-proton-bridge/proton-bridge-native/target/release/libproton_bridge_native.dylib"
-      "proton-bridge-native/target/debug/libproton_bridge_native.so"
-      "proton-bridge-native/target/debug/libproton_bridge_native.dylib"
-      "./proton-bridge-native/target/debug/libproton_bridge_native.so"
-      "./proton-bridge-native/target/debug/libproton_bridge_native.dylib"
-      "../jerboa-proton-bridge/proton-bridge-native/target/debug/libproton_bridge_native.so"
-      "../jerboa-proton-bridge/proton-bridge-native/target/debug/libproton_bridge_native.dylib"))
-
   (define *native-loaded?* #f)
   (define *native-bindings-ready?* #f)
   (define c-drive-session-key #f)
@@ -234,14 +219,14 @@
 
   (define (try-load-native!)
     (or *native-loaded?*
-        (let loop ([ps *native-paths*])
-          (cond
-            [(null? ps) #f]
-            [(guard (e [#t #f])
-               (load-shared-object* (car ps)) #t)
-             (set! *native-loaded?* #t)
-             #t]
-            [else (loop (cdr ps))]))))
+        (and (proton-crypto-native-available?)
+             (begin (set! *native-loaded?* #t) #t))
+        (let ([path (proton-native-configured-path)])
+          (and path
+               (begin
+                 (load-shared-object* path)
+                 (set! *native-loaded?* #t)
+                 #t)))))
 
   (define (ensure-native-bindings!)
     (when (and (try-load-native!) (not *native-bindings-ready?*))
diff --git a/protonstorage/s3/drive.ss b/protonstorage/s3/drive.ss
index be73d3a..890ffda 100644
--- a/protonstorage/s3/drive.ss
+++ b/protonstorage/s3/drive.ss
@@ -16,6 +16,7 @@
     jdrive-s3-encrypt-chunk-bytes
     jdrive-s3-decrypt-chunk-bytes
     jdrive-s3-empty-manifest
+    jdrive-s3-manifest-validate!
     jdrive-s3-manifest-add-directory
     jdrive-s3-manifest-add-file
     jdrive-s3-manifest-list
@@ -43,6 +44,7 @@
                 file-info file-info-size file-info-type)
           (only (std text json) json-object->string string->json-object)
           (only (std text base64) base64-string->u8vector u8vector->base64-string)
+          (std os secure-output)
           (jerboa-crypto)
           (yubikey auth)
           (jerboa-aws s3 api)
@@ -58,9 +60,9 @@
   (define vault-version 1)
   (define vault-cipher "chacha20-poly1305")
   (define vault-kdf "scrypt")
-  (define vault-scrypt-N 32768)
+  (define vault-scrypt-N 65536)
   (define vault-scrypt-r 8)
-  (define vault-scrypt-p 1)
+  (define vault-scrypt-p 2)
   (define manifest-magic (string->utf8 "JDRIVE-MANIFEST-V1\n"))
   (define file-magic (string->utf8 "JDRIVE-FILE-V1\n"))
   (define chunk-magic (string->utf8 "JDRIVE-CHUNK-V1\n"))
@@ -534,14 +536,91 @@
         (number-pad-left index 8)
         ".bin")))
 
+  (define remote-path-max-bytes 4096)
+  (define remote-component-max-bytes 255)
+
+  (define (string-downcase-ascii value)
+    (list->string (map char-downcase (string->list value))))
+
+  (define (windows-device-component? component)
+    (let* ([lower (string-downcase-ascii component)]
+           [separator
+            (let loop ([i 0])
+              (cond
+                [(= i (string-length lower)) #f]
+                [(or (char=? (string-ref lower i) #\.)
+                     (char=? (string-ref lower i) #\:)) i]
+                [else (loop (+ i 1))]))]
+           [base (if separator (substring lower 0 separator) lower)])
+      (or (member base '("con" "prn" "aux" "nul"))
+          (and (= (string-length base) 4)
+               (or (string=? (substring base 0 3) "com")
+                   (string=? (substring base 0 3) "lpt"))
+               (char>=? (string-ref base 3) #\1)
+               (char<=? (string-ref base 3) #\9)))))
+
+  (define (validate-remote-component! component)
+    (unless (and (> (string-length component) 0)
+                 (not (string=? component "."))
+                 (not (string=? component ".."))
+                 (<= (bytevector-length (string->utf8 component))
+                     remote-component-max-bytes)
+                 (not (windows-device-component? component)))
+      (error 'remote-path-normalize "unsafe remote path component" component))
+    (let ([last (string-ref component (- (string-length component) 1))])
+      (when (or (char=? last #\space) (char=? last #\.))
+        (error 'remote-path-normalize
+               "remote path component may not end in space or dot"
+               component)))
+    (let loop ([i 0])
+      (when (< i (string-length component))
+        (let* ([ch (string-ref component i)]
+               [code (char->integer ch)])
+          (when (or (char=? ch #\\)
+                    (= code 0)
+                    (< code 32)
+                    (= code 127))
+            (error 'remote-path-normalize
+                   "remote path component contains a forbidden character"
+                   component))
+          (loop (+ i 1))))))
+
+  (define (validate-canonical-remote-path! path)
+    (when (> (bytevector-length (string->utf8 path)) remote-path-max-bytes)
+      (error 'remote-path-normalize "remote path exceeds byte limit"))
+    (unless (and (> (string-length path) 0)
+                 (char=? (string-ref path 0) #\/))
+      (error 'remote-path-normalize "remote path must be rooted" path))
+    (unless (string=? path "/")
+      (let loop ([start 1] [at 1])
+        (cond
+          [(= at (string-length path))
+           (validate-remote-component! (substring path start at))]
+          [(char=? (string-ref path at) #\/)
+           (validate-remote-component! (substring path start at))
+           (loop (+ at 1) (+ at 1))]
+          [else (loop start (+ at 1))])))
+    path)
+
   (define (remote-path-normalize path)
     (let ([path (nonempty-string 'remote-path-normalize 'path path)])
       (let ([path
              (if (char=? (string-ref path 0) #\/)
                  path
                  (string-append "/" path))])
-        (let ([trimmed (trim-trailing-slash path)])
-          (if (= (string-length trimmed) 0) "/" trimmed)))))
+        (when (and (> (string-length path) 1)
+                   (char=? (string-ref path (- (string-length path) 1)) #\/)
+                   (char=? (string-ref path (- (string-length path) 2)) #\/))
+          (error 'remote-path-normalize
+                 "remote path contains an empty trailing component" path))
+        (let* ([trailing-directory-marker?
+                (and (> (string-length path) 1)
+                     (char=? (string-ref path (- (string-length path) 1)) #\/))]
+               [normalized
+                (if trailing-directory-marker?
+                    (substring path 0 (- (string-length path) 1))
+                    path)])
+          (validate-canonical-remote-path! normalized)))))
 
   (define (remote-dir-normalize path)
     (let ([path (remote-path-normalize path)])
@@ -708,6 +787,57 @@
     (let ([entries (jmaybe manifest "Entries" '())])
       (if (list? entries) entries '())))
 
+  (define (jdrive-s3-manifest-validate! manifest)
+    (unless (hashtable? manifest)
+      (error 'jdrive-s3-manifest-validate! "manifest must be a JSON object"))
+    (let ([version (jref 'jdrive-s3-manifest-validate! manifest "Version")]
+          [directories (jref 'jdrive-s3-manifest-validate! manifest "Directories")]
+          [entries (jref 'jdrive-s3-manifest-validate! manifest "Entries")])
+      (unless (and (integer? version) (= version 1))
+        (error 'jdrive-s3-manifest-validate! "unsupported manifest version" version))
+      (unless (list? directories)
+        (error 'jdrive-s3-manifest-validate! "manifest Directories must be a list"))
+      (unless (list? entries)
+        (error 'jdrive-s3-manifest-validate! "manifest Entries must be a list"))
+      (let directory-loop ([remaining directories] [seen '()])
+        (unless (null? remaining)
+          (let ([path (car remaining)])
+            (unless (string? path)
+              (error 'jdrive-s3-manifest-validate!
+                     "manifest directory path must be a string"))
+            (let ([normalized (remote-dir-normalize path)])
+              (unless (string=? normalized path)
+                (error 'jdrive-s3-manifest-validate!
+                       "manifest directory path is not canonical" path))
+              (when (string-list-member? path seen)
+                (error 'jdrive-s3-manifest-validate!
+                       "duplicate manifest directory path" path))
+              (directory-loop (cdr remaining) (cons path seen))))))
+      (let entry-loop ([remaining entries] [seen '()])
+        (unless (null? remaining)
+          (let ([entry (car remaining)])
+            (unless (hashtable? entry)
+              (error 'jdrive-s3-manifest-validate!
+                     "manifest entry must be a JSON object"))
+            (unless (string=? (jref 'jdrive-s3-manifest-validate! entry "Type")
+                              "file")
+              (error 'jdrive-s3-manifest-validate!
+                     "manifest entry type must be file"))
+            (let ([path (jref 'jdrive-s3-manifest-validate! entry "Path")])
+              (unless (string? path)
+                (error 'jdrive-s3-manifest-validate!
+                       "manifest file path must be a string"))
+              (let ([normalized (remote-path-normalize path)])
+                (unless (string=? normalized path)
+                  (error 'jdrive-s3-manifest-validate!
+                         "manifest file path is not canonical" path))
+                (when (or (string-list-member? path seen)
+                          (string-list-member? path directories))
+                  (error 'jdrive-s3-manifest-validate!
+                         "duplicate or ambiguous manifest file path" path))
+                (entry-loop (cdr remaining) (cons path seen)))))))
+      manifest))
+
   (define (string-list-member? value xs)
     (let loop ([xs xs])
       (cond
@@ -844,14 +974,15 @@
       (string->utf8 (json-object->string manifest))))
 
   (define (sealed->manifest drive-key sealed)
-    (string->json-object
-      (utf8->string
-        (open-sealed-bytes
-          'sealed->manifest
-          manifest-magic
-          drive-key
-          (manifest-aad)
-          sealed))))
+    (jdrive-s3-manifest-validate!
+      (string->json-object
+        (utf8->string
+          (open-sealed-bytes
+            'sealed->manifest
+            manifest-magic
+            drive-key
+            (manifest-aad)
+            sealed)))))
 
   (define (load-manifest client bucket config drive-key)
     (let ([key (manifest-object-key config)])
@@ -1683,10 +1814,11 @@
           (add-summary! summary "CopiedBytes" (bytevector-length plain))
           (inc-summary! summary "CopiedChunks"))))
 
-  (define (copy-entry-to-local! client bucket drive-key path entry local-path summary)
-    (ensure-directory! (parent-directory local-path))
-    (call-with-port
-      (open-file-output-port local-path (file-options no-fail) (buffer-mode block))
+  (define (copy-entry-to-secure-root!
+           client bucket drive-key path entry root-handle relative-path summary)
+    (call-with-secure-output-file
+      root-handle
+      relative-path
       (lambda (out)
         (write-entry-to-port! client bucket drive-key path entry out summary)))
     (inc-summary! summary "CopiedFiles"))
@@ -1698,29 +1830,41 @@
         (error 'copy-remote-file-to-local! "remote file not found" path))
       (let ([local-path
              (local-destination-for-remote-file path local-destination)])
-        (copy-entry-to-local! client bucket drive-key path entry local-path summary)
+        (let ([root-handle
+               (secure-directory-open (parent-directory local-path) #t)]
+              [name (path-basename local-path)])
+          (dynamic-wind
+            (lambda () (void))
+            (lambda ()
+              (copy-entry-to-secure-root!
+                client bucket drive-key path entry root-handle name summary))
+            (lambda () (secure-directory-close root-handle))))
         local-path)))
 
   (define (copy-remote-tree-to-local! client bucket drive-key manifest remote-root local-destination summary)
     (let* ([root (remote-dir-normalize remote-root)]
-           [local-root (local-destination-for-remote-directory root local-destination)])
-      (ensure-directory! local-root)
-      (inc-summary! summary "CreatedDirectories")
-      (for-each
-        (lambda (dir)
-          (when (under-prefix? root dir)
-            (let ([rel (remote-relative-path root dir)])
-              (unless (string=? rel "")
-                (ensure-directory! (path-join local-root rel))
-                (inc-summary! summary "CreatedDirectories")))))
-        (manifest-directories manifest))
-      (for-each
-        (lambda (entry)
-          (let* ([path (entry-path entry)]
-                 [rel (remote-relative-path root path)]
-                 [local-path (path-join local-root rel)])
-            (copy-entry-to-local! client bucket drive-key path entry local-path summary)))
-        (manifest-file-entries-under manifest root '() '()))
+           [local-root (local-destination-for-remote-directory root local-destination)]
+           [root-handle (secure-directory-open local-root #t)])
+      (dynamic-wind
+        (lambda () (void))
+        (lambda ()
+          (inc-summary! summary "CreatedDirectories")
+          (for-each
+            (lambda (dir)
+              (when (under-prefix? root dir)
+                (let ([rel (remote-relative-path root dir)])
+                  (unless (string=? rel "")
+                    (secure-directory-mkdirs root-handle rel)
+                    (inc-summary! summary "CreatedDirectories")))))
+            (manifest-directories manifest))
+          (for-each
+            (lambda (entry)
+              (let* ([path (entry-path entry)]
+                     [rel (remote-relative-path root path)])
+                (copy-entry-to-secure-root!
+                  client bucket drive-key path entry root-handle rel summary)))
+            (manifest-file-entries-under manifest root '() '())))
+        (lambda () (secure-directory-close root-handle)))
       local-root))
 
   (define (copy-single-entry-remote-to-remote!
diff --git a/scripts/package-security-test.sh b/scripts/package-security-test.sh
new file mode 100755
index 0000000..bddf6b5
--- /dev/null
+++ b/scripts/package-security-test.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+tmp=$(mktemp -d "$repo_root/.package-security.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+umask 077
+
+if grep -nE 'export (DYLD_|LD_|JERBOA_|JPGP_|JYUBIKEY_|PROTON_BRIDGE_)' \
+    "$repo_root/support/package-bundle.sh"; then
+  echo "production package script still emits native-loader overrides" >&2
+  exit 1
+fi
+if grep -nE "echo 'export (DYLD_|LD_|JERBOA_|JPGP_|JYUBIKEY_|PROTON_BRIDGE_)" \
+    "$repo_root/Makefile"; then
+  echo "installed launcher still emits native-loader overrides" >&2
+  exit 1
+fi
+grep -q 'ffi-symbols "support/bundle-ffi-symbols.list"' \
+  "$repo_root/.jerbuild.release-darwin"
+grep -q '(static-native #t)' "$repo_root/.jerbuild.release-darwin"
+grep -q 'scrub_loader_environment' "$repo_root/support/bundle-launcher.c"
+
+mkdir -p "$tmp/bundle/bin" "$tmp/bundle/lib"
+cc -std=c11 -O2 -Wall -Wextra -Werror \
+  "$repo_root/support/bundle-launcher.c" -o "$tmp/bundle/jdrive"
+cc -std=c11 -O2 -Wall -Wextra -Werror \
+  "$repo_root/test/bundle-env-probe.c" -o "$tmp/bundle/bin/protonstorage"
+
+case $(uname -s) in
+  Darwin)
+    required='libproton_bridge_native.dylib jerboa_ssl_shim.dylib libyubikey_native.dylib libjpgp_native.dylib libjerboa_fuse_mount.so'
+    ;;
+  *)
+    required='libproton_bridge_native.so jerboa_ssl_shim.so libyubikey_native.so libjpgp_native.so libjerboa_fuse_mount.so'
+    ;;
+esac
+for library in $required; do
+  : > "$tmp/bundle/lib/$library"
+  chmod 0644 "$tmp/bundle/lib/$library"
+done
+chmod 0755 "$tmp/bundle" "$tmp/bundle/bin" "$tmp/bundle/lib" \
+  "$tmp/bundle/jdrive" "$tmp/bundle/bin/protonstorage"
+
+output="$tmp/environment.txt"
+env \
+  JDRIVE_TEST_OUTPUT="$output" \
+  DYLD_LIBRARY_PATH=/attacker/dyld \
+  DYLD_FALLBACK_LIBRARY_PATH=/attacker/fallback \
+  LD_LIBRARY_PATH=/attacker/ld \
+  JERBOA_HOME=/attacker/jerboa \
+  JERBOA_NATIVE_LIB=/attacker/native \
+  JERBOA_SSL_LIB=/attacker/ssl \
+  JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1 \
+  JERBOA_CRYPTO_LIB=/attacker/crypto \
+  JERBOA_CRYPTO_ALLOW_DYNAMIC_NATIVE=1 \
+  JERBOA_FUSE_LIB=/attacker/fuse \
+  JERBOA_FUSE_DEV_NATIVE=1 \
+  JPGP_NATIVE_LIB=/attacker/pgp \
+  JPGP_ALLOW_DYNAMIC_NATIVE=1 \
+  JYUBIKEY_NATIVE_LIB=/attacker/yubikey \
+  JYUBIKEY_DEV_NATIVE=1 \
+  PROTON_BRIDGE_NATIVE_LIB=/attacker/proton \
+  CHEZSCHEMELIBDIRS=/attacker/scheme \
+  SCHEMEHEAPDIRS=/attacker/heap \
+  "$tmp/bundle/jdrive" preserved-argument
+
+if grep -vE '^(ARG1=preserved-argument|[A-Z0-9_]+=<unset>)$' "$output"; then
+  echo "bundle launcher preserved a forbidden loader override" >&2
+  exit 1
+fi
+grep -q '^ARG1=preserved-argument$' "$output"
+
+chmod 0777 "$tmp/bundle/lib"
+if "$tmp/bundle/jdrive" >/dev/null 2>"$tmp/writable.err"; then
+  echo "bundle launcher accepted a writable library directory" >&2
+  exit 1
+fi
+grep -q 'group/world-writable bundle component rejected' "$tmp/writable.err"
+chmod 0755 "$tmp/bundle/lib"
+
+first_library=${required%% *}
+rm -f "$tmp/bundle/lib/$first_library"
+ln -s "${required##* }" "$tmp/bundle/lib/$first_library"
+if "$tmp/bundle/jdrive" >/dev/null 2>"$tmp/symlink.err"; then
+  echo "bundle launcher accepted a symlinked native library" >&2
+  exit 1
+fi
+grep -q 'non-regular bundle component rejected' "$tmp/symlink.err"
+
+echo "package_security_status=pass"
diff --git a/scripts/sbom.sh b/scripts/sbom.sh
index 148e163..795d3b5 100755
--- a/scripts/sbom.sh
+++ b/scripts/sbom.sh
@@ -91,6 +91,8 @@ hash_manifest "$repo_root/docs" "$out_dir/docs.sha256"
 hash_manifest "$repo_root/support" "$out_dir/support.sha256"
 hash_manifest "$repo_root/scripts" "$out_dir/scripts.sha256"
 hash_manifest "$repo_root/.github" "$out_dir/github-workflows.sha256"
+sha256_file "$repo_root/dependency-lock.tsv" > "$out_dir/dependency-lock.sha256"
+cp "$repo_root/dependency-lock.tsv" "$out_dir/dependency-lock.tsv"
 
 find "$repo_root" -maxdepth 1 -type f \
   \( -name '*.ss' -o -name 'Makefile' -o -name 'README.md' -o -name 'SECURITY.md' -o -name 'LICENSE' -o -name '*.md' \) \
diff --git a/support/bundle-ffi-symbols.list b/support/bundle-ffi-symbols.list
new file mode 100644
index 0000000..3c1e9f5
--- /dev/null
+++ b/support/bundle-ffi-symbols.list
@@ -0,0 +1,97 @@
+jerboa_conn_read
+jerboa_conn_write
+jerboa_fuse_block_signal
+jerboa_fuse_blockstore_create_bv
+jerboa_fuse_blockstore_open_bv
+jerboa_fuse_close_fd
+jerboa_fuse_fsync_fd
+jerboa_fuse_gcm_decrypt
+jerboa_fuse_gcm_encrypt
+jerboa_fuse_get_errno
+jerboa_fuse_getgid
+jerboa_fuse_getpid
+jerboa_fuse_getppid_of
+jerboa_fuse_getuid
+jerboa_fuse_mount
+jerboa_fuse_mount_bv
+jerboa_fuse_open_device
+jerboa_fuse_pbkdf2_sha256
+jerboa_fuse_pread_full
+jerboa_fuse_pwrite_full
+jerboa_fuse_rand_bytes
+jerboa_fuse_read_fd
+jerboa_fuse_secmem_alloc
+jerboa_fuse_secmem_copy_in_checked
+jerboa_fuse_secmem_copy_out_checked
+jerboa_fuse_secmem_free_checked
+jerboa_fuse_secmem_zero_checked
+jerboa_fuse_unblock_signal
+jerboa_fuse_unmount
+jerboa_fuse_unmount_bv
+jerboa_fuse_unmount_lazy
+jerboa_fuse_unmount_lazy_bv
+jerboa_fuse_write_fd
+jerboa_ssl_cleanup
+jerboa_ssl_close
+jerboa_ssl_connect
+jerboa_ssl_free_buf
+jerboa_ssl_init
+jerboa_ssl_memcpy
+jerboa_ssl_read
+jerboa_ssl_read_all
+jerboa_ssl_server_accept
+jerboa_ssl_server_ctx
+jerboa_ssl_server_ctx_free
+jerboa_ssl_set_timeout
+jerboa_ssl_write
+jerboa_tcp_accept
+jerboa_tcp_close
+jerboa_tcp_conn_wrap
+jerboa_tcp_connect
+jerboa_tcp_listen
+jerboa_tcp_read
+jerboa_tcp_read_all
+jerboa_tcp_set_timeout
+jerboa_tcp_write
+jpgp_abi_version
+jpgp_age_decrypt
+jpgp_age_encrypt
+jpgp_age_keygen
+jpgp_ed25519_keygen
+jpgp_ed25519_sign
+jpgp_ed25519_verify
+jpgp_pass_decrypt
+jpgp_pass_encrypt
+jpgp_pgp_decrypt
+jpgp_pgp_encrypt
+jpgp_pgp_sign
+jpgp_pgp_verify
+jpgp_sha256
+pb_native_drive_decrypt_block
+pb_native_drive_decrypt_session_key
+pb_native_drive_encrypt_block
+pb_native_drive_generate_content_key
+pb_native_drive_generate_key
+pb_native_drive_generate_passphrase
+pb_native_mailbox_password
+pb_native_pgp_decrypt
+pb_native_pgp_encrypt
+pb_native_pgp_encrypt_signed
+pb_native_pgp_sign_detached
+pb_native_pgp_sign_detached_encrypted
+pb_native_proton_token_passphrase
+pb_native_read_secret
+pb_native_srp_available
+pb_native_srp_last_error
+pb_native_srp_proofs
+yk_native_abi_version
+yk_native_available
+yk_native_connect
+yk_native_disconnect
+yk_native_fido2_assert
+yk_native_list_readers
+yk_native_random_bytes
+yk_native_read_secret_fd
+yk_native_read_secret_tty
+yk_native_transmit
+yk_native_write_secret_fd
diff --git a/support/bundle-launcher.c b/support/bundle-launcher.c
new file mode 100644
index 0000000..2a1a754
--- /dev/null
+++ b/support/bundle-launcher.c
@@ -0,0 +1,241 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include <dirent.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#if defined(__APPLE__)
+#include <mach-o/dyld.h>
+#elif defined(__FreeBSD__)
+#include <sys/sysctl.h>
+#endif
+
+#ifndef PATH_MAX
+#define PATH_MAX 4096
+#endif
+
+static int copy_path(char *out, size_t out_size, const char *value) {
+    size_t length = strlen(value);