security: gate release path leaks

Jaime Fournier

279d204442ebf0539f70918c1a6f99c12dd83c02

diff --git a/Makefile b/Makefile
index cc7c040..1c05b47 100644
--- a/Makefile
+++ b/Makefile
@@ -608,6 +608,7 @@ release-evidence:
 	$(MAKE) unification-release-check > "$(EVIDENCE_DIR)/unification-release-check.txt" 2>&1
 	$(MAKE) security-hardware-smoke > "$(EVIDENCE_DIR)/security-hardware-smoke.txt" 2>&1
 	$(MAKE) reproducibility-report > "$(EVIDENCE_DIR)/reproducibility-report.txt" 2>&1
+	support/check-release-path-leaks.sh dist/jerboa > "$(EVIDENCE_DIR)/path-leaks.txt" 2>&1
 	JERBOA_RELEASE_DIR="$(RELEASE_DIR)" JERBOA_SIGNING_EVIDENCE_DIR="$(EVIDENCE_DIR)/signing" support/sign-release-artifacts.sh evidence > "$(EVIDENCE_DIR)/signing-evidence.txt" 2>&1
 	$(MAKE) sbom > "$(EVIDENCE_DIR)/sbom.txt" 2>&1
 	shasum -a 256 Dockerfile Makefile SECURITY.md README.md rust-toolchain.toml \
@@ -615,12 +616,13 @@ release-evidence:
 		support/container-dependencies.lock support/container-inputs.sh \
 		support/install.sh support/ensure-jerboa.sh support/sign-release-artifacts.sh \
 		support/sbom.sh support/sanitize-evidence.sh support/fasl-cache-equivalence.ss \
-		support/reproducibility-report.sh tools/security-audit.sh \
+		support/check-release-path-leaks.sh support/reproducibility-report.sh tools/security-audit.sh \
 		> "$(EVIDENCE_DIR)/release-inputs-sha256.txt"
 	rm -rf "$(EVIDENCE_DIR)/sbom" "$(EVIDENCE_DIR)/reproducibility"
 	cp -R "$(SBOM_DIR)" "$(EVIDENCE_DIR)/sbom"
 	cp -R "$(REPRO_DIR)" "$(EVIDENCE_DIR)/reproducibility"
 	grep -q '^signature_status=' "$(EVIDENCE_DIR)/signing/status.txt"
+	grep -q '^path_leak_status=pass$$' "$(EVIDENCE_DIR)/path-leaks.txt"
 	grep -q '^status=match$$' "$(EVIDENCE_DIR)/reproducibility/result.txt"
 	grep -q '^sbom_status=present$$' "$(EVIDENCE_DIR)/sbom/manifest.txt"
 	support/sanitize-evidence.sh "$(EVIDENCE_DIR)"
diff --git a/docs/harden.md b/docs/harden.md
index 44a8d42..62b164a 100644
--- a/docs/harden.md
+++ b/docs/harden.md
@@ -533,7 +533,12 @@ Boot files contain Scheme symbol names as strings. Even encrypted, they'll be vi
 
 ### Removing Build Paths
 
