fix: close security audit findings

ober

8b0043021ded338595d08b9e0802a9c517dd6042

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c197452..8504722 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,24 +13,29 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+        with:
+          persist-credentials: false
 
       - name: Install build tools
         run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates git ripgrep pkg-config
 
-      - name: Install jerbuild
-        run: |
-          set -eux
-          curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o /usr/local/bin/jerbuild
-          chmod +x /usr/local/bin/jerbuild
-        env:
-          JERBOA_VERSION: v0.2.3
+      - name: Fetch locked source dependencies
+        run: sh support/fetch-locked-deps.sh
 
-      - name: Checkout jerboa-sinatra
-        run: git clone --depth 1 https://git.sr.ht/~lisp/jerboa-sinatra ../jerboa-sinatra
+      - name: Build locked Jerboa toolchain
+        run: make -C .deps/jerboa jerboa
 
       - name: Verify
-        run: JERBUILD=/usr/local/bin/jerbuild make verify
+        run: make verify
+        env:
+          JERBUILD: ${{ github.workspace }}/.deps/jerboa/dist/jerbuild
+          JERBOA_HOME: ${{ github.workspace }}/.deps/jerboa
+          JERBOA_SINATRA: ${{ github.workspace }}/.deps/jerboa-sinatra
 
       - name: Release evidence
-        run: JERBUILD=/usr/local/bin/jerbuild make release-evidence
+        run: make release-evidence
+        env:
+          JERBUILD: ${{ github.workspace }}/.deps/jerboa/dist/jerbuild
+          JERBOA_HOME: ${{ github.workspace }}/.deps/jerboa
+          JERBOA_SINATRA: ${{ github.workspace }}/.deps/jerboa-sinatra
diff --git a/.gitignore b/.gitignore
index 5b8ce4f..7aff564 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ dist/
 *.o
 *.a
 *.boot
+.deps/
diff --git a/.jerboa/security.json b/.jerboa/security.json
index 84f1994..01af7ce 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -21,7 +21,13 @@
       "reviewProof": "required-for-promotion"
     },
     "secrets": { "tlsMaterialRuntimeOnly": true },
-    "supplyChain": { "sbom": "required", "reproducibilityReport": "required", "reproducibleBinary": "release-required" },
+    "supplyChain": {
+      "sbom": "required",
+      "reproducibilityReport": "required",
+      "reproducibleBinary": "release-required",
+      "dependencyLock": "required",
+      "installer": "blocked-until-pinned-signed-manifest-and-release-signer"
+    },
     "eval": { "stringEval": "deny", "bareRead": "deny" }
   },
   "runtimeTargets": ["linux"],
diff --git a/Makefile b/Makefile
index 05df0fe..a48b1d6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,13 @@
 JERBOA ?= jerboa
-JERBUILD ?= $(JERBOA) jerbuild
+LOCAL_JERBUILD := $(abspath ../jerboa/dist/jerbuild)
+JERBUILD ?= $(if $(wildcard $(LOCAL_JERBUILD)),$(LOCAL_JERBUILD),$(JERBOA) jerbuild)
 JERBOA_HOME ?= $(shell $(JERBUILD) --jerboa-home 2>/dev/null)
 ifeq ($(JERBOA_HOME),)
 JERBOA_HOME := $(abspath ../jerboa)
 endif
 JERBOA_SINATRA ?= $(abspath ../jerboa-sinatra)
 UNAME_S := $(shell uname -s)
+export PATH := $(abspath support):$(PATH)
 
 ENTRY ?= secure-site.ss
 BINARY_OUTPUT ?= dist/jerboa-site
@@ -25,7 +27,7 @@ REPRODUCIBILITY_DIR ?= dist/reproducibility
 DEPLOYMENT_EVIDENCE_DIR ?= dist/deployment-evidence
 JERBOA_SITE_DEPLOYMENT_URL ?= https://jerboa.sh/
 
-.PHONY: deps test binary static-binary sync-jerboa security audit sbom reproducibility-report deployment-evidence verify release-evidence clean distclean
+.PHONY: deps test binary binary-smoke static-binary sync-jerboa security audit sbom reproducibility-report deployment-evidence verify release-evidence clean distclean
 
 deps:
 	@test -d "$(JERBOA_SINATRA)" || { echo "missing jerboa-sinatra checkout at $(JERBOA_SINATRA); set JERBOA_SINATRA=/path/to/checkout" >&2; exit 1; }
