Commit Diff


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'