update

ober

9f8e8051439aeeb5efc51554e50930b9c627829f

diff --git a/.jerbuild b/.jerbuild
index 9160887..0113842 100644
--- a/.jerbuild
+++ b/.jerbuild
@@ -29,7 +29,8 @@
 ;; by the shim.
 (extra-sources
   ("vendor/jerboa-sqlite/jerboa_sqlite_shim.c" cflags: "-Isupport")
-  ("src/jcode/ui/jcode_tui_shim.c" cflags: "-DTB_OPT_ATTR_W=32 -Ivendor/termbox2"))
+  ("src/jcode/ui/jcode_tui_shim.c" cflags: "-DTB_OPT_ATTR_W=32 -Ivendor/termbox2")
+  ("support/landlock-shim.c"))
 
 (extra-archives "support/sqlite-bundled/target/release/libjcode_sqlite_bundled.a")
 
diff --git a/build-jcode-cross.ss b/build-jcode-cross.ss
index ebb5b7e..79e774b 100644
--- a/build-jcode-cross.ss
+++ b/build-jcode-cross.ss
@@ -303,8 +303,10 @@
     "jcode_tb_event_key" "jcode_tb_event_ch"
     "jcode_tb_event_w" "jcode_tb_event_h"
     "jcode_tb_event_x" "jcode_tb_event_y"
-    ;; landlock-shim (~/mine/jerboa/support/landlock-shim.c)
-    "jerboa_landlock_sandbox"))
+    ;; landlock-shim (support/landlock-shim.c)
+    "jerboa_landlock_sandbox"
+    "jerboa_landlock_sandbox_ex"
+    "jerboa_landlock_abi_version"))
 
 (define jerboa-native-symbols
   '(;; jerboa-native (TLS/rustls)
@@ -370,8 +372,8 @@
     "system" "getenv" "putenv" "_exit" "exit" "execvp" "execve"
     ;; temporary files/directories — std/os/temp
     "mkstemp" "mkdtemp" "unlink" "rmdir"
-    ;; errno location
-    "__errno_location" "fcntl"))
+    ;; errno location + Linux sandboxing
+    "__errno_location" "fcntl" "prctl" "syscall"))
 
 (define main-c-path (string-append output "-main.c"))
 
@@ -387,6 +389,7 @@
   (display "#include <sys/stat.h>\n" out)
   (display "#include <sys/wait.h>\n" out)
   (display "#include <sys/resource.h>\n" out)
+  (display "#include <sys/prctl.h>\n" out)
   (display "#include <sys/socket.h>\n" out)
   (display "#include <netinet/in.h>\n" out)
   (display "#include <arpa/inet.h>\n" out)
@@ -489,7 +492,7 @@
 
 (define sqlite-shim-c    "vendor/jerboa-sqlite/jerboa_sqlite_shim.c")
 (define jcode-tui-shim-c "src/jcode/ui/jcode_tui_shim.c")
-(define landlock-shim-c  (format "~a/support/landlock-shim.c" jerboa-home))
+(define landlock-shim-c  "support/landlock-shim.c")
 
 (require-file sqlite-shim-c)
 (require-file jcode-tui-shim-c)
diff --git a/build-jcode-freebsd-cross.ss b/build-jcode-freebsd-cross.ss
index 3884a5c..60766b8 100644
--- a/build-jcode-freebsd-cross.ss
+++ b/build-jcode-freebsd-cross.ss
@@ -289,6 +289,7 @@
 ;; the Scheme code never calls them on this platform.
 (define freebsd-stub-symbols
   '("jerboa_landlock_sandbox"
+    "jerboa_landlock_sandbox_ex"
     "jerboa_landlock_abi_version" "jerboa_landlock_create_ruleset"
     "jerboa_landlock_add_path_rule" "jerboa_landlock_add_net_rule"
     "jerboa_landlock_enforce"
diff --git a/build-jcode-musl.ss b/build-jcode-musl.ss
index dde124e..9caa951 100644
--- a/build-jcode-musl.ss
+++ b/build-jcode-musl.ss
@@ -220,8 +220,8 @@
     "system" "getenv" "putenv" "_exit" "exit" "execvp" "execve"
     ;; temporary files/directories — std/os/temp
     "mkstemp" "mkdtemp" "unlink" "rmdir"
-    ;; errno location (Linux glibc + musl convention)
-    "__errno_location" "fcntl"))
+    ;; errno location + Linux sandboxing
+    "__errno_location" "fcntl" "prctl" "syscall"))
 
 ;; FFI symbols that need Sforeign_symbol registration
 (define ffi-symbols
@@ -288,7 +288,11 @@
     "jerboa_sqlite_column_text" "jerboa_sqlite_column_blob"
     "jerboa_sqlite_column_name"
     ;; jerboa-native (net/io) — vectored I/O wrapper used by (std net io)
-    "jerboa_writev2"))
+    "jerboa_writev2"
+    ;; landlock-shim (support/landlock-shim.c)
+    "jerboa_landlock_sandbox"
+    "jerboa_landlock_sandbox_ex"
+    "jerboa_landlock_abi_version"))
 
 ;; ========== Step 0: Prepare isolated object output ==========
 
@@ -549,6 +553,7 @@
     (display "#include <netdb.h>\n"         out)  ;; getaddrinfo, freeaddrinfo
     (display "#include <sys/wait.h>\n"      out)  ;; waitpid
     (display "#include <sys/resource.h>\n"  out)  ;; getrlimit
+    (display "#include <sys/prctl.h>\n"     out)  ;; prctl
     (display "#include <termios.h>\n"       out)  ;; tcgetattr/tcsetattr/tcgetpgrp/tcsetpgrp
     (display "#include <signal.h>\n"        out)  ;; kill, sigemptyset/sigprocmask family
     (display "#include <time.h>\n"          out)  ;; localtime, strftime
@@ -639,8 +644,8 @@
 (run-cmd (format "~a -c -O2 ~a -DTB_OPT_ATTR_W=32 -Ivendor/termbox2 -o '~a/jcode-tui-shim.o' src/jcode/ui/jcode_tui_shim.c -Wall"
                  gcc harden-cflags build-dir))
 