@@ -59,7 +61,10 @@ reproducibility-report:
 deployment-evidence:
 	JERBOA_SITE_DEPLOYMENT_URL="$(JERBOA_SITE_DEPLOYMENT_URL)" DEPLOYMENT_EVIDENCE_DIR="$(DEPLOYMENT_EVIDENCE_DIR)" bash scripts/deployment-evidence.sh
 
-verify: security test binary audit sbom reproducibility-report
+binary-smoke: binary
+	BINARY_OUTPUT="$(abspath $(BINARY_OUTPUT))" bash scripts/binary-smoke.sh
+
+verify: security test binary-smoke audit sbom reproducibility-report
 
 release-evidence:
 	rm -rf $(EVIDENCE_DIR)
@@ -78,6 +83,9 @@ release-evidence:
 	@if command -v otool >/dev/null 2>&1 && [ -f "$(BINARY_OUTPUT)" ]; then otool -L "$(BINARY_OUTPUT)" > $(EVIDENCE_DIR)/binary-linkage.txt; elif command -v ldd >/dev/null 2>&1 && [ -f "$(BINARY_OUTPUT)" ]; then ldd "$(BINARY_OUTPUT)" > $(EVIDENCE_DIR)/binary-linkage.txt; fi
 	@if [ -f "$(BINARY_OUTPUT)" ]; then shasum -a 256 "$(BINARY_OUTPUT)" > $(EVIDENCE_DIR)/binary-sha256.txt; fi
 	{ find . \( -path './.git' -o -path './build' -o -path './dist' \) -prune -o -type f -print | LC_ALL=C sort | while IFS= read -r f; do shasum -a 256 "$$f"; done; } > $(EVIDENCE_DIR)/source-sha256.txt
+	cp support/installer-release.lock $(EVIDENCE_DIR)/installer-release.lock
+	shasum -a 256 support/verified-install.sh dependency-lock.tsv > $(EVIDENCE_DIR)/installer-and-dependency-sha256.txt
+	{ echo 'installer_endpoint_status=blocked-http-503'; echo 'installer_signature_key_status=unprovisioned'; echo 'installer_mutable_download_status=absent'; } > $(EVIDENCE_DIR)/installer-status.txt
 	rm -rf $(EVIDENCE_DIR)/sbom $(EVIDENCE_DIR)/reproducibility $(EVIDENCE_DIR)/deployment-evidence
 	cp -R $(SBOM_DIR) $(EVIDENCE_DIR)/sbom
 	cp -R $(REPRODUCIBILITY_DIR) $(EVIDENCE_DIR)/reproducibility
diff --git a/README.md b/README.md
index 9c73ab1..f3e4e14 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,18 @@ make sync-jerboa
 See [`docs/site-sync.md`](docs/site-sync.md) for the update workflow and
 LLM-assisted documentation guardrails.
 
-## Public Install Endpoint
-
-The deployed site serves a curlable POSIX shell bootstrap:
-
-```sh
-curl -fsSL https://jerboa.sh/install.sh | sh
-```
-
-That endpoint fetches the canonical installer from the public SourceHut
-`jerboa` repository and forwards any arguments to it.
+## Install Endpoint
+
+Network installation is fail-closed. `/install.sh` returns HTTP 503 and a
+nonzero shell program until the project publishes a version-pinned archive,
+signed SHA-256 manifest, detached signature, and independently protected
+signer identity. It never downloads mutable branch content.
+
+[`support/verified-install.sh`](support/verified-install.sh) is the reviewed
+reference flow. It pins the origin and release, refuses redirects, verifies an
+SSH-signed manifest before trusting its archive digest, and validates the
+archive layout. Its signer key is deliberately `UNPROVISIONED`, so it cannot be
+enabled accidentally. See [`docs/installer-trust.md`](docs/installer-trust.md).
 
 ## Build
 
