Harden in-place edits and native loading

ober

070e094bbb1a08a92e73aae072d9df954f58489f

diff --git a/.build.yml b/.build.yml
index 0fce6e2..f079bf3 100644
--- a/.build.yml
+++ b/.build.yml
@@ -3,14 +3,13 @@ packages:
   - build-essential
   - ca-certificates
   - curl
+  - openssh-client
   - ripgrep
   - libpcre2-dev
-environment:
-  JERBOA_VERSION: v0.2.3
 tasks:
   - install-jerbuild: |
-      curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o "$HOME/jerbuild"
-      chmod +x "$HOME/jerbuild"
+      cd jerboa-sed
+      support/install-verified-jerbuild.sh "$HOME/jerbuild"
   - verify: |
       cd jerboa-sed
       JERBUILD="$HOME/jerbuild" make verify
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ad013e..006ad98 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,18 +13,13 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install build tools
-        run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates ripgrep libpcre2-dev
+        run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates openssh-client ripgrep libpcre2-dev
 
       - 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 /usr/local/bin/jerbuild
 
       - name: Verify
         run: make verify
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 b/.jerbuild
index 129482a..3d071f6 100644
--- a/.jerbuild
+++ b/.jerbuild
@@ -8,3 +8,5 @@
 (entry "main.ss")
 (output "jsed")
 (libdirs "lib")
+(extra-sources "support/secure-inplace.c")
+(ffi-symbols "support/ffi-symbols.list")
diff --git a/Makefile b/Makefile
index 9eba65b..6594150 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ SBOM_DIR ?= dist/sbom
 REPRO_DIR ?= dist/reproducibility
 TARGET_EVIDENCE_DIR ?= dist/target-evidence
 
-.PHONY: all build binary run test test-parser parser-corpus test-cli-policy import-check clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean install
+.PHONY: all build binary run test test-parser test-security parser-corpus test-cli-policy import-check clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean install
 
 all: binary
 
@@ -57,7 +57,11 @@ test-cli-policy: binary
 	@echo "--- script file I/O opt-in ---"; rm -f .jsed-test-out; echo ok | JSED_ALLOW_SCRIPT_FILE_IO=1 ./$(JSED_BIN) 'w .jsed-test-out' >/dev/null; grep -qx ok .jsed-test-out; rm -f .jsed-test-out
 	@echo "--- script size cap ---"; printf 'pp' > .jsed-test-script.sed; if JSED_MAX_SCRIPT_CHARS=1 ./$(JSED_BIN) -f .jsed-test-script.sed >/tmp/jsed-size.out 2>/tmp/jsed-size.err; then cat /tmp/jsed-size.out /tmp/jsed-size.err; rm -f .jsed-test-script.sed; exit 1; fi; rm -f .jsed-test-script.sed
 
-test: test-parser binary
+test-security: binary tests/test-security.sh
+	@sh tests/native-loader-security-test.sh
+	@sh tests/test-security.sh
+
+test: test-parser binary test-security
 	@echo "--- s/// ---";        echo "hello world" | ./$(JSED_BIN) 's/hello/goodbye/'
 	@echo "--- = (line #) ---";  printf "a\nb\nc\n" | ./$(JSED_BIN) '='
 	@echo "--- 2d ---";          printf "a\nb\nc\n" | ./$(JSED_BIN) '2d'
diff --git a/README.md b/README.md
index efa5654..9441453 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,14 @@ Secure defaults:
 - `--sandbox` disables both script shell and script file effects even when the
   opt-in environment variables are set;
 - script text and input records are capped by `JSED_MAX_SCRIPT_CHARS` and
-  `JSED_MAX_INPUT_LINE_CHARS`, both defaulting to 1 MiB.
+  `JSED_MAX_INPUT_LINE_CHARS`, both defaulting to 1 MiB;
+- compiled syntax trees are capped by `JSED_MAX_COMMANDS` (65,536 by default,
+  with a hard configuration ceiling of 1,000,000 nodes), including labels and
+  block containers;
+- `-i` creates an unpredictable same-directory file with exclusive,
+  no-symlink-following descriptor operations, preserves owner/mode, syncs file
+  and directory data, verifies source/temp identities, and atomically renames.
+  A source symlink or unavailable native helper fails closed.
 
 CLI input files, `-f` script files, and explicit `-i` in-place edits are treated
 as caller intent and are still supported with path validation and taint checks.
