Harden FUSE loading and mount helper validation

ober

3509258ad20173e54b4f25cae5a91d56a61d9590

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dfaf894..afea10d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
   build-test-audit:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 73910ed..e3fb233 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/Makefile b/Makefile
index d0ee2aa..487316c 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,7 @@ CFLAGS ?= -O2 -fPIC $(WARN_CFLAGS) $(HARDEN_CFLAGS)
 
 SHIM := libjerboa_fuse_mount$(SO_EXT)
 
-.PHONY: all build transpile test test-memfs test-secmem test-access test-vault clean shim ensure-jerboa-tools audit security openssl-advisory-check sbom reproducibility-report target-evidence verify release-evidence
+.PHONY: all build transpile test test-loader-policy test-native-hardening test-memfs test-secmem test-access test-vault clean shim ensure-jerboa-tools audit security openssl-advisory-check sbom reproducibility-report target-evidence verify release-evidence
 
 all: build
 
@@ -62,9 +62,20 @@ else
   EXTRA_LDPATH =
 endif
 
-RUNTEST = JERBOA_FUSE_LIB=$(CURDIR) $(LD_VAR)=$(CURDIR)$(EXTRA_LDPATH) $(JERBUILD) exec --libdirs "$(LIBDIRS)"
+RUNTEST = JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB=$(CURDIR) $(LD_VAR)=$(CURDIR)$(EXTRA_LDPATH) $(JERBUILD) exec --libdirs "$(LIBDIRS)"
 
-test: test-memfs test-secmem test-access test-vault
+test: test-loader-policy test-native-hardening test-memfs test-secmem test-access test-vault
+
+test-loader-policy: build
+	@REPO_ROOT="$(CURDIR)" JERBUILD="$(JERBUILD)" \
+	  LIBDIRS="$(LIBDIRS)" SHIM="$(CURDIR)/$(SHIM)" \
+	  sh tests/loader-policy.sh
+
+test-native-hardening: shim
+	$(CC) $(CFLAGS) $(OPENSSL_CFLAGS) -o tests/test_mount_helper \
+		tests/test_mount_helper.c src/mount_helper.c $(OPENSSL_LIBS)
+	./tests/test_mount_helper
+	rm -f tests/test_mount_helper
 
 test-memfs: build
 	$(RUNTEST) tests/test-memfs.ss
diff --git a/docs/ffi-boundary.md b/docs/ffi-boundary.md
index 1c53be3..5f7a5fb 100644
--- a/docs/ffi-boundary.md
+++ b/docs/ffi-boundary.md
@@ -8,6 +8,10 @@
 - Copy mountpoint, filesystem-name, and vault-file paths from NUL-free bytevectors into temporary C strings.
 - Perform fixed-arity blockstore open/create wrappers so Scheme never declares variadic `open(2)`.
 - Retry `read`, `write`, `pread`, `pwrite`, and `fsync` around `EINTR` where retrying is safe.
+- On macOS, launch the fixed `umount` argv/environment with `posix_spawn`, a
+  clean signal mask/default set, `/dev/null` stderr, and `--` before the
+  caller-controlled mountpoint. No Scheme or C callback resumes after raw
+  `fork`.
 - Allocate, zero, copy, and free mlock-backed secure memory with null/size guards.
 - Delegate vault CSPRNG, PBKDF2-HMAC-SHA256, and AES-256-GCM to OpenSSL.
 
diff --git a/lib/jerboa-fuse/mount.sls b/lib/jerboa-fuse/mount.sls
index 674dd97..7b83ea3 100644
--- a/lib/jerboa-fuse/mount.sls
+++ b/lib/jerboa-fuse/mount.sls
@@ -14,7 +14,12 @@
      with-input-from-string with-output-to-string iota \x31;+
      \x31;- partition make-date make-time meta atom?)
     (except (jerboa prelude) c-lambda)
