commit 9ed49f84562145a3245831fa21ecd166a6b6ad03 from: mtmn date: Sun Sep 6 20:38:43 2026 UTC hardening, use chicken terminal-port commit - b89b0f9f7cd2526f6cf5e8100dbf094df79b2806 commit + 9ed49f84562145a3245831fa21ecd166a6b6ad03 blob - 264f0de9c56fe8475ec036508dc367fbed7d5396 blob + adcbeb49b51889b957dcc764d39bdb5c9a248808 --- Makefile +++ Makefile @@ -9,7 +9,7 @@ RM ?= rm -f CSC ?= csc CSC_LINK_FLAGS ?= -static SHFMT ?= shfmt -SHELL_FILES = bin/pisol test/integration.sh +SHELL_FILES = bin/pisol test/integration.sh config/pisol/init .PHONY: build tui test fmt fmt-check copy-config clean run install uninstall help default: help @@ -57,10 +57,16 @@ run: install $(DESTDIR)$(BINDIR)/pisol install: build - $(INSTALL) -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(LIBEXECDIR) $(DESTDIR)$(MANDIR)/man1 - $(INSTALL) -m 755 bin/pisol $(DESTDIR)$(BINDIR)/pisol - $(INSTALL) -m 755 build/runtime/pisol $(DESTDIR)$(LIBEXECDIR)/pisol - $(INSTALL) -m 644 doc/pisol.1 $(DESTDIR)$(MANDIR)/man1/pisol.1 + @sudo=; \ + if [ -z "$(DESTDIR)" ]; then \ + d="$(BINDIR)"; \ + while [ -n "$$d" ] && [ ! -e "$$d" ]; do d=$${d%/*}; done; \ + [ -w "$$d" ] || sudo=sudo; \ + fi; \ + $$sudo $(INSTALL) -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(LIBEXECDIR) $(DESTDIR)$(MANDIR)/man1; \ + $$sudo $(INSTALL) -m 755 bin/pisol $(DESTDIR)$(BINDIR)/pisol; \ + $$sudo $(INSTALL) -m 755 build/runtime/pisol $(DESTDIR)$(LIBEXECDIR)/pisol; \ + $$sudo $(INSTALL) -m 644 doc/pisol.1 $(DESTDIR)$(MANDIR)/man1/pisol.1 uninstall: $(RM) $(DESTDIR)$(BINDIR)/pisol $(DESTDIR)$(LIBEXECDIR)/pisol blob - cbe803f49517cbee8e5aea0b0cfdcd4e5340152b blob + 58e961af7f394a25b7fbdd7167ab8771d6a9940a --- bin/pisol +++ bin/pisol @@ -96,13 +96,18 @@ export ISOLATE_ENV=$pisol_project # only widen the baseline, never narrow it. args=() -declare -A pisol_seen=() +# A plain indexed array rather than an associative array, since macOS ships +# bash 3.2 by default and declare -A requires bash 4+. +pisol_seen=() pisol_add_path() { - local mode=$1 path=$2 key + local mode=$1 path=$2 key seen [[ -e $path ]] || return 0 key=$mode:$path - [[ -z ${pisol_seen[$key]:-} ]] || return 0 - pisol_seen[$key]=1 + for seen in "${pisol_seen[@]:-}"; do + [[ $seen == "$key" ]] || continue + return 0 + done + pisol_seen+=("$key") args+=("$mode" "$path") } @@ -205,7 +210,10 @@ pisol_validate_grants() { ;; --connect-tcp) value=${args[index + 1]:-} - if [[ ! $value =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then + # 10#$value forces base-10 arithmetic; without it bash treats a + # leading-zero value like 08 or 09 as invalid octal and the + # comparison errors out, silently passing validation. + if [[ ! $value =~ ^[0-9]+$ ]] || ((10#$value < 1 || 10#$value > 65535)); then printf 'pisol: --connect-tcp port must be 1 to 65535, got %s\n' "$value" >&2 exit 2 fi @@ -271,9 +279,13 @@ pisol_exec_seatbelt() { printf 'pisol: missing value for %s\n' "$grant" >&2 exit 2 fi - socket=$(pisol_sbpl_quote "$path") - profile+="(allow network-outbound (literal $socket))"$'\n' - profile+="(allow file-read* file-write* (literal $socket))"$'\n' + # Resolved the same way as the file grants above: seatbelt + # subpath/literal filters only match fully resolved paths. + if path=$(pisol_realpath "$path"); then + socket=$(pisol_sbpl_quote "$path") + profile+="(allow network-outbound (literal $socket))"$'\n' + profile+="(allow file-read* file-write* (literal $socket))"$'\n' + fi index=$((index + 2)) ;; --connect-tcp) @@ -306,7 +318,9 @@ pisol_exec_seatbelt() { # A port grant is useless without name resolution, which seatbelt filters # separately because it restricts UDP and Mach IPC as well as TCP. - if ((${#ports[@]} > 0)); then + # --unrestricted-network needs the same resolver access: network* does + # not cover the mach-lookup DNS goes through. + if ((${#ports[@]} > 0)) || [[ -n $unrestricted_network ]]; then profile+='(allow network-outbound (remote udp "*:53")) (allow mach-lookup (global-name "com.apple.mDNSResponder")) (allow network-outbound (literal "/private/var/run/mDNSResponder")) @@ -318,7 +332,10 @@ pisol_exec_seatbelt() { profile+='(allow network*)'$'\n' fi - if ! grep -qF -- "$(pisol_sbpl_target "$pisol_project")" <<<"$profile"; then + if ! pisol_project_target=$(pisol_sbpl_target "$pisol_project"); then + pisol_project_target= + fi + if [[ -z $pisol_project_target ]] || ! grep -qF -- "$pisol_project_target" <<<"$profile"; then printf 'pisol: failed to grant the project directory %s\n' "$pisol_project" >&2 exit 1 fi blob - 87cdc9574857faac634fd70aedf1ce7440a19d82 blob + bf7ca4885a0750173c4c4410aa54dc0208b460c9 --- src/pisol/core.scm +++ src/pisol/core.scm @@ -164,20 +164,24 @@ (dynamic-wind void (lambda () - (receive (_ normal? status) (process-wait pid) - (unless (and normal? (zero? status)) - (let* ((raw (read-string #f err)) - (message (if (eof-object? raw) - "configuration evaluation failed" - (trim raw)))) - (error 'load-configuration message path)))) - (let ((text (read-string #f in))) + (close-output-port out) + ;; Drain both pipes before waiting on the child: a config that + ;; emits more than a pipe buffer of output would otherwise block + ;; bash on write while this process blocks in process-wait, + ;; deadlocking pisol. + (let ((text (read-string #f in)) + (raw-err (read-string #f err))) + (receive (_ normal? status) (process-wait pid) + (unless (and normal? (zero? status)) + (let ((message (if (eof-object? raw-err) + "configuration evaluation failed" + (trim raw-err)))) + (error 'load-configuration message path)))) (if (or (eof-object? text) (zero? (string-length text))) '() (nul-strings text)))) (lambda () (close-input-port in) - (close-output-port out) (close-input-port err)))))) (define (digits? text) @@ -264,8 +268,10 @@ (begin (when (file-exists? temporary) (delete-file* temporary)) (abort exn)) (with-output-to-file temporary (lambda () (display (config->shell config)))) (set-file-permissions! temporary #o600) - (when (file-exists? path) (delete-file path)) - (rename-file temporary path) + ;; Clobber via rename-file's own flag rather than delete-then-rename, + ;; so the replacement is atomic and a kill between the two steps + ;; cannot leave the user with neither file. + (rename-file temporary path #t) (configuration-source-style-set! config 'canonical) (configuration-dirty?-set! config #f) #t)))) blob - 1f69d2762d5ab78674c417578d4669c2c3f072f3 blob + f0b1333a5958b6a74337dbd610c36ebb1b186c92 --- src/pisol/tui.scm +++ src/pisol/tui.scm @@ -1,6 +1,6 @@ (module pisol.tui (run-tui) (import scheme (chicken base) (chicken condition) (chicken file) (chicken file posix) (chicken format) - (chicken io) (chicken process) (chicken process-context) (chicken time) + (chicken io) (chicken port) (chicken process) (chicken process-context) pisol.core) (define esc (string (integer->char 27))) (define color? (not (get-environment-variable "NO_COLOR"))) @@ -17,11 +17,15 @@ ((char-whitespace? (string-ref text at)) (values (substring text 0 at) (trim (substring text at (string-length text))))) (else (loop (+ at 1))))))) (define (read-input prompt) (display prompt) (flush-output) (let ((line (read-line))) (and (not (eof-object? line)) line))) - (define (terminal-input?) (zero? (system "test -t 0"))) - (define tui-temporary-counter 0) + (define (terminal-input?) (terminal-port? (current-input-port))) (define (temporary-path) - (set! tui-temporary-counter (+ tui-temporary-counter 1)) - (string-append (or (get-environment-variable "TMPDIR") "/tmp") "/pisol-path-" (number->string (current-seconds)) "." (number->string tui-temporary-counter))) + ;; file-mkstemp rather than a counter derived from the current second: + ;; two tui runs started in the same second could otherwise collide on + ;; the same path and race to read each other's captured input. + (let ((template (string-append (or (get-environment-variable "TMPDIR") "/tmp") "/pisol-path.XXXXXX"))) + (receive (fd path) (file-mkstemp template) + (file-close fd) + path))) (define (read-directory-path prompt initial) (if (not (terminal-input?)) (read-input prompt) (let ((output (temporary-path)))