commit dee5383eeb614c4e3be52ee47fbc80195009eba9 from: mtmn date: Mon Sep 14 16:04:38 2026 UTC add `isolate-seatbelt` script for macos commit - 9fa634dfaef8457ee8f022da7f08db32e81f7002 commit + dee5383eeb614c4e3be52ee47fbc80195009eba9 blob - /dev/null blob + 5656d280a04f54842fb04dec391672526f00b93d (mode 644) --- /dev/null +++ misc/isolate-seatbelt/README.md @@ -0,0 +1,8 @@ +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 @@ -0,0 +1,58 @@ +#!/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 @@ -0,0 +1,352 @@ +#!/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:])