-    (only (jerboa ffi) c-lambda load-shared-object*))
+    (only (jerboa ffi) c-lambda load-shared-object*)
+    (only (std native-loader) native-loader-privileged?
+      native-loader-development-enabled?
+      native-loader-validate-library!
+      native-loader-validate-directory!)
+    (only (chezscheme) foreign-entry?))
   (def *native-loaded?* #f)
   (def *bindings-ready?* #f)
   (def (try-load-one! path)
@@ -27,15 +32,46 @@
            [(null? paths) #f]
            [(try-load-one! (car paths)) #t]
            [else (loop (cdr paths))])))
+  (def (native-symbols-registered?)
+       (and (foreign-entry? "jerboa_fuse_open_device")
+            (foreign-entry? "jerboa_fuse_mount_bv")
+            (foreign-entry? "jerboa_fuse_close_fd")))
   (def (native-search-paths)
-       (let ([base (or (getenv "JERBOA_FUSE_LIB") ".")])
-         (list (string-append base "/libjerboa_fuse_mount.so")
-           (string-append base "/libjerboa_fuse_mount.dylib")
-           "./libjerboa_fuse_mount.so" "./libjerboa_fuse_mount.dylib"
-           "libjerboa_fuse_mount.so" "libjerboa_fuse_mount.dylib")))
+       (let ([base (getenv "JERBOA_FUSE_LIB")])
+         (unless (native-loader-development-enabled?
+                   "JERBOA_FUSE_DEV_NATIVE")
+           (error 'jerboa-fuse/mount
+             "dynamic loading requires JERBOA_FUSE_DEV_NATIVE=1"))
+         (native-loader-validate-directory!
+           'jerboa-fuse/mount
+           base
+           #t)
+         (let ([candidates (filter
+                             file-regular?
+                             (list
+                               (string-append
+                                 base
+                                 "/libjerboa_fuse_mount.so")
+                               (string-append
+                                 base
+                                 "/libjerboa_fuse_mount.dylib")))])
+           (when (null? candidates)
+             (error 'jerboa-fuse/mount
+               "configured FUSE native shim is missing"
+               base))
+           (map (lambda (path)
+                  (native-loader-validate-library!
+                    'jerboa-fuse/mount
+                    path
+                    #t))
+                candidates))))
   (def (try-load-native!)
        (or *native-loaded?*
-           (and (try-load-any! (native-search-paths))
+           (and (native-symbols-registered?)
+                (begin (set! *native-loaded?* #t) #t))
+           (and (not (native-loader-privileged?))
+                (try-load-any! (native-search-paths))
+                (native-symbols-registered?)
                 (begin (set! *native-loaded?* #t) #t))))
   (def (ensure-mount-lib!) (try-load-native!))
   (def c-open-device #f)
diff --git a/lib/jerboa-fuse/vault/format.sls b/lib/jerboa-fuse/vault/format.sls
index 742bcbc..bebf799 100644
--- a/lib/jerboa-fuse/vault/format.sls
+++ b/lib/jerboa-fuse/vault/format.sls
@@ -69,6 +69,11 @@
          out))
   (def (encode-vault-header total-blocks salt kdf-iterations
          mk-enc sb-enc)
+       (unless (= kdf-iterations KDF-ITERATIONS)
+         (error 'encode-vault-header
+           "unsupported KDF iteration count for vault format version"
+           VAULT-VERSION
+           kdf-iterations))
        (let ([bv (make-bytevector HEADER-SIZE 0)])
          (bv-set-u32le! bv 0 VAULT-MAGIC)
          (bytevector-u16-set! bv 4 VAULT-VERSION (endianness little))
@@ -87,9 +92,15 @@
                       (= ver VAULT-VERSION)
                       (= blksz BLOCK-SIZE))
            (error 'decode-vault-header "not a valid vault file"))
-         (values magic ver blksz (bv-u64le bv 10) (bv-sub bv 18 32)
-           (bv-u64le bv 50) (bv-sub bv 58 VAULT-MK-ENC-LEN)
-           (bv-sub bv 118 VAULT-SB-ENC-LEN))))
+         (let ([kdf-iterations (bv-u64le bv 50)])
+           (unless (= kdf-iterations KDF-ITERATIONS)
+             (error 'decode-vault-header
+               "unsupported KDF iteration count for vault format version"
+               ver
+               kdf-iterations))
+           (values magic ver blksz (bv-u64le bv 10) (bv-sub bv 18 32)
+             kdf-iterations (bv-sub bv 58 VAULT-MK-ENC-LEN)
+             (bv-sub bv 118 VAULT-SB-ENC-LEN)))))
   (def (encode-superblock root-inode-block bitmap-start
          bitmap-blocks generation policy-block)
        (let ([bv (make-bytevector BLOCK-PAYLOAD 0)])
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 551b69a..b001e1c 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -66,6 +66,26 @@ if ! rg -q 'load-shared-object[*]' src ||
   fail=1
 fi
 
+if ! test -f tests/loader-policy.sh ||
+   ! grep -q 'JERBOA_FUSE_DEV_NATIVE' src/jerboa-fuse/mount.ss ||
+   ! grep -q 'native-loader-validate-library!' src/jerboa-fuse/mount.ss; then
+  printf '%s\n' "FUSE shared-loader trust policy or regression is missing" >&2
+  fail=1
+fi
+
+if rg -n -e '"\./libjerboa_fuse_mount|"libjerboa_fuse_mount' \
+    src/jerboa-fuse/mount.ss; then
+  printf '%s\n' "FUSE native loader must not search CWD or bare library names" >&2
+  fail=1
+fi
+
+if ! grep -q 'O_EXCL | O_NOFOLLOW' src/mount_helper.c ||
+   ! grep -q 'if (mlock(p, size) != 0)' src/mount_helper.c ||
+   ! grep -q 'if (ok != 1 && out && out_len > 0)' src/mount_helper.c; then
+  printf '%s\n' "vault create, secure-memory, or GCM failure hardening is missing" >&2
+  fail=1
+fi
+
 if ! grep -q 'jerboa_fuse_secmem_zero_checked' src/mount_helper.c ||
    ! grep -q 'volatile unsigned char' src/mount_helper.c ||
    ! grep -q 'copy_cstring' src/mount_helper.c ||
diff --git a/src/jerboa-fuse/mount.ss b/src/jerboa-fuse/mount.ss
index d195898..407c416 100644
--- a/src/jerboa-fuse/mount.ss
+++ b/src/jerboa-fuse/mount.ss
@@ -17,7 +17,13 @@
 
   (import
     (jerboa prelude)
-    (only (jerboa ffi) c-lambda load-shared-object*))
+    (only (jerboa ffi) c-lambda load-shared-object*)
+    (only (std native-loader)
+          native-loader-privileged?
+          native-loader-development-enabled?
+          native-loader-validate-library!
+          native-loader-validate-directory!)
+    (only (chezscheme) foreign-entry?))
 
   ;; Load native objects lazily so importing this module does not crash static
   ;; binaries or tools that only inspect the Scheme API.
@@ -36,19 +42,38 @@
         [(try-load-one! (car paths)) #t]
         [else (loop (cdr paths))])))
 
+  (def (native-symbols-registered?)
+    (and (foreign-entry? "jerboa_fuse_open_device")
+         (foreign-entry? "jerboa_fuse_mount_bv")
+         (foreign-entry? "jerboa_fuse_close_fd")))
+
   (def (native-search-paths)
-    (let ([base (or (getenv "JERBOA_FUSE_LIB") ".")])
-      (list
-        (string-append base "/libjerboa_fuse_mount.so")
-        (string-append base "/libjerboa_fuse_mount.dylib")
-        "./libjerboa_fuse_mount.so"
-        "./libjerboa_fuse_mount.dylib"
-        "libjerboa_fuse_mount.so"
-        "libjerboa_fuse_mount.dylib")))
+    (let ([base (getenv "JERBOA_FUSE_LIB")])
+      (unless (native-loader-development-enabled?
+                "JERBOA_FUSE_DEV_NATIVE")
+        (error 'jerboa-fuse/mount
+               "dynamic loading requires JERBOA_FUSE_DEV_NATIVE=1"))
+      (native-loader-validate-directory! 'jerboa-fuse/mount base #t)
+      (let ([candidates
+             (filter file-regular?
+               (list
+                 (string-append base "/libjerboa_fuse_mount.so")
+                 (string-append base "/libjerboa_fuse_mount.dylib")))])
+        (when (null? candidates)
+          (error 'jerboa-fuse/mount
+                 "configured FUSE native shim is missing" base))
+        (map (lambda (path)
+               (native-loader-validate-library!
+                 'jerboa-fuse/mount path #t))
+             candidates))))
 
   (def (try-load-native!)
     (or *native-loaded?*
-        (and (try-load-any! (native-search-paths))
+        (and (native-symbols-registered?)
+             (begin (set! *native-loaded?* #t) #t))
+        (and (not (native-loader-privileged?))
+             (try-load-any! (native-search-paths))
+             (native-symbols-registered?)
              (begin
                (set! *native-loaded?* #t)
                #t))))
diff --git a/src/jerboa-fuse/vault/format.ss b/src/jerboa-fuse/vault/format.ss
index 89d37b9..b198c26 100644
--- a/src/jerboa-fuse/vault/format.ss
+++ b/src/jerboa-fuse/vault/format.ss
@@ -93,6 +93,10 @@
   ;; Total: 256 bytes
 
   (def (encode-vault-header total-blocks salt kdf-iterations mk-enc sb-enc)
+    (unless (= kdf-iterations KDF-ITERATIONS)
+      (error 'encode-vault-header
+             "unsupported KDF iteration count for vault format version"
+             VAULT-VERSION kdf-iterations))
     (let ([bv (make-bytevector HEADER-SIZE 0)])
       (bv-set-u32le!  bv 0  VAULT-MAGIC)
       (bytevector-u16-set! bv 4 VAULT-VERSION (endianness little))
@@ -112,13 +116,21 @@
            [blksz  (bv-u32le bv 6)])
       (unless (and (= magic VAULT-MAGIC) (= ver VAULT-VERSION) (= blksz BLOCK-SIZE))
         (error 'decode-vault-header "not a valid vault file"))
-      (values
-        magic ver blksz
-        (bv-u64le bv 10)
-        (bv-sub   bv 18 32)
-        (bv-u64le bv 50)
-        (bv-sub   bv 58  VAULT-MK-ENC-LEN)
-        (bv-sub   bv 118 VAULT-SB-ENC-LEN))))
+      (let ([kdf-iterations (bv-u64le bv 50)])
+        ;; The version-1 KDF cost is part of the format policy.  Validate it
+        ;; before any password work so an unauthenticated header cannot turn
+        ;; vault-open into an attacker-selected multi-billion-round job.
+        (unless (= kdf-iterations KDF-ITERATIONS)
+          (error 'decode-vault-header
+                 "unsupported KDF iteration count for vault format version"
+                 ver kdf-iterations))
+        (values
+          magic ver blksz
+          (bv-u64le bv 10)
+          (bv-sub   bv 18 32)
+          kdf-iterations
+          (bv-sub   bv 58  VAULT-MK-ENC-LEN)
+          (bv-sub   bv 118 VAULT-SB-ENC-LEN)))))
 
   ;; ---- Superblock ----
   ;; Encrypted; occupies one BLOCK-PAYLOAD-byte block (block 0).
@@ -252,4 +264,3 @@
            [name-bv  (bv-sub bv (+ slot-offset 9) name-len)])
       (values iblock name-bv)))
 
-
diff --git a/src/mount_helper.c b/src/mount_helper.c
index 4067e0f..8f87965 100644
--- a/src/mount_helper.c
+++ b/src/mount_helper.c
@@ -39,10 +39,19 @@
 #include <string.h>
 #include <unistd.h>
 #include <signal.h>
+#include <spawn.h>
 #include <sys/mman.h>
 #include <sys/wait.h>
 #include <sys/types.h>
 
+#if defined(__FreeBSD__) && !defined(FREEBSD)
+#define FREEBSD 1
+#elif defined(__linux__) && !defined(LINUX)
+#define LINUX 1
+#elif defined(__APPLE__) && !defined(DARWIN)
+#define DARWIN 1
+#endif
+
 #ifndef JERBOA_FUSE_NO_OPENSSL
 #include <openssl/evp.h>
 #include <openssl/rand.h>
@@ -52,6 +61,10 @@
 #define O_CLOEXEC 0
 #endif
 
+#ifndef O_NOFOLLOW
+#error "jerboa-fuse requires O_NOFOLLOW for safe vault file handling"
+#endif
+
 #define JERBOA_FUSE_MAX_CSTRING 4096
 #define JERBOA_FUSE_MAX_FSNAME 255
 #define JERBOA_FUSE_MAX_SECMEM (64u * 1024u * 1024u)
@@ -59,6 +72,12 @@
 #define JERBOA_FUSE_GCM_TAG_LEN 16
 #define JERBOA_FUSE_KEY_LEN 32
 
+static void jerboa_secure_zero(void *ptr, size_t size) {
+    volatile unsigned char *p = (volatile unsigned char *)ptr;
+    if (!p) return;
+    for (size_t i = 0; i < size; i++) p[i] = 0;
+}
+
 static int bytes_have_nul(const unsigned char *src, int len) {
     if (!src || len < 0) return 1;
     for (int i = 0; i < len; i++) {
@@ -156,6 +175,7 @@ static ssize_t retry_pwrite_full(int fd, const unsigned char *buf, size_t len,
 #elif defined(DARWIN)
 #include <libproc.h>
 #include <sys/proc_info.h>
+#include <sys/resource.h>
 #endif
 
 /* ====================================================================
@@ -173,14 +193,66 @@ void *jerboa_fuse_secmem_alloc(size_t size) {
                    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
     if (p == MAP_FAILED) return NULL;
 
-    /* Lock into RAM — prevent swapping */
-    mlock(p, size);  /* best-effort; may fail without RLIMIT_MEMLOCK */
+    /* Lock into RAM.  Key callers rely on this as a security boundary, so a
+     * best-effort allocation would be a dangerous lie. */
+    if (mlock(p, size) != 0) {
+        int saved_errno = errno;
+        jerboa_secure_zero(p, size);
+        (void)munmap(p, size);
+        errno = saved_errno;
+        return NULL;
+    }
 
     /* Exclude from core dumps */
 #if defined(FREEBSD)
-    madvise(p, size, MADV_NOCORE);
+    if (madvise(p, size, MADV_NOCORE) != 0) {
+        int saved_errno = errno;
+        jerboa_secure_zero(p, size);
+        (void)munlock(p, size);
+        (void)munmap(p, size);
+        errno = saved_errno;
+        return NULL;
+    }
 #elif defined(LINUX)
-    madvise(p, size, MADV_DONTDUMP);
+    if (madvise(p, size, MADV_DONTDUMP) != 0) {
+        int saved_errno = errno;
+        jerboa_secure_zero(p, size);
+        (void)munlock(p, size);
+        (void)munmap(p, size);
+        errno = saved_errno;
+        return NULL;
+    }
+#elif defined(DARWIN)
+    /* Darwin has no per-range DONTDUMP advice.  Fail closed by disabling core
+     * dumps for the process and ask the kernel to zero wired pages if the
+     * mapping is torn down without an explicit munlock. */
+    {
+        struct rlimit no_core = {0, 0};
+        if (setrlimit(RLIMIT_CORE, &no_core) != 0 ||
+            madvise(p, size, MADV_ZERO_WIRED_PAGES) != 0) {
+            int saved_errno = errno;
+            jerboa_secure_zero(p, size);
+            (void)munlock(p, size);
+            (void)munmap(p, size);
+            errno = saved_errno;
+            return NULL;
+        }
+    }
+#elif defined(MADV_DONTDUMP)
+    if (madvise(p, size, MADV_DONTDUMP) != 0) {
+        int saved_errno = errno;
+        jerboa_secure_zero(p, size);
+        (void)munlock(p, size);
+        (void)munmap(p, size);
+        errno = saved_errno;
+        return NULL;
+    }
+#else
+    jerboa_secure_zero(p, size);
+    (void)munlock(p, size);
+    (void)munmap(p, size);
+    errno = ENOTSUP;
+    return NULL;
 #endif
 
     memset(p, 0, size);
@@ -193,8 +265,7 @@ int jerboa_fuse_secmem_free_checked(void *ptr, size_t size) {
         return -1;
     }
     /* Volatile-safe zeroing — compiler cannot optimize this away */
-    volatile unsigned char *vp = (volatile unsigned char *)ptr;
-    for (size_t i = 0; i < size; i++) vp[i] = 0;
+    jerboa_secure_zero(ptr, size);
     munlock(ptr, size);
     return munmap(ptr, size);
 }
@@ -204,8 +275,7 @@ int jerboa_fuse_secmem_zero_checked(void *ptr, size_t size) {
         errno = EINVAL;
         return -1;
     }
-    volatile unsigned char *vp = (volatile unsigned char *)ptr;
-    for (size_t i = 0; i < size; i++) vp[i] = 0;
+    jerboa_secure_zero(ptr, size);
     return 0;
 }
 
@@ -297,6 +367,9 @@ int jerboa_fuse_gcm_encrypt(const unsigned char *key, int key_len,
     ok = 1;
 
 done:
+    if (ok != 1 && out && out_len > 0) {
+        jerboa_secure_zero(out, (size_t)out_len);
+    }
     EVP_CIPHER_CTX_free(ctx);
     return ok;
 }
@@ -346,6 +419,9 @@ int jerboa_fuse_gcm_decrypt(const unsigned char *key, int key_len,
     ok = 1;
 
 done:
+    if (ok != 1 && out && out_len > 0) {
+        jerboa_secure_zero(out, (size_t)out_len);
+    }
     EVP_CIPHER_CTX_free(ctx);
     return ok;
 }
@@ -509,7 +585,8 @@ int jerboa_fuse_blockstore_create_bv(const unsigned char *path, int path_len) {
     char *path_c = copy_cstring(path, path_len, JERBOA_FUSE_MAX_CSTRING, 0);
     int fd;
     if (!path_c) return -1;
-    fd = open(path_c, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
+    fd = open(path_c, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+              0600);
     free(path_c);
     return fd;
 }
@@ -676,14 +753,64 @@ int jerboa_fuse_mount(int fd, const char *mountpoint, const char *fsname,
 }
 
 int jerboa_fuse_unmount(const char *mountpoint) {
-    pid_t pid = fork();
-    if (pid < 0) return -1;
-    if (pid == 0) {
-        /* Child: exec umount directly — no shell, no injection */
-        int devnull = open("/dev/null", O_WRONLY);
-        if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); }
-        execl("/sbin/umount", "umount", mountpoint, (char *)NULL);
-        _exit(127);
+    char *const argv[] = {"umount", "--", (char *)mountpoint, NULL};
+    char *const envp[] = {
+        "PATH=/usr/bin:/bin:/usr/sbin:/sbin",
+        "LC_ALL=C",
+        NULL
+    };
+    posix_spawn_file_actions_t actions;
+    posix_spawnattr_t attributes;
+    sigset_t default_signals;
+    sigset_t empty_signal_mask;
+    short flags = POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK;
+    pid_t pid;
+    int rc;
+
+    if (!mountpoint) {
+        errno = EINVAL;
+        return -1;
+    }
+
+    rc = posix_spawn_file_actions_init(&actions);
+    if (rc != 0) {
+        errno = rc;
+        return -1;
+    }
+    rc = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO,
+                                          "/dev/null", O_WRONLY, 0);
+    if (rc != 0) {
+        (void)posix_spawn_file_actions_destroy(&actions);
+        errno = rc;
+        return -1;
+    }
+    rc = posix_spawnattr_init(&attributes);
+    if (rc != 0) {
+        (void)posix_spawn_file_actions_destroy(&actions);
+        errno = rc;
+        return -1;
+    }
+    if (sigfillset(&default_signals) != 0 ||
+        sigdelset(&default_signals, SIGKILL) != 0 ||
+        sigdelset(&default_signals, SIGSTOP) != 0 ||
+        sigemptyset(&empty_signal_mask) != 0) {
+        rc = errno;
+    } else if ((rc = posix_spawnattr_setsigdefault(&attributes,
+                                                    &default_signals)) == 0 &&
+               (rc = posix_spawnattr_setsigmask(&attributes,
+                                                &empty_signal_mask)) == 0) {
+        rc = posix_spawnattr_setflags(&attributes, flags);
+    }
+    if (rc == 0) {
+        /* `--` keeps a mountpoint beginning with '-' in operand position. */
+        rc = posix_spawn(&pid, "/sbin/umount", &actions, &attributes,
+                         argv, envp);
+    }
+    (void)posix_spawnattr_destroy(&attributes);
+    (void)posix_spawn_file_actions_destroy(&actions);
+    if (rc != 0) {
+        errno = rc;
+        return -1;
     }
     int status;
     while (waitpid(pid, &status, 0) < 0) {
diff --git a/tests/loader-policy.sh b/tests/loader-policy.sh
new file mode 100644
index 0000000..81eacfb
--- /dev/null
+++ b/tests/loader-policy.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+set -eu
+
+: "${REPO_ROOT:?}"
+: "${JERBUILD:?}"
+: "${LIBDIRS:?}"
+: "${SHIM:?}"
+
+unsafe_tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-fuse-loader.XXXXXXXX")
+trusted_tmp=$(mktemp -d "${HOME:?}/.jerboa-fuse-loader.XXXXXXXX")
+trap 'rm -rf "$unsafe_tmp" "$trusted_tmp"' EXIT HUP INT TERM
+chmod 700 "$trusted_tmp"
+probe=$REPO_ROOT/tests/loader-probe.ss
+shim_name=$(basename "$SHIM")
+
+mkdir "$unsafe_tmp/cwd"
+cp "$SHIM" "$unsafe_tmp/cwd/$shim_name"
+if (cd "$unsafe_tmp/cwd" && env -u JERBOA_FUSE_LIB -u JERBOA_FUSE_DEV_NATIVE \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then
+  echo "FUSE loader accepted a shim from the current directory" >&2
+  exit 1
+fi
+
+if (cd "$unsafe_tmp/cwd" && JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB=. \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then
+  echo "FUSE loader accepted a relative override" >&2
+  exit 1
+fi
+
+if JERBOA_FUSE_LIB="$REPO_ROOT" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "FUSE loader accepted an absolute override without development opt-in" >&2
+  exit 1
+fi
+
+mkdir "$trusted_tmp/real"
+cp "$SHIM" "$trusted_tmp/real/$shim_name"
+chmod go-w "$trusted_tmp/real" "$trusted_tmp/real/$shim_name"
+ln -s "$trusted_tmp/real" "$trusted_tmp/link"
+if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$trusted_tmp/link" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "FUSE loader accepted a symlink ancestor" >&2
+  exit 1
+fi
+
+mkdir "$trusted_tmp/writable"
+cp "$SHIM" "$trusted_tmp/writable/$shim_name"
+chmod 777 "$trusted_tmp/writable"
+if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$trusted_tmp/writable" \
+   "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+  echo "FUSE loader accepted a world-writable ancestor" >&2
+  exit 1
+fi
+
+if test "$(id -u)" -eq 0; then
+  if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$REPO_ROOT" \
+     "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then
+    echo "FUSE loader honored an environment override as root" >&2
+    exit 1
+  fi
+else
+  JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$REPO_ROOT" \
+    "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null
+fi
+
+echo "fuse-loader-policy: ok"
diff --git a/tests/loader-probe.ss b/tests/loader-probe.ss
new file mode 100644
index 0000000..888714f
--- /dev/null
+++ b/tests/loader-probe.ss
@@ -0,0 +1,5 @@
+(import (jerboa prelude)
+        (only (jerboa-fuse mount) ensure-mount-lib!))
+
+(unless (ensure-mount-lib!)
+  (error 'loader-probe "FUSE native shim was not loaded"))
diff --git a/tests/test-vault.ss b/tests/test-vault.ss
index b921814..1b968db 100644
--- a/tests/test-vault.ss
+++ b/tests/test-vault.ss
@@ -4,6 +4,7 @@
 (import (jerboa prelude))
 (import (jerboa-fuse vault))
 (import (jerboa-fuse access))
+(import (jerboa-fuse vault format))
 
 (define pass 0)
 (define fail 0)
@@ -36,6 +37,17 @@
 
 (display "=== vault create / open / close ===") (newline)
 
+;; Reject unauthenticated attacker-selected KDF work before deriving a key.
+(let* ([salt (make-bytevector 32 0)]
+       [mk   (make-bytevector VAULT-MK-ENC-LEN 0)]
+       [sb   (make-bytevector VAULT-SB-ENC-LEN 0)]
+       [hdr  (encode-vault-header 256 salt KDF-ITERATIONS mk sb)])
+  (bv-set-u64le! hdr 50 (+ KDF-ITERATIONS 1))
+  (test-assert "vault header rejects unsupported KDF iterations"
+    (guard (exn [#t #t])
+      (call-with-values (lambda () (decode-vault-header hdr)) list)
+      #f)))
+
 ;; Test 1: Create a vault
 (mkdir-if-missing test-dir)
 (cleanup)
diff --git a/tests/test_mount_helper.c b/tests/test_mount_helper.c
new file mode 100644
index 0000000..2c6dbed
--- /dev/null
+++ b/tests/test_mount_helper.c
@@ -0,0 +1,163 @@
+#define _DARWIN_C_SOURCE
+#define _POSIX_C_SOURCE 200809L
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/resource.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+int jerboa_fuse_blockstore_create_bv(const unsigned char *path, int path_len);
+int jerboa_fuse_gcm_encrypt(const unsigned char *key, int key_len,
+                            const unsigned char *plaintext, int plaintext_len,
+                            unsigned char *out, int out_len);
+int jerboa_fuse_gcm_decrypt(const unsigned char *key, int key_len,
+                            const unsigned char *ciphertext, int ciphertext_len,
+                            unsigned char *out, int out_len);
+void *jerboa_fuse_secmem_alloc(size_t size);
+int jerboa_fuse_secmem_free_checked(void *ptr, size_t size);
+int jerboa_fuse_unmount(const char *mountpoint);
+
+static int failures;
+
+static void check(int condition, const char *message) {
+    if (!condition) {
+        fprintf(stderr, "FAIL: %s\n", message);
+        failures++;
+    }
+}
+
+static int read_exact(const char *path, char *buf, size_t len) {
+    int fd = open(path, O_RDONLY | O_CLOEXEC);
+    ssize_t got;
+    if (fd < 0) return -1;
+    do {
+        got = read(fd, buf, len); /* jerboa-security: suppress missing-eintr-retry -- errno/EINTR is retried immediately below */
+    } while (got < 0 && errno == EINTR);
+    (void)close(fd);
+    return got == (ssize_t)len ? 0 : -1;
+}
+
+static int write_exact(int fd, const char *buf, size_t len) {
+    size_t done = 0;
+    while (done < len) {
+        ssize_t wrote = write(fd, buf + done, len - done);
+        if (wrote < 0 && errno == EINTR) continue;
+        if (wrote <= 0) return -1;
+        done += (size_t)wrote;
+    }
+    return 0;
+}
+
+static void test_exclusive_nofollow_create(void) {
+    char tmp[] = "/tmp/jerboa-fuse-native-XXXXXX";
+    char target[PATH_MAX];
+    char linkpath[PATH_MAX];
+    char buf[8] = {0};
+    int fd;
+
+    check(mkdtemp(tmp) != NULL, "mkdtemp succeeds");
+    if (failures) return;
+    (void)snprintf(target, sizeof(target), "%s/target", tmp);
+    (void)snprintf(linkpath, sizeof(linkpath), "%s/vault", tmp);
+
+    fd = open(target, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+    check(fd >= 0, "sentinel target create succeeds");
+    if (fd >= 0) {
+        check(write_exact(fd, "sentinel", 8) == 0,
+              "sentinel target write succeeds");
+        (void)close(fd);
+    }
+    check(symlink(target, linkpath) == 0, "vault symlink create succeeds");
+
+    errno = 0;
+    fd = jerboa_fuse_blockstore_create_bv((const unsigned char *)linkpath,
+                                           (int)strlen(linkpath));
+    check(fd < 0, "blockstore create rejects a symlink destination");
+    if (fd >= 0) (void)close(fd);
+    check(read_exact(target, buf, sizeof(buf)) == 0 &&
+              memcmp(buf, "sentinel", sizeof(buf)) == 0,
+          "rejected symlink leaves its target unchanged");
+
+    (void)unlink(linkpath);
+    errno = 0;
+    fd = jerboa_fuse_blockstore_create_bv((const unsigned char *)target,
+                                           (int)strlen(target));
+    check(fd < 0, "blockstore create rejects an existing regular file");
+    if (fd >= 0) (void)close(fd);
+    memset(buf, 0, sizeof(buf));
+    check(read_exact(target, buf, sizeof(buf)) == 0 &&
+              memcmp(buf, "sentinel", sizeof(buf)) == 0,
+          "rejected existing file remains unchanged");
+
+    (void)unlink(target);
+    (void)rmdir(tmp);
+}
+
+static void test_bad_gcm_tag_zeros_plaintext(void) {
+    unsigned char key[32] = {0};
+    unsigned char plaintext[32];
+    unsigned char ciphertext[12 + sizeof(plaintext) + 16];
+    unsigned char output[sizeof(plaintext)];
+    int rc;
+
+    memset(plaintext, 0x3c, sizeof(plaintext));
+    rc = jerboa_fuse_gcm_encrypt(key, sizeof(key), plaintext, sizeof(plaintext),
+                                ciphertext, sizeof(ciphertext));
+    check(rc == 1, "GCM encryption succeeds");
+    if (rc != 1) return;
+
+    ciphertext[sizeof(ciphertext) - 1] ^= 1;
+    memset(output, 0xa5, sizeof(output));
+    rc = jerboa_fuse_gcm_decrypt(key, sizeof(key), ciphertext,
+                                sizeof(ciphertext), output, sizeof(output));
+    check(rc == 0, "GCM decryption rejects a bad tag");
+    for (size_t i = 0; i < sizeof(output); i++) {
+        if (output[i] != 0) {
+            check(0, "bad-tag plaintext buffer is completely zeroed");
+            break;
+        }
+    }
+}
+
+static void test_secmem_lock_failure_is_fatal(void) {
+#ifdef RLIMIT_MEMLOCK
+    struct rlimit zero = {0, 0};
+    if (setrlimit(RLIMIT_MEMLOCK, &zero) == 0) {
+        void *p = jerboa_fuse_secmem_alloc(4096);
+        check(p == NULL, "secure-memory allocation fails when mlock is denied");
+        if (p) (void)jerboa_fuse_secmem_free_checked(p, 4096);
+    } else {
+        fprintf(stderr, "SKIP: unable to lower RLIMIT_MEMLOCK\n");
+    }
+#endif
+}
+
+static void test_unmount_launcher_fails_safely(void) {
+#if defined(__APPLE__)
+    char tmp[] = "/tmp/jerboa-fuse-unmount-test-XXXXXX";
+    char *created = mkdtemp(tmp);
+    check(created != NULL, "unmount probe directory create succeeds");
+    if (!created) return;
+    check(jerboa_fuse_unmount(tmp) != 0,
+          "posix_spawn unmount reports a non-mount without hanging");
+    check(rmdir(tmp) == 0, "unmount probe leaves directory intact");
+#endif
+    errno = 0;
+    check(jerboa_fuse_unmount(NULL) == -1 && errno == EINVAL,
+          "unmount rejects a null mountpoint before launching");
+}
+
+int main(void) {
+    test_exclusive_nofollow_create();
+    test_bad_gcm_tag_zeros_plaintext();
+    test_unmount_launcher_fails_safely();
+    test_secmem_lock_failure_is_fatal();
+    if (failures) return 1;
+    puts("native hardening tests: ok");
+    return 0;
+}