security: gate shstk defaults on no-callcc binaries

Jaime Fournier <jaimef@linbsd.org>

a5561dedba6a1da51f00c07b8cf0d74cebf36e1c

diff --git a/docs/chez-hardening.md b/docs/chez-hardening.md
index 1825558..8bd763f 100644
--- a/docs/chez-hardening.md
+++ b/docs/chez-hardening.md
@@ -317,7 +317,8 @@ Don't enable SHSTK by default in the Chez kernel. Instead:
 5. **For programs that do use `call/cc`** — punt until Phase 3.4.
 
 **Implemented for generated binaries:** the Jerboa binary launchers now
-include an x86_64 Linux startup hook before `Sscheme_init`. It is opt-in:
+include an x86_64 Linux startup hook before `Sscheme_init`. It is opt-in for
+general runtime/tooling binaries:
 
 ```bash
 JERBOA_ENABLE_SHSTK=1   ./dist/jerboa ...
@@ -326,10 +327,19 @@ JERBOA_ENABLE_SHSTK=try ./dist/jerboa ...  # continue if unsupported
 
 This covers `make binary`, `make jerboa`, standalone `jerbuild`,
 `jerbuild binary`, `make jmcp`, and native `make jlsp`. The hook is deliberately
-not enabled by default until the continuation-aware kernel work in 6.4 lands.
+not enabled by default for REPL/tooling binaries until the continuation-aware
+kernel work in 6.4 lands. Standalone generated binaries may opt into a stricter
+build contract with `JERBOA_BINARY_NO_CALLCC=1`; under that contract the
+launcher compiles with `JERBOA_SHSTK_DEFAULT="try"` and enables SHSTK before
+Scheme startup unless runtime `JERBOA_ENABLE_SHSTK` overrides it. Use that only
+for entries built from a profile that excludes `call/cc` and
+`call-with-current-continuation`.
 Set `JERBOA_SECURITY_REPORT=1` on those generated launchers to print the
-compiled CET/BTI/PAC state and the current SHSTK runtime status, then exit
-without booting Scheme or enabling SHSTK.
+compiled CET/BTI/PAC state, the current SHSTK runtime status,
+`shstk-default`, and `shstk-continuation-contract`, then exit without booting
+Scheme. On x86_64 Linux the report path applies the effective SHSTK request
+inside the reporting process before reading status, so release evidence can
+show whether a continuation-free binary defaulted to `try`.
 
 The same launcher build paths also apply target-aware hardening flags:
 Linux x86_64 gets `-fcf-protection=full` and, by default,
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index a5f4486..40f9928 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -978,6 +978,15 @@ error messages/logs. Scanner rules from P2-05 enforce going forward.
 evidence; keep opt-in for REPL/tooling. Record the decision and the
 call/cc hazard in `release-security.md`.
 
+- **Status:** complete for the repository-local launcher policy. General
+  runtime/tooling binaries remain opt-in because Chez continuation restoration
+  is not yet shadow-stack-aware. Standalone generated binaries built with
+  `JERBOA_BINARY_NO_CALLCC=1` now compile with `JERBOA_SHSTK_DEFAULT="try"` and
+  `JERBOA_SHSTK_CONTINUATION_FREE=1`; runtime `JERBOA_ENABLE_SHSTK` still
+  overrides. `JERBOA_SECURITY_REPORT=1` now records `shstk-default` and
+  `shstk-continuation-contract` so release evidence can distinguish
+  continuation-free daemon/parser binaries from REPL/tooling artifacts.
+
 ---
 
 ## 9. P3 — Research and frontier
diff --git a/docs/release-security.md b/docs/release-security.md
index 41d03ec..65c4133 100644
--- a/docs/release-security.md
+++ b/docs/release-security.md
@@ -46,6 +46,18 @@ For reproducible-build investigations, `jerbuild binary` honors
 absolute and its basename must start with `jerbuild-binary-`; this keeps the
 default per-process temporary directory for normal builds while allowing release
 evidence jobs to remove one source of path nondeterminism safely.
+For standalone daemon/parser binaries that are built from a profile that
+excludes `call/cc` and `call-with-current-continuation`, set
+`JERBOA_BINARY_NO_CALLCC=1` for the `jerbuild binary` or
+`support/build-binary.sh` invocation. That build contract compiles the launcher
+with `JERBOA_SHSTK_DEFAULT="try"` and `JERBOA_SHSTK_CONTINUATION_FREE=1`, so
+Linux x86_64 SHSTK is attempted before Scheme startup unless the runtime
+environment explicitly sets `JERBOA_ENABLE_SHSTK`. Do not set this for
+`jerboa`, `jerbuild`, `jmcp`, `jlsp`, REPL-capable artifacts, or any program
+that can invoke first-class continuations. `JERBOA_SECURITY_REPORT=1` evidence
+for such binaries must include `shstk-default: try` and
+`shstk-continuation-contract: no-callcc`; general tooling should continue to
+report `shstk-default: <unset>` and `shstk-continuation-contract: unchecked`.
 On macOS targets, `jerbuild binary` also passes `-Wl,-no_uuid` by default so
 the linker does not inject a fresh Mach-O `LC_UUID` into each standalone binary.
 Set `JERBOA_BINARY_DETERMINISTIC_LINK=0` only for local linker diagnostics.
diff --git a/docs/single-binary.md b/docs/single-binary.md
index 4edd238..5298247 100644
--- a/docs/single-binary.md
+++ b/docs/single-binary.md
@@ -407,6 +407,11 @@ symbols automatically:
 jerbuild binary --static-native --libdirs lib entry.ss app
 ```
 
