commit 8d770450250cef56e61239916df4b54abc0cfebf from: mtmn date: Wed Sep 2 15:37:34 2026 UTC support darwin commit - 36d14dc55db5ada1238a2d27232416ee1b997448 commit + 8d770450250cef56e61239916df4b54abc0cfebf blob - bc848fb3ebf7040cd3892c92409f8f4555a6add3 blob + e64a081a9d55296ded0da4d035996f21e4b889c5 --- README.md +++ README.md @@ -1,6 +1,6 @@ # pisol -Run a command in a Landrun sandbox for the current directory. +Run a command in a Linux or macOS sandbox for the current directory. ```sh pisol COMMAND [ARG...] @@ -12,9 +12,11 @@ Run `pisol` with no arguments to edit `.isolate`. ## Requirements - Bash -- [Landrun](https://github.com/Zouuup/landrun) - Chez Scheme 10.4 or later - Make +- A sandbox backend for the host: + - Linux: [Landrun](https://github.com/Zouuup/landrun) + - macOS: `sandbox-exec` ## Build and use @@ -25,12 +27,18 @@ pisol COMMAND [ARG...] ``` `make tui` opens the editor. Add writable directories and outbound TCP ports, -then save with `x`. +then save with `x`. The editor shows and changes only grants from the local +file. Grants from the global init file apply to every project; edit those in +the global file itself. The sandbox allows read, write and execute access to the current directory. It allows writable temporary storage and read access to system runtime files. -Outbound TCP is denied unless configuration allows it. +Outbound TCP is denied unless configuration allows it. A configured port also +grants the name resolution that port needs. +pisol picks its backend from `uname`. Set `PISOL_BACKEND` to `landrun` or +`seatbelt` to override it. + ## Configuration `.isolate` is trusted Bash. pisol loads it after the optional global init file. @@ -42,6 +50,10 @@ args+=( ) ``` +Both files use one grant vocabulary on every platform, so a saved `.isolate` +stays valid across backends. A grant that a backend cannot express is reported +as an error rather than dropped. + Set `ISOLATE_EXTRA_CONFIG` to use a different local file. Set `PISOL_INIT_CONFIG` to use a different global init file. The default global path is `~/.config/pisol/init`. @@ -50,6 +62,7 @@ Saving creates a static argument list. It removes comm code from the local file. `NO_COLOR=1` disables editor colour. `ISOLATE_ENV` prevents nested sandboxes. +Run `make copy-config` to create the global init file from the shipped default. ## Install blob - 6700b955a568d39338aef1b7ee0798fd5f529cd4 blob + c1bff999d1f4288c3f057e07c3219d1e3bf4f67c --- bin/pisol +++ bin/pisol @@ -8,7 +8,7 @@ Usage: pisol pisol -- COMMAND [ARG...] With no arguments, edit the local .isolate configuration. -With a command, execute it in the current directory Landrun sandbox. +With a command, execute it in the current directory sandbox. Use -- before commands whose names begin with a hyphen. EOF } @@ -48,33 +48,55 @@ case ${1:-} in ;; esac +# Nested isolation is resolved before the backend, so an already-isolated +# command still runs where no backend exists. if [[ -n ${ISOLATE_ENV:-} ]]; then printf '%s\n' 'pisol: warning: already isolated; executing command directly' >&2 exec "$@" fi -if ! command -v landrun >/dev/null 2>&1; then - printf '%s\n' 'pisol: landrun is not installed or not on PATH' >&2 +pisol_backend=${PISOL_BACKEND:-} +if [[ -z $pisol_backend ]]; then + case $(uname -s) in + Linux) pisol_backend=landrun ;; + Darwin) pisol_backend=seatbelt ;; + *) pisol_backend=none ;; + esac +fi + +case $pisol_backend in +landrun) pisol_backend_exe=landrun ;; +seatbelt) pisol_backend_exe=sandbox-exec ;; +none) + if [[ -n ${PISOL_BACKEND:-} ]]; then + printf '%s\n' 'pisol: PISOL_BACKEND=none selects no sandbox backend' >&2 + else + printf 'pisol: no sandbox backend for %s\n' "$(uname -s)" >&2 + fi exit 127 + ;; +*) + printf 'pisol: unknown backend %s\n' "$pisol_backend" >&2 + exit 2 + ;; +esac + +if ! command -v "$pisol_backend_exe" >/dev/null 2>&1; then + printf 'pisol: %s is not installed or not on PATH\n' "$pisol_backend_exe" >&2 + exit 127 fi pisol_project=$(pwd -P) pisol_config=${ISOLATE_EXTRA_CONFIG:-$pisol_project/.isolate} -env_args=() -while IFS= read -r name; do - env_args+=(--env "$name") -done < <(compgen -e) +export PROMPT_ENV_INDICATOR=isolated +export ISOLATE_ENV=$pisol_project -args=( - --best-effort - --ignore-missing - --unrestricted-scoped - --rw /tmp - --env PROMPT_ENV_INDICATOR=isolated - --env "ISOLATE_ENV=$pisol_project" - --rwx "$pisol_project" -) +# Grants are collected in one portable vocabulary regardless of backend, so a +# saved .isolate stays valid on either platform. Each backend seeds its own +# baseline before the configuration files are sourced, so configuration can +# only widen the baseline, never narrow it. +args=() declare -A pisol_seen=() pisol_add_path() { @@ -86,29 +108,228 @@ pisol_add_path() { args+=("$mode" "$path") } -for path in /bin /sbin /lib /lib64 /usr/bin /usr/sbin /usr/lib /usr/lib64 /nix /run/current-system/sw; do - pisol_add_path --rox "$path" -done -for path in /etc /usr/share; do - pisol_add_path --ro "$path" -done -for path in /dev/null /dev/zero /dev/random /dev/urandom /dev/tty; do - pisol_add_path --rw "$path" -done +pisol_baseline_common() { + local path + args+=(--rwx "$pisol_project") -IFS=: read -r -a pisol_path_entries <<<"${PATH:-}" -for path in "${pisol_path_entries[@]}"; do - [[ $path = /* ]] && pisol_add_path --rox "$path" -done + IFS=: read -r -a pisol_path_entries <<<"${PATH:-}" + for path in "${pisol_path_entries[@]}"; do + [[ $path = /* ]] && pisol_add_path --rox "$path" + done -if [[ ${TMPDIR:-} = /* && ${TMPDIR:-} != /tmp ]]; then - pisol_add_path --rw "$TMPDIR" -fi + if [[ ${TMPDIR:-} = /* && ${TMPDIR:-} != /tmp ]]; then + pisol_add_path --rw "$TMPDIR" + fi -if [[ -n ${WAYLAND_DISPLAY:-} && -n ${XDG_RUNTIME_DIR:-} ]]; then - args+=(--unix "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}") -fi + for path in /dev/null /dev/zero /dev/random /dev/urandom /dev/tty; do + pisol_add_path --rw "$path" + done +} +pisol_baseline_landrun() { + local path + args+=( + --best-effort + --ignore-missing + --unrestricted-scoped + --rw /tmp + ) + pisol_baseline_common + for path in /bin /sbin /lib /lib64 /usr/bin /usr/sbin /usr/lib /usr/lib64 /nix /run/current-system/sw; do + pisol_add_path --rox "$path" + done + for path in /etc /usr/share; do + pisol_add_path --ro "$path" + done + if [[ -n ${WAYLAND_DISPLAY:-} && -n ${XDG_RUNTIME_DIR:-} ]]; then + args+=(--unix "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}") + fi +} + +pisol_baseline_seatbelt() { + local path + args+=(--rw /tmp) + pisol_baseline_common + for path in /bin /sbin /usr/bin /usr/sbin /usr/lib /usr/libexec /opt/homebrew /usr/local /nix; do + pisol_add_path --rox "$path" + done + for path in /System /Library /etc /private/etc /usr/share /private/var/db; do + pisol_add_path --ro "$path" + done +} + +# Resolve symbolic links without readlink -f, which BSD userland lacks. +# Seatbelt subpath filters only match fully resolved paths, and on macOS /tmp +# and /var are symbolic links into /private. +pisol_realpath() { + local path=$1 dir base + if [[ -d $path ]]; then + (CDPATH='' cd -P -- "$path" && pwd) + else + dir=$(dirname -- "$path") + base=$(basename -- "$path") + dir=$(CDPATH='' cd -P -- "$dir" && pwd) || return 1 + printf '%s/%s\n' "${dir%/}" "$base" + fi +} + +pisol_sbpl_quote() { + local text=$1 + text=${text//\\/\\\\} + text=${text//\"/\\\"} + printf '"%s"' "$text" +} + +pisol_sbpl_target() { + local path=$1 resolved + resolved=$(pisol_realpath "$path") || return 1 + if [[ -d $resolved ]]; then + printf '(subpath %s)' "$(pisol_sbpl_quote "$resolved")" + else + printf '(literal %s)' "$(pisol_sbpl_quote "$resolved")" + fi +} + +# The portable grant vocabulary is checked for every backend. Arguments +# outside it are backend specific: landrun receives them unchanged, and the +# seatbelt translator rejects them because it cannot pass them on. +pisol_validate_grants() { + local index=0 grant value + while ((index < ${#args[@]})); do + grant=${args[index]} + case $grant in + --rwx|--rw|--rox|--ro|--unix) + if ((index + 1 >= ${#args[@]})); then + printf 'pisol: missing value for %s\n' "$grant" >&2 + exit 2 + fi + index=$((index + 2)) + ;; + --connect-tcp) + value=${args[index + 1]:-} + if [[ ! $value =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then + printf 'pisol: --connect-tcp port must be 1 to 65535, got %s\n' "$value" >&2 + exit 2 + fi + index=$((index + 2)) + ;; + *) + index=$((index + 1)) + ;; + esac + done +} + +pisol_exec_landrun() { + local name env_args=() + # env -0 rather than compgen -e, which a minimal bash build does not + # provide. Losing it drops every variable from the sandbox environment + # without reporting anything. + while IFS='=' read -r -d '' name _; do + env_args+=(--env "$name") + done < <(env -0) + exec landrun "${env_args[@]}" "${args[@]}" -- "$@" +} + +# Translate the collected grants into an SBPL profile. Every grant is either +# translated or rejected; none is silently discarded. +pisol_exec_seatbelt() { + local profile grant path target operations index=0 socket + local ports=() unrestricted_network= + + profile='(version 1) +(import "/System/Library/Sandbox/Profiles/bsd.sb") +(deny default) +(allow process-fork) +(allow sysctl-read) +(allow signal (target same-sandbox)) +' + + while ((index < ${#args[@]})); do + grant=${args[index]} + case $grant in + --rwx|--rw|--rox|--ro) + path=${args[index + 1]:-} + if [[ -z $path ]]; then + printf 'pisol: missing value for %s\n' "$grant" >&2 + exit 2 + fi + case $grant in + --rwx) operations='file-read* file-write* process-exec*' ;; + --rw) operations='file-read* file-write*' ;; + --rox) operations='file-read* process-exec*' ;; + --ro) operations='file-read*' ;; + esac + # A path that does not exist grants nothing, matching the + # --ignore-missing baseline used on Linux. + if target=$(pisol_sbpl_target "$path"); then + profile+="(allow $operations $target)"$'\n' + fi + index=$((index + 2)) + ;; + --unix) + path=${args[index + 1]:-} + if [[ -z $path ]]; then + 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' + index=$((index + 2)) + ;; + --connect-tcp) + path=${args[index + 1]:-} + ports+=("$path") + index=$((index + 2)) + ;; + --unrestricted-network) + unrestricted_network=1 + index=$((index + 1)) + ;; + --env) + # Seatbelt inherits the caller environment, so landrun's + # explicit forwarding has no equivalent and no effect. + index=$((index + 2)) + ;; + --best-effort|--ignore-missing|--unrestricted-scoped) + index=$((index + 1)) + ;; + *) + printf 'pisol: %s cannot be translated for the seatbelt backend\n' "$grant" >&2 + exit 2 + ;; + esac + done + + for path in "${ports[@]}"; do + profile+="(allow network-outbound (remote tcp \"*:$path\"))"$'\n' + done + + # 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 + profile+='(allow network-outbound (remote udp "*:53")) +(allow mach-lookup (global-name "com.apple.mDNSResponder")) +(allow network-outbound (literal "/private/var/run/mDNSResponder")) +(allow file-read* file-write* (literal "/private/var/run/mDNSResponder")) +' + fi + + if [[ -n $unrestricted_network ]]; then + profile+='(allow network*)'$'\n' + fi + + if ! grep -qF -- "$(pisol_sbpl_target "$pisol_project")" <<<"$profile"; then + printf 'pisol: failed to grant the project directory %s\n' "$pisol_project" >&2 + exit 1 + fi + + exec sandbox-exec -p "$profile" "$@" +} + +"pisol_baseline_$pisol_backend" + pisol_init=${PISOL_INIT_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/pisol/init} if [[ -f $pisol_init ]]; then # shellcheck disable=SC1090 @@ -120,8 +341,6 @@ if [[ -f $pisol_config ]]; then source "$pisol_config" fi -exec landrun \ - "${env_args[@]}" \ - "${args[@]}" \ - -- \ - "$@" +pisol_validate_grants + +"pisol_exec_$pisol_backend" "$@" blob - 1c6a2cb10b3921fbcddd56b0df72f9f5daa4cf4d blob + 40bb845fd9fa47d442c6865c78cb1b1071cec579 --- config/pisol/init +++ config/pisol/init @@ -2,17 +2,43 @@ # Default global init for pisol that is sourced before # the project-specific .isolate file. -args+=(--unrestricted-network) +# Outbound TCP stays denied here. Add the ports a project needs to its own +# .isolate file, or add --unrestricted-network below to opt the whole machine +# out of network restriction. +# System paths differ by platform; home paths do not. +case $(uname -s) in +Darwin) + system_paths=( + /opt/homebrew + /usr/local/bin + /bin + /usr/bin + /usr/lib + /usr/libexec + /etc + /private/etc + /Library + /System + /nix + ) + ;; +*) + system_paths=( + /usr/local/bin + /bin + /lib + /lib64 + /usr/bin + /usr/lib + /etc + /nix + ) + ;; +esac + for p in \ - /usr/local/bin \ - /bin \ - /lib \ - /lib64 \ - /usr/bin \ - /usr/lib \ - /etc \ - /nix \ + "${system_paths[@]}" \ "$HOME/bin" \ "$HOME/.zvm" \ "$HOME/.cargo" \ blob - f814c847ae23501bfe8be65f85a5784813de3d5b blob + 4c386355288109cd681c7e4ec6e8a592cf55a0f5 --- doc/pisol.1 +++ doc/pisol.1 @@ -1,6 +1,6 @@ -.TH PISOL 1 "2026-08-29" "pisol" "User Commands" +.TH PISOL 1 "2026-09-02" "pisol" "User Commands" .SH NAME -pisol \- run commands in a Landrun sandbox +pisol \- run commands in an OS sandbox .SH SYNOPSIS .B pisol .br @@ -15,18 +15,29 @@ pisol \- run commands in a Landrun sandbox .B pisol opens an editor for the local .I .isolate -file when run without a command. Otherwise, it runs the command in a Landrun -sandbox for the current directory. +file when run without a command. Otherwise, it runs the command in a sandbox +for the current directory. .PP +The backend follows the host: Landrun on Linux, seatbelt +.RB ( sandbox-exec ) +on macOS. Set +.B PISOL_BACKEND +to +.B landrun +or +.B seatbelt +to override the choice. +.PP Use .B -- before a command name that starts with a hyphen. .SH SANDBOX pisol grants read, write and execute access to the current directory. Temporary storage is writable. System runtime files are readable. Outbound TCP -is denied unless configuration allows a port. +is denied unless configuration allows a port. A configured port also grants the +name resolution that port requires. .PP -The command receives the caller environment. pisol also sets +The command inherits the caller environment. pisol also sets .B PROMPT_ENV_INDICATOR=isolated and .B ISOLATE_ENV @@ -39,13 +50,15 @@ is set, pisol runs the command without another sandbox Add a writable directory. .TP .B r -Remove a writable directory by number. +Remove a writable directory by number. Only grants from the local file can be +removed; grants from the global init file belong to that file. .TP .BI p " PORT" Allow outbound TCP to PORT. .TP .B d -Remove an outbound TCP port. +Remove an outbound TCP port. Local grants only, as for +.BR r . .TP .B c Show the global and local configuration files. @@ -58,8 +71,11 @@ Save and exit. .SH CONFIGURATION .I .isolate is trusted Bash. pisol loads the optional global init file before this local -file. Both files add Landrun arguments to the Bash array +file. Both files add grants to the Bash array .IR args . +The vocabulary is the same on every platform and each backend translates it, so +a saved configuration is portable. A grant that the selected backend cannot +express is an error, never a silent omission. .PP For example: .PP @@ -70,6 +86,11 @@ args+=( ) .fi .PP +The global init file grants no network access. Add the ports a project +needs to its own +.I .isolate +file. +.PP The default global init path is .IR ~/.config/pisol/init . Set @@ -91,6 +112,9 @@ Sets the global init path. .B ISOLATE_ENV Prevents nested sandboxes. .TP +.B PISOL_BACKEND +Selects the sandbox backend, overriding host detection. +.TP .B NO_COLOR Disables editor colour. .TP @@ -100,6 +124,11 @@ Provide runtime paths and sandbox grants. .BR WAYLAND_DISPLAY , " XDG_RUNTIME_DIR" Provide an optional Wayland socket grant. .SH EXIT STATUS -The editor returns zero after exit. Load and save errors return nonzero. In -command mode, pisol returns the command status. Usage errors return 2. A -missing Landrun executable returns 127. +The editor exits with status zero when it ends normally. Load and save +errors exit nonzero. In +command mode, pisol returns the command status. Usage errors, untranslatable +grants, an out-of-range port and an unrecognised +.B PISOL_BACKEND +return 2. An unsupported platform, a missing backend executable and +.B PISOL_BACKEND=none +return 127. blob - deea5a5cd9bc438d38efd8180505449c9f483752 blob + 6dbe826a2abdfc3295989d4be8f75d976c22b2f3 --- src/pisol/tui.sls +++ src/pisol/tui.sls @@ -175,7 +175,7 @@ (newline) (display "Custom .isolate files are trusted shell code. Saving replaces them with") (newline) - (display "a static list of Landrun arguments.") + (display "a static list of sandbox grants.") (newline) (display "c shows the raw global and local configuration files.") (newline) blob - 3a6505499fbcbbb348dac2226dc9734c39f81d9c blob + d3c58dc68cceafe9b06dfeba519a83eeb9039b6a --- test/integration.sh +++ test/integration.sh @@ -8,6 +8,7 @@ fake_bin=$test_root/bin project=$test_root/project home=$test_root/home capture=$test_root/landrun.args +profile_capture=$test_root/sandbox.sb trap 'rm -rf "$test_root"' 0 HUP INT TERM mkdir -p "$fake_bin" "$project" "$home" "$home/extra dir" @@ -32,6 +33,8 @@ run_pisol() { HOME=$home \ PISOL_RUNTIME_DIR=$project_root/build/runtime \ PISOL_TEST_CAPTURE=$capture \ + PISOL_TEST_PROFILE=$profile_capture \ + PISOL_BACKEND=${PISOL_BACKEND:-} \ NO_COLOR=1 \ "$project_root/bin/pisol" "$@" ) @@ -50,6 +53,9 @@ grep -Fx -- '--best-effort' "$test_root/args.txt" >/de grep -Fx -- '--unrestricted-scoped' "$test_root/args.txt" >/dev/null grep -Fx -- "$project" "$test_root/args.txt" >/dev/null grep -Fx -- '/dev/null' "$test_root/args.txt" >/dev/null +# The caller environment reaches the sandbox, and ISOLATE_ENV is part of it. +grep -Fx -- '--env' "$test_root/args.txt" >/dev/null +grep -Fx -- 'ISOLATE_ENV' "$test_root/args.txt" >/dev/null if grep -Fx -- '--unrestricted-network' "$test_root/args.txt" >/dev/null; then echo "pisol integration: unrestricted network unexpectedly enabled" >&2 exit 1 @@ -134,4 +140,208 @@ grep -F -- "$project/.isolate" "$test_root/showconfig. grep -F -- "$home/extra dir" "$test_root/showconfig.out" >/dev/null grep -F -- '--connect-tcp 443' "$test_root/showconfig.out" >/dev/null + +# --- seatbelt (darwin) backend ------------------------------------------------ +# +# Driven through PISOL_BACKEND so the darwin translation is exercised from any +# host. The fake captures the generated profile and the residual argv +# separately, because an SBPL profile contains newlines. + +cat >"$fake_bin/sandbox-exec" <<'FAKE' +#!/bin/sh +set -eu +: "${PISOL_TEST_PROFILE:?}" +: "${PISOL_TEST_CAPTURE:?}" +sandbox_profile= +while [ "$#" -gt 0 ]; do + case $1 in + -p) sandbox_profile=$2; shift 2 ;; + -f|-n|-D) shift 2 ;; + *) break ;; + esac +done +printf '%s' "$sandbox_profile" >"$PISOL_TEST_PROFILE" +printf '%s\0' "$@" >"$PISOL_TEST_CAPTURE" +exec "$@" +FAKE +chmod +x "$fake_bin/sandbox-exec" + +profile_has() { + grep -F -- "$1" "$profile_capture" >/dev/null || { + printf 'pisol integration: seatbelt profile is missing %s\n' "$1" >&2 + exit 1 + } +} + +profile_lacks() { + if grep -F -- "$1" "$profile_capture" >/dev/null; then + printf 'pisol integration: seatbelt profile unexpectedly contains %s\n' "$1" >&2 + exit 1 + fi +} + +rm -f "$project/.isolate" "$global_init" + +# A bare configuration denies by default and never reaches the network. +PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' +profile_has '(version 1)' +profile_has '(deny default)' +profile_has "(subpath \"$project\")" +profile_lacks '(allow network*)' +profile_lacks 'remote tcp' +profile_lacks 'mDNSResponder' + +# A port grant carries the name resolution it needs to be useful. +printf '%s\n' 'args+=(' ' --connect-tcp 443' " --rwx '$home/extra dir'" ')' >"$project/.isolate" +PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' +profile_has '(remote tcp "*:443")' +profile_has '(remote udp "*:53")' +profile_has 'com.apple.mDNSResponder' +profile_has "(subpath \"$home/extra dir\")" +profile_lacks '(allow network*)' + +# An explicit unrestricted-network grant is honoured. +printf '%s\n' 'args+=( --unrestricted-network )' >"$project/.isolate" +PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' +profile_has '(allow network*)' + +# A read-only grant does not become writable. +printf '%s\n' "args+=( --ro '$home/extra dir' )" >"$project/.isolate" +PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' +profile_has "(allow file-read* (subpath \"$home/extra dir\"))" + +# An untranslatable grant is refused rather than silently dropped. +printf '%s\n' 'args+=( --bogus-grant /somewhere )' >"$project/.isolate" +if PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' 2>/dev/null; then + echo "pisol integration: unknown grant was not rejected" >&2 + exit 1 +else + status=$? + [ "$status" -eq 2 ] || { + printf 'pisol integration: unknown grant exited %s, wanted 2\n' "$status" >&2 + exit 1 + } +fi + +# Command exit status is propagated through the sandbox. +rm -f "$project/.isolate" +if PISOL_BACKEND=seatbelt run_pisol sh -c 'exit 7'; then + echo "pisol integration: seatbelt did not propagate command status" >&2 + exit 1 +else + status=$? + [ "$status" -eq 7 ] || exit 1 +fi + +# Nested isolation is detected before the backend is consulted. +before=$(cksum "$profile_capture") +if ISOLATE_ENV=already PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 9' 2>/dev/null; then + exit 1 +else + status=$? + [ "$status" -eq 9 ] || exit 1 +fi +[ "$before" = "$(cksum "$profile_capture")" ] + +# A missing backend executable reports unavailability rather than running the +# command. Only meaningful where the host has no real sandbox-exec. +if ! command -v sandbox-exec >/dev/null 2>&1; then + if ( + cd "$project" + unset ISOLATE_ENV + HOME=$home \ + PISOL_BACKEND=seatbelt \ + "$project_root/bin/pisol" -- sh -c 'exit 0' + ) 2>/dev/null; then + echo "pisol integration: missing backend did not fail" >&2 + exit 1 + else + status=$? + [ "$status" -eq 127 ] || { + printf 'pisol integration: missing backend exited %s, wanted 127\n' "$status" >&2 + exit 1 + } + fi +fi + + +# Execution follows the grants: a read-only path does not become executable, +# and the profile carries no blanket exec permission. +printf '%s\n' "args+=( --ro '$home/extra dir' )" >"$project/.isolate" +PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' +profile_lacks '(allow process-exec* process-fork)' +profile_has '(allow process-fork)' +profile_lacks "process-exec* (subpath \"$home/extra dir\")" + +# The port range is checked for every backend, not only through the editor. +for pisol_bad_port in 0 65536 99999; do + printf 'args+=( --connect-tcp %s )\n' "$pisol_bad_port" >"$project/.isolate" + for pisol_backend_under_test in landrun seatbelt; do + if PISOL_BACKEND=$pisol_backend_under_test run_pisol -- sh -c 'exit 0' 2>/dev/null; then + printf 'pisol integration: %s accepted out-of-range port %s\n' \ + "$pisol_backend_under_test" "$pisol_bad_port" >&2 + exit 1 + else + status=$? + [ "$status" -eq 2 ] || { + printf 'pisol integration: %s exited %s for port %s, wanted 2\n' \ + "$pisol_backend_under_test" "$status" "$pisol_bad_port" >&2 + exit 1 + } + fi + done +done + +# A grant missing its value is refused rather than silently ignored. +printf '%s\n' 'args+=( --rwx )' >"$project/.isolate" +if run_pisol -- sh -c 'exit 0' 2>/dev/null; then + echo "pisol integration: grant without a value was accepted" >&2 + exit 1 +else + status=$? + [ "$status" -eq 2 ] || exit 1 +fi + +# Backend-specific arguments reach the backend that owns them unchanged. +printf '%s\n' "args+=( --ro '$home/extra dir' --some-landrun-only-flag )" >"$project/.isolate" +PISOL_BACKEND=landrun run_pisol -- sh -c 'exit 0' +tr '\0' '\n' <"$capture" >"$test_root/args.txt" +grep -Fx -- '--some-landrun-only-flag' "$test_root/args.txt" >/dev/null + +# The backend that does not own them refuses the launch instead of guessing. +if PISOL_BACKEND=seatbelt run_pisol -- sh -c 'exit 0' 2>/dev/null; then + echo "pisol integration: seatbelt accepted a landrun-only argument" >&2 + exit 1 +else + status=$? + [ "$status" -eq 2 ] || exit 1 +fi + +# An operator who names no known backend gets a usage error, not a sandbox. +rm -f "$project/.isolate" +if PISOL_BACKEND=nonsuch run_pisol -- sh -c 'exit 0' 2>/dev/null; then + echo "pisol integration: unrecognised backend was accepted" >&2 + exit 1 +else + status=$? + [ "$status" -eq 2 ] || exit 1 +fi + +# An operator who selects no backend at all is told so, and nothing runs. +if PISOL_BACKEND=none run_pisol -- sh -c 'exit 0' 2>/dev/null; then + echo "pisol integration: PISOL_BACKEND=none ran the command" >&2 + exit 1 +else + status=$? + [ "$status" -eq 127 ] || exit 1 +fi + +# The shipped default init must not opt the machine out of network +# restriction. Comments may name the flag; only live code counts. +if grep -v '^[[:space:]]*#' "$project_root/config/pisol/init" | + grep -F -- '--unrestricted-network' >/dev/null; then + echo "pisol integration: shipped init grants unrestricted network" >&2 + exit 1 +fi + printf 'pisol: shell and TUI integration checks passed\n'