update
ober
9f8e8051439aeeb5efc51554e50930b9c627829f
--- 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") --- 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) --- 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" --- 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)) --- 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`, --- 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 --- 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)) --- 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)) --- 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) --- 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 '())) --- 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 ---------- --- 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 new file mode 100644 --- /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 */ +