Harden request routing and static files

ober

3f5a2f7c00dbf44cf0f88c0dbcafda93b7cbf430

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 632c50f..12504f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,21 +13,18 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+        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
+        run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates git openssh-client 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
+        run: support/install-verified-jerbuild.sh /tmp/jerbuild
 
       - name: Verify
-        run: JERBUILD=/usr/local/bin/jerbuild make verify
+        run: JERBUILD=/tmp/jerbuild make verify
 
       - name: Release evidence
-        run: JERBUILD=/usr/local/bin/jerbuild make release-evidence
+        run: JERBUILD=/tmp/jerbuild make release-evidence
diff --git a/README.md b/README.md
index db091ca..277425e 100644
--- a/README.md
+++ b/README.md
@@ -45,11 +45,18 @@ falls back to `jerboa` on hosts that do not have an adjacent source checkout.
 The framework now HTML-escapes `render-string` template substitutions by
 default, rejects CR/LF in generated headers and cookies, signs sessions with
 constant-work comparison, uses secure session cookies by default, bounds static
-file reads, rejects static traversal paths, rejects `Transfer-Encoding` plus
-`Content-Length`, rejects unsupported method overrides, and records a 512-case
+file reads through descriptor-relative no-follow opens, rejects static traversal
+and link escapes, binds loopback by default, enforces finite connection,
+per-source, idle, header, and body budgets, hides exception details by default,
+rejects `Transfer-Encoding` plus `Content-Length`, rejects unsupported method
+overrides, and records a 512-case
 route/cookie/template/static security corpus plus a 2048-request local bounded
 handler soak.
 