+For static daemon/parser artifacts that are built from a continuation-free
+profile, add `JERBOA_BINARY_NO_CALLCC=1` to the build environment. That makes
+the generated launcher default Linux x86_64 SHSTK to `try`; leave it unset for
+REPL-capable tools or code that can use `call/cc`.
+
 In a `.jerbuild` manifest, use:
 
 ```scheme
diff --git a/jerbuild.ss b/jerbuild.ss
index 1ded6be..33198e8 100644
--- a/jerbuild.ss
+++ b/jerbuild.ss
@@ -1777,11 +1777,22 @@
            ""))]
     [else ""]))
 
-(define (binary-hardening-cflags mt)
-  (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
-      (target-hardening-cflags mt)
+(define (binary-no-callcc?)
+  (env-enabled? "JERBOA_BINARY_NO_CALLCC" #f))
+
+(define (binary-shstk-contract-cflags)
+  (if (and (env-enabled? "JERBOA_BINARY_HARDEN" #t)
+           (binary-no-callcc?))
+      " -DJERBOA_SHSTK_DEFAULT=\\\"try\\\" -DJERBOA_SHSTK_CONTINUATION_FREE=1"
       ""))
 
+(define (binary-hardening-cflags mt)
+  (string-append
+    (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
+        (target-hardening-cflags mt)
+        "")
+    (binary-shstk-contract-cflags)))
+
 (define (binary-hardening-ldflags mt)
   (if (env-enabled? "JERBOA_BINARY_HARDEN" #t)
       (target-hardening-ldflags mt)
@@ -2454,6 +2465,16 @@
 #endif
 #endif
 
+#ifndef JERBOA_SHSTK_DEFAULT
+#define JERBOA_SHSTK_DEFAULT \"\"
+#endif
+
+#ifdef JERBOA_SHSTK_CONTINUATION_FREE
+#define JERBOA_SHSTK_CONTINUATION_CONTRACT \"no-callcc\"
+#else
+#define JERBOA_SHSTK_CONTINUATION_CONTRACT \"unchecked\"
+#endif
+
 static int jerboa_env_flag_enabled(const char *v) {
     return v && *v &&
            strcmp(v, \"0\") != 0 &&
@@ -2472,23 +2493,43 @@ static int jerboa_security_report_requested(void) {
     return jerboa_env_flag_enabled(getenv(\"JERBOA_SECURITY_REPORT\"));
 }
 
-static void jerboa_maybe_enable_shadow_stack(const char *program_name) {
-#if defined(__linux__) && defined(__x86_64__)
+static const char *jerboa_shadow_stack_default(void) {
+    return JERBOA_SHSTK_DEFAULT;
+}
+
+static const char *jerboa_shadow_stack_request(void) {
     const char *v = getenv(\"JERBOA_ENABLE_SHSTK\");
-    if (!jerboa_env_flag_enabled(v)) return;
+    if (v) return v;
+    v = jerboa_shadow_stack_default();
+    return (v && *v) ? v : NULL;
+}
+
+static int jerboa_try_enable_shadow_stack(const char *program_name,
+                                          const char *request) {
+#if defined(__linux__) && defined(__x86_64__)
+    if (!jerboa_env_flag_enabled(request)) return 0;
     if (syscall(SYS_arch_prctl, ARCH_SHSTK_ENABLE, ARCH_SHSTK_SHSTK) != 0) {
-        if (strcmp(v, \"try\") == 0) return;
+        if (strcmp(request, \"try\") == 0) return 0;
         fprintf(stderr, \"%s: ARCH_SHSTK_ENABLE failed: %s\\n\",
                 program_name ? program_name : \"jerboa\", strerror(errno));
         exit(127);
     }
+    return 1;
 #else
     (void)program_name;
+    (void)request;
+    return 0;
 #endif
 }
 
+static void jerboa_maybe_enable_shadow_stack(const char *program_name) {
+    (void)jerboa_try_enable_shadow_stack(program_name,
+                                         jerboa_shadow_stack_request());
+}
+
 static void jerboa_print_security_report(const char *program_name) {
-    const char *request = getenv(\"JERBOA_ENABLE_SHSTK\");
+    const char *request = jerboa_shadow_stack_request();
+    const char *default_request = jerboa_shadow_stack_default();
     printf(\"program: %s\\n\", program_name ? program_name : \"jerboa\");
 #if defined(__linux__)
     printf(\"os: linux\\n\");
@@ -2527,6 +2568,7 @@ static void jerboa_print_security_report(const char *program_name) {
     printf(\"arm-pac-compiled: no\\n\");
 #endif
 #if defined(__linux__) && defined(__x86_64__)
+    (void)jerboa_try_enable_shadow_stack(program_name, request);
     {
         unsigned long features = 0;
         errno = 0;
@@ -2542,6 +2584,10 @@ static void jerboa_print_security_report(const char *program_name) {
 #else
     printf(\"shstk-runtime: not-applicable\\n\");
 #endif
+    printf(\"shstk-default: %s\\n\",
+           default_request && *default_request ? default_request : \"<unset>\");
+    printf(\"shstk-continuation-contract: %s\\n\",
+           JERBOA_SHSTK_CONTINUATION_CONTRACT);
     printf(\"shstk-request: %s\\n\", request && *request ? request : \"<unset>\");
 }
 
diff --git a/support/build-binary.sh b/support/build-binary.sh
index fe151fd..d528c03 100755
--- a/support/build-binary.sh
+++ b/support/build-binary.sh
@@ -196,6 +196,9 @@ STATIC_CFLAGS=""
 if [ -n "$BINARY_STATIC_ENV" ] && [ "$BINARY_STATIC_ENV" != 0 ]; then
     STATIC_CFLAGS="-DJERBOA_BINARY_STATIC_ENV=1"
 fi
+if flag_enabled "${JERBOA_BINARY_NO_CALLCC:-0}"; then
+    STATIC_CFLAGS="$STATIC_CFLAGS -DJERBOA_SHSTK_DEFAULT=\\\"try\\\" -DJERBOA_SHSTK_CONTINUATION_FREE=1"
+fi
 
 # ── Determine target machine type ───────────────────────────────────────────
 if [ "$CROSS_BUILD" = yes ]; then
diff --git a/support/hardening-report.h b/support/hardening-report.h
index 44b6742..885613a 100644
--- a/support/hardening-report.h
+++ b/support/hardening-report.h
@@ -2,8 +2,12 @@
  *
  * Normal startup behavior is unchanged unless one of the documented
  * environment variables is set:
- *   JERBOA_ENABLE_SHSTK=1|try   opt in to Linux x86_64 shadow stacks
+ *   JERBOA_ENABLE_SHSTK=0|1|try override Linux x86_64 shadow-stack startup
  *   JERBOA_SECURITY_REPORT=1    print build/runtime hardening state and exit
+ *
+ * Builders may define JERBOA_SHSTK_DEFAULT as a string literal, typically
+ * "try", and JERBOA_SHSTK_CONTINUATION_FREE when the entry profile excludes
+ * call/cc. Runtime JERBOA_ENABLE_SHSTK is still the final override.
  */
 #ifndef JERBOA_HARDENING_REPORT_H
 #define JERBOA_HARDENING_REPORT_H
@@ -30,6 +34,16 @@
 #endif
 #endif
 
+#ifndef JERBOA_SHSTK_DEFAULT
+#define JERBOA_SHSTK_DEFAULT ""
+#endif
+
+#ifdef JERBOA_SHSTK_CONTINUATION_FREE
+#define JERBOA_SHSTK_CONTINUATION_CONTRACT "no-callcc"
+#else
+#define JERBOA_SHSTK_CONTINUATION_CONTRACT "unchecked"
+#endif
+
 static int jerboa_env_flag_enabled(const char *v) {
     return v && *v &&
            strcmp(v, "0") != 0 &&
@@ -48,23 +62,44 @@ static int jerboa_security_report_requested(void) {
     return jerboa_env_flag_enabled(getenv("JERBOA_SECURITY_REPORT"));
 }
 
-static void jerboa_maybe_enable_shadow_stack(const char *program_name) {
-#if defined(__linux__) && defined(__x86_64__)
+static const char *jerboa_shadow_stack_default(void) {
+    return JERBOA_SHSTK_DEFAULT;
+}
+
+static const char *jerboa_shadow_stack_request(void) {
     const char *v = getenv("JERBOA_ENABLE_SHSTK");
-    if (!jerboa_env_flag_enabled(v)) return;
+    if (v) return v;
+
+    v = jerboa_shadow_stack_default();
+    return (v && *v) ? v : NULL;
+}
+
+static int jerboa_try_enable_shadow_stack(const char *program_name,
+                                          const char *request) {
+#if defined(__linux__) && defined(__x86_64__)
+    if (!jerboa_env_flag_enabled(request)) return 0;
     if (syscall(SYS_arch_prctl, ARCH_SHSTK_ENABLE, ARCH_SHSTK_SHSTK) != 0) {
-        if (strcmp(v, "try") == 0) return;
+        if (strcmp(request, "try") == 0) return 0;
         fprintf(stderr, "%s: ARCH_SHSTK_ENABLE failed: %s\n",
                 program_name ? program_name : "jerboa", strerror(errno));
         exit(127);
     }
+    return 1;
 #else
     (void)program_name;
+    (void)request;
+    return 0;
 #endif
 }
 
+static void jerboa_maybe_enable_shadow_stack(const char *program_name) {
+    (void)jerboa_try_enable_shadow_stack(program_name,
+                                         jerboa_shadow_stack_request());
+}
+
 static void jerboa_print_security_report(const char *program_name) {
-    const char *request = getenv("JERBOA_ENABLE_SHSTK");
+    const char *request = jerboa_shadow_stack_request();
+    const char *default_request = jerboa_shadow_stack_default();
 
     printf("program: %s\n", program_name ? program_name : "jerboa");
 
@@ -112,6 +147,7 @@ static void jerboa_print_security_report(const char *program_name) {
 #endif
 
 #if defined(__linux__) && defined(__x86_64__)
+    (void)jerboa_try_enable_shadow_stack(program_name, request);
     {
         unsigned long features = 0;
         errno = 0;
@@ -128,6 +164,10 @@ static void jerboa_print_security_report(const char *program_name) {
     printf("shstk-runtime: not-applicable\n");
 #endif
 
+    printf("shstk-default: %s\n",
+           default_request && *default_request ? default_request : "<unset>");
+    printf("shstk-continuation-contract: %s\n",
+           JERBOA_SHSTK_CONTINUATION_CONTRACT);
     printf("shstk-request: %s\n", request && *request ? request : "<unset>");
 }