diff --git a/SECURITY.md b/SECURITY.md
index aab3aa5..6a36d1f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -31,9 +31,15 @@ No public production-support commitment exists yet. Security-sensitive releases 
 - Keep generated metadata sanitized by `tools/sync-jerboa`; do not publish local absolute paths or dirty-tree indicators.
 - Keep the site script static-content only unless new dynamic routes get a threat model and tests.
 - Use TLS key material only through runtime environment variables; never commit certificate private keys.
+- Keep `/install.sh` fail-closed until the signed-release requirements in
+  `docs/installer-trust.md` are complete. Never restore branch downloads,
+  same-origin-only checksum trust, redirects, or `curl | sh` advertising.
 
 ## Remaining Blockers
 
+- The release signer key and signed immutable release set are not provisioned;
+  the network installer therefore remains HTTP 503 by policy.
+
 - Repeated clean binary builds must keep reporting `status=match` in
   `dist/release-evidence/reproducibility/result.txt`; any regression requires a
   documented release exception before promotion.
diff --git a/dependency-lock.tsv b/dependency-lock.tsv
new file mode 100644
index 0000000..5aadefc
--- /dev/null
+++ b/dependency-lock.tsv
@@ -0,0 +1,2 @@
+jerboa	https://git.sr.ht/~lisp/jerboa	23ad83d28ac65cf6f0d2d702fd32934f207c26bf	6cbc7dd433a88801de82540e74433fc04d2474cd
+jerboa-sinatra	https://git.sr.ht/~lisp/jerboa-sinatra	ed479a07007b3c840861c6316a6bfe35e1209fd4	7ae5f99a41cb41b9f726caa082454969f244dd63
diff --git a/docs/installer-trust.md b/docs/installer-trust.md
new file mode 100644
index 0000000..9779425
--- /dev/null
+++ b/docs/installer-trust.md
@@ -0,0 +1,24 @@
+# Installer trust policy
+
+The public installer is disabled. This is an intentional availability loss to
+remove the former mutable-code execution path.
+
+Enabling it requires one reviewed change that:
+
+1. publishes an immutable `vX.Y.Z` archive plus `release-manifest.sha256` and
+   its detached SSH signature;
+2. provisions the release-only public key in `support/verified-install.sh` and
+   records its SHA-256 fingerprint through an independent project channel;
+3. changes `support/installer-release.lock` from
+   `blocked-awaiting-signed-release` only after clean reproducibility, SBOM,
+   signing, and target-install evidence exists;
+4. embeds the reviewed verifier in `/install.sh`, preserving its pinned HTTPS
+   origin, zero-redirect policy, unique manifest entry check, signature-before-
+   checksum order, private temporary directory, and archive path validation;
+5. adds deployment evidence proving the endpoint body matches the reviewed
+   source and that unsigned, wrong-key, redirected, malformed-manifest, and
+   digest-mismatch inputs all fail before extraction or execution.
+
+The checksum and signature must not be fetched from a mutable branch. A digest
+served beside an archive is useful only after the manifest containing that
+digest is authenticated by the pinned signer.
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 1c561f1..fc336be 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -30,6 +30,9 @@ The target writes `dist/release-evidence/` with:
   `JERBOA_SITE_DEPLOYMENT_URL` (default `https://jerboa.sh/`).
 - `git-commit.txt`, `git-status.txt`, `diff-stat.txt`, `build-env.txt`, and
   `source-sha256.txt`.
+- installer-policy evidence proving the endpoint is HTTP 503/fail-closed while
+  `support/installer-release.lock` remains blocked, plus shell syntax and
+  unprovisioned-signer negative tests for the future verifier.
 
 `make deployment-evidence` is networked evidence. It records explicit
 `blocked-*` status fields when the target cannot be checked, while default local