-GCC and Chez embed source paths. Strip them:
+GCC and Chez can embed source paths. The canonical release policy lives in
+[`release-security.md`](release-security.md): release binary builders install
+Chez source-path sanitizers, pass `-ffile-prefix-map=<repo>=.`, and
+use same-length byte redaction for embedded boot/bundle payloads. The
+`support/check-release-path-leaks.sh` gate checks both `dist/jerboa` and
+packaged `.tar.gz` artifacts against `$HOME` / checkout path leaks.
 
 ```makefile
 # GCC: use -ffile-prefix-map to replace paths
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 0b2e141..1056529 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -779,9 +779,14 @@ they are build-time. Close the runtime loop.
   failures now log internal details server-side and return client-visible
   opaque refs; `(std net thread-httpd)` does the same for handler exceptions.
   `jerboa_security_scan` now flags raw `condition-message`/`display-condition`
-  values written into HTTP response constructors. Remaining work: router
+  values written into HTTP response constructors. Release binary builds now
+  suppress Chez source metadata, pass `-ffile-prefix-map=<repo>=.`, redact
+  embedded boot/bundle build-root byte sequences before C embedding, and gate
+  both `dist/jerboa` and packaged `.tar.gz` artifacts with
+  `support/check-release-path-leaks.sh`; the release evidence bundle records
+  `path-leaks.txt` and requires `path_leak_status=pass`. Remaining work: router
   integrations plus MCP/LSP/repl-protocol surfaces need opaque client error
-  refs, and release artifact path-leak checks still need a gate.
+  refs.
 
 ### K3-P1-11 — Parameterized-only SQL in the safe surface
 **Serves:** G1. **Effort:** 2–3 days.
diff --git a/docs/release-security.md b/docs/release-security.md
index be4b09e..18ee170 100644
--- a/docs/release-security.md
+++ b/docs/release-security.md
@@ -139,6 +139,18 @@ release unification check, hardware-hardening smoke, signing evidence, SBOM,
 reproducibility report, release-input hashes, git state, and host-neutral build
 environment under `dist/release-evidence/`. The evidence target sanitizes and
 fails closed on private path, SSH remote, host, or `uname -a` markers.
+It also runs `support/check-release-path-leaks.sh` against `dist/jerboa` and
+requires `path_leak_status=pass` in `path-leaks.txt`. Release packaging runs
+the same checker against the final `.tar.gz`, so shipped artifacts cannot
+embed `$HOME`, the repository root, or the physical checkout path. The binary
+builders install Chez source-path sanitizers (`current-make-source-object`,
+`generate-inspector-information #f`, no procedure source info) and pass
+`-ffile-prefix-map=<repo>=.` by default. Embedded Chez boot files, program
+images, and bundle payloads are additionally processed by
+`support/redact-build-paths.ss`, which replaces exact local build-root byte
+sequences with same-length placeholder bytes before C array generation or
+bundle hashing. Set `JERBOA_BINARY_PATH_MAP=0` only for local compiler
+diagnostics.
 
 Current release artifacts must have SHA-256 files and detached publisher
 signatures. `make sign-release-artifacts` creates a canonical
diff --git a/support/build-binary.sh b/support/build-binary.sh
index 7a5557c..fe151fd 100755
--- a/support/build-binary.sh
+++ b/support/build-binary.sh
@@ -215,6 +215,9 @@ if flag_enabled "${JERBOA_BINARY_HARDEN:-1}"; then
     HARDEN_CFLAGS=$(target_hardening_cflags "$TARGET_OS" "$MACHINE_TYPE")
     HARDEN_LDFLAGS=$(target_hardening_ldflags "$TARGET_OS" "$MACHINE_TYPE")
 fi
+if flag_enabled "${JERBOA_BINARY_PATH_MAP:-1}"; then
+    HARDEN_CFLAGS="$HARDEN_CFLAGS -ffile-prefix-map=$JERBOA_HOME=."
+fi
 
 # ── Find the Chez install dir (libkernel.a + scheme.h + boot files) ──────────
 CSV_DIR=""
@@ -263,6 +266,13 @@ echo ""
 # ── Step 2: Embed boot files + program as C byte arrays ──────────────────────
 echo "==> [2/4] Embedding boot files as C arrays"
 