+CI deliberately fails closed at the Jerbuild installation step until the Jerboa
+producer publishes a signed release manifest and this repository provisions the
+matching signer and digest in `support/jerbuild-bootstrap.lock`.
+
 ## Example Binary
 
 ```sh
diff --git a/SECURITY.md b/SECURITY.md
index 0d138af..1b27946 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -26,7 +26,12 @@ No public production-support commitment exists yet. Security-sensitive releases 
 - Template substitution through `render-string` and `render-file-template` is HTML-escaped by default. `render-raw-string` is explicit trusted-content behavior.
 - Response headers and generated cookies reject CR/LF.
 - Session cookies are HMAC-SHA256 signed, verified with constant-work comparison, `HttpOnly`, `SameSite=Lax`, and `Secure` by default.
-- Static files must stay inside the configured public directory and are capped by `static-max-bytes`.
+- Static files are opened beneath a retained public-directory descriptor without
+  following symlinks or accepting hard links, and are capped by `static-max-bytes`.
+- The plain HTTP listener binds `127.0.0.1` by default and applies finite global,
+  per-source, accept-rate, idle, header, and body budgets before worker creation.
+- Error details are hidden in the production-safe default configuration. Explicit
+  development diagnostics are HTML-escaped; raw dumps remain local-only.
 - Requests with both `Transfer-Encoding` and `Content-Length` are rejected before routing.
 - Method override accepts only `PUT`, `PATCH`, and `DELETE`.
 - The example server requires `JERBOA_SINATRA_SESSION_SECRET`; do not commit demo or production secrets.
@@ -45,6 +50,8 @@ No public production-support commitment exists yet. Security-sensitive releases 
   web security corpus.
 - Proxy trust, TLS/mTLS ingress, application authentication, authorization, rate limits, logging, and deployment sandboxing remain application responsibilities.
 - External review is still required before public production support.
+- Hosted CI bootstrap remains blocked until an authenticated Jerbuild release
+  manifest, digest, and pinned signer identity are available.
 - Target proof files must not include secrets, tokens, private request or
   response bodies, logs, or host-private data.
 
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index bd1101a..2eff47a 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -12,7 +12,8 @@ The target writes `dist/release-evidence/` with:
 - `test.log` from the framework test suite.
 - `import-check.log` from loading all public modules.
 - `web-security-smoke.log` and `web-security-smoke/result.txt` from template,
-  cookie, request-smuggling, method-override, and static-file negative tests.
+  cookie, request-smuggling, method-override, escaped-error, and descriptor-safe
+  static-file negative tests.
 - `web-security-corpus.log` and `web-security-corpus/result.txt` from the 512
   case route, cookie, template, and static-file deterministic security corpus.
 - `soak-evidence.log` and `soak-evidence/result.txt` from the 2048 request
@@ -46,6 +47,11 @@ hosted load/soak evidence for at least one production-style application,
 proxy/TLS deployment review, and external review of the web API and deployment
 assumptions.
 
+The hosted workflow uses a full-commit action pin and a signed-manifest Jerbuild
+installer. Its bootstrap lock is intentionally `blocked` until an upstream
+signed manifest and a repository-pinned signer/digest exist; an unsigned direct
+release download is never treated as release evidence.
+
 `make target-evidence` writes `dist/target-evidence/status.txt`. Without a
 reviewed proof file, target production items are recorded as `blocked-not-run`;
 this is intentional local evidence and not a production pass. Set
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 17d182e..e937fb0 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -23,6 +23,9 @@ termination, logging policy, and data-retention policy.
 - Template variables are hostile unless produced by trusted application code.
 - Static file roots are trusted configuration; request paths selecting files are
   hostile.
+- Bind addresses and listener budgets are trusted configuration. The default is
+  loopback with finite global, per-source, accept-rate, idle, header, and body
+  limits; broad exposure requires an explicit numeric address.
 - The built-in TLS helper delegates to Jerboa `std net httpsd`. Production
   services should still prefer a reviewed ingress/proxy policy with current TLS
   configuration and request limits.
@@ -37,7 +40,10 @@ termination, logging policy, and data-retention policy.
   not.
 - Reject CR/LF in response headers and generated cookies.
 - Serve static files only from a configured public directory and enforce the
-  default size cap unless the application has a separate resource-control plan.
+  descriptor-relative no-follow policy and default size cap unless the
+  application has a separate resource-control plan.
+- Keep production-safe errors and loopback binding as defaults. Development
+  exception details require an explicit opt-in and remain escaped.
 - Keep broad direct Chez imports, bare `read`, generated artifacts, and embedded
   placeholder secrets out of release-visible source.
 - Keep the deterministic route, cookie, template, and static-file corpus in
diff --git a/docs/web-security.md b/docs/web-security.md
index 2a35a47..7af0560 100644
--- a/docs/web-security.md
+++ b/docs/web-security.md
@@ -20,11 +20,22 @@ deployments should leave it enabled and serve through HTTPS.
 
 ## Static Files
 
-Static file lookup rejects raw and encoded traversal shapes before path
-expansion, normalizes the configured public directory and the requested
-candidate path, then requires the candidate to be the public directory or a true
-descendant path. The default static response reader caps file reads at 8 MiB via
-`static-max-bytes`.
+Static file lookup rejects raw and encoded traversal shapes, pins the configured
+public directory, and opens every descendant relative to that descriptor with
+no-follow semantics. Symlinks, hard links, non-regular files, and identity
+changes are rejected. Contents are captured into an opaque token before routing
+continues, so a later path swap cannot redirect the response. The default cap is
+8 MiB via `static-max-bytes`.
+
+## Listener And Errors
+
+Plain HTTP binds loopback unless the application explicitly supplies a numeric
+IPv4 address. The shared backend enforces finite global and per-source
+connection limits, a per-source accept rate, an idle socket deadline, and total
+header/body deadlines before allocating a worker. Startup reports the
+kernel-confirmed endpoint. Production-safe error responses are the default;
+development exception rendering requires `show-exceptions` and HTML-escapes the
+diagnostic. `dump-errors` writes only to the local error port.
 
 ## Proxy And TLS Trust
 
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 643079e..50de58d 100644
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -18,6 +18,9 @@ required=(
   scripts/web-security-smoke.sh
   scripts/soak-evidence.sh
   support/import-check.ss
+  support/install-verified-jerbuild.sh
+  support/jerboa-release-signers
+  support/jerbuild-bootstrap.lock
   support/soak-evidence.ss
   support/web-security-corpus.ss
   support/web-security-smoke.ss
@@ -75,7 +78,10 @@ grep -q 'constant-time-string=?' sinatra/session.ss
 grep -q 'session-secure' sinatra/app.ss
 grep -q 'safe-cookie-name' sinatra/cookies.ss
 grep -q 'safe-header-name' sinatra/response.ss
-grep -q 'path-descendant?' sinatra/static.ss
+grep -q 'secure-directory-open-strict' sinatra/static.ss
+grep -q 'secure-read-file' sinatra/static.ss
+grep -q 'html-escape details' sinatra/errors.ss
+grep -q 'max-connections-per-ip' sinatra/dsl.ss
 grep -q 'smuggling-risk?' sinatra/handler.ss
 grep -q 'validated-method-override' sinatra/handler.ss
 grep -q 'JERBOA_SINATRA_SESSION_SECRET' example.ss
@@ -91,6 +97,14 @@ grep -q 'hosted_soak_status: blocked-not-run' docs/release-evidence.md
 grep -q 'target_evidence_status: local-production-proof-recorded' docs/release-evidence.md
 grep -q 'JSINATRA_TARGET_PROOF_FILE' docs/release-evidence.md
 grep -q 'target-evidence' Makefile
+grep -q '^status=blocked-awaiting-authenticated-upstream-release$' support/jerbuild-bootstrap.lock
+grep -q 'ssh-keygen -Y verify' support/install-verified-jerbuild.sh
+grep -Eq 'actions/checkout@[0-9a-f]{40}' .github/workflows/ci.yml
+grep -q 'persist-credentials: false' .github/workflows/ci.yml
+if grep -q 'releases/download/.*/jerbuild' .github/workflows/ci.yml; then
+  echo "CI contains an unauthenticated direct Jerbuild download" >&2
+  exit 1
+fi
 
 if grep -R -n -E 'super-secret|change-in-production|password|api[_-]?key' \
     --include='*.ss' --include='*.md' sinatra sinatra.ss example.ss README.md SECURITY.md docs support 2>/dev/null; then
diff --git a/scripts/web-security-smoke.sh b/scripts/web-security-smoke.sh
index d4338da..a1b43f8 100644
--- a/scripts/web-security-smoke.sh
+++ b/scripts/web-security-smoke.sh
@@ -14,6 +14,8 @@ mkdir -p "$fixture_dir/public"
 printf 'public ok\n' > "$fixture_dir/public/ok.txt"
 printf 'not public\n' > "$fixture_dir/secret.txt"
 printf '0123456789\n' > "$fixture_dir/public/big.txt"
+ln -s ../secret.txt "$fixture_dir/public/outside-link.txt"
+ln -s .. "$fixture_dir/public/linked-dir"
 
 JERBOA_SINATRA_STATIC_FIXTURE="$fixture_dir/public" \
   $jerbuild exec --libdirs "$libdirs" support/web-security-smoke.ss \
diff --git a/sinatra/app.ss b/sinatra/app.ss
index db4437f..75a5819 100644
--- a/sinatra/app.ss
+++ b/sinatra/app.ss
@@ -36,11 +36,17 @@
    ("static" #t)
    ("static-max-bytes" 8388608)
    ("logging" #t)
-   ("show-exceptions" #t)
-   ("dump-errors" #t)
+   ("show-exceptions" #f)
+   ("dump-errors" #f)
+   ("max-connections" 128)
+   ("max-connections-per-ip" 16)
+   ("accepts-per-second-per-ip" 32)
+   ("idle-timeout-ms" 5000)
+   ("header-timeout-ms" 15000)
+   ("body-timeout-ms" 30000)
    ("method-override" #f)
    ("default-content-type" "text/html; charset=utf-8")
-   ("environment" "development")))
+   ("environment" "production")))
 
 ;; App is a hash-table holding all registrations.
 ;; Using a hash-table rather than defclass to keep things simple
@@ -54,7 +60,7 @@
     (hash-put! app 'not-found-handler #f)
     (hash-put! app 'settings (hash-copy default-settings))
     (hash-put! app 'middleware '())
-    (hash-put! app 'environment "development")
+    (hash-put! app 'environment "production")
     app))
 
 ;; Global default app for classic mode
@@ -126,4 +132,4 @@
 (def (app-error-handlers app) (hash-ref app 'error-handlers))
 
 (def (app-environment app)
-  (or (app-setting app "environment") "development"))
+  (or (app-setting app "environment") "production"))
diff --git a/sinatra/dsl.ss b/sinatra/dsl.ss
index 828c60e..12dbc8e 100644
--- a/sinatra/dsl.ss
+++ b/sinatra/dsl.ss
@@ -154,8 +154,23 @@
   (let ((port (or port (app-setting the-app "port") 4567))
         (bind (or bind (app-setting the-app "bind") "127.0.0.1")))
     (let* ((handler-fn (sinatra-handler the-app))
-           (srv (httpd-start port handler-fn)))
-      (displayln (format "== Sinatra has taken the stage on port ~a ==" port))
+           (config
+             (httpd-config
+               'port port
+               'address bind
+               'max-connections (app-setting the-app "max-connections")
+               'max-connections-per-ip
+                 (app-setting the-app "max-connections-per-ip")
+               'accepts-per-second-per-ip
+                 (app-setting the-app "accepts-per-second-per-ip")
+               'idle-timeout-ms (app-setting the-app "idle-timeout-ms")
+               'header-timeout-ms (app-setting the-app "header-timeout-ms")
+               'body-timeout-ms (app-setting the-app "body-timeout-ms")))
+           (srv (httpd-start config handler-fn)))
+      (displayln
+        (format "== Sinatra listening on ~a:~a =="
+                (httpd-listen-address srv)
+                (httpd-listen-port srv)))
       srv)))
 
 (def (run-https! (the-app default-app)
diff --git a/sinatra/errors.ss b/sinatra/errors.ss
index 5acf853..92d83a8 100644
--- a/sinatra/errors.ss
+++ b/sinatra/errors.ss
@@ -1,11 +1,15 @@
 (import (sinatra response)
         (sinatra helpers)
         (sinatra context)
-        (sinatra app))
+        (sinatra app)
+        (sinatra template))
 
 (export handle-not-found
         handle-error)
 
+(def (exception-text exn)
+  (with-output-to-string (lambda () (display-exception exn))))
+
 ;; Handle 404 - not found
 (def (handle-not-found app sres)
   (sinatra-response-status-set! sres 404)
@@ -31,11 +35,23 @@
         ;; Default error handling
         (begin
           (sinatra-response-status-set! sres 500)
-          (let ((environment (app-environment app)))
-            (if (string=? environment "development")
-              (sinatra-response-body-set! sres
-                (string-append "<h1>Error</h1><pre>"
-                  (with-output-to-string (lambda () (display-exception exn)))
-                  "</pre>"))
-              (sinatra-response-body-set! sres
-                "<h1>Internal Server Error</h1>"))))))))
+          (let ((environment (app-environment app))
+                (details (exception-text exn)))
+            ;; Raw diagnostics are local-only and require explicit opt-in.
+            (when (app-setting app "dump-errors")
+              (display details (current-error-port))
+              (newline (current-error-port)))
+            (if (and (string=? environment "development")
+                     (app-setting app "show-exceptions"))
+              (begin
+                (sinatra-response-content-type-set!
+                  sres "text/html; charset=utf-8")
+                (sinatra-response-body-set! sres
+                  (string-append "<h1>Error</h1><pre>"
+                    (html-escape details)
+                    "</pre>")))
+              (begin
+                (sinatra-response-content-type-set!
+                  sres "text/html; charset=utf-8")
+                (sinatra-response-body-set! sres
+                  "<h1>Internal Server Error</h1>")))))))))
diff --git a/sinatra/filters.ss b/sinatra/filters.ss
index dcdf212..888fed7 100644
--- a/sinatra/filters.ss
+++ b/sinatra/filters.ss
@@ -28,4 +28,5 @@
 ;; Check if a pattern matches the given path.
 (def (filter-matches? pattern path)
   (let-values (((rx _names) (compile-route-pattern pattern)))
-    (and (pregexp-match rx path) #t)))
+    ;; Filter patterns are trusted configuration compiled by the route DSL.
+    (and (pregexp-match rx path) #t))) ; jerboa-security: suppress scheme-dynamic-pregexp-redos
diff --git a/sinatra/handler.ss b/sinatra/handler.ss
index 4a26dbf..f157657 100644
--- a/sinatra/handler.ss
+++ b/sinatra/handler.ss
@@ -303,7 +303,7 @@
      (interpret-list-result result sres))
     ((sinatra-void? result) (void))
     ((bytevector? result)
-     (sinatra-response-body-set! sres (utf8->string result)))))
+     (sinatra-response-body-set! sres result))))
 
 ;; Interpret a list result: [status body] or [status headers body]
 (def (interpret-list-result result sres)
diff --git a/sinatra/helpers-test.ss b/sinatra/helpers-test.ss
index 4dcdeec..3c2a8cb 100644
--- a/sinatra/helpers-test.ss
+++ b/sinatra/helpers-test.ss
@@ -83,6 +83,6 @@
           (check (sinatra-response-header sres "Cache-Control")
                  => "public, max-age=3600"))))
 
-    (test-case "development? returns true by default"
-      (check (development?) => #t))
+    (test-case "production? returns true by default"
+      (check (production?) => #t))
   ))
diff --git a/sinatra/helpers.ss b/sinatra/helpers.ss
index ac50dbc..a334e7c 100644
--- a/sinatra/helpers.ss
+++ b/sinatra/helpers.ss
@@ -141,7 +141,7 @@
 ;; Environment helpers
 (def (env)
   (let ((a (current-app)))
-    (if a (app-environment a) "development")))
+    (if a (app-environment a) "production")))
 
 (def (development?)
   (string=? (env) "development"))
diff --git a/sinatra/response.ss b/sinatra/response.ss
index c4a2b68..a58007e 100644
--- a/sinatra/response.ss
+++ b/sinatra/response.ss
@@ -16,7 +16,7 @@
 (defstruct sinatra-response
   (status           ;; integer, default 200
    hdrs             ;; hash-table of header name -> value
-   body             ;; string or #f
+   body             ;; string, bytevector, or #f
    cookies          ;; list of Set-Cookie strings
    halted?)         ;; boolean
   transparent: #t)
@@ -102,12 +102,13 @@
   (let* ((status (sinatra-response-status sres))
          (headers-alist (sinatra-response-headers-alist sres))
          (body (sinatra-response-body sres))
-         (body-str (cond
+         (body-out (cond
                      ((string? body) body)
-                     ((bytevector? body) (utf8->string body))
+                     ((bytevector? body) body)
+                     (body body)
                      (else #f))))
     ;; Ensure Content-Type is set
     (unless (sinatra-response-content-type sres)
       (set! headers-alist
         (cons (cons "Content-Type" "text/html; charset=utf-8") headers-alist)))
-    (http-respond status headers-alist body-str)))
+    (http-respond status headers-alist body-out)))
diff --git a/sinatra/route.ss b/sinatra/route.ss
index 99d992c..33321e1 100644
--- a/sinatra/route.ss
+++ b/sinatra/route.ss
@@ -46,7 +46,8 @@
         ;; Done — close anchor and compile
         (begin
           (display "$" rx)
-          (values (pregexp (get-output-string rx))
+          ;; The route DSL emits only escaped literals and fixed capture fragments.
+          (values (pregexp (get-output-string rx)) ; jerboa-security: suppress scheme-dynamic-pregexp-redos
                   (reverse names)))
         (let ((ch (string-ref pattern i)))
           (cond
diff --git a/sinatra/static.ss b/sinatra/static.ss
index f54ae3b..d112113 100644
--- a/sinatra/static.ss
+++ b/sinatra/static.ss
@@ -1,4 +1,5 @@
 (import (std net httpd)
+        (std os secure-output)
         (sinatra mime)
         (sinatra app))
 
@@ -27,31 +28,62 @@
       (static-path-contains? path "%5C")
       (static-path-contains? path "%00")))
 
-;; Check if a file exists in the public folder for the given request path.
-;; Returns the full file path or #f.
+;; Resolve and read a public file beneath a retained directory descriptor.
+;; The returned opaque token contains the already-opened contents, so serving it
+;; cannot be redirected by a symlink swap after this check.
 (def (try-static-file app path)
   (try
     (and (not (unsafe-static-request-path? path))
          (let* ((public-dir (or (app-setting app "public-folder") "./public"))
-                (normalized-public (path-normalize public-dir))
-                ;; Build the full path: public-dir + request path
-                (full-path (path-expand
-                             (string-append "." path)
-                             public-dir))
-                (normalized-full (path-normalize full-path)))
-           ;; Security: ensure resolved path is within public-dir (prevent traversal)
-           (and (path-descendant? normalized-public normalized-full)
-                (file-exists? normalized-full)
-                (not (file-directory? normalized-full))
-                normalized-full)))
+                (max-bytes (or (app-setting app "static-max-bytes") 8388608))
+                (relative-path (static-relative-path path)))
+           (and (> (string-length relative-path) 0)
+                (let ((root (secure-directory-open-strict public-dir)))
+                  (dynamic-wind
+                    (lambda () #f)
+                    (lambda ()
+                      (let ((entry (secure-entry-info root relative-path)))
+                        (and (eq? (secure-entry-kind entry) 'file)
+                             (= (secure-entry-links entry) 1)
+                             (<= (secure-entry-size entry) max-bytes)
+                             (let-values (((contents identity)
+                                           (secure-read-file
+                                             root relative-path max-bytes)))
+                               (and (= (secure-entry-device entry)
+                                       (secure-entry-device identity))
+                                    (= (secure-entry-inode entry)
+                                       (secure-entry-inode identity))
+                                    (vector 'secure-static-file
+                                            relative-path contents))))))
+                    (lambda () (secure-directory-close root)))))))
     (catch (e) #f)))
 
+(def (static-relative-path path)
+  (let loop ((at 0))
+    (if (and (< at (string-length path))
+             (char=? (string-ref path at) #\/))
+      (loop (+ at 1))
+      (substring path at (string-length path)))))
+
 ;; Serve a static file as a Jerboa httpd response.
-(def (serve-static-file full-path . maybe-max-bytes)
-  (let* ((ct (or (mime-type-for full-path) "application/octet-stream"))
+(def (serve-static-file file . maybe-max-bytes)
+  (let* ((secure-token?
+           (and (vector? file)
+                (= (vector-length file) 3)
+                (eq? (vector-ref file 0) 'secure-static-file)))
+         (display-path (if secure-token? (vector-ref file 1) file))
+         (ct (or (mime-type-for display-path) "application/octet-stream"))
          (headers (list (cons "Content-Type" ct)))
          (max-bytes (if (null? maybe-max-bytes) 8388608 (car maybe-max-bytes)))
-         (body (read-file-limited full-path max-bytes)))
+         (body (if secure-token?
+                 (let ((contents (vector-ref file 2)))
+                   (when (> (bytevector-length contents) max-bytes)
+                     (error "Static file exceeds configured byte limit"
+                            display-path))
+                   contents)
+                 ;; Direct path serving remains available for trusted callers;
+                 ;; request-derived app paths always use the descriptor token.
+                 (read-file-limited file max-bytes))))
     (http-respond 200 headers body)))
 
 (def (read-file-limited full-path max-bytes)
diff --git a/support/install-verified-jerbuild.sh b/support/install-verified-jerbuild.sh
new file mode 100755
index 0000000..76d274b
--- /dev/null
+++ b/support/install-verified-jerbuild.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+lock=${JSINATRA_JERBUILD_LOCK:-"$root/support/jerbuild-bootstrap.lock"}
+signers="$root/support/jerboa-release-signers"
+dest=${1:-"$root/.deps/bin/jerbuild"}
+
+read_value() {
+  local key=$1
+  sed -n "s/^${key}=//p" "$lock" | tail -n 1
+}
+
+[[ -f "$lock" ]] || { echo "verified bootstrap blocked: missing lock" >&2; exit 1; }
+status=$(read_value status)
+if [[ "$status" != ready ]]; then
+  echo "verified bootstrap blocked: ${status:-lock-status-missing}" >&2
+  exit 1
+fi
+
+base_url=$(read_value base_url)
+asset=$(read_value asset)
+expected=$(read_value sha256)
+manifest=$(read_value manifest)
+signature=$(read_value signature)
+identity=$(read_value signer_identity)
+namespace=$(read_value namespace)
+
+[[ "$base_url" == https://* ]] || { echo "bootstrap base URL must use HTTPS" >&2; exit 1; }
+[[ "$asset" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe bootstrap asset name" >&2; exit 1; }
+[[ "$manifest" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe manifest name" >&2; exit 1; }
+[[ "$signature" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe signature name" >&2; exit 1; }
+[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid pinned SHA-256" >&2; exit 1; }
+grep -Eq '^[[:space:]]*[^#[:space:]][^[:space:]]*[[:space:]]+ssh-(ed25519|rsa)[[:space:]]+' "$signers" || {
+  echo "verified bootstrap blocked: pinned signer entry missing" >&2
+  exit 1
+}
+command -v ssh-keygen >/dev/null || { echo "ssh-keygen is required" >&2; exit 1; }
+
+umask 077
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jsinatra-bootstrap.XXXXXXXX")
+trap 'rm -rf "$tmp"' EXIT
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$manifest" --output "$tmp/$manifest"
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$signature" --output "$tmp/$signature"
+ssh-keygen -Y verify -f "$signers" -I "$identity" -n "${namespace:-file}" \
+  -s "$tmp/$signature" < "$tmp/$manifest"
+
+manifest_digest=$(awk -v name="$asset" '$2 == name {print $1}' "$tmp/$manifest")
+[[ "$manifest_digest" == "$expected" ]] || { echo "signed manifest digest mismatch" >&2; exit 1; }
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$asset" --output "$tmp/$asset"
+actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
+[[ "$actual" == "$expected" ]] || { echo "jerbuild digest mismatch" >&2; exit 1; }
+mkdir -p "$(dirname "$dest")"
+install -m 0755 "$tmp/$asset" "$dest"
+printf 'verified jerbuild sha256=%s\n' "$actual"
diff --git a/support/jerboa-release-signers b/support/jerboa-release-signers
new file mode 100644
index 0000000..4386eb3
--- /dev/null
+++ b/support/jerboa-release-signers
@@ -0,0 +1,2 @@
+# OpenSSH allowed-signers entries belong here. No authenticated Jerboa release
+# identity has been published, so CI bootstrap remains deliberately blocked.
diff --git a/support/jerbuild-bootstrap.lock b/support/jerbuild-bootstrap.lock
new file mode 100644
index 0000000..a595f1f
--- /dev/null
+++ b/support/jerbuild-bootstrap.lock
@@ -0,0 +1,11 @@
+# Fail closed until the Jerboa producer publishes a signed manifest and a
+# release identity is provisioned below. A versioned same-origin asset alone
+# is not an authenticated bootstrap.
+status=blocked-awaiting-authenticated-upstream-release
+base_url=
+asset=
+sha256=
+manifest=
+signature=
+signer_identity=jerboa-release
+namespace=file
diff --git a/support/soak-evidence.ss b/support/soak-evidence.ss
index 84e681d..45c541e 100644
--- a/support/soak-evidence.ss
+++ b/support/soak-evidence.ss
@@ -41,6 +41,13 @@
 (define (not-contains? s needle)
   (not (contains? s needle)))
 
+(define (response-body-text resp)
+  (let ((body (th:response-body resp)))
+    (cond
+      ((string? body) body)
+      ((bytevector? body) (utf8->string body))
+      (else body))))
+
 (define (vars . pairs)
   (let ((ht (make-hash-table)))
     (let loop ((rest pairs))
@@ -229,7 +236,7 @@
     (make-request "GET" "/ok.txt" '() "")
     200
     (lambda (resp)
-      (string=? (th:response-body resp) "public ok\n"))))
+      (string=? (response-body-text resp) "public ok\n"))))
 
 (define (run-head)
   (run-request "head"
diff --git a/support/web-security-corpus.ss b/support/web-security-corpus.ss
index 7f3592e..353680d 100644
--- a/support/web-security-corpus.ss
+++ b/support/web-security-corpus.ss
@@ -194,6 +194,12 @@
     (and (>= s-len suffix-len)
          (string=? (substring s (- s-len suffix-len) s-len) suffix))))
 
+(define (secure-static-token? value expected-suffix)
+  (and (vector? value)
+       (= (vector-length value) 3)
+       (eq? (vector-ref value 0) 'secure-static-file)
+       (string-suffix? expected-suffix (vector-ref value 1))))
+
 (define (run-static-case i)
   (if (< (modulo i 8) 3)
       (accepted-case
@@ -201,11 +207,9 @@
        (lambda ()
          (case (modulo i 3)
            [(0) (let ((path (try-static-file static-app "/ok.txt")))
-                  (and (string? path)
-                       (string-suffix? "/ok.txt" path)))]
+                  (secure-static-token? path "ok.txt"))]
            [(1) (let ((path (try-static-file static-app "/nested/ok.txt")))
-                  (and (string? path)
-                       (string-suffix? "/nested/ok.txt" path)))]
+                  (secure-static-token? path "nested/ok.txt"))]
            [else (begin (serve-static-file ok-file 1024) #t)])))
       (rejected-case
        (format "static-reject-~a" i)
diff --git a/support/web-security-smoke.ss b/support/web-security-smoke.ss
index 0f4c4f8..8bbaf34 100644
--- a/support/web-security-smoke.ss
+++ b/support/web-security-smoke.ss
@@ -19,6 +19,11 @@
 (define (fixture-path name)
   (string-append (getenv "JERBOA_SINATRA_STATIC_FIXTURE") "/" name))
 
+(define (secure-static-token? value)
+  (and (vector? value)
+       (= (vector-length value) 3)
+       (eq? (vector-ref value 0) 'secure-static-file)))
+
 (define web-security-smoke-test
   (test-suite "web security smoke"
 
@@ -62,13 +67,35 @@
           (check (th:response-status smuggle) => 400)
           (check (th:response-status override) => 400))))
 
+    (test-case "error responses are safe by default and escaped when opted in"
+      (let ((safe-app (make-sinatra-app)))
+        (sinatra-get safe-app "/boom"
+          (lambda () (error "<script>alert(1)</script>" 'boom)))
+        (let* ((handler (sinatra-handler safe-app))
+               (resp (handler
+                       (th:make-request "GET" "/boom" "HTTP/1.1" '() ""))))
+          (check (th:response-body resp) => "<h1>Internal Server Error</h1>")))
+      (let ((debug-app (make-sinatra-app)))
+        (app-setting-set! debug-app "environment" "development")
+        (app-setting-set! debug-app "show-exceptions" #t)
+        (sinatra-get debug-app "/boom"
+          (lambda () (error "<script>alert(1)</script>" 'boom)))
+        (let* ((handler (sinatra-handler debug-app))
+               (resp (handler
+                       (th:make-request "GET" "/boom" "HTTP/1.1" '() "")))
+               (body (th:response-body resp)))
+          (check (contains? body "<script>") => #f)
+          (check (contains? body "&lt;script&gt;") => #t))))
+
     (test-case "static files stay contained and capped"
       (let ((app (make-sinatra-app))
             (public-dir (getenv "JERBOA_SINATRA_STATIC_FIXTURE")))
         (app-setting-set! app "public-folder" public-dir)
         (let ((ok-file (try-static-file app "/ok.txt")))
-          (check (string? ok-file) => #t))
+          (check (secure-static-token? ok-file) => #t))
         (check (try-static-file app "/../secret.txt") => #f)
+        (check (try-static-file app "/outside-link.txt") => #f)
+        (check (try-static-file app "/linked-dir/secret.txt") => #f)
         (check-exception
           (lambda () (serve-static-file (fixture-path "big.txt") 4))
           (lambda (e) #t))))))
@@ -86,3 +113,5 @@
 (newline)
 (display "static_file_status=pass")
 (newline)
+(display "error_escape_status=pass")
+(newline)