@@ -41,3 +48,7 @@ make verify
 ```
 
 `jsed` depends on the platform `libpcre2-8` runtime library.
+Standalone builds also link `support/secure-inplace.c`. Programs embedding the
+`(sed main)` library must link and register the `jsed_secure_inplace_*`
+symbols listed in `support/ffi-symbols.list` to enable `-i`; without them,
+ordinary stream editing works but in-place editing is rejected.
diff --git a/SECURITY.md b/SECURITY.md
index fc87490..7ad8e65 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -31,6 +31,12 @@ Security-sensitive releases must be cut from a clean checkout after:
   environment opt-ins.
 - `JSED_MAX_SCRIPT_CHARS` and `JSED_MAX_INPUT_LINE_CHARS` cap parser and input
   memory use. Both default to 1 MiB.
+- `JSED_MAX_COMMANDS` caps the complete parsed command tree, including labels
+  and block containers, at 65,536 nodes by default.
+- In-place editing uses descriptor-owned random temporary files created with
+  exclusive/no-follow flags in the source directory, verifies file identities,
+  preserves owner/mode, and syncs before atomic publication. Final-component
+  source symlinks and missing secure-native support are rejected.
 
 CLI input files, `-f` script files, and explicit `-i` in-place edits are caller
 intent. They are not sandboxed, but paths are validated and taint-checked.
diff --git a/lib/sed/engine.sls b/lib/sed/engine.sls
index 35668c6..bd3c484 100644
--- a/lib/sed/engine.sls
+++ b/lib/sed/engine.sls
@@ -91,9 +91,44 @@
   ;;; Compiled program
   (define-record-type sed-program (fields cmds labels))
 
+  (define default-max-compiled-commands 65536)
+
+  (define max-compiled-commands
+    (let ([value (getenv "JSED_MAX_COMMANDS")])
+      (if value
+          (let ([n (string->number value)])
+            (if (and n (integer? n) (> n 0) (<= n 1000000))
+                n
+                default-max-compiled-commands))
+          default-max-compiled-commands)))
+
+  ;; Count every parsed node, including labels and block containers, before
+  ;; allocating the instruction buffer. Labels do not emit an instruction but
+  ;; must still consume the parser/compile resource budget.
+  (define (checked-command-count cmds)
+    (letrec ([count-list
+              (lambda (rest total)
+                (if (null? rest)
+                    total
+                    (let* ([cmd (car rest)]
+                           [with-node (+ total 1)])
+                      (when (> with-node max-compiled-commands)
+                        (error 'compile-program
+                               "compiled command count exceeds configured limit"
+                               with-node max-compiled-commands))
+                      (let ([with-children
+                             (if (sed-cmd-block? cmd)
+                                 (count-list (sed-cmd-block-cmds cmd) with-node)
+                                 with-node)])
+                        (count-list (cdr rest) with-children)))))])
+      (count-list cmds 0)))
+
   ;;; Compile tree -> flat vector
   (define (compile-program cmds)
-    (let ([buf (make-vector 2048 #f)]
+    (let* ([command-count (checked-command-count cmds)]
+           ;; One slot is convenient for an empty program; only the first PC
+           ;; entries are returned below.
+           [buf (make-vector (if (= command-count 0) 1 command-count) #f)]
           [pc  (box 0)]
           [labels (make-hashtable string-hash string=?)])
       (define (emit! instr)
diff --git a/lib/sed/main.sls b/lib/sed/main.sls
index c81b913..84326e7 100644
--- a/lib/sed/main.sls
+++ b/lib/sed/main.sls
@@ -8,8 +8,8 @@
   (export main run-sed)
   (import (except (scheme) compile-program)
           (only (std os env) getenv)
-          (only (std security taint) check-untainted! safe-delete-file safe-open-input-file)
-          (sed ast) (sed parser) (sed engine))
+          (only (std security taint) check-untainted! safe-open-input-file)
+          (sed ast) (sed parser) (sed engine) (sed secure-file))
 
   (define default-max-script-chars (* 1024 1024))
   (define default-max-input-line-chars (* 1024 1024))
@@ -405,24 +405,10 @@
     (for-each
       (lambda (filename)
         (unless (string=? filename "-")
-          (let* ([filename (check-sed-path! 'process-in-place filename)]
-                 [tmp (check-sed-path! 'process-in-place
-                                       (string-append filename ".jsed-tmp"))])
-            (guard (e [else
-                       (display (string-append "sed: " filename ": ")
-                                (current-error-port))
-                       (display-condition e (current-error-port))
-                       (newline (current-error-port))
-                       (when (file-exists? tmp) (safe-delete-file tmp))])
-              (let ([out-port (open-file-output-port tmp
-                                (file-options no-fail)
-                                (buffer-mode block)
-                                (make-transcoder (utf-8-codec)))])
-                (process-one-file! state prog filename #t out-port)
-                (close-port out-port))
-              (when (and suffix (not (string=? suffix "")))
-                (rename-file filename (string-append filename suffix)))
-              (rename-file tmp filename)))))
+          (let ([filename (check-sed-path! 'process-in-place filename)])
+            (call-with-secure-inplace-output filename suffix
+              (lambda (out-port)
+                (process-one-file! state prog filename #t out-port))))))
       files))
 
   ;;; Utilities
diff --git a/lib/sed/pcre2.sls b/lib/sed/pcre2.sls
index 9893f52..4030090 100644
--- a/lib/sed/pcre2.sls
+++ b/lib/sed/pcre2.sls
@@ -11,24 +11,36 @@
   (import (scheme)
           (only (chezscheme)
             foreign-alloc foreign-free foreign-procedure foreign-ref
-            load-shared-object))
+            foreign-entry?)
+          (only (std native-loader) native-loader-ensure-system-symbol!))
 
   ;;; Constants
   (define PCRE2_CASELESS    #x00000008)
   (define PCRE2_MULTILINE   #x00000400)
   (define PCRE2_ZERO_TERMINATED (- (expt 2 64) 1))
 
-  ;; Load shared library — guarded so static binaries (JERBOA_STATIC) don't
-  ;; abort at library init; pcre2 calls will fail at use time instead.
-  ;; Try the platform-appropriate names so the same binary works on
-  ;; Linux (.so), macOS (.dylib, incl. Homebrew), and FreeBSD.
+  ;; Prefer a symbol already registered in a static image. Dynamic builds may
+  ;; load PCRE2 only from reviewed absolute system/package-manager locations;
+  ;; bare names and current-directory loader searches are never candidates.
   (define init
-    (or (guard (e [#t #f]) (load-shared-object "libpcre2-8.so") #t)
-        (guard (e [#t #f]) (load-shared-object "libpcre2-8.dylib") #t)
-        (guard (e [#t #f]) (load-shared-object "libpcre2-8.so.0") #t)
-        (guard (e [#t #f]) (load-shared-object "/opt/homebrew/lib/libpcre2-8.dylib") #t)
-        (guard (e [#t #f]) (load-shared-object "/usr/local/lib/libpcre2-8.dylib") #t)
-        #f))
+    (native-loader-ensure-system-symbol!
+      'sed/pcre2 "pcre2_compile_8"
+      #f
+      "/opt/homebrew/lib/libpcre2-8.dylib"
+      "/opt/homebrew/opt/pcre2/lib/libpcre2-8.dylib"
+      "/usr/local/lib/libpcre2-8.dylib"
+      "/lib/x86_64-linux-gnu/libpcre2-8.so.0"
+      "/usr/lib/x86_64-linux-gnu/libpcre2-8.so.0"
+      "/lib/aarch64-linux-gnu/libpcre2-8.so.0"
+      "/usr/lib/aarch64-linux-gnu/libpcre2-8.so.0"
+      "/lib64/libpcre2-8.so.0"
+      "/usr/lib64/libpcre2-8.so.0"
+      "/usr/local/lib/libpcre2-8.so.0"
+      "/usr/local/lib/libpcre2-8.so"
+      "/usr/lib/libpcre2-8.so.0"
+      "/usr/lib/libpcre2-8.so"
+      "/lib/libpcre2-8.so.0"
+      "/lib/libpcre2-8.so"))
 
   ;;; FFI declarations — use string for PCRE2_SPTR (auto null-terminated),
   ;;; uptr for C pointers
diff --git a/lib/sed/secure-file.sls b/lib/sed/secure-file.sls
new file mode 100644
index 0000000..fc827e5
--- /dev/null
+++ b/lib/sed/secure-file.sls
@@ -0,0 +1,72 @@
+#!chezscheme
+;;; Descriptor-owned, same-directory in-place output.
+
+(library (sed secure-file)
+  (export call-with-secure-inplace-output)
+  (import (scheme)
+          (only (chezscheme) foreign-entry? foreign-procedure))
+
+  (define native-ready?
+    (and (foreign-entry? "jsed_secure_inplace_begin")
+         (foreign-entry? "jsed_secure_inplace_dup_fd")
+         (foreign-entry? "jsed_secure_inplace_finish")
+         (foreign-entry? "jsed_secure_inplace_abort")
+         (foreign-entry? "jsed_secure_inplace_close_fd")))
+
+  (define c-begin
+    (and native-ready?
+         (foreign-procedure "jsed_secure_inplace_begin" (string) uptr)))
+  (define c-dup-fd
+    (and native-ready?
+         (foreign-procedure "jsed_secure_inplace_dup_fd" (uptr) int)))
+  (define c-finish
+    (and native-ready?
+         (foreign-procedure "jsed_secure_inplace_finish" (uptr string) int)))
+  (define c-abort
+    (and native-ready?
+         (foreign-procedure "jsed_secure_inplace_abort" (uptr) void)))
+  (define c-close
+    (and native-ready?
+         (foreign-procedure "jsed_secure_inplace_close_fd" (int) int)))
+
+  (define (call-with-secure-inplace-output path suffix proc)
+    (unless native-ready?
+      (error 'jsed
+             "secure in-place helper is unavailable; rebuild with support/secure-inplace.c"))
+    (let ([ctx (c-begin path)] [finished? #f])
+      (when (= ctx 0)
+        (error 'jsed "cannot securely open in-place input" path))
+      (dynamic-wind
+        (lambda () #t)
+        (lambda ()
+          (let ([fd (c-dup-fd ctx)])
+            (when (< fd 0)
+              (error 'jsed "cannot duplicate secure temporary descriptor" path))
+            (let ([port
+                   (guard (e [else
+                              (c-close fd)
+                              (raise e)])
+                     (open-fd-output-port fd
+                       (buffer-mode block)
+                       (make-transcoder (utf-8-codec))))]
+                  [port-open? #t]
+                  [result #f])
+              (dynamic-wind
+                (lambda () #t)
+                (lambda ()
+                  (set! result (proc port))
+                  (flush-output-port port))
+                (lambda ()
+                  (when port-open?
+                    ;; close(2) ownership transfers to the port exactly once.
+                    (set! port-open? #f)
+                    (close-port port))))
+              (let ([rc (c-finish ctx (or suffix ""))])
+                ;; c-finish consumes CTX on success and failure.
+                (set! finished? #t)
+                (when (< rc 0)
+                  (error 'jsed "secure in-place commit failed" path rc)))
+              result)))
+        (lambda ()
+          (unless finished? (c-abort ctx))))))
+)
diff --git a/main.ss b/main.ss
index 9f839a3..3f5918e 100644
--- a/main.ss
+++ b/main.ss
@@ -3,8 +3,8 @@
 
 (import (except (scheme) compile-program)
         (only (std os env) getenv)
-        (only (std security taint) check-untainted! safe-delete-file safe-open-input-file)
-        (sed ast) (sed parser) (sed engine))
+        (only (std security taint) check-untainted! safe-open-input-file)
+        (sed ast) (sed parser) (sed engine) (sed secure-file))
 
 (define default-max-script-chars (* 1024 1024))
 (define default-max-input-line-chars (* 1024 1024))
@@ -400,24 +400,10 @@
   (for-each
     (lambda (filename)
       (unless (string=? filename "-")
-        (let* ([filename (check-sed-path! 'process-in-place filename)]
-               [tmp (check-sed-path! 'process-in-place
-                                     (string-append filename ".jsed-tmp"))])
-          (guard (e [else
-                     (display (string-append "sed: " filename ": ")
-                              (current-error-port))
-                     (display-condition e (current-error-port))
-                     (newline (current-error-port))
-                     (when (file-exists? tmp) (safe-delete-file tmp))])
-            (let ([out-port (open-file-output-port tmp
-                              (file-options no-fail)
-                              (buffer-mode block)
-                              (make-transcoder (utf-8-codec)))])
-              (process-one-file! state prog filename #t out-port)
-              (close-port out-port))
-            (when (and suffix (not (string=? suffix "")))
-              (rename-file filename (string-append filename suffix)))
-            (rename-file tmp filename)))))
+        (let ([filename (check-sed-path! 'process-in-place filename)])
+          (call-with-secure-inplace-output filename suffix
+            (lambda (out-port)
+              (process-one-file! state prog filename #t out-port))))))
     files))
 
 ;;; Utilities
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 5b90c64..32357f8 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -18,8 +18,12 @@ required=(
   scripts/target-evidence.sh
   scripts/sanitize-evidence.sh
   support/import-check.ss
+  support/secure-inplace.c
+  support/ffi-symbols.list
   support/parser-corpus-evidence.ss
   tests/test-parser.ss
+  tests/native-loader-security-test.sh
+  tests/test-security.sh
 )
 
 for path in "${required[@]}"; do
@@ -72,9 +76,9 @@ chez_matches="$(
   grep -R -n -E '\(chezscheme\)' --include='*.ss' --include='*.sls' . \
     --exclude-dir=.git --exclude-dir=dist --exclude-dir=.build || true
 )"
-if [[ -n "$chez_matches" ]] && grep -v 'lib/sed/pcre2.sls' <<<"$chez_matches" >/dev/null; then
+if [[ -n "$chez_matches" ]] && grep -v -E 'lib/sed/(pcre2|secure-file)[.]sls' <<<"$chez_matches" >/dev/null; then
   echo "$chez_matches" >&2
-  echo "targeted Chez FFI imports are only allowed in lib/sed/pcre2.sls." >&2
+  echo "targeted Chez FFI imports are only allowed in reviewed sed FFI modules." >&2
   exit 1
 fi
 
@@ -83,12 +87,22 @@ ffi_matches="$(
     --include='*.ss' --include='*.sls' . \
     --exclude-dir=.git --exclude-dir=dist --exclude-dir=.build || true
 )"
-if [[ -n "$ffi_matches" ]] && grep -v 'lib/sed/pcre2.sls' <<<"$ffi_matches" >/dev/null; then
+if [[ -n "$ffi_matches" ]] && grep -v -E 'lib/sed/(pcre2|secure-file)[.]sls' <<<"$ffi_matches" >/dev/null; then
   echo "$ffi_matches" >&2
-  echo "FFI is only allowed in lib/sed/pcre2.sls." >&2
+  echo "FFI is only allowed in reviewed sed FFI modules." >&2
   exit 1
 fi
 
+grep -q 'native-loader-ensure-system-symbol!' lib/sed/pcre2.sls || {
+  echo "PCRE2 resolution bypasses the shared native-loader policy." >&2
+  exit 1
+}
+if grep -Eq 'load-shared-object[[:space:]]+"libpcre2' lib/sed/pcre2.sls; then
+  echo "bare/CWD PCRE2 loader remains." >&2
+  exit 1
+fi
+sh tests/native-loader-security-test.sh
+
 process_matches="$(
   grep -R -n -E 'open-process-ports|\(system[[:space:]]' \
     --include='*.ss' --include='*.sls' . \
@@ -112,6 +126,15 @@ fi
 grep -q 'JSED_ALLOW_SCRIPT_FILE_IO' lib/sed/engine.sls
 grep -q 'JSED_MAX_SCRIPT_CHARS' lib/sed/parser.sls
 grep -q 'JSED_MAX_INPUT_LINE_CHARS' main.ss
+grep -q 'JSED_MAX_COMMANDS' lib/sed/engine.sls
+grep -q 'O_EXCL.*O_NOFOLLOW' support/secure-inplace.c
+grep -q 'fchown' support/secure-inplace.c
+grep -q 'fchmod' support/secure-inplace.c
+grep -q 'fsync(ctx->temp_fd)' support/secure-inplace.c
+if grep -q '[.]jsed-tmp"' main.ss lib/sed/main.sls; then
+  echo "predictable in-place temporary path found" >&2
+  exit 1
+fi
 grep -q 'parser_corpus_status: documented' docs/parser-hardening.md
 grep -q 'parser_corpus_cases_minimum: 512' docs/parser-hardening.md
 grep -q 'parser_corpus_scope_status: substitution-address-block-file-shell-malformed' docs/parser-hardening.md
@@ -166,4 +189,21 @@ if [[ -n "$secret_matches" ]]; then
   exit 1
 fi
 
+for bootstrap_file in \
+  support/install-verified-jerbuild.sh \
+  support/jerbuild-bootstrap.lock \
+  support/jerboa-release-signers; do
+  test -f "$bootstrap_file" || { echo "missing authenticated bootstrap input: $bootstrap_file" >&2; exit 1; }
+done
+grep -q 'ssh-keygen -Y verify' support/install-verified-jerbuild.sh
+grep -q '^status=blocked-awaiting-authenticated-upstream-release$' support/jerbuild-bootstrap.lock
+if grep -R -n -E 'jerbuild-linux|releases/download/.*/jerbuild' .github/workflows .build.yml 2>/dev/null; then
+  echo "unsigned Jerbuild bootstrap remains" >&2
+  exit 1
+fi
+if grep -R -n -E 'uses:[[:space:]]+[^[:space:]#]+@(v[0-9]+|main|master|stable|latest)([[:space:]#]|$)' .github/workflows 2>/dev/null; then
+  echo "mutable GitHub Action reference remains" >&2
+  exit 1
+fi
+
 echo "security-check: ok"
diff --git a/support/ffi-symbols.list b/support/ffi-symbols.list
new file mode 100644
index 0000000..624afba
--- /dev/null
+++ b/support/ffi-symbols.list
@@ -0,0 +1,6 @@
+# Native in-place-edit helper linked by .jerbuild.
+jsed_secure_inplace_begin
+jsed_secure_inplace_dup_fd
+jsed_secure_inplace_finish
+jsed_secure_inplace_abort
+jsed_secure_inplace_close_fd
diff --git a/support/install-verified-jerbuild.sh b/support/install-verified-jerbuild.sh
new file mode 100755
index 0000000..37e6169
--- /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=${JERBOA_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}/jerboa-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/secure-inplace.c b/support/secure-inplace.c
new file mode 100644
index 0000000..075f046
--- /dev/null
+++ b/support/secure-inplace.c
@@ -0,0 +1,341 @@
+#define _GNU_SOURCE 1
+#define _DARWIN_C_SOURCE 1
+#define _POSIX_C_SOURCE 200809L
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <sys/random.h>
+#endif
+
+#ifndef O_DIRECTORY
+#error "secure in-place editing requires O_DIRECTORY"
+#endif
+#ifndef O_NOFOLLOW
+#error "secure in-place editing requires O_NOFOLLOW"
+#endif
+#ifndef AT_SYMLINK_NOFOLLOW
+#error "secure in-place editing requires AT_SYMLINK_NOFOLLOW"
+#endif
+
+#ifndef O_CLOEXEC
+#define O_CLOEXEC 0
+#endif
+
+typedef struct jsed_inplace_ctx {
+    int dir_fd;
+    int source_fd;
+    int temp_fd;
+    dev_t source_dev;
+    ino_t source_ino;
+    dev_t temp_dev;
+    ino_t temp_ino;
+    char *base_name;
+    char temp_name[64];
+    int temp_linked;
+} jsed_inplace_ctx;
+
+static int close_once(int fd) {
+    if (fd < 0) return 0;
+    if (close(fd) == 0) return 0;
+#if defined(__linux__)
+    /* Linux consumes the descriptor even when close reports EINTR. */
+    if (errno == EINTR) return 0;
+#endif
+    return -1;
+}
+
+static int random_bytes(unsigned char *out, size_t len) {
+#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
+    arc4random_buf(out, len);
+    return 0;
+#elif defined(__linux__)
+    size_t off = 0;
+    while (off < len) {
+        ssize_t n = getrandom(out + off, len - off, 0);
+        if (n > 0) {
+            off += (size_t)n;
+        } else if (n < 0 && errno == EINTR) {
+            continue;
+        } else if (n < 0 && errno == ENOSYS) {
+            break;
+        } else {
+            return -1;
+        }
+    }
+    if (off == len) return 0;
+#endif
+
+    int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+    if (fd < 0) return -1;
+    size_t read_off = 0;
+    while (read_off < len) {
+        ssize_t n = read(fd, out + read_off, len - read_off);
+        if (n > 0) {
+            read_off += (size_t)n;
+        } else if (n < 0 && errno == EINTR) {
+            continue;
+        } else {
+            int saved = (n < 0) ? errno : EIO;
+            (void)close_once(fd);
+            errno = saved;
+            return -1;
+        }
+    }
+    if (close_once(fd) < 0) return -1;
+    return 0;
+}
+
+static int split_path(const char *path, char **dir_out, char **base_out) {
+    const char *slash;
+    char *dir;
+    char *base;
+
+    if (!path || !path[0]) {
+        errno = EINVAL;
+        return -1;
+    }
+    slash = strrchr(path, '/');
+    if (!slash) {
+        dir = strdup(".");
+        base = strdup(path);
+    } else if (slash == path) {
+        dir = strdup("/");
+        base = strdup(slash + 1);
+    } else {
+        size_t dir_len = (size_t)(slash - path);
+        dir = (char *)malloc(dir_len + 1);
+        if (dir) {
+            memcpy(dir, path, dir_len);
+            dir[dir_len] = '\0';
+        }
+        base = strdup(slash + 1);
+    }
+    if (!dir || !base) {
+        free(dir);
+        free(base);
+        errno = ENOMEM;
+        return -1;
+    }
+    if (!base[0] || strcmp(base, ".") == 0 || strcmp(base, "..") == 0 ||
+        strchr(base, '/')) {
+        free(dir);
+        free(base);
+        errno = EINVAL;
+        return -1;
+    }
+    *dir_out = dir;
+    *base_out = base;
+    return 0;
+}
+
+static int same_identity(const struct stat *st, dev_t dev, ino_t ino) {
+    return st->st_dev == dev && st->st_ino == ino;
+}
+
+static void unlink_owned_temp(jsed_inplace_ctx *ctx) {
+    struct stat st;
+    if (!ctx || !ctx->temp_linked || ctx->dir_fd < 0) return;
+    if (fstatat(ctx->dir_fd, ctx->temp_name, &st, AT_SYMLINK_NOFOLLOW) == 0 &&
+        same_identity(&st, ctx->temp_dev, ctx->temp_ino)) {
+        (void)unlinkat(ctx->dir_fd, ctx->temp_name, 0);
+    }
+    ctx->temp_linked = 0;
+}
+
+static void destroy_ctx(jsed_inplace_ctx *ctx, int remove_temp) {
+    if (!ctx) return;
+    if (remove_temp) unlink_owned_temp(ctx);
+    (void)close_once(ctx->temp_fd);
+    (void)close_once(ctx->source_fd);
+    (void)close_once(ctx->dir_fd);
+    free(ctx->base_name);
+    free(ctx);
+}
+
+uintptr_t jsed_secure_inplace_begin(const char *path) {
+    jsed_inplace_ctx *ctx = NULL;
+    char *dir_name = NULL;
+    char *base_name = NULL;
+    struct stat source_st;
+    struct stat temp_st;
+    unsigned char random[16];
+    static const char hex[] = "0123456789abcdef";
+    static const char prefix[] = ".jsed-tmp-";
+    const size_t prefix_len = sizeof(prefix) - 1;
+
+    if (split_path(path, &dir_name, &base_name) < 0) return (uintptr_t)0;
+    ctx = (jsed_inplace_ctx *)calloc(1, sizeof(*ctx));
+    if (!ctx) {
+        free(dir_name);
+        free(base_name);
+        return (uintptr_t)0;
+    }
+    ctx->dir_fd = -1;
+    ctx->source_fd = -1;
+    ctx->temp_fd = -1;
+    ctx->base_name = base_name;
+
+    ctx->dir_fd = open(dir_name, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+    free(dir_name);
+    if (ctx->dir_fd < 0) goto fail;
+
+    ctx->source_fd = openat(ctx->dir_fd, ctx->base_name,
+                            O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+    if (ctx->source_fd < 0 || fstat(ctx->source_fd, &source_st) < 0)
+        goto fail;
+    if (!S_ISREG(source_st.st_mode)) {
+        errno = EINVAL;
+        goto fail;
+    }
+    ctx->source_dev = source_st.st_dev;
+    ctx->source_ino = source_st.st_ino;
+
+    for (int attempt = 0; attempt < 128; ++attempt) {
+        if (random_bytes(random, sizeof(random)) < 0) goto fail;
+        memcpy(ctx->temp_name, prefix, prefix_len);
+        for (size_t i = 0; i < sizeof(random); ++i) {
+            ctx->temp_name[prefix_len + i * 2] = hex[random[i] >> 4];
+            ctx->temp_name[prefix_len + i * 2 + 1] = hex[random[i] & 15];
+        }
+        ctx->temp_name[prefix_len + sizeof(random) * 2] = '\0';
+        ctx->temp_fd = openat(ctx->dir_fd, ctx->temp_name,
+                              O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+                              0600);
+        if (ctx->temp_fd >= 0) break;
+        if (errno != EEXIST) goto fail;
+    }
+    if (ctx->temp_fd < 0) {
+        errno = EEXIST;
+        goto fail;
+    }
+    ctx->temp_linked = 1;
+
+    if (fstat(ctx->temp_fd, &temp_st) < 0) {
+        (void)unlinkat(ctx->dir_fd, ctx->temp_name, 0);
+        ctx->temp_linked = 0;
+        goto fail;
+    }
+    ctx->temp_dev = temp_st.st_dev;
+    ctx->temp_ino = temp_st.st_ino;
+    if (fchown(ctx->temp_fd, source_st.st_uid, source_st.st_gid) < 0 ||
+        fchmod(ctx->temp_fd, source_st.st_mode & 07777) < 0) {
+        goto fail;
+    }
+    return (uintptr_t)ctx;
+
+fail:
+    {
+        int saved = errno ? errno : EIO;
+        destroy_ctx(ctx, 1);
+        errno = saved;
+        return (uintptr_t)0;
+    }
+}
+
+int jsed_secure_inplace_dup_fd(uintptr_t handle) {
+    jsed_inplace_ctx *ctx = (jsed_inplace_ctx *)handle;
+    if (!ctx || ctx->temp_fd < 0) return -EINVAL;
+#ifdef F_DUPFD_CLOEXEC
+    int fd = fcntl(ctx->temp_fd, F_DUPFD_CLOEXEC, 3);
+#else
+    int fd = dup(ctx->temp_fd);
+    if (fd >= 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) {
+        int saved = errno;
+        (void)close_once(fd);
+        errno = saved;
+        fd = -1;
+    }
+#endif
+    return fd < 0 ? -errno : fd;
+}
+
+static int suffix_valid(const char *suffix) {
+    return suffix && !strchr(suffix, '/');
+}
+
+int jsed_secure_inplace_finish(uintptr_t handle, const char *suffix) {
+    jsed_inplace_ctx *ctx = (jsed_inplace_ctx *)handle;
+    struct stat source_now;
+    struct stat temp_now;
+    char *backup = NULL;
+    int moved_source = 0;
+    int saved = 0;
+
+    if (!ctx) return -EINVAL;
+    if (!suffix_valid(suffix)) {
+        saved = EINVAL;
+        goto fail;
+    }
+    if (fsync(ctx->temp_fd) < 0 ||
+        fstat(ctx->temp_fd, &temp_now) < 0 ||
+        !same_identity(&temp_now, ctx->temp_dev, ctx->temp_ino) ||
+        fstatat(ctx->dir_fd, ctx->temp_name, &temp_now, AT_SYMLINK_NOFOLLOW) < 0 ||
+        !same_identity(&temp_now, ctx->temp_dev, ctx->temp_ino) ||
+        fstatat(ctx->dir_fd, ctx->base_name, &source_now, AT_SYMLINK_NOFOLLOW) < 0 ||
+        !S_ISREG(source_now.st_mode) ||
+        !same_identity(&source_now, ctx->source_dev, ctx->source_ino)) {
+        saved = errno ? errno : ESTALE;
+        goto fail;
+    }
+
+    if (suffix[0]) {
+        size_t base_len = strlen(ctx->base_name);
+        size_t suffix_len = strlen(suffix);
+        if (base_len > SIZE_MAX - suffix_len - 1) {
+            saved = EOVERFLOW;
+            goto fail;
+        }
+        backup = (char *)malloc(base_len + suffix_len + 1);
+        if (!backup) {
+            saved = ENOMEM;
+            goto fail;
+        }
+        memcpy(backup, ctx->base_name, base_len);
+        memcpy(backup + base_len, suffix, suffix_len + 1);
+        if (renameat(ctx->dir_fd, ctx->base_name, ctx->dir_fd, backup) < 0) {
+            saved = errno;
+            goto fail;
+        }
+        moved_source = 1;
+    }
+
+    if (renameat(ctx->dir_fd, ctx->temp_name,
+                 ctx->dir_fd, ctx->base_name) < 0) {
+        saved = errno;
+        if (moved_source)
+            (void)renameat(ctx->dir_fd, backup, ctx->dir_fd, ctx->base_name);
+        goto fail;
+    }
+    ctx->temp_linked = 0;
+    if (fsync(ctx->dir_fd) < 0) {
+        saved = errno;
+        goto fail;
+    }
+
+    free(backup);
+    destroy_ctx(ctx, 0);
+    return 0;
+
+fail:
+    free(backup);
+    destroy_ctx(ctx, 1);
+    return -(saved ? saved : EIO);
+}
+
+void jsed_secure_inplace_abort(uintptr_t handle) {
+    destroy_ctx((jsed_inplace_ctx *)handle, 1);
+}
+
+int jsed_secure_inplace_close_fd(int fd) {
+    if (fd < 0) return -EINVAL;
+    return close_once(fd) == 0 ? 0 : -errno;
+}
diff --git a/tests/native-loader-security-test.sh b/tests/native-loader-security-test.sh
new file mode 100644
index 0000000..ffac325
--- /dev/null
+++ b/tests/native-loader-security-test.sh
@@ -0,0 +1,125 @@
+#!/bin/sh
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
+if [ -n "${JERBUILD:-}" ]; then
+  jerbuild=$JERBUILD
+elif [ -x "$root/.jerboa/bin/jerbuild" ]; then
+  jerbuild=$root/.jerboa/bin/jerbuild
+elif [ -x "$root/../jerboa/dist/jerbuild" ]; then
+  jerbuild=$root/../jerboa/dist/jerbuild
+else
+  jerbuild=$(command -v jerbuild || true)
+fi
+[ -n "$jerbuild" ] || {
+  echo "native-loader-security: jerbuild is required" >&2
+  exit 1
+}
+case $jerbuild in
+  */*) jerbuild=$(CDPATH= cd -- "$(dirname -- "$jerbuild")" && pwd -P)/$(basename -- "$jerbuild") ;;
+  *) jerbuild=$(command -v "$jerbuild" || true) ;;
+esac
+[ -n "$jerbuild" ] && [ -x "$jerbuild" ] || {
+  echo "native-loader-security: jerbuild is not executable" >&2
+  exit 1
+}
+
+jh=$("$jerbuild" --jerboa-home)
+core_lib=
+for candidate in "$jh/lib" "$root/../jerboa/lib"; do
+  if [ -f "$candidate/std/native-loader.ss" ] &&
+      grep -q 'native-loader-ensure-system-symbol!' "$candidate/std/native-loader.ss"; then
+    core_lib=$candidate
+    break
+  fi
+done
+[ -n "$core_lib" ] || {
+  echo "native-loader-security: updated (std native-loader) is unavailable" >&2
+  exit 1
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-sed-loader.XXXXXX")
+cleanup() { rm -rf -- "$tmp"; }
+trap cleanup EXIT HUP INT TERM
+mkdir -p "$tmp/hostile"
+
+cat >"$tmp/sentinel.c" <<'EOF'
+#include <stdio.h>
+#include <stdlib.h>
+__attribute__((constructor)) static void loaded(void) {
+    const char *marker = getenv("JERBOA_LOADER_SENTINEL_MARKER");
+    if (marker) {
+        FILE *out = fopen(marker, "w");
+        if (out) { fputs("loaded\n", out); fclose(out); }
+    }
+}
+EOF
+case $(uname -s) in
+  Darwin) ${CC:-cc} -dynamiclib -fPIC -o "$tmp/sentinel" "$tmp/sentinel.c" ;;
+  *) ${CC:-cc} -shared -fPIC -o "$tmp/sentinel" "$tmp/sentinel.c" ;;
+esac
+for name in libpcre2-8.so libpcre2-8.dylib libpcre2-8.so.0; do
+  cp "$tmp/sentinel" "$tmp/hostile/$name"
+done
+
+cat >"$tmp/bare-control.ss" <<'EOF'
+#!chezscheme
+(import (scheme))
+(load-shared-object "libpcre2-8.so")
+EOF
+control_marker=$tmp/bare-control-loaded
+case $(uname -s) in
+  Darwin)
+    (cd "$tmp/hostile" &&
+      DYLD_FALLBACK_LIBRARY_PATH="$tmp/hostile${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" \
+      JERBOA_LOADER_SENTINEL_MARKER="$control_marker" \
+      "$jerbuild" exec --libdirs "$jh/lib" "$tmp/bare-control.ss")
+    ;;
+  *)
+    (cd "$tmp/hostile" &&
+      LD_LIBRARY_PATH="$tmp/hostile${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
+      JERBOA_LOADER_SENTINEL_MARKER="$control_marker" \
+      "$jerbuild" exec --libdirs "$jh/lib" "$tmp/bare-control.ss")
+    ;;
+esac
+[ -e "$control_marker" ] || {
+  echo "native-loader-security: bare-name negative control did not load sentinel" >&2
+  exit 1
+}
+rm -f "$control_marker"
+
+cat >"$tmp/probe.ss" <<'EOF'
+#!chezscheme
+(import (scheme) (sed pcre2))
+(let ([rx (pcre2-compile "a+" 0)])
+  (unless (pcre2-matches? rx "caaab")
+    (error 'sed-native-loader-probe "PCRE2 match failed"))
+  (pcre2-free rx))
+(display "sed-native-loader-import: pass\n")
+EOF
+
+marker=$tmp/sentinel-loaded
+rc=0
+case $(uname -s) in
+  Darwin)
+    (cd "$tmp/hostile" &&
+      DYLD_FALLBACK_LIBRARY_PATH="$tmp/hostile${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" \
+      JERBOA_LOADER_SENTINEL_MARKER="$marker" \