Commit Diff


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:])