-;; Compile landlock shim from jerboa
-(let ([landlock-src (format "~a/support/landlock-shim.c" jerboa-dir-base)])
+;; Compile landlock shim from this repo.
+(let ([landlock-src "support/landlock-shim.c"])
   (if (file-exists? landlock-src)
     (run-cmd (format "~a -c -O2 ~a -o '~a/landlock-shim.o' '~a' -Wall"
                      gcc harden-cflags build-dir landlock-src))
diff --git a/docs/providers.md b/docs/providers.md
index 5fd7a23..cf86b0d 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -81,6 +81,32 @@ Serve a model with `mlx_lm.server` (default `http://127.0.0.1:8080/v1`), then:
 MLX gets sane sampling defaults automatically (see below) — without them, small
 quantized models loop on greetings or truncate XML mid-stream.
 
+### Prompt caching
+
+`jcode` uses provider-specific cache controls instead of spraying one field at
+every OpenAI-compatible endpoint:
+
+- **Anthropic**: marks the stable system prompt and tool schema with
+  `cache_control` and 1 h TTL, and marks the last two dynamic turns as ephemeral
+  cache breakpoints. Streaming usage reports become `cache-read` and
+  `cache-creation`.
+- **OpenAI / OpenRouter**: sends a stable per-process `prompt_cache_key` so
+  repeated turns route to the same cache shard. For OpenAI GPT-5 and GPT-4.1
+  model families, `jcode` also asks for `prompt_cache_retention: "24h"`;
+  older model families stay on OpenAI's default retention so requests do not
+  fail on unsupported cache-retention options.
+- **MLX / omlx**: sends the same stable `prompt_cache_key` on the local
+  OpenAI-compatible path and records
+  `usage.prompt_tokens_details.cached_tokens` as `cache-read` when the server
+  reports it. Stock `mlx_lm.server` and `omlx` already do prefix-cache matching
+  server-side; the cache key is a routing hint for compatible frontends and is
+  ignored by servers that do not need it.
+- **Ollama**: the `/v1/chat/completions` compatibility endpoint is strict and
+  does not document `prompt_cache_key`, so `jcode` deliberately omits it instead
+  of risking 4xx failures. Keep model residency and Ollama KV-cache settings on
+  the Ollama side (`OLLAMA_KEEP_ALIVE`, `OLLAMA_KV_CACHE_TYPE`, etc.) while
+  `jcode` keeps using `/v1` for tool-call parity.
+
 ### Grok CLI session
 
 `grok` is a separate provider from `xai` even though both surface Grok models.
@@ -129,7 +155,7 @@ model cards — Qwen3, Gemma, Mistral, Granite, Nemotron, gpt-oss, and more. Whe
 a model is unknown and the provider is **MLX**, a safe fallback applies:
 
 ```
-temperature 0.3   top_p 0.9   repetition_penalty 1.05
+temperature 0.6   top_p 0.95   top_k 20   repetition_penalty 1.1
 ```
 
 Request-body token caps: OpenAI-style requests default to `max_tokens 32768`,
diff --git a/docs/tools.md b/docs/tools.md
index c72bf70..8e41d4f 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -128,16 +128,20 @@ model instead of executing; `prompt` surfaces an interactive
 
 ### 3. OS sandboxing
 
-`bash` (and the `external-llm` tool) wrap commands in Jerboa's OS sandbox —
-**Landlock** on Linux, **Seatbelt** on macOS, **Capsicum** on FreeBSD,
-**pledge+unveil** on OpenBSD. The external-LLM sandbox is tight by design: it
-can read system roots, write only `cwd` / `/tmp` / the chosen provider's auth
-dir (`~/.claude`, `~/.gemini`, `~/.codex`, `~/.config/opencode`, **`~/.grok`**),
-and exec only from standard bin paths — every other provider's credentials plus
-`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.config/gh`, `~/.docker` are
-denied. The deny list is symmetric: when the chosen CLI is `claude`, `~/.grok`
-is denied; when the chosen CLI is `grok`, `~/.claude` is denied; no CLI can
-read another's bearer token.
+When `sandbox.enabled` is true, `bash` and `external-llm` launch through
+Jerboa's unified OS sandbox. Linux runs fail-closed with Landlock filesystem,
+exec, network, and safe-syscall axes. macOS runs with a Seatbelt profile.
+FreeBSD dynamic shell/CLI commands report a degraded sandbox refusal when the
+requested axes cannot be enforced, rather than silently pretending path ACLs are
+installed.
+
+The external-LLM sandbox is tight by design: it can read system roots, write
+only `cwd` / `/tmp` / the chosen provider's auth dir (`~/.claude`, `~/.gemini`,
+`~/.codex`, `~/.config/opencode`, **`~/.grok`**), and exec only from standard
+bin paths. Every other provider's credentials plus `~/.ssh`, `~/.aws`,
+`~/.gnupg`, `~/.netrc`, `~/.config/gh`, `~/.docker` are denied. The deny list is
+symmetric: when the chosen CLI is `claude`, `~/.grok` is denied; when the chosen
+CLI is `grok`, `~/.claude` is denied; no CLI can read another's bearer token.
 
 ### 4. Generated-artifact protection
 
diff --git a/main-binary.ss b/main-binary.ss
index e68972d..eb6b8bb 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -4,6 +4,7 @@
 
 (import (chezscheme)
         (jcode core config)
+        (jcode core prompts)
         (jcode core agent)
         (jcode ui cli))
 
diff --git a/src/jcode/core/sandbox.ss b/src/jcode/core/sandbox.ss
index e84e7ec..ba1aa9e 100644
--- a/src/jcode/core/sandbox.ss
+++ b/src/jcode/core/sandbox.ss
@@ -1,8 +1,8 @@
 ;;; jcode bash sandboxing
 ;;;
-;;; Wraps a shell command in a platform-specific sandbox. On macOS uses
-;;; `sandbox-exec` with a Seatbelt profile derived from codex-rs's base
-;;; policy. On Linux a no-op for now (TODO: bwrap+landlock).
+;;; Runs shell commands through Jerboa's unified sandbox launcher when
+;;; enabled: Landlock + seccomp on Linux, Seatbelt on macOS, and an honest
+;;; degraded Capsicum report on FreeBSD for dynamic shell commands.
 ;;;
 ;;; Config in jcode.json:
 ;;;
@@ -18,27 +18,35 @@
 
 (export sandbox-enabled?
         sandbox-wrap-command
+        sandbox-run-command
         sandbox-platform)
 
 (import :std/misc/string
         :std/os/path
         :std/os/aproc     ;; P3.1: argv-style spawn replaces (system "cmd > tmp 2> tmp")
+        :std/os/platform
+        (only (std os limits sandbox)
+              sandbox-policy
+              sandbox-launch
+              sandbox-result-process
+              sandbox-result-launched?
+              sandbox-result-refused-axes)
+        (only (std os supervise)
+              process-result-status
+              process-result-stdout
+              process-result-stderr)
         :jcode/core/config
         :jcode/core/log)
 
 (def logger (make-logger "sandbox"))
 
 (def (sandbox-platform)
-  ;; Conservative platform probe via uname.
-  (let ((sysname (try
-                   (let-values (((out _err _rc)
-                                 (process-run-capture "uname -s")))
-                     (string-trim (or out "")))
-                   (catch (e) ""))))
-    (cond
-      ((string=? sysname "Darwin") 'darwin)
-      ((string=? sysname "Linux")  'linux)
-      (else                        'unknown))))
+  (cond
+    ((platform-macos?) 'darwin)
+    ((platform-linux?) 'linux)
+    ((string=? (platform-name) "freebsd") 'freebsd)
+    ((string=? (platform-name) "openbsd") 'openbsd)
+    (else 'unknown)))
 
 (def (sandbox-enabled?)
   (let ((block (config-ref "sandbox")))
@@ -63,6 +71,55 @@
            ((list? xs) xs)
            (else '())))))))
 
+(def (sandbox-exec-paths)
+  (list "/usr/bin" "/bin" "/usr/local/bin" "/opt/homebrew/bin"
+        "/sbin" "/usr/sbin"
+        (path-join (or (getenv "HOME") "/") ".local/bin")))
+
+(def (sandbox-write-paths cwd)
+  (let ((base (list (or cwd (current-directory))
+                    "/tmp" "/private/tmp" "/private/var/folders")))
+    (append base (sandbox-extra-writable))))
+
+(def (sandbox-timeout-ms timeout)
+  (cond
+    ((and (number? timeout) (> timeout 0))
+     (inexact->exact (round (* timeout 1000))))
+    (else #f)))
+
+(def (sandbox-required-axes)
+  ;; Linux can enforce all of these for exec'd children. macOS can enforce
+  ;; fs/net via sandbox-exec. FreeBSD Capsicum cannot safely express path ACLs
+  ;; for arbitrary dynamically linked shell commands, so we launch with a
+  ;; degraded report instead of pretending path isolation is installed.
+  (cond
+    ((platform-linux?)
+     (if (sandbox-allow-network?)
+       '(fs exec syscalls)
+       '(fs exec net syscalls)))
+    ((platform-macos?)
+     (if (sandbox-allow-network?)
+       '(fs)
+       '(fs net)))
+    (else '())))
+
+(def (make-bash-sandbox-policy cwd)
+  (sandbox-policy
+    read-paths:  (list "/")
+    write-paths: (sandbox-write-paths cwd)
+    exec-paths:  (sandbox-exec-paths)
+    net:         (if (sandbox-allow-network?) 'allow 'deny)
+    syscalls:    'safe
+    capsicum?:   #f))
+
+(def (bytevector->safe-string bv)
+  (try (utf8->string bv)
+    (catch (e) "")))
+
+(def (sandbox-refusal-message r)
+  (format "sandbox refused to launch; missing required axes: ~a"
+          (sandbox-result-refused-axes r)))
+
 (def (sandbox-wrap-command command cwd)
   "Return a possibly-wrapped shell command string. If sandboxing is
    not enabled or the platform is unsupported, return COMMAND
@@ -76,6 +133,38 @@
         (log-debug logger "platform-not-supported" `((command . ,command)))
         command)))))
 
+(def (sandbox-run-command command timeout cwd)
+  "Run COMMAND and return (values stdout stderr exit-code). When sandboxing is
+   disabled this preserves the historical aproc path. When enabled, use the
+   unified sandbox launcher with argv, captured stdout/stderr, timeout, and
+   fail-closed requirements on platforms that can actually enforce them."
+  (cond
+    ((not (sandbox-enabled?))
+     (aproc-run/status command dir: cwd timeout-ms: (sandbox-timeout-ms timeout)))
+    (else
+     (let* ((work (or cwd (current-directory)))
+            (policy (make-bash-sandbox-policy work))
+            (result
+              (sandbox-launch policy
+                command: (list "/bin/sh" "-c" command)
+                env: #f
+                cwd: work
+                capture-stdout?: #t
+                capture-stderr?: #t
+                timeout-ms: (sandbox-timeout-ms timeout)
+                stdout-cap-bytes: 10485760
+                stderr-cap-bytes: 1048576
+                require: (sandbox-required-axes)
+                fail-closed?: #t)))
+       (cond
+         ((not (sandbox-result-launched? result))
+          (values "" (sandbox-refusal-message result) 126))
+         (else
+          (let ((proc (sandbox-result-process result)))
+            (values (bytevector->safe-string (process-result-stdout proc))
+                    (bytevector->safe-string (process-result-stderr proc))
+                    (or (process-result-status proc) -1)))))))))
+
 (def (darwin-wrap command cwd)
   (let* ((work (or cwd (current-directory)))
          (profile (darwin-profile work))
diff --git a/src/jcode/provider/provider.ss b/src/jcode/provider/provider.ss
index 658515b..5b5a843 100644
--- a/src/jcode/provider/provider.ss
+++ b/src/jcode/provider/provider.ss
@@ -50,7 +50,7 @@
 
 (def (stream-aborted?)
   (let ((abort? (current-stream-abort?)))
-    (and abort? (guard (e [#t #f]) (abort?)))))
+    (and abort? (guard (e [(condition? e) #f]) (abort?)))))
 
 (def (check-stream-abort!)
   (when (stream-aborted?)
@@ -983,14 +983,30 @@
     `(("Content-Type"  . "application/json")
       ("Authorization" . ,(string-append "Bearer " key)))))
 
-;; Stable per-process key for OpenAI prompt-cache routing: keeps a session's
-;; requests on the same cache shard so server-side prefix caching hits more
-;; reliably. Only sent to providers known to accept it — strict APIs
-;; (e.g. mistral) 4xx on unknown fields.
+;; Stable per-process key for prompt-cache routing: keeps a session's requests
+;; on the same cache shard so server-side prefix caching hits more reliably.
+;; Only sent to providers known to accept or safely ignore it — strict APIs
+;; (e.g. mistral, ollama's /v1 compatibility endpoint) 4xx on unknown fields.
 (def *prompt-cache-key* (format "jcode-~a" (real-time)))
 
 (def (prompt-cache-key-provider? name)
-  (member name '("openai" "openrouter")))
+  (member name '("openai" "openrouter" "mlx")))
+
+(def (openai-extended-cache-model? model)
+  ;; OpenAI documents 24h prompt-cache retention for GPT-5-family models and
+  ;; GPT-4.1-family models. Keep older families on their default in-memory
+  ;; policy so we do not turn prompt caching into a request-time 400.
+  (and (string? model)
+       (or (string-prefix? "gpt-5" model)
+           (string-prefix? "gpt-4.1" model))))
+
+(def (apply-prompt-cache-controls! body provider)
+  (let ((name (provider-name provider)))
+    (when (prompt-cache-key-provider? name)
+      (hash-put! body "prompt_cache_key" *prompt-cache-key*))
+    (when (and (equal? name "openai")
+               (openai-extended-cache-model? (provider-model provider)))
+      (hash-put! body "prompt_cache_retention" "24h"))))
 
 (def (openai-cached-tokens usage)
   ;; OpenAI-compat servers (openai, openrouter, vLLM, llama-server) report
@@ -1007,8 +1023,7 @@
     (hash-put! body "max_tokens" 32768)
     (apply-sampling-to-body! body (provider-model provider) (provider-name provider))
     (apply-logprobs! body)
-    (when (prompt-cache-key-provider? (provider-name provider))
-      (hash-put! body "prompt_cache_key" *prompt-cache-key*))
+    (apply-prompt-cache-controls! body provider)
     (hash-put! body "messages" (map message->json messages))
     (when (and tools (not (null? tools))
                (not (model-rejects-tools? (provider-model provider))))
@@ -1116,7 +1131,7 @@
       (let ((block (make-hash-table)))
         (hash-put! block "type" "text")
         (hash-put! block "text" (message-content system-msg))
-        (hash-put! block "cache_control" (cache-control-ephemeral))
+        (hash-put! block "cache_control" (cache-control-1h))
         (hash-put! body "system" (list block))))
     ;; Convert messages and apply caching to last 2 turns
     (let ((converted (map anthropic-convert-message other-msgs)))
@@ -1422,8 +1437,7 @@
     (apply-sampling-to-body! body (provider-model provider) (provider-name provider))
     (unless (skip-stream-logprobs? provider tools)
       (apply-logprobs! body))
-    (when (prompt-cache-key-provider? (provider-name provider))
-      (hash-put! body "prompt_cache_key" *prompt-cache-key*))
+    (apply-prompt-cache-controls! body provider)
     ;; Request usage data in stream
     (let ((opts (make-hash-table)))
       (hash-put! opts "include_usage" #t)
diff --git a/src/jcode/tool/bash.ss b/src/jcode/tool/bash.ss
index c30e30c..06919a5 100644
--- a/src/jcode/tool/bash.ss
+++ b/src/jcode/tool/bash.ss
@@ -2,8 +2,7 @@
 
 (export init-bash-tool)
 
-(import :std/os/aproc
-        :std/misc/string
+(import :std/misc/string
         :jcode/core/log
         :jcode/core/permissions
         :jcode/core/sandbox
@@ -56,17 +55,12 @@
          (run-bash-command command timeout cwd))))))
 
 (def (run-bash-command command timeout cwd)
-  ;; aproc-run/status is __collect_safe under the hood, so a long-running
-  ;; bash tool call (e.g. `make build`) doesn't pin the TC mutex and the
-  ;; TUI keeps rendering. shell/status — which it replaces — read through
-  ;; Chez ports and froze every other green thread for the subprocess's
-  ;; lifetime.
-  (let ((cmd (sandbox-wrap-command command cwd)))
-    (try
-      (let-values (((stdout stderr exit-code) (aproc-run/status cmd dir: cwd)))
-        (format-result stdout stderr exit-code))
-      (catch (e)
-        (format "Error: ~a" (err->string e))))))
+  (try
+    (let-values (((stdout stderr exit-code)
+                  (sandbox-run-command command timeout cwd)))
+      (format-result stdout stderr exit-code))
+    (catch (e)
+      (format "Error: ~a" (err->string e)))))
 
 (def (format-result stdout stderr exit-code)
   (let ((parts '()))
diff --git a/src/jcode/tool/external-llm.ss b/src/jcode/tool/external-llm.ss
index 910909d..c6e180e 100644
--- a/src/jcode/tool/external-llm.ss
+++ b/src/jcode/tool/external-llm.ss
@@ -1,11 +1,12 @@
 ;;; jcode external-LLM second-opinion runner
 ;;;
-;;; Spawns an external LLM CLI (claude / gemini / codex / opencode) under
-;;; jerboa's (std os sandbox) — Landlock on Linux, Seatbelt on macOS,
-;;; Capsicum on FreeBSD, pledge+unveil on OpenBSD. The CLI's own sandbox
-;;; is disabled because the kernel-level profile we apply is the real
-;;; isolation; nesting two sandboxes nondeterministically deadlocks on
-;;; macOS Seatbelt.
+;;; Spawns an external LLM CLI (claude / gemini / codex / opencode / grok)
+;;; under Jerboa's unified sandbox launcher. Linux uses Landlock + safe
+;;; syscalls, macOS uses Seatbelt, and platforms that cannot enforce the
+;;; requested axes for dynamic CLI execution fail closed with a degraded
+;;; sandbox report. The CLI's own sandbox is disabled because the kernel-level
+;;; profile we apply is the real isolation; nesting two sandboxes can deadlock
+;;; on macOS Seatbelt.
 ;;;
 ;;; Read paths:  system roots (/usr, /bin, /etc, /opt, /Library, /System).
 ;;; Write paths: cwd, /tmp, only THIS provider's auth/config dir
@@ -37,9 +38,16 @@
         :jerboa/runtime
         :std/os/path
         :std/os/platform
-        :std/os/sandbox
-        :std/os/temp     ;; P3.1: mkstemp-backed temp files (no predictable /tmp paths)
-        :std/misc/ports
+        (only (std os limits sandbox)
+              sandbox-policy
+              sandbox-launch
+              sandbox-result-process
+              sandbox-result-launched?
+              sandbox-result-refused-axes)
+        (only (std os supervise)
+              process-result-status
+              process-result-stdout
+              process-result-stderr)
         :std/misc/string
         :std/misc/uuid
         :std/text/json
@@ -107,34 +115,6 @@
                  (path-join (home) ".config/grok"))))
     (else #f)))
 
-;; POSIX single-quote escape: anything between single quotes is literal
-;; except for the single quote itself, which we close-quote, escape with
-;; backslash, then re-open. Handles newlines, $, backticks, the lot.
-(def (sh-single-quote s)
-  (let ((out (open-output-string)))
-    (display "'" out)
-    (let lp ((cs (string->list s)))
-      (cond
-        ((null? cs) (void))
-        ((char=? (car cs) #\')
-         (display "'\\''" out)
-         (lp (cdr cs)))
-        (else
-         (display (car cs) out)
-         (lp (cdr cs)))))
-    (display "'" out)
-    (get-output-string out)))
-
-(def (build-cmdline argv stdout-path)
-  (string-append
-    (string-join (map sh-single-quote argv) " ")
-    " > " (sh-single-quote stdout-path) " 2>&1"))
-
-(def (read-text-or-empty path)
-  (try
-    (if (file-exists? path) (read-file-string path) "")
-    (catch (e) "")))
-
 ;; Auth paths per provider (the same lists embedded in provider-spec, but
 ;; reachable by name so we can build a deny-list of OTHER providers'
 ;; tokens for the macOS profile).
@@ -175,22 +155,85 @@
             (path-join (home) ".docker")
             (path-join (current-directory) "jcode.json")))))
 
-;; macOS Seatbelt: jerboa's default `(deny default)` profile blocks
-;; network and sub-process fork, which kills any LLM CLI before it can
-;; reach its API. Use the inverse model: allow default, then deny only
-;; the specific paths we want kept invisible.
-(def (build-deny-profile chosen)
-  (let ((deny (sensitive-deny-paths chosen)))
-    (string-append
-      "(version 1)(allow default)"
-      (apply string-append
-        (map (lambda (p)
-               (format "(deny file-read* (subpath ~s))" p))
-             deny))
-      (apply string-append
-        (map (lambda (p)
-               (format "(deny file-write* (subpath ~s))" p))
-             deny)))))
+(def (external-read-paths)
+  (list "/usr" "/bin" "/sbin" "/etc" "/opt" "/Library" "/System"
+        "/private/etc" "/private/var/db" "/dev"
+        (path-join (home) ".gitconfig")
+        (path-join (home) ".config/git")))
+
+(def (external-write-paths cwd auth)
+  (cons cwd (cons "/tmp" (cons "/private/tmp" auth))))
+
+(def (external-exec-paths)
+  (list "/usr/bin" "/bin" "/usr/local/bin" "/opt/homebrew/bin"
+        (path-join (home) ".local/bin")))
+
+(def (external-required-axes)
+  (cond
+    ((platform-linux?) '(fs exec syscalls))
+    ((platform-macos?) '(fs))
+    (else '())))
+
+(def (external-sandbox-policy chosen read-paths write-paths exec-paths)
+  ;; macOS keeps the historical allow-default profile with selective deny
+  ;; paths. Linux/OpenBSD use an allow-list model where other providers'
+  ;; credentials are denied by omission rather than by an explicit deny rule.
+  ;; FreeBSD dynamic CLI execution reports degraded Capsicum support instead of
+  ;; pretending path ACLs are enforceable.
+  (cond
+    ((platform-macos?)
+     (let ((deny (sensitive-deny-paths chosen)))
+       (sandbox-policy
+         deny-read-paths:  deny
+         deny-write-paths: deny
+         net:              'allow
+         syscalls:         'unrestricted
+         capsicum?:        #f)))
+    (else
+     (sandbox-policy
+       read-paths:  read-paths
+       write-paths: write-paths
+       exec-paths:  exec-paths
+       net:         'allow
+       syscalls:    'safe
+       capsicum?:   #f))))
+
+(def (bytevector->safe-string bv)
+  (try (utf8->string bv)
+    (catch (e) "")))
+
+(def (combine-output stdout stderr)
+  (string-append stdout stderr))
+
+(def (sandbox-refusal-text result)
+  (format "sandbox refused to launch; missing required axes: ~a"
+          (sandbox-result-refused-axes result)))
+
+(def (run-sandboxed-argv label chosen argv cwd auth)
+  (let* ((read-paths  (external-read-paths))
+         (write-paths (external-write-paths cwd auth))
+         (exec-paths  (external-exec-paths))
+         (policy      (external-sandbox-policy chosen read-paths write-paths exec-paths))
+         (result
+           (sandbox-launch policy
+             command: argv
+             env: #f
+             cwd: cwd
+             capture-stdout?: #t
+             capture-stderr?: #t
+             stdout-cap-bytes: 10485760
+             stderr-cap-bytes: 1048576
+             require: (external-required-axes)
+             fail-closed?: #t)))
+    (cond
+      ((not (sandbox-result-launched? result))
+       (values 126 (sandbox-refusal-text result)))
+      (else
+       (let* ((proc (sandbox-result-process result))
+              (out  (bytevector->safe-string (process-result-stdout proc)))
+              (err  (bytevector->safe-string (process-result-stderr proc))))
+         (values (or (process-result-status proc) -1)
+                 (combine-output out err)))))))
 
 (def (ask-external-llm provider prompt)
   "Run PROVIDER's CLI in a sandboxed child, pass PROMPT, return captured
@@ -214,39 +257,15 @@
          (auth     (caddr spec))
          (chosen   (string->symbol label))
          (argv     (build prompt))
-         (cwd      (current-directory))
-         ;; P3.1: mkstemp gives an unguessable, atomic-create path (mode 0600)
-         ;; so a same-uid attacker can't pre-seed a symlink at the target.
-         (tmp-out  (make-temporary-file (format "/tmp/jcode-ask-~a-XXXXXX" label)))
-         (cmd      (build-cmdline argv tmp-out))
-         ;; Linux/BSD path lists (Landlock / Capsicum / unveil consume
-         ;; these directly). macOS branch builds its own SBPL below.
-         (read-paths
-           (list "/usr" "/bin" "/sbin" "/etc" "/opt" "/Library" "/System"
-                 "/private/etc" "/private/var/db" "/dev"
-                 (path-join (home) ".gitconfig")
-                 (path-join (home) ".config/git")))
-         (write-paths
-           (cons cwd (cons "/tmp" (cons "/private/tmp" auth))))
-         (exec-paths
-           (list "/usr/bin" "/bin" "/usr/local/bin" "/opt/homebrew/bin"
-                 (path-join (home) ".local/bin"))))
-    (log-info logger "ask-external-llm" `((provider . ,label) (cmd . ,cmd)))
-    (let ((status
-           (try
-             (cond
-               ((platform-macos?)
-                (sandbox-run/profile (build-deny-profile chosen)
-                  (lambda () (system cmd))))
-               (else
-                (sandbox-run/command read-paths write-paths
-                                     exec-paths cmd)))
-             (catch (e) -1))))
-      (let ((text (read-text-or-empty tmp-out)))
-        (cond
-          ((eqv? status 0) text)
-          (else (format "ERROR: ~a exited with status ~a\n\n~a"
-                        label status text)))))))
+         (cwd      (current-directory)))
+    (log-info logger "ask-external-llm" `((provider . ,label) (argv . ,argv)))
+    (let-values (((status text)
+                  (try (run-sandboxed-argv label chosen argv cwd auth)
+                    (catch (e) (values -1 (err->string e))))))
+      (cond
+        ((eqv? status 0) text)
+        (else (format "ERROR: ~a exited with status ~a\n\n~a"
+                      label status text))))))
 
 ;; ============================================================
 ;; Sessioned API — keeps a stable session id across CLI invocations
@@ -387,39 +406,19 @@
          (eff-sid  (and (> (length spec) 4) (list-ref spec 4)))
          (sid-fallback (or eff-sid session-id))
          (chosen   (string->symbol label))
-         (cwd      (current-directory))
-         ;; P3.1: see run-with-spec — mkstemp instead of predictable /tmp path.
-         (tmp-out  (make-temporary-file (format "/tmp/jcode-tab-~a-XXXXXX" label)))
-         (cmd      (build-cmdline argv tmp-out))
-         (read-paths
-           (list "/usr" "/bin" "/sbin" "/etc" "/opt" "/Library" "/System"
-                 "/private/etc" "/private/var/db" "/dev"
-                 (path-join (home) ".gitconfig")
-                 (path-join (home) ".config/git")))
-         (write-paths
-           (cons cwd (cons "/tmp" (cons "/private/tmp" auth))))
-         (exec-paths
-           (list "/usr/bin" "/bin" "/usr/local/bin" "/opt/homebrew/bin"
-                 (path-join (home) ".local/bin"))))
+         (cwd      (current-directory)))
     (log-info logger "ask-external-llm-session"
       `((provider . ,label) (session-in . ,(or session-id "<new>"))))
-    (let ((status
-           (try
-             (cond
-               ((platform-macos?)
-                (sandbox-run/profile (build-deny-profile chosen)
-                  (lambda () (system cmd))))
-               (else
-                (sandbox-run/command read-paths write-paths exec-paths cmd)))
-             (catch (e) -1))))
-      (let ((text (read-text-or-empty tmp-out)))
-        (cond
-          ((eqv? status 0)
-           (parse-fn text sid-fallback))
-          (else
-           (make-ext-result
-             (format "ERROR: ~a exited with status ~a\n\n~a" label status text)
-             #t sid-fallback 0 0 0.0)))))))
+    (let-values (((status text)
+                  (try (run-sandboxed-argv label chosen argv cwd auth)
+                    (catch (e) (values -1 (err->string e))))))
+      (cond
+        ((eqv? status 0)
+         (parse-fn text sid-fallback))
+        (else
+         (make-ext-result
+           (format "ERROR: ~a exited with status ~a\n\n~a" label status text)
+           #t sid-fallback 0 0 0.0))))))
 
 ;; ---------- per-provider parsers ----------
 
diff --git a/support/ffi-symbols.list b/support/ffi-symbols.list
index dcf254c..1cfc846 100644
--- a/support/ffi-symbols.list
+++ b/support/ffi-symbols.list
@@ -98,3 +98,10 @@ jerboa_pbkdf2_derive
 jerboa_pbkdf2_verify
 jerboa_argon2id_hash
 jerboa_argon2id_verify
+
+# ── Linux sandboxing (Landlock + seccomp) ───────────────────────
+jerboa_landlock_abi_version
+jerboa_landlock_sandbox
+jerboa_landlock_sandbox_ex
+prctl
+syscall
diff --git a/support/landlock-shim.c b/support/landlock-shim.c
new file mode 100644
index 0000000..3878fc3
--- /dev/null
+++ b/support/landlock-shim.c
@@ -0,0 +1,359 @@
+#define _GNU_SOURCE
+/* landlock-shim.c — Non-variadic wrappers for Linux Landlock syscalls.
+ *
+ * syscall() is variadic, which means foreign-procedure can't call it
+ * directly (calling convention differs for variadic on some ABIs).
+ * These thin wrappers provide fixed-arity entry points.
+ *
+ * Compile: gcc -shared -fPIC -O2 -o libjerboa-landlock.so support/landlock-shim.c
+ * Static:  gcc -c -O2 -o landlock-shim.o support/landlock-shim.c
+ *          (then register symbols via Sforeign_symbol)
+ */
+
+#include <errno.h>
+
+#ifdef __linux__
+
+#include <sys/types.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <string.h>
+#include <stdio.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <limits.h>
+
+/* ========== Landlock Definitions ========== */
+/* Defined inline — neither glibc nor musl provides these. */
+
+#ifndef __NR_landlock_create_ruleset
+#define __NR_landlock_create_ruleset 444
+#endif
+#ifndef __NR_landlock_add_rule
+#define __NR_landlock_add_rule 445
+#endif
+#ifndef __NR_landlock_restrict_self
+#define __NR_landlock_restrict_self 446
+#endif
+
+#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
+
+#define LANDLOCK_ACCESS_FS_EXECUTE      (1ULL << 0)
+#define LANDLOCK_ACCESS_FS_WRITE_FILE   (1ULL << 1)
+#define LANDLOCK_ACCESS_FS_READ_FILE    (1ULL << 2)
+#define LANDLOCK_ACCESS_FS_READ_DIR     (1ULL << 3)
+#define LANDLOCK_ACCESS_FS_REMOVE_DIR   (1ULL << 4)
+#define LANDLOCK_ACCESS_FS_REMOVE_FILE  (1ULL << 5)
+#define LANDLOCK_ACCESS_FS_MAKE_CHAR    (1ULL << 6)
+#define LANDLOCK_ACCESS_FS_MAKE_DIR     (1ULL << 7)
+#define LANDLOCK_ACCESS_FS_MAKE_REG     (1ULL << 8)
+#define LANDLOCK_ACCESS_FS_MAKE_SOCK    (1ULL << 9)
+#define LANDLOCK_ACCESS_FS_MAKE_FIFO    (1ULL << 10)
+#define LANDLOCK_ACCESS_FS_MAKE_BLOCK   (1ULL << 11)
+#define LANDLOCK_ACCESS_FS_MAKE_SYM     (1ULL << 12)
+#define LANDLOCK_ACCESS_FS_REFER        (1ULL << 13)
+#define LANDLOCK_ACCESS_FS_TRUNCATE     (1ULL << 14)
+#define LANDLOCK_ACCESS_FS_IOCTL_DEV    (1ULL << 15)
+
+#define LANDLOCK_ACCESS_NET_BIND_TCP    (1ULL << 0)
+#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
+
+#define LANDLOCK_RULE_PATH_BENEATH 1
+#define LANDLOCK_RULE_NET_PORT 2
+
+struct landlock_ruleset_attr {
+    uint64_t handled_access_fs;
+    uint64_t handled_access_net;
+};
+
+struct landlock_path_beneath_attr {
+    uint64_t allowed_access;
+    int32_t  parent_fd;
+} __attribute__((packed));
+
+struct landlock_net_port_attr {
+    uint64_t allowed_access;
+    uint64_t port;
+};
+
+/* ========== Aggregate Access Masks ========== */
+
+#define ACCESS_FS_READ ( \
+    LANDLOCK_ACCESS_FS_EXECUTE   | \
+    LANDLOCK_ACCESS_FS_READ_FILE | \
+    LANDLOCK_ACCESS_FS_READ_DIR)
+
+#define ACCESS_FS_WRITE ( \
+    LANDLOCK_ACCESS_FS_WRITE_FILE  | \
+    LANDLOCK_ACCESS_FS_REMOVE_DIR  | \
+    LANDLOCK_ACCESS_FS_REMOVE_FILE | \
+    LANDLOCK_ACCESS_FS_MAKE_CHAR   | \
+    LANDLOCK_ACCESS_FS_MAKE_DIR    | \
+    LANDLOCK_ACCESS_FS_MAKE_REG    | \
+    LANDLOCK_ACCESS_FS_MAKE_SOCK   | \
+    LANDLOCK_ACCESS_FS_MAKE_FIFO   | \
+    LANDLOCK_ACCESS_FS_MAKE_BLOCK  | \
+    LANDLOCK_ACCESS_FS_MAKE_SYM)
+
+/* Access bits valid for regular (non-directory) files. Landlock rejects
+ * landlock_add_rule with EINVAL if any directory-only bit (READ_DIR,
+ * MAKE_*, REMOVE_*, REFER) appears in a rule targeting a regular file,
+ * silently dropping the entire grant. Mask requested access against
+ * this set when the target is a file. IOCTL_DEV is included because
+ * it is valid on character/block device files (e.g. /dev/tty). */
+#define ACCESS_FS_FILE_BITS ( \
+    LANDLOCK_ACCESS_FS_EXECUTE    | \
+    LANDLOCK_ACCESS_FS_WRITE_FILE | \
+    LANDLOCK_ACCESS_FS_READ_FILE  | \
+    LANDLOCK_ACCESS_FS_TRUNCATE   | \
+    LANDLOCK_ACCESS_FS_IOCTL_DEV)
+
+/* ========== API Functions ========== */
+
+int jerboa_landlock_sandbox_ex(const char *packed_read,
+                               const char *packed_write,
+                               const char *packed_exec,
+                               int fs_mode,
+                               int net_mode,
+                               unsigned long long connect_port);
+
+/* Query Landlock ABI version. Returns version (>=1) or -1 if unsupported. */
+int jerboa_landlock_abi_version(void) {
+    int v = syscall(__NR_landlock_create_ruleset, NULL, 0,
+                    LANDLOCK_CREATE_RULESET_VERSION);
+    if (v < 0) return -1;
+    return v;
+}
+
+/* Get the full set of handled_access_fs flags for a given ABI version. */
+static uint64_t landlock_handled_fs(int abi) {
+    uint64_t a = ACCESS_FS_READ | ACCESS_FS_WRITE;
+    if (abi >= 2) a |= LANDLOCK_ACCESS_FS_REFER;
+    if (abi >= 3) a |= LANDLOCK_ACCESS_FS_TRUNCATE;
+    if (abi >= 5) a |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
+    return a;
+}
+
+/*
+ * jerboa_landlock_sandbox — Apply Landlock restrictions to the current process.
+ *
+ * packed_read:  SOH-separated paths for read-only access (or empty/NULL)
+ * packed_write: SOH-separated paths for read+write access (or empty/NULL)
+ * packed_exec:  SOH-separated paths for execute access (or empty/NULL)
+ *
+ * Returns: 0 on success, 1 if Landlock unsupported, -1 on error.
+ *
+ * Once applied, restrictions are PERMANENT and IRREVERSIBLE for this process
+ * and all children. This is the point — it's real enforcement.
+ */
+int jerboa_landlock_sandbox(const char *packed_read,
+                            const char *packed_write,
+                            const char *packed_exec) {
+    return jerboa_landlock_sandbox_ex(packed_read, packed_write, packed_exec,
+                                      1, 0, 0);
+}
+
+/*
+ * jerboa_landlock_sandbox_ex — Apply filesystem and/or TCP connect rules.
+ *
+ * fs_mode:
+ *   0 — do not handle filesystem accesses
+ *   1 — apply the same filesystem allowlist as jerboa_landlock_sandbox
+ *
+ * net_mode:
+ *   0 — do not handle network accesses
+ *   1 — deny all outbound TCP dial attempts
+ *   2 — allow outbound TCP dial attempts only to connect_port
+ *
+ * Landlock network rules are port-based (ABI v4+). They cannot match the
+ * destination address, so callers that need host allowlists should pair this
+ * with a loopback proxy on a freshly allocated port.
+ */
+int jerboa_landlock_sandbox_ex(const char *packed_read,
+                               const char *packed_write,
+                               const char *packed_exec,
+                               int fs_mode,
+                               int net_mode,
+                               unsigned long long connect_port) {
+    /* 1. Check ABI version */
+    int abi = syscall(__NR_landlock_create_ruleset, NULL, 0,
+                      LANDLOCK_CREATE_RULESET_VERSION);
+    if (abi < 0) {
+        if (errno == ENOSYS || errno == EOPNOTSUPP)
+            return 1;  /* unsupported — graceful degradation */
+        return -1;
+    }
+    if (net_mode != 0 && abi < 4)
+        return 1;  /* network rules require Landlock ABI v4 */
+    if (net_mode == 2 && connect_port == 0)
+        return -1;
+
+    /* 2. Create ruleset handling requested access types */
+    uint64_t handled = fs_mode ? landlock_handled_fs(abi) : 0;
+    uint64_t handled_net = net_mode ? LANDLOCK_ACCESS_NET_CONNECT_TCP : 0;
+    struct landlock_ruleset_attr attr;
+    memset(&attr, 0, sizeof(attr));
+    attr.handled_access_fs = handled;
+    attr.handled_access_net = handled_net;
+
+    int ruleset_fd = syscall(__NR_landlock_create_ruleset,
+                             &attr, sizeof(attr), 0);
+    if (ruleset_fd < 0) return -1;
+
+    /* Helper: add one path rule.
+     * Uses O_NOFOLLOW to prevent symlink traversal at the final component.
+     * Falls back to O_PATH without O_NOFOLLOW for directories that may
+     * be symlinks to essential system paths (e.g. /lib -> /usr/lib). */
+    #define ADD_RULE(path, access) do { \
+        /* Resolve symlinks to canonical path to prevent bypass */ \
+        char *resolved = realpath((path), NULL); \
+        const char *target = resolved ? resolved : (path); \
+        int fd = open(target, O_PATH | O_CLOEXEC); \
+        if (fd >= 0) { \
+            uint64_t eff = (access); \
+            struct stat st; \
+            if (fstat(fd, &st) == 0 && !S_ISDIR(st.st_mode)) { \
+                /* Regular file (or device/socket/etc): strip dir-only bits \
+                 * to avoid EINVAL silently dropping the entire grant. */ \
+                eff &= ACCESS_FS_FILE_BITS; \
+            } \
+            struct landlock_path_beneath_attr pb; \
+            pb.allowed_access = eff & handled; \
+            pb.parent_fd = fd; \
+            if (pb.allowed_access) { \
+                syscall(__NR_landlock_add_rule, ruleset_fd, \
+                        LANDLOCK_RULE_PATH_BENEATH, &pb, 0); \
+            } \
+            close(fd); \
+        } \
+        free(resolved); \
+    } while(0)
+
+    if (fs_mode) {
+        /* 3. Always allow read access to essential system paths */
+        ADD_RULE("/usr", ACCESS_FS_READ);
+        ADD_RULE("/lib", ACCESS_FS_READ);
+        ADD_RULE("/lib64", ACCESS_FS_READ);
+        ADD_RULE("/bin", ACCESS_FS_READ);
+        ADD_RULE("/sbin", ACCESS_FS_READ);
+        ADD_RULE("/etc", ACCESS_FS_READ);
+        ADD_RULE("/proc", ACCESS_FS_READ);
+        /* /dev needs IOCTL_DEV (ABI 5+) so the child can run tty ioctls on
+         * /dev/tty and /dev/pts/* — without it, Node's tcgetattr/isatty on
+         * stdin/stdout returns EACCES and TUIs (claude, etc.) hang or fall
+         * back to non-tty mode. ADD_RULE masks (access) & handled, so the
+         * IOCTL_DEV bit is silently dropped on ABI < 5 kernels. */
+        ADD_RULE("/dev", ACCESS_FS_READ | LANDLOCK_ACCESS_FS_WRITE_FILE
+                         | LANDLOCK_ACCESS_FS_IOCTL_DEV);
+
+        /* 4. Parse packed paths and add user rules */
+