+PETITE_BOOT="$OBJ_DIR/petite.boot"
+SCHEME_BOOT="$OBJ_DIR/scheme.boot"
+cp "$CSV_DIR/petite.boot" "$PETITE_BOOT"
+cp "$CSV_DIR/scheme.boot" "$SCHEME_BOOT"
+JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \
+    "$PETITE_BOOT" "$SCHEME_BOOT" "$WPO_SO"
+
 embed() {
     in="$1"; stem="$2"
     var="${stem}_data"; sz="${stem}_size"; out="$stem.h"
@@ -275,8 +285,8 @@ embed() {
     printf '};\nstatic const unsigned int %s = sizeof(%s);\n' "$sz" "$var" >> "$out"
 }
 
-embed "$CSV_DIR/petite.boot"  petite_boot
-embed "$CSV_DIR/scheme.boot"  scheme_boot
+embed "$PETITE_BOOT"          petite_boot
+embed "$SCHEME_BOOT"          scheme_boot
 embed "$WPO_SO"               program_boot
 
 # When baking a Typed Jerboa Rust staticlib, extract its jt_* exports and
diff --git a/support/build-boot.ss b/support/build-boot.ss
index e76de44..cea9727 100644
--- a/support/build-boot.ss
+++ b/support/build-boot.ss
@@ -14,6 +14,27 @@
 
 (import (scheme))
 
+(define source-path-placeholder "<jerboa-release-source>")
+
+(define (sanitized-source-object sfd bfp efp)
+  (if (source-file-descriptor? sfd)
+      (make-source-object
+        (source-file-descriptor
+          source-path-placeholder
+          (source-file-descriptor-checksum sfd))
+        bfp
+        efp)
+      #f))
+
+(define (install-release-source-path-sanitizer!)
+  (current-make-source-object sanitized-source-object)
+  (debug-level 0)
+  (generate-inspector-information #f)
+  (generate-procedure-source-information #f)
+  (enable-error-source-expression #f))
+
+(install-release-source-path-sanitizer!)
+
 (define (string-suffix? str suffix)
   (let ([slen (string-length str)]
         [xlen (string-length suffix)])
diff --git a/support/build-jerboa-multicall.ss b/support/build-jerboa-multicall.ss
index f358a16..1cecd45 100644
--- a/support/build-jerboa-multicall.ss
+++ b/support/build-jerboa-multicall.ss
@@ -56,6 +56,18 @@
           (lp (+ i 1))))
       (display "'" out))))
 
+(define (shell-join paths)
+  (apply string-append
+         (map (lambda (path) (string-append " " (shell-quote path)))
+              paths)))
+
+(define (redact-build-paths! paths)
+  (run (format "JERBOA_HOME=~a ~a --script ~a/support/redact-build-paths.ss~a"
+               (shell-quote repo)
+               (shell-quote scheme-exe)
+               (shell-quote repo)
+               (shell-join paths))))
+
 (define (run cmd)
   (printf "    ~a~n" cmd)
   (let ([rc (safe-system cmd)])
@@ -116,6 +128,25 @@
         (gensym-prefix old-gensym-prefix)
         (gensym-count old-gensym-count)))))
 
+(define source-path-placeholder "<jerboa-release-source>")
+
+(define (sanitized-source-object sfd bfp efp)
+  (if (source-file-descriptor? sfd)
+      (make-source-object
+        (source-file-descriptor
+          source-path-placeholder
+          (source-file-descriptor-checksum sfd))
+        bfp
+        efp)
+      #f))
+
+(define (install-release-source-path-sanitizer!)
+  (current-make-source-object sanitized-source-object)
+  (debug-level 0)
+  (generate-inspector-information #f)
+  (generate-procedure-source-information #f)
+  (enable-error-source-expression #f))
+
 (define entry-build-id 'build-deterministic)
 
 (define (entry-library-name name)
@@ -439,6 +470,7 @@
   (let ([dir (getenv "JERBOA_SEED_OBJECT_CACHE_DIR")])
     (and dir (> (string-length dir) 0) dir)))
 (define project-version (read-project-version repo))
+(install-release-source-path-sanitizer!)
 ;; ── cross-compilation params (mirror support/build-jerbuild.sh) ───────────────
 ;; TARGET_MACHINE set => cross build. JERBOA_CROSS_PREFIX is the install prefix
 ;; of the cross-built Chez (`.chez-cross-<mt>`, holding lib/csv*/<mt>/...) and
@@ -1311,11 +1343,18 @@
       " -Wl,-no_uuid"
       ""))
 
-(define harden-cflags
-  (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
-      (target-hardening-cflags target-os machine)
+(define path-map-cflags
+  (if (env-enabled? "JERBOA_BINARY_PATH_MAP" #t)
+      (format " -ffile-prefix-map=~a=." (shell-quote repo))
       ""))
 
+(define harden-cflags
+  (string-append
+    (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
+        (target-hardening-cflags target-os machine)
+        "")
+    path-map-cflags))
+
 (define harden-ldflags
   (string-append
     (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
@@ -1662,6 +1701,7 @@
 (run (format "rm -f ~a && cd ~a && find . -type f | sort | ~a -cf ~a -T -"
              (shell-quote bundle-tar) (shell-quote stage)
              bundle-tar-command (shell-quote bundle-tar)))
+(redact-build-paths! (list bundle-tar))
 
 (define sha-file (format "~a/bundle.sha256" build-dir))
 (run (format "(shasum -a 256 ~a 2>/dev/null || sha256sum ~a) | awk '{print $1}' > ~a"
@@ -1669,8 +1709,16 @@
 (define bundle-sha (trim-ws (read-text-file sha-file)))
 
 (printf "==> [4/6] embed boot files + program + bundle as C arrays~n")
-(file->c-header (format "~a/petite.boot" csv-dir) (format "~a/petite_boot.h" build-dir) "petite_boot_data" "petite_boot_size")
-(file->c-header (format "~a/scheme.boot" csv-dir) (format "~a/scheme_boot.h" build-dir) "scheme_boot_data" "scheme_boot_size")
+(define petite-boot (format "~a/petite.boot" build-dir))
+(define scheme-boot (format "~a/scheme.boot" build-dir))
+(run (format "cp ~a ~a && cp ~a ~a"
+             (shell-quote (format "~a/petite.boot" csv-dir))
+             (shell-quote petite-boot)
+             (shell-quote (format "~a/scheme.boot" csv-dir))
+             (shell-quote scheme-boot)))
+(redact-build-paths! (list petite-boot scheme-boot program-image))
+(file->c-header petite-boot (format "~a/petite_boot.h" build-dir) "petite_boot_data" "petite_boot_size")
+(file->c-header scheme-boot (format "~a/scheme_boot.h" build-dir) "scheme_boot_data" "scheme_boot_size")
 (file->c-header program-image (format "~a/program_boot.h" build-dir) "program_boot_data" "program_boot_size")
 (file->c-header bundle-tar (format "~a/bundle_tar.h" build-dir) "bundle_tar_data" "bundle_tar_size")
 (call-with-replacing-output-file (format "~a/bundle_meta.h" build-dir)
diff --git a/support/build-jerbuild.sh b/support/build-jerbuild.sh
index cdd518c..9b36df3 100755
--- a/support/build-jerbuild.sh
+++ b/support/build-jerbuild.sh
@@ -167,6 +167,9 @@ if flag_enabled "${JERBOA_BINARY_HARDEN:-1}"; then
     HARDEN_CFLAGS=$(target_hardening_cflags "$TARGET_OS" "$MACHINE_TYPE")
     HARDEN_LDFLAGS=$(target_hardening_ldflags "$TARGET_OS" "$MACHINE_TYPE")
 fi
+if flag_enabled "${JERBOA_BINARY_PATH_MAP:-1}"; then
+    HARDEN_CFLAGS="$HARDEN_CFLAGS -ffile-prefix-map=$JERBOA_HOME=."
+fi
 
 CSV_DIR=""
 for prefix in "$CSV_SEARCH_PREFIX" /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib; do
@@ -250,6 +253,8 @@ for f in libkernel.a scheme.h petite.boot scheme.boot liblz4.a libz.a; do
     [ -f "$CSV_DIR/$f" ] && cp "$CSV_DIR/$f" "$BUNDLE_STAGE/csv/$MACHINE_TYPE/"
 done
 (cd "$BUNDLE_STAGE" && tar -rf "$BUNDLE_TAR" csv)
+JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \
+    "$WPO_SO" "$BUNDLE_TAR"
 
 BUNDLE_BYTES=$(wc -c < "$BUNDLE_TAR" | tr -d ' ')
 BUNDLE_SHA=$(shasum -a 256 "$BUNDLE_TAR" 2>/dev/null | awk '{print $1}' \
@@ -258,6 +263,13 @@ echo "    bundle: $BUNDLE_BYTES bytes, sha256=${BUNDLE_SHA%?????????????????????
 echo ""
 
 echo "==> [3/5] Embed boot files + program + bundle as C arrays"
+PETITE_BOOT="$OBJ_DIR/petite.boot"
+SCHEME_BOOT="$OBJ_DIR/scheme.boot"
+cp "$CSV_DIR/petite.boot" "$PETITE_BOOT"
+cp "$CSV_DIR/scheme.boot" "$SCHEME_BOOT"
+JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \
+    "$PETITE_BOOT" "$SCHEME_BOOT"
+
 embed() {
     in="$1"; stem="$2"
     var="${stem}_data"; sz="${stem}_size"; out="$stem.h"
@@ -267,8 +279,8 @@ embed() {
               -e 's/^ *//;s/ *$//;s/  */ /g;s/ /,0x/g;s/^/0x/;s/$/,/' >> "$out"
     printf '};\nstatic const unsigned int %s = sizeof(%s);\n' "$sz" "$var" >> "$out"
 }
-embed "$CSV_DIR/petite.boot" petite_boot
-embed "$CSV_DIR/scheme.boot" scheme_boot
+embed "$PETITE_BOOT"         petite_boot
+embed "$SCHEME_BOOT"         scheme_boot
 embed "$WPO_SO"              program_boot
 embed "$BUNDLE_TAR"          bundle_tar
 
diff --git a/support/check-release-path-leaks.sh b/support/check-release-path-leaks.sh
new file mode 100755
index 0000000..4c2d92d
--- /dev/null
+++ b/support/check-release-path-leaks.sh
@@ -0,0 +1,102 @@
+#!/bin/sh
+# Verify release binaries/artifacts do not embed host build paths.
+
+set -eu
+
+usage() {
+    echo "Usage: $0 ARTIFACT_OR_BINARY..." >&2
+}
+
+[ "$#" -gt 0 ] || { usage; exit 2; }
+
+repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+repo_physical=$(CDPATH= cd -- "$repo" && pwd -P)
+home_path=${HOME:-}
+
+tmp_root=
+cleanup() {
+    [ -z "$tmp_root" ] || rm -rf "$tmp_root"
+}
+trap cleanup EXIT HUP INT TERM
+
+make_tmp_root() {
+    if [ -z "$tmp_root" ]; then
+        tmp_parent=${TMPDIR:-/tmp}
+        tmp_root=$(mktemp -d "$tmp_parent/jerboa-path-leaks.XXXXXX")
+    fi
+}
+
+scan_stream() {
+    label=$1
+    pattern_name=$2
+    pattern=$3
+    data=$4
+
+    if [ -n "$pattern" ] && [ "$pattern" != "/" ] && grep -F -q -- "$pattern" "$data"; then
+        printf 'leak_status=fail artifact=%s pattern=%s\n' "$label" "$pattern_name"
+        return 1
+    fi
+    return 0
+}
+
+scan_file() {
+    file=$1
+    label=$2
+    make_tmp_root
+    strings_out="$tmp_root/strings.$$"
+    : > "$strings_out"
+    strings -a "$file" > "$strings_out" 2>/dev/null || true
+
+    failed=0
+    scan_stream "$label" HOME "$home_path" "$strings_out" || failed=1
+    scan_stream "$label" repo-root "$repo" "$strings_out" || failed=1
+    if [ "$repo_physical" != "$repo" ]; then
+        scan_stream "$label" repo-root-physical "$repo_physical" "$strings_out" || failed=1
+    fi
+
+    rm -f "$strings_out"
+    return "$failed"
+}
+
+scan_tar_gz() {
+    artifact=$1
+    make_tmp_root
+    extract_dir="$tmp_root/extract.$$"
+    mkdir -p "$extract_dir"
+    tar -xzf "$artifact" -C "$extract_dir"
+
+    failed=0
+    while IFS= read -r file; do
+        rel=${file#"$extract_dir"/}
+        scan_file "$file" "$artifact:$rel" || failed=1
+    done <<EOF
+$(find "$extract_dir" -type f | sort)
+EOF
+    rm -rf "$extract_dir"
+    return "$failed"
+}
+
+checked=0
+failed=0
+
+for artifact in "$@"; do
+    [ -f "$artifact" ] || {
+        printf 'leak_status=fail artifact=%s reason=missing\n' "$artifact"
+        failed=1
+        continue
+    }
+
+    checked=$((checked + 1))
+    case "$artifact" in
+        *.tar.gz|*.tgz) scan_tar_gz "$artifact" || failed=1 ;;
+        *) scan_file "$artifact" "$artifact" || failed=1 ;;
+    esac
+done
+
+printf 'checked_count=%s\n' "$checked"
+if [ "$failed" -eq 0 ]; then
+    printf 'path_leak_status=pass\n'
+else
+    printf 'path_leak_status=fail\n'
+    exit 1
+fi
diff --git a/support/package-jerboa-release.sh b/support/package-jerboa-release.sh
index 8c09da9..bcd4635 100755
--- a/support/package-jerboa-release.sh
+++ b/support/package-jerboa-release.sh
@@ -100,6 +100,8 @@ else
     (cd "$tmp" && COPYFILE_DISABLE=1 tar -czf "$cwd/$versioned" "$name")
 fi
 
+support/check-release-path-leaks.sh "$versioned"
+
 if command -v sha256sum >/dev/null 2>&1; then
     sum=$(sha256sum "$versioned" | awk '{print $1}')
 elif command -v shasum >/dev/null 2>&1; then
diff --git a/support/redact-build-paths.ss b/support/redact-build-paths.ss
new file mode 100644
index 0000000..3c003ef
--- /dev/null
+++ b/support/redact-build-paths.ss
@@ -0,0 +1,97 @@
+#!chezscheme
+;;; Replace absolute local build roots in binary artifacts with same-length bytes.
+
+(import (scheme))
+
+(define (ascii-string->bytevector s)
+  (let* ([n (string-length s)]
+         [bv (make-bytevector n 0)])
+    (let loop ([i 0])
+      (when (< i n)
+        (bytevector-u8-set! bv i (char->integer (string-ref s i)))
+        (loop (+ i 1))))
+    bv))
+
+(define (read-file-bytevector path)
+  (call-with-port
+    (open-file-input-port path)
+    (lambda (p) (get-bytevector-all p))))
+
+(define (write-file-bytevector path data)
+  (call-with-port
+    (open-file-output-port path (file-options replace) (buffer-mode block))
+    (lambda (p) (put-bytevector p data))))
+
+(define (bytevector-match-at? bv start needle)
+  (let ([n (bytevector-length needle)]
+        [limit (bytevector-length bv)])
+    (and (<= (+ start n) limit)
+         (let loop ([i 0])
+           (cond
+             [(= i n) #t]
+             [(= (bytevector-u8-ref bv (+ start i))
+                 (bytevector-u8-ref needle i))
+              (loop (+ i 1))]
+             [else #f])))))
+
+(define (redaction-bytevector len)
+  (make-bytevector len (char->integer #\x)))
+
+(define (replace-bytevector! data needle replacement)
+  (let ([needle-len (bytevector-length needle)]
+        [data-len (bytevector-length data)])
+    (unless (= needle-len (bytevector-length replacement))
+      (error 'replace-bytevector! "replacement length mismatch"))
+    (let loop ([i 0] [count 0])
+      (cond
+        [(or (= needle-len 0) (> (+ i needle-len) data-len)) count]
+        [(bytevector-match-at? data i needle)
+         (bytevector-copy! replacement 0 data i needle-len)
+         (loop (+ i needle-len) (+ count 1))]
+        [else (loop (+ i 1) count)]))))
+
+(define (unique-strings xs)
+  (let loop ([rest xs] [seen '()] [out '()])
+    (cond
+      [(null? rest) (reverse out)]
+      [(or (not (car rest))
+           (= (string-length (car rest)) 0)
+           (string=? (car rest) "/")
+           (member (car rest) seen))
+       (loop (cdr rest) seen out)]
+      [else
+       (loop (cdr rest)
+             (cons (car rest) seen)
+             (cons (car rest) out))])))
+
+(define (redact-file! path patterns)
+  (let ([data (read-file-bytevector path)])
+    (let loop ([rest patterns] [count 0])
+      (if (null? rest)
+          (begin
+            (when (> count 0)
+              (write-file-bytevector path data)
+              (printf "redacted_build_path_count=~a artifact=~a~n" count path))
+            count)
+          (let* ([needle (ascii-string->bytevector (car rest))]
+                 [replacement (redaction-bytevector (bytevector-length needle))])
+            (loop (cdr rest)
+                  (+ count (replace-bytevector! data needle replacement))))))))
+
+(define args
+  (let ([script-args (command-line-arguments)])
+    (if (null? script-args)
+        (cdr (command-line))
+        script-args)))
+
+(define patterns
+  (unique-strings
+   (list (getenv "JERBOA_HOME")
+         (getenv "HOME"))))
+
+(for-each
+ (lambda (path)
+   (unless (file-exists? path)
+     (error 'redact-build-paths "missing artifact" path))
+   (redact-file! path patterns))
+ args)
diff --git a/tests/test-release-path-leaks.sh b/tests/test-release-path-leaks.sh
new file mode 100755
index 0000000..c941665
--- /dev/null
+++ b/tests/test-release-path-leaks.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+set -eu
+
+repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-path-leaks-test.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+
+clean="$tmp/clean.bin"
+leaky="$tmp/leaky.bin"
+tar_root="$tmp/tar-root"
+tarball="$tmp/leaky.tar.gz"
+redacted="$tmp/redacted.bin"
+
+printf 'ordinary release payload\n' > "$clean"
+"$repo/support/check-release-path-leaks.sh" "$clean" >/dev/null
+
+printf 'compiled from %s/lib/std/example.ss\n' "$HOME" > "$leaky"
+if "$repo/support/check-release-path-leaks.sh" "$leaky" >/dev/null 2>&1; then
+    echo "expected HOME leak fixture to fail" >&2
+    exit 1
+fi
+
+mkdir -p "$tar_root/bin"
+printf 'compiled from %s/mcp/server.ss\n' "$repo" > "$tar_root/bin/jerboa"
+(cd "$tar_root" && tar -czf "$tarball" .)
+if "$repo/support/check-release-path-leaks.sh" "$tarball" >/dev/null 2>&1; then
+    echo "expected tarball repo-root leak fixture to fail" >&2
+    exit 1
+fi
+
+printf 'compiled from %s/lib/std/example.ss\n' "$repo" > "$redacted"
+JERBOA_HOME="$repo" "$repo/.chez/bin/scheme" --script "$repo/support/redact-build-paths.ss" "$redacted" >/dev/null
+"$repo/support/check-release-path-leaks.sh" "$redacted" >/dev/null
+
+echo "release-path-leaks: PASS"