commit - 9fa634dfaef8457ee8f022da7f08db32e81f7002
commit + dee5383eeb614c4e3be52ee47fbc80195009eba9
blob - /dev/null
blob + 5656d280a04f54842fb04dec391672526f00b93d (mode 644)
--- /dev/null
+++ misc/isolate-seatbelt/README.md
+isolate-seatbelt runs an executable under a macOS Seatbelt profile. It denies everything by default, allows read-only access to system paths and gives write access to the project it runs in. Put extra grants in the .isolate file in the project, for example:
+
+args+=(
+ --rwx '/Users/mtmn/Downloads'
+ --connect-tcp 8080
+)
+
+Use --yolo to launch pi without the sandbox when you need broader access.
blob - /dev/null
blob + 5ba993412e0a084b842a553af13df85f99cc55c9 (mode 755)
--- /dev/null
+++ misc/isolate-seatbelt/functions
+#!/bin/bash
+
+pi_recent_models() {
+ local n=${1:-10}
+ local session_dir="${HOME}/.pi/agent/sessions"
+ local files
+ if [ -n "${2:-}" ]; then
+ local enc
+ enc=$(echo "/$2" | sed 's|[./]|-|g')
+ files=$(find "$session_dir/$enc"* -name '*.jsonl' 2>/dev/null)
+ else
+ files=$(find "$session_dir" -name '*.jsonl' 2>/dev/null)
+ fi
+ [ -n "$files" ] || return 1
+ # shellcheck disable=SC2086
+ jaq -cr 'select(.type=="model_change") | "\(.timestamp)\t\(.provider) \(.modelId)"' $files |
+ sort -r |
+ cut -f2- |
+ awk '!seen[$0]++' |
+ head -n "$n"
+}
+
+pa() {
+ [ "$PWD" = "$HOME" ] && return
+ if [ "${1:-}" = "--yolo" ]; then
+ shift
+ pi --offline --provider "synthetic" --model "syn:small:text" "$@"
+ else
+ isolate-seatbelt pi --offline --provider "synthetic" --model "syn:small:text" "$@"
+ fi
+}
+
+paj() {
+ [ "$PWD" = "$HOME" ] && return
+ local yolo=0 model provider rest candidates recents
+ [ "${1:-}" = "--yolo" ] && { yolo=1; shift; }
+ candidates=$(pi --list-models 2>/dev/null | awk 'NR > 1 {print $1 " "$2}')
+ recents=$(pi_recent_models 100) || recents=
+ candidates=$(
+ printf '%s\n' "$recents" | grep -xFf <(printf '%s\n' "$candidates") || true
+ printf '%s\n' "$candidates" | grep -Fxvf <(printf '%s\n' "$recents") || true
+ )
+ model=$(printf '%s\n' "$candidates" | fzf) || return
+ provider=${model%% *}
+ rest=${model#* }
+ [ "$rest" = "$model" ] && rest=$model
+ if [ "$yolo" = 1 ]; then
+ pi --offline --provider "$provider" --model "$rest" "$@"
+ else
+ isolate-seatbelt pi --offline --provider "$provider" --model "$rest" "$@"
+ fi
+}
+
+alias pajol='paj --yolo'
+alias pajolo='paj --yolo'
+
+_miro_dir=$(cd "$(dirname "${(%):-%x}")" && pwd)
+export PATH="${PATH:+$PATH:}$_miro_dir"
blob - /dev/null
blob + 2d5408ce6d44bd6626c7c2cb6f5cbb59233cbe4b (mode 755)
--- /dev/null
+++ misc/isolate-seatbelt/isolate-seatbelt
+#!/usr/bin/env python3
+"""isolate-seatbelt: Darwin-only seatbelt runner.
+
+It collects the portable grant vocabulary, translates it into an SBPL
+profile, and execs sandbox-exec.
+
+Only the standard library is used. The trusted Bash configuration files
+use Bash array syntax, so they are evaluated through `bash`.
+"""
+
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+
+VERSION = "0.1.0"
+PROG = "isolate-seatbelt"
+
+SBPL_HEADER = """(version 1)
+(import "/System/Library/Sandbox/Profiles/bsd.sb")
+(deny default)
+(allow process-fork)
+(allow sysctl-read)
+(allow signal (target same-sandbox))
+;; TUI raw mode needs tcsetattr (TIOCSETA/TIOCSETAW) on the pty.
+;; file-read*/file-write* alone still denies it with setRawMode EPERM.
+(allow file-ioctl (regex #"^/dev/tty.*"))
+;; LMDB (fff frecency/history) needs SysV/POSIX IPC for its lock env.
+;; Without this: Failed to open frecency database env: Operation not permitted.
+(allow ipc-sysv-sem)
+(allow ipc-posix-sem)
+(allow ipc-posix-shm)
+"""
+
+# A port grant is useless without name resolution, which seatbelt filters
+# separately because it restricts UDP and Mach IPC as well as TCP.
+# --unrestricted-network needs the same resolver access: network* does
+# not cover the mach-lookup DNS goes through.
+DNS_STANZA = """(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"))
+"""
+
+FILE_OPS = {
+ "--rwx": "file-read* file-write* process-exec*",
+ "--rw": "file-read* file-write*",
+ "--rox": "file-read* process-exec*",
+ "--ro": "file-read*",
+}
+
+# Grants taking a path value.
+PATH_GRANTS = ("--rwx", "--rw", "--rox", "--ro", "--unix")
+
+# Landrun baselines that have no seatbelt equivalent and no effect here.
+IGNORED_SINGLE = ("--best-effort", "--ignore-missing", "--unrestricted-scoped")
+
+BASELINE_ROX = (
+ "/bin",
+ "/sbin",
+ "/usr/bin",
+ "/usr/sbin",
+ "/usr/lib",
+ "/usr/libexec",
+ "/opt/homebrew",
+ "/usr/local",
+ "/nix",
+)
+BASELINE_RO = (
+ "/System",
+ "/Library",
+ "/etc",
+ "/private/etc",
+ "/usr/share",
+ "/private/var/db",
+)
+BASELINE_DEV = ("/dev/null", "/dev/zero", "/dev/random", "/dev/urandom", "/dev/tty")
+
+PORT_RE = re.compile(r"[0-9]+")
+
+
+def usage(out):
+ out.write(
+ "Usage: isolate-seatbelt COMMAND [ARG...]\n"
+ " isolate-seatbelt -- COMMAND [ARG...]\n"
+ "\n"
+ "Execute the command in the current directory sandbox.\n"
+ "Use -- before commands whose names begin with a hyphen.\n"
+ )
+
+
+def die(message, status):
+ sys.stderr.write(f"{PROG}: {message}\n")
+ sys.exit(status)
+
+
+def parse_argv(argv):
+ """Split runner options from the command."""
+ if not argv:
+ usage(sys.stderr)
+ sys.exit(2)
+ first = argv[0]
+ if first in ("-h", "--help"):
+ usage(sys.stdout)
+ sys.exit(0)
+ if first == "--version":
+ sys.stdout.write(f"{PROG} {VERSION}\n")
+ sys.exit(0)
+ if first == "--":
+ rest = argv[1:]
+ if not rest:
+ sys.stderr.write(f"{PROG}: missing command after --\n")
+ usage(sys.stderr)
+ sys.exit(2)
+ return rest
+ return argv
+
+
+def env_or(name, default):
+ """Bash ${VAR:-default}: default when unset or empty."""
+ value = os.environ.get(name)
+ return value if value else default
+
+
+def add_path(args, seen, mode, path):
+ """Append a baseline grant for an existing path, deduplicated."""
+ if not os.path.exists(path):
+ return
+ key = mode + ":" + path
+ if key in seen:
+ return
+ seen.add(key)
+ args.append(mode)
+ args.append(path)
+
+
+def baseline(project):
+ """Seed grants before configuration files are sourced."""
+ args = []
+ seen = set()
+ args.extend(("--rw", "/tmp"))
+ args.extend(("--rwx", project))
+ for entry in env_or("PATH", "").split(":"):
+ if entry.startswith("/"):
+ add_path(args, seen, "--rox", entry)
+ tmpdir = env_or("TMPDIR", "")
+ if tmpdir.startswith("/") and tmpdir != "/tmp":
+ add_path(args, seen, "--rw", tmpdir)
+ home = env_or("HOME", "")
+ if home.startswith("/"):
+ add_path(args, seen, "--rwx", home + "/.pi")
+ for path in BASELINE_DEV:
+ add_path(args, seen, "--rw", path)
+ for path in BASELINE_ROX:
+ add_path(args, seen, "--rox", path)
+ for path in BASELINE_RO:
+ add_path(args, seen, "--ro", path)
+ return args
+
+
+def load_grants(seed, init_path, config_path):
+ """Source the Bash configs starting from the baseline seed.
+
+ The files manipulate the `args` array with trusted Bash, so they run
+ under `bash` and the resulting array is read back NUL-separated.
+ The snippet runs with `set -eu`, matching the launcher the code
+ was extracted from.
+ """
+ script = ["set -eu;", "args=("]
+ script.append(" ".join(shlex.quote(a) for a in seed))
+ script.append(");")
+ for path in (init_path, config_path):
+ if path is not None and os.path.isfile(path):
+ script.append("source " + shlex.quote(path) + ";")
+ script.append("if ((${#args[@]})); then printf '%s\\0' \"${args[@]}\"; fi")
+ try:
+ proc = subprocess.run(
+ ["bash", "-c", "".join(script), PROG],
+ stdout=subprocess.PIPE,
+ check=False,
+ )
+ except OSError as exc:
+ die(f"cannot run bash: {exc.strerror}", 127)
+ if proc.returncode != 0:
+ sys.exit(proc.returncode)
+ if not proc.stdout:
+ return []
+ text = proc.stdout.decode("utf-8", "surrogateescape")
+ return text.split("\0")[:-1]
+
+
+def validate_grants(args):
+ i = 0
+ while i < len(args):
+ grant = args[i]
+ if grant in PATH_GRANTS:
+ if i + 1 >= len(args):
+ die(f"missing value for {grant}", 2)
+ i += 2
+ elif grant == "--connect-tcp":
+ value = args[i + 1] if i + 1 < len(args) else ""
+ # ASCII base-10 only: int() would also accept Unicode digits,
+ # and very long input can exceed the int-string digit limit.
+ if PORT_RE.fullmatch(value) is None:
+ die(f"--connect-tcp port must be 1 to 65535, got {value}", 2)
+ try:
+ port = int(value, 10)
+ except ValueError:
+ die(f"--connect-tcp port must be 1 to 65535, got {value}", 2)
+ if not 1 <= port <= 65535:
+ die(f"--connect-tcp port must be 1 to 65535, got {value}", 2)
+ i += 2
+ else:
+ i += 1
+
+
+def realpath_or_none(path):
+ """Resolve directories fully, files via their parent.
+
+ Seatbelt subpath filters only match fully resolved paths, and on macOS
+ /tmp and /var are symbolic links into /private. Returns None when the
+ path resolves to nothing, matching --ignore-missing on Linux.
+ """
+ try:
+ if os.path.isdir(path):
+ return os.path.realpath(path)
+ parent = os.path.dirname(path)
+ base = os.path.basename(path)
+ if not base or not parent or not os.path.isdir(parent):
+ return None
+ resolved_parent = os.path.realpath(parent)
+ if resolved_parent == "/":
+ return "/" + base
+ return resolved_parent.rstrip("/") + "/" + base
+ except OSError:
+ return None
+
+
+def sbpl_quote(text):
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+def sbpl_target(path):
+ resolved = realpath_or_none(path)
+ if resolved is None:
+ return None
+ if os.path.isdir(resolved):
+ return "(subpath " + sbpl_quote(resolved) + ")"
+ return "(literal " + sbpl_quote(resolved) + ")"
+
+
+def build_profile(args, project):
+ """Translate every grant or refuse it; none is silently discarded."""
+ chunks = [SBPL_HEADER]
+ ports = []
+ unrestricted = False
+ i = 0
+ while i < len(args):
+ grant = args[i]
+ if grant in FILE_OPS:
+ path = args[i + 1] if i + 1 < len(args) else ""
+ if not path:
+ die(f"missing value for {grant}", 2)
+ target = sbpl_target(path)
+ if target is not None:
+ chunks.append(f"(allow {FILE_OPS[grant]} {target})\n")
+ i += 2
+ elif grant == "--unix":
+ path = args[i + 1] if i + 1 < len(args) else ""
+ if not path:
+ die(f"missing value for {grant}", 2)
+ # Resolved the same way as the file grants above: seatbelt
+ # subpath/literal filters only match fully resolved paths.
+ resolved = realpath_or_none(path)
+ if resolved is not None:
+ socket = sbpl_quote(resolved)
+ chunks.append(f"(allow network-outbound (literal {socket}))\n")
+ chunks.append(f"(allow file-read* file-write* (literal {socket}))\n")
+ i += 2
+ elif grant == "--connect-tcp":
+ ports.append(args[i + 1])
+ i += 2
+ elif grant == "--unrestricted-network":
+ unrestricted = True
+ i += 1
+ elif grant == "--env":
+ # Seatbelt inherits the caller environment, so landrun's
+ # explicit forwarding has no equivalent and no effect.
+ i += 2
+ elif grant in IGNORED_SINGLE:
+ i += 1
+ else:
+ die(f"{grant} cannot be translated for the seatbelt backend", 2)
+ for port in ports:
+ chunks.append(f'(allow network-outbound (remote tcp "*:{port}"))\n')
+ if ports or unrestricted:
+ chunks.append(DNS_STANZA)
+ if unrestricted:
+ chunks.append("(allow network*)\n")
+ profile = "".join(chunks)
+ project_target = sbpl_target(project)
+ if project_target is None or project_target not in profile:
+ die(f"failed to grant the project directory {project}", 1)
+ return profile
+
+
+def exec_command(argv):
+ try:
+ os.execvp(argv[0], argv)
+ except OSError as exc:
+ die(f"cannot execute {argv[0]}: {exc.strerror}", 127)
+
+
+def main(argv):
+ command = parse_argv(argv)
+
+ # Nested isolation is resolved before anything else, so an
+ # already-isolated command still runs where no backend exists.
+ if env_or("ISOLATE_ENV", ""):
+ sys.stderr.write(
+ f"{PROG}: warning: already isolated; executing command directly\n"
+ )
+ exec_command(command)
+
+ if shutil.which("sandbox-exec") is None:
+ die("sandbox-exec is not installed or not on PATH", 127)
+
+ try:
+ project = os.path.realpath(os.getcwd())
+ except OSError:
+ die("failed to grant the project directory .", 1)
+ config = env_or("ISOLATE_EXTRA_CONFIG", project + "/.isolate")
+ xdg_default = env_or("HOME", "") + "/.config"
+ init_default = env_or("XDG_CONFIG_HOME", xdg_default) + "/isolate/init"
+ init = env_or("ISOLATE_INIT_CONFIG", init_default)
+
+ collected = load_grants(baseline(project), init, config)
+ validate_grants(collected)
+ profile = build_profile(collected, project)
+
+ os.environ["PROMPT_ENV_INDICATOR"] = "isolated"
+ os.environ["ISOLATE_ENV"] = project
+ try:
+ os.execvp("sandbox-exec", ["sandbox-exec", "-p", profile] + command)
+ except OSError as exc:
+ die(f"cannot execute sandbox-exec: {exc.strerror}", 127)
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])