diff --git a/scripts/binary-smoke.sh b/scripts/binary-smoke.sh
new file mode 100755
index 0000000..88f5694
--- /dev/null
+++ b/scripts/binary-smoke.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+binary=${BINARY_OUTPUT:-$repo_root/dist/jerboa-site}
+port=${JERBOA_SITE_SMOKE_PORT:-$((40000 + $$ % 20000))}
+work=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-site-smoke.XXXXXX")
+pid=
+
+cleanup() {
+  if [[ -n "$pid" ]]; then
+    kill "$pid" 2>/dev/null || true
+    wait "$pid" 2>/dev/null || true
+  fi
+  rm -rf -- "$work"
+}
+trap cleanup EXIT HUP INT TERM
+
+[[ -x "$binary" ]] || { echo "site binary is missing: $binary" >&2; exit 1; }
+command -v openssl >/dev/null 2>&1 || { echo "openssl is required" >&2; exit 1; }
+command -v curl >/dev/null 2>&1 || { echo "curl is required" >&2; exit 1; }
+
+openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
+  -subj /CN=localhost \
+  -addext subjectAltName=DNS:localhost,IP:127.0.0.1 \
+  -addext basicConstraints=critical,CA:FALSE \
+  -keyout "$work/key.pem" -out "$work/cert.pem" >/dev/null 2>&1
+
+TLS_CERT="$work/cert.pem" TLS_KEY="$work/key.pem" PORT="$port" \
+  "$binary" >"$work/server.log" 2>&1 &
+pid=$!
+
+ready=0
+for _ in {1..50}; do
+  if curl -ksS --connect-timeout 1 "https://127.0.0.1:$port/healthz" \
+      >"$work/health" 2>/dev/null; then
+    ready=1
+    break
+  fi
+  if ! kill -0 "$pid" 2>/dev/null; then
+    break
+  fi
+  sleep 0.1
+done
+if [[ "$ready" -ne 1 ]]; then
+  sed -n '1,120p' "$work/server.log" >&2
+  echo "site binary did not become ready" >&2
+  exit 1
+fi
+[[ "$(<"$work/health")" == ok ]] || { echo "health response mismatch" >&2; exit 1; }
+
+status=$(curl -ksS -D "$work/headers" -o "$work/installer" \
+  -w '%{http_code}' "https://127.0.0.1:$port/install.sh")
+[[ "$status" == 503 ]] || { echo "installer endpoint returned $status" >&2; exit 1; }
+grep -Eiq '^Cache-Control:[[:space:]]*no-store' "$work/headers" || {
+  echo "installer response lacks Cache-Control: no-store" >&2
+  exit 1
+}
+grep -Fq 'exit 1' "$work/installer" || { echo "installer body does not fail closed" >&2; exit 1; }
+if grep -Eiq 'https?://|curl|wget|fetch' "$work/installer"; then
+  echo "installer body contains a network download path" >&2
+  exit 1
+fi
+
+echo "binary-smoke: health ok; installer blocked with HTTP 503"
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 6d7562a..a99aa35 100644
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -19,6 +19,13 @@ required=(
   docs/threat-model.md
   docs/content-security.md
   docs/release-evidence.md
+  docs/installer-trust.md
+  dependency-lock.tsv
+  support/fetch-locked-deps.sh
+  support/installer-release.lock
+  support/nm
+  support/verified-install.sh
+  scripts/binary-smoke.sh
   scripts/security-check.sh
   scripts/sbom.sh
   scripts/reproducibility-report.sh
@@ -34,7 +41,7 @@ if [[ "$content_only" -eq 0 ]]; then
   done
 fi
 
-content_files=(README.md SECURITY.md docs/site-sync.md docs/threat-model.md docs/content-security.md docs/release-evidence.md secure-site.ss data/jerboa-source.json Makefile tools/sync-jerboa)
+content_files=(README.md SECURITY.md docs/site-sync.md docs/threat-model.md docs/content-security.md docs/release-evidence.md docs/installer-trust.md secure-site.ss data/jerboa-source.json dependency-lock.tsv support/installer-release.lock Makefile tools/sync-jerboa)
 present_files=()
 for path in "${content_files[@]}"; do
   [[ -f "$path" ]] && present_files+=("$path")
@@ -68,6 +75,44 @@ if grep -n -E '<script|[[:space:]<]on[a-z]+=' secure-site.ss; then
   exit 1
 fi
 
+grep -q '^status=blocked-awaiting-signed-release$' support/installer-release.lock
+grep -q '^signer_public_key=UNPROVISIONED$' support/installer-release.lock
+grep -q 'SIGNER_PUBLIC_KEY=UNPROVISIONED' support/verified-install.sh
+grep -q -- '--max-redirs 0' support/verified-install.sh
+grep -q 'ssh-keygen -Y verify' support/verified-install.sh
+grep -q 'release-manifest.sha256' support/verified-install.sh
+grep -q '(status! 503)' secure-site.ss
+grep -q '(cache-control! "no-store")' secure-site.ss
+if grep -n -E 'blob/(master|main)/support/install\.sh|installer_url=|curl -fsSL https://jerboa\.sh/install\.sh[[:space:]]*\|[[:space:]]*sh' \
+    secure-site.ss README.md docs/*.md; then
+  echo "mutable or unauthenticated installer path is forbidden" >&2
+  exit 1
+fi
+sh -n support/verified-install.sh
+sh -n support/fetch-locked-deps.sh
+sh -n support/nm
+bash -n scripts/binary-smoke.sh
+grep -q 'export PATH :=.*support' Makefile
+grep -q 'llvm-nm' support/nm
+if support/verified-install.sh >/dev/null 2>&1; then
+  echo "unprovisioned installer must fail closed" >&2
+  exit 1
+fi
+awk -F '\t' '
+  NF != 4 { exit 1 }
+  $1 !~ /^jerboa(-sinatra)?$/ { exit 1 }
+  $2 !~ /^https:\/\/git\.sr\.ht\/~lisp\/jerboa(-sinatra)?$/ { exit 1 }
+  $3 !~ /^[0-9a-f]{40}$/ || $4 !~ /^[0-9a-f]{40}$/ { exit 1 }
+' dependency-lock.tsv || {
+  echo "dependency lock is malformed" >&2
+  exit 1
+}
+grep -Eq 'actions/checkout@[0-9a-f]{40}' .github/workflows/ci.yml
+if grep -n -E 'uses:.*@(v[0-9]+|main|master)|git clone|curl .*jerbuild' .github/workflows/ci.yml; then
+  echo "CI contains a mutable action, clone, or unverified toolchain download" >&2
+  exit 1
+fi
+
 if [[ "$content_only" -eq 0 ]]; then
   unignored_generated="$(
     git ls-files --others --exclude-standard -- \
diff --git a/secure-site.ss b/secure-site.ss
index 5a02808..d021869 100644
--- a/secure-site.ss
+++ b/secure-site.ss
@@ -27,28 +27,11 @@
 (define install-script
   (string-append
     "#!/bin/sh\n"
-    "# Install the base Jerboa toolchain from SourceHut release artifacts.\n"
-    "# Public entry point: https://jerboa.sh/install.sh\n"
-    "# Canonical installer source: https://git.sr.ht/~lisp/jerboa/blob/master/support/install.sh\n"
-    "\n"
     "set -eu\n"
-    "\n"
-    "installer_url=${JERBOA_INSTALLER_URL:-https://git.sr.ht/~lisp/jerboa/blob/master/support/install.sh}\n"
-    "tmp=$(mktemp \"${TMPDIR:-/tmp}/jerboa-install.XXXXXX\")\n"
-    "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM\n"
-    "\n"
-    "if command -v curl >/dev/null 2>&1; then\n"
-    "    curl -fsSL \"$installer_url\" -o \"$tmp\"\n"
-    "elif command -v fetch >/dev/null 2>&1; then\n"
-    "    fetch -q -o \"$tmp\" \"$installer_url\"\n"
-    "elif command -v wget >/dev/null 2>&1; then\n"
-    "    wget -q -O \"$tmp\" \"$installer_url\"\n"
-    "else\n"
-    "    echo \"ERROR: need curl, fetch, or wget to download Jerboa\" >&2\n"
-    "    exit 1\n"
-    "fi\n"
-    "\n"
-    "sh \"$tmp\" \"$@\"\n"))
+    "echo 'ERROR: Jerboa network installation is temporarily disabled.' >&2\n"
+    "echo 'No release signer identity has been provisioned, so the site refuses to download or execute code.' >&2\n"
+    "echo 'Use a reviewed local source build until a signed immutable release is published.' >&2\n"
+    "exit 1\n"))
 
 (define (nav-link key href label active)
   (string-append "<a class=\"nav-link"
@@ -102,7 +85,8 @@
     (string-append
       "<section class=\"content\"><h1>Get Started</h1>"
       "<p class=\"lead\">Jerboa uses one command for the language runtime, builder, packages, LSP, and service tooling.</p>"
-      "<h2>Install</h2><div class=\"code\"><pre>curl -fsSL https://jerboa.sh/install.sh | sh</pre></div>"
+      "<h2>Install</h2><div class=\"code\"><pre>Network installer disabled pending a signed immutable release.</pre></div>"
+      "<p>Until the project provisions an independently protected release signer, build from a reviewed local checkout. The install endpoint fails closed and never fetches mutable source.</p>"
       "<h2>Run a file</h2><div class=\"code\"><pre>jerboa app.ss</pre></div>"
       "<h2>Build a project</h2><div class=\"code\"><pre>jerboa jerbuild transpile src lib --force\njerboa jerbuild exec --libdirs lib app.ss\njerboa jerbuild binary --libdirs lib app.ss dist/app</pre></div>"
       "<h2>Serve HTTPS</h2><div class=\"code\"><pre>(import (std net httpsd))\n\n(httpsd-start 8443 handler \"fullchain.pem\" \"privkey.pem\")</pre></div>"
@@ -176,8 +160,9 @@
   site-css)
 
 (GET "/install.sh"
+  (status! 503)
   (content-type! "text/plain; charset=utf-8")
-  (cache-control! "no-cache")
+  (cache-control! "no-store")
   install-script)
 
 (GET "/"
diff --git a/support/fetch-locked-deps.sh b/support/fetch-locked-deps.sh
new file mode 100755
index 0000000..c423e84
--- /dev/null
+++ b/support/fetch-locked-deps.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+set -eu
+
+lock_file=${1:-dependency-lock.tsv}
+destination_root=${2:-.deps}
+
+[ "$destination_root" = .deps ] || {
+    echo "ERROR: dependency destination must be .deps" >&2
+    exit 1
+}
+[ -f "$lock_file" ] || {
+    echo "ERROR: missing dependency lock: $lock_file" >&2
+    exit 1
+}
+
+mkdir -p "$destination_root"
+tab=$(printf '\t')
+while IFS="$tab" read -r name origin commit tree extra; do
+    [ -n "$name" ] || continue
+    [ -z "${extra:-}" ] || {
+        echo "ERROR: malformed lock row for $name" >&2
+        exit 1
+    }
+    case "$name:$origin" in
+        jerboa:https://git.sr.ht/~lisp/jerboa|\
+        jerboa-sinatra:https://git.sr.ht/~lisp/jerboa-sinatra) ;;
+        *) echo "ERROR: unapproved locked dependency: $name $origin" >&2; exit 1 ;;
+    esac
+    printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$' || {
+        echo "ERROR: invalid commit for $name" >&2
+        exit 1
+    }
+    printf '%s\n' "$tree" | grep -Eq '^[0-9a-f]{40}$' || {
+        echo "ERROR: invalid tree for $name" >&2
+        exit 1
+    }
+
+    destination=$destination_root/$name
+    rm -rf "$destination"
+    mkdir -p "$destination"
+    git -C "$destination" init -q
+    git -C "$destination" remote add origin "$origin"
+    git -C "$destination" fetch -q --no-tags --depth 1 origin "$commit"
+    [ "$(git -C "$destination" rev-parse FETCH_HEAD)" = "$commit" ] || {
+        echo "ERROR: fetched commit mismatch for $name" >&2
+        exit 1
+    }
+    git -C "$destination" checkout -q --detach "$commit"
+    [ "$(git -C "$destination" rev-parse 'HEAD^{tree}')" = "$tree" ] || {
+        echo "ERROR: fetched tree mismatch for $name" >&2
+        exit 1
+    }
+    test -z "$(git -C "$destination" status --short)" || {
+        echo "ERROR: locked dependency checkout is dirty: $name" >&2
+        exit 1
+    }
+    printf 'locked_dependency=%s commit=%s tree=%s\n' "$name" "$commit" "$tree"
+done < "$lock_file"
diff --git a/support/installer-release.lock b/support/installer-release.lock
new file mode 100644
index 0000000..e5e0858
--- /dev/null
+++ b/support/installer-release.lock
@@ -0,0 +1,9 @@
+# Installer publication remains fail-closed until every blocked field is
+# replaced in one reviewed release change.
+status=blocked-awaiting-signed-release
+version=v0.2.4
+origin=https://git.sr.ht
+repository=~lisp/jerboa
+signer_identity=jerboa-release
+signing_namespace=file
+signer_public_key=UNPROVISIONED
diff --git a/support/nm b/support/nm
new file mode 100755
index 0000000..4b6df71
--- /dev/null
+++ b/support/nm
@@ -0,0 +1,48 @@
+#!/bin/sh
+# Jerbuild discovers static native symbols with a tool named `nm`. Apple's
+# system nm cannot read object code produced by newer Rust LLVM releases, so a
+# partial symbol list can otherwise yield a binary that links but has no TLS at
+# runtime. Prefer an LLVM/GNU reader and fail closed when macOS has neither.
+set -eu
+
+if [ -n "${JERBOA_LLVM_NM:-}" ]; then
+    [ -x "$JERBOA_LLVM_NM" ] || {
+        echo "configured JERBOA_LLVM_NM is not executable" >&2
+        exit 1
+    }
+    exec "$JERBOA_LLVM_NM" "$@"
+fi
+
+for candidate in \
+    /opt/homebrew/opt/llvm/bin/llvm-nm \
+    /usr/local/opt/llvm/bin/llvm-nm \
+    /opt/homebrew/opt/binutils/bin/nm \
+    /usr/local/opt/binutils/bin/nm
+do
+    if [ -x "$candidate" ]; then
+        exec "$candidate" "$@"
+    fi
+done
+
+if command -v llvm-nm >/dev/null 2>&1; then
+    exec "$(command -v llvm-nm)" "$@"
+fi
+if command -v gnm >/dev/null 2>&1; then
+    exec "$(command -v gnm)" "$@"
+fi
+
+case "$(uname -s)" in
+    Darwin)
+        echo "a modern llvm-nm or GNU nm is required for Rust static archives" >&2
+        exit 1
+        ;;
+esac
+
+for candidate in /usr/bin/nm /bin/nm; do
+    if [ -x "$candidate" ]; then
+        exec "$candidate" "$@"
+    fi
+done
+
+echo "no supported nm implementation was found" >&2
+exit 1
diff --git a/support/verified-install.sh b/support/verified-install.sh
new file mode 100755
index 0000000..6ce8880
--- /dev/null
+++ b/support/verified-install.sh
@@ -0,0 +1,128 @@
+#!/bin/sh
+# Reference installer for the future signed release endpoint. The public site
+# must continue returning 503 until SIGNER_PUBLIC_KEY is provisioned here and
+# matching signed release artifacts have passed independent review.
+set -eu
+umask 077
+
+VERSION=v0.2.4
+ORIGIN=https://git.sr.ht
+REPOSITORY=~lisp/jerboa
+SIGNER_IDENTITY=jerboa-release
+SIGNING_NAMESPACE=file
+SIGNER_PUBLIC_KEY=UNPROVISIONED
+
+error() {
+    echo "ERROR: $*" >&2
+    exit 1
+}
+
+[ "$SIGNER_PUBLIC_KEY" != UNPROVISIONED ] || \
+    error "release signer is not provisioned; network installation is disabled"
+
+command -v curl >/dev/null 2>&1 || error "curl is required"
+command -v ssh-keygen >/dev/null 2>&1 || error "ssh-keygen is required"
+command -v tar >/dev/null 2>&1 || error "tar is required"
+
+case "${JERBOA_RELEASE_TARGET:-}" in
+    "")
+        case "$(uname -s)-$(uname -m)" in
+            Darwin-arm64) target=macos-arm64 ;;
+            Linux-x86_64|Linux-amd64) target=linux-amd64 ;;
+            Linux-aarch64|Linux-arm64) target=linux-arm64 ;;
+            FreeBSD-amd64|FreeBSD-x86_64) target=freebsd-amd64 ;;
+            *) error "unsupported platform" ;;
+        esac
+        ;;
+    macos-arm64|linux-amd64|linux-arm64|freebsd-amd64)
+        target=$JERBOA_RELEASE_TARGET
+        ;;
+    *) error "unsupported release target" ;;
+esac
+
+if [ -n "${JERBOA_INSTALL_DIR:-}" ]; then
+    bindir=$JERBOA_INSTALL_DIR
+elif [ -n "${HOME:-}" ]; then
+    bindir=$HOME/.local/bin
+else
+    error "HOME is unset; set JERBOA_INSTALL_DIR"
+fi
+
+base=$ORIGIN/$REPOSITORY/refs/download/$VERSION
+archive_name=jerboa-$VERSION-$target.tar.gz
+manifest_name=release-manifest.sha256
+signature_name=$manifest_name.sig
+
+tmp_parent=${TMPDIR:-/tmp}
+[ -d "$tmp_parent" ] && [ -w "$tmp_parent" ] || error "temporary directory is unavailable"
+tmp=$(mktemp -d "$tmp_parent/jerboa-install.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+
+download() {
+    # Redirects are deliberately disabled: every URL stays on the pinned
+    # HTTPS origin and immutable version path.
+    curl -fSs --proto '=https' --tlsv1.2 --max-redirs 0 "$base/$1" -o "$tmp/$1"
+}
+
+download "$manifest_name"
+download "$signature_name"
+download "$archive_name"
+
+printf '%s %s\n' "$SIGNER_IDENTITY" "$SIGNER_PUBLIC_KEY" > "$tmp/allowed_signers"
+chmod 0600 "$tmp/allowed_signers"
+ssh-keygen -Y verify \
+    -f "$tmp/allowed_signers" \
+    -I "$SIGNER_IDENTITY" \
+    -n "$SIGNING_NAMESPACE" \
+    -s "$tmp/$signature_name" < "$tmp/$manifest_name" >/dev/null 2>&1 || \
+    error "release manifest signature verification failed"
+
+expected=$(awk -v file="$archive_name" '
+    $2 == file {
+        if (found) exit 2
+        value = $1
+        found = 1
+    }
+    END {
+        if (!found) exit 1
+        print value
+    }
+' "$tmp/$manifest_name") || error "manifest has no unique archive entry"
+
+printf '%s\n' "$expected" | grep -Eq '^[0-9a-f]{64}$' || \
+    error "manifest archive digest is not lowercase SHA-256"
+
+if command -v sha256sum >/dev/null 2>&1; then
+    actual=$(sha256sum "$tmp/$archive_name" | awk '{print $1}')
+elif command -v shasum >/dev/null 2>&1; then
+    actual=$(shasum -a 256 "$tmp/$archive_name" | awk '{print $1}')
+elif command -v sha256 >/dev/null 2>&1; then
+    actual=$(sha256 -q "$tmp/$archive_name")
+else
+    error "a SHA-256 utility is required"
+fi
+[ "$actual" = "$expected" ] || error "signed-manifest archive digest mismatch"
+
+root=jerboa-$VERSION-$target
+tar -tzf "$tmp/$archive_name" | awk -v root="$root/" '
+    index($0, "/") == 1 || $0 ~ /(^|\/)\.\.($|\/)/ || index($0, root) != 1 { exit 1 }
+' || error "archive contains an unsafe path"
+
+# Only ordinary files and directories are allowed. Rejecting links and special
+# nodes prevents a later member from escaping through a path created by an
+# earlier member during extraction.
+tar -tvzf "$tmp/$archive_name" | awk '
+    substr($0, 1, 1) != "-" && substr($0, 1, 1) != "d" { exit 1 }
+' || error "archive contains links or special files"
+
+mkdir "$tmp/extract"
+tar -xzf "$tmp/$archive_name" -C "$tmp/extract"
+[ -x "$tmp/extract/$root/bin/jerboa" ] || error "archive lacks bin/jerboa"
+
+mkdir -p "$bindir"
+install -m 0755 "$tmp/extract/$root/bin/jerboa" "$bindir/jerboa"
+for command in jmcp jlsp jerbuild jpkg; do
+    ln -sf jerboa "$bindir/$command"
+done
+
+echo "Installed signed Jerboa $VERSION for $target into $bindir"