commit - 36d14dc55db5ada1238a2d27232416ee1b997448
commit + 8d770450250cef56e61239916df4b54abc0cfebf
blob - bc848fb3ebf7040cd3892c92409f8f4555a6add3
blob + e64a081a9d55296ded0da4d035996f21e4b889c5
--- README.md
+++ README.md
# 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...]
## 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
```
`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.
)
```
+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`.
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
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
}
;;
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() {
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
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
# 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
-.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
.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
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.
.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
)
.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
.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
.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
(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
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"
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" "$@"
)
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
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'