diff --git a/bin/CLAUDE.md b/bin/CLAUDE.md index 1267719..878c5dd 100644 --- a/bin/CLAUDE.md +++ b/bin/CLAUDE.md @@ -45,5 +45,6 @@ Spotlight is kept enabled (Alfred requires it) but specific directories are excl - `bin/av-ssh-agent-holds-key `: Exit 0 (printing the socket) when ssh resolves to Automic Vault's agent and it serves that key. The per-machine "is AV set up?" gate; see [ADR 0059](../doc/adr/0059-automic-vault-for-git-signing-and-agent-ssh.md). - `bin/retire-agent-ssh-key `: Delete an agent key's private file once AV holds it, after a live GitHub auth through AV only. Keeps the `.pub`. Refuses on machines where AV isn't serving the key. - `bin/setup-av-signing-key`: Create the personal GPG key Automic Vault's GPG Signing Gate uses as its default credential (AV can only import a default, not generate one). No-op if GitHub already lists a GPG key for the email (`--check` only tests that). Otherwise generates an ed25519 key with a random passphrase in a throwaway `GNUPGHOME`, backs the key, revocation cert and passphrase up to 1Password first (stops and keeps the keyring if any backup fails), walks the AV import and GitHub upload through the clipboard, then clears the clipboard and deletes the keyring. Needs a terminal (or `--yes`), `op` signed in, and network, so run it outside the Claude Code sandbox. See [ADR 0059](../doc/adr/0059-automic-vault-for-git-signing-and-agent-ssh.md). -- `bin/safe `: Run an agent CLI under agent-safehouse (`safe pi`) with `config/agent-safehouse/{common,role-}.sb` appended, clipboard enabled, and a dedicated ssh-agent holding only the agent keys so in-sandbox commits sign as the agent. See [ADR 0060](../doc/adr/0060-agent-safehouse-wrapper.md). +- `bin/safe `: Run an agent CLI under agent-safehouse (`safe pi`) with `config/agent-safehouse/{common,role-}.sb` appended, clipboard enabled, and a dedicated ssh-agent holding only the agent keys so in-sandbox commits sign as the agent. Tool profiles in `config/agent-safehouse/tools/.{sb,sh}` (picked by command name or `SAFE_TOOL`) add per-tool grants, env and an isolated cache/state root. Per-invocation grants come from safehouse's `SAFEHOUSE_ADD_DIRS`/`SAFEHOUSE_ADD_DIRS_RO`/`SAFEHOUSE_ENV_PASS` and `SAFE_SOCKET_DIRS` (unix-socket dirs). See [ADR 0060](../doc/adr/0060-agent-safehouse-wrapper.md), [ADR 0063](../doc/adr/0063-per-invocation-grants-for-safe.md), [ADR 0064](../doc/adr/0064-isolation-policy-for-sandboxed-agents.md). +- `bin/safe-tmux`: Drive a shell under `safe` from a detached tmux session (`start`/`run`/`capture`/`stop`), so commands can be sent and output read back without an interactive terminal. `SAFE_TMUX_CMD` overrides what the session runs. Start it outside any agent sandbox. - `bin/check-agent-ssh-key`: Validate an agent SSH identity setup (local files, Keychain, GitHub registration, Claude settings, and -- for the work role -- per-org SAML SSO authorization). Pass the same `--email` used at setup time. When the identity name differs from the role (the `home` role signs as the `personal` identity, per [ADR 0035](../doc/adr/0035-canonical-dotpickles-role-names.md)), pass `--key ` so it checks the real key (claudeconfig.sh derives this from the role's gitconfig include automatically). diff --git a/bin/safe b/bin/safe index bf77a76..4b99f64 100755 --- a/bin/safe +++ b/bin/safe @@ -4,6 +4,18 @@ # safe pi # safe --env-pass=OPENROUTER_API_KEY -- pi # +# Per-invocation grants, for tools that need a little more than the roles give: +# SAFEHOUSE_ADD_DIRS / SAFEHOUSE_ADD_DIRS_RO / SAFEHOUSE_ENV_PASS +# safehouse's own env knobs: read-write dirs, read-only dirs, +# env var names to pass through +# SAFE_SOCKET_DIRS colon-separated dirs where the command may bind, accept on +# and connect to unix sockets (daemons, worker IPC). Created +# if missing. Safehouse has no flag for sockets. +# SAFE_TOOL tool profile to use, when it isn't the command's basename +# SAFE_ROOT where per-tool private roots live (default ~/.local/share/safe) +# +# Tool profiles live in config/agent-safehouse/tools/.{sb,sh}. +# # Safehouse has no global config file and no env var for --append-profile or # --enable, so this wrapper is how the overrides get applied. See # doc/adr/0060-agent-safehouse-wrapper.md. @@ -51,9 +63,101 @@ fi # hatch (`open -a Terminal ...`) the role escape guards don't # cover. # Rationale and tradeoffs: doc/adr/0060-agent-safehouse-wrapper.md. -exec safehouse \ + +# Tool profiles (ADR 0064): tools/.sb is appended after common.sb, and +# tools/.sh is sourced to set env and grants. The tool is SAFE_TOOL, else +# the basename of the command (the arg after `--`, or the first non-option arg). +tool="${SAFE_TOOL:-}" +if [ -z "$tool" ]; then + seen_dd="" + first="" + after_dd="" + for arg in "$@"; do + if [ -n "$seen_dd" ]; then + after_dd=$arg + break + fi + case $arg in + --) seen_dd=1 ;; + -*) ;; + *) [ -n "$first" ] || first=$arg ;; + esac + done + tool=$(basename "${after_dd:-$first}") +fi +case $tool in *[!A-Za-z0-9._-]*) tool="" ;; esac + +tool_profile="" +if [ -n "$tool" ] && [ -f "$profiles/tools/$tool.sh" ]; then + # The tool's private root: granted read-write, mode 700. A profile puts its + # state under it, and SAFE_XDG_ISOLATE puts cache and XDG state there too. + SAFE_TOOL_ROOT="${SAFE_ROOT:-$HOME/.local/share/safe}/$tool" + mkdir -p "$SAFE_TOOL_ROOT" + chmod 700 "$SAFE_TOOL_ROOT" + SAFEHOUSE_ADD_DIRS="${SAFEHOUSE_ADD_DIRS:+$SAFEHOUSE_ADD_DIRS:}$SAFE_TOOL_ROOT" + export SAFEHOUSE_ADD_DIRS + + # A profile sets plain variables, lists the ones the tool needs in + # SAFE_TOOL_ENV (comma-separated, passed through the env strip), and may set + # SAFE_XDG_ISOLATE=1 and SAFE_SOCKET_DIRS. + SAFE_TOOL_ENV="" + SAFE_XDG_ISOLATE="" + # shellcheck disable=SC1090 + . "$profiles/tools/$tool.sh" + + if [ -n "$SAFE_XDG_ISOLATE" ]; then + real_mise_state="${XDG_STATE_HOME:-$HOME/.local/state}/mise" + mkdir -p "$SAFE_TOOL_ROOT/cache" "$SAFE_TOOL_ROOT/state/mise" + # mise trust lives in its state dir. Seed the isolated copy one way so + # existing repos stay trusted, without the tool being able to trust + # anything in the real one. -n keeps trust the tool added itself. + for trust_dir in trusted-configs tracked-configs; do + [ -d "$real_mise_state/$trust_dir" ] && cp -RPn "$real_mise_state/$trust_dir" "$SAFE_TOOL_ROOT/state/mise/" 2> /dev/null + done + XDG_CACHE_HOME="$SAFE_TOOL_ROOT/cache" + XDG_STATE_HOME="$SAFE_TOOL_ROOT/state" + SAFE_TOOL_ENV="$SAFE_TOOL_ENV,XDG_CACHE_HOME,XDG_STATE_HOME" + fi + + old_ifs=$IFS + IFS=, + for name in $SAFE_TOOL_ENV; do + [ -n "$name" ] && export "$name" + done + IFS=$old_ifs +fi +[ -n "$tool" ] && [ -f "$profiles/tools/$tool.sb" ] && tool_profile="$profiles/tools/$tool.sb" + +# SAFE_SOCKET_DIRS becomes a generated profile between common and role, so role +# denies still win. It needs cleanup afterwards, so that path can't exec. +socket_profile="" +if [ -n "${SAFE_SOCKET_DIRS:-}" ]; then + socket_profile=$(mktemp "${TMPDIR:-/tmp}/safe-sockets.XXXXXX") + trap 'rm -f "$socket_profile"' EXIT + old_ifs=$IFS + IFS=: + for dir in $SAFE_SOCKET_DIRS; do + [ -n "$dir" ] || continue + mkdir -p "$dir" + dir=$(cd "$dir" && pwd -P) + printf '(allow network-bind (local unix-socket (subpath "%s")))\n' "$dir" >> "$socket_profile" + printf '(allow network-inbound (local unix-socket (subpath "%s")))\n' "$dir" >> "$socket_profile" + printf '(allow network-outbound (remote unix-socket (subpath "%s")))\n' "$dir" >> "$socket_profile" + done + IFS=$old_ifs +fi + +set -- \ --enable=clipboard,keychain,launch-services \ - --env-pass=SSH_AUTH_SOCK,GIT_CONFIG_GLOBAL \ + --env-pass="SSH_AUTH_SOCK,GIT_CONFIG_GLOBAL${SAFE_TOOL_ENV:+,$SAFE_TOOL_ENV}" \ --append-profile="$profiles/common.sb" \ + ${tool_profile:+--append-profile="$tool_profile"} \ + ${socket_profile:+--append-profile="$socket_profile"} \ ${role_profile:+"$role_profile"} \ "$@" + +if [ -z "$socket_profile" ]; then + exec safehouse "$@" +fi +safehouse "$@" +exit $? diff --git a/bin/safe-tmux b/bin/safe-tmux new file mode 100644 index 0000000..aa8e7e7 --- /dev/null +++ b/bin/safe-tmux @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Drive a shell under `safe` from a detached tmux session, so commands can be +# sent and output read back without an interactive terminal. +# +# safe-tmux start [dir] start the session (default dir: cwd) +# safe-tmux run send a command, wait for it, print its output and exit code +# safe-tmux capture print the visible pane +# safe-tmux stop kill the session +# +# SAFE_TMUX_CMD overrides what the session runs (default: `safe $SHELL`), e.g. +# a wrapper that sets per-tool grants first. Uses its own tmux server (-L) so +# it never touches an attached terminal's focus. Start it outside any agent +# sandbox: safe can't nest. +set -euo pipefail + +t=(tmux -L safe-tmux) +session=safe-shell + +case ${1:-} in + start) + dir=$(cd "${2:-.}" && pwd) + "${t[@]}" new-session -d -s "$session" -x 200 -y 50 -c "$dir" "${SAFE_TMUX_CMD:-safe ${SHELL:-/bin/zsh}}" + ;; + run) + shift + marker="__DONE_$$_$RANDOM" + # The sentinel carries $? so the caller gets the exit code without parsing prompts. + "${t[@]}" send-keys -t "$session" "$* ; echo \"$marker:\$?\"" Enter + for _ in $(seq 1 600); do + out=$("${t[@]}" capture-pane -p -J -S -2000 -t "$session") + if grep -q "^$marker:" <<< "$out"; then + awk -v m="$marker" ' + index($0, m ":") == 1 { split($0, a, ":"); print "EXIT:" a[2]; exit } + seen { print } + index($0, "echo \"" m) { seen = 1 } + ' <<< "$out" + exit 0 + fi + sleep 1 + done + echo "timed out waiting for: $*" >&2 + exit 1 + ;; + capture) + "${t[@]}" capture-pane -p -J -S -2000 -t "$session" + ;; + stop) + "${t[@]}" kill-session -t "$session" + ;; + *) + sed -n '2,12p' "${BASH_SOURCE[0]}" >&2 + exit 2 + ;; +esac diff --git a/config/agent-safehouse/common.sb b/config/agent-safehouse/common.sb index 5b92fad..9c3ef77 100644 --- a/config/agent-safehouse/common.sb +++ b/config/agent-safehouse/common.sb @@ -22,11 +22,6 @@ (remote unix-socket (path-literal (string-append HOME_DIR "/.ssh/agent/safehouse.sock"))) ) -;; plannotator pi extension: reads config.json, writes drafts/history/plans/sessions. -(allow file-read* file-write* - (home-subpath "/.plannotator") -) - ;; mcporter: scripts that call MCP servers through it read and refresh OAuth ;; tokens in its vault here, and write ;; schema caches, locks and daemon logs. The config is denied back: it names diff --git a/config/agent-safehouse/tools/pi.sb b/config/agent-safehouse/tools/pi.sb new file mode 100644 index 0000000..26b3e3e --- /dev/null +++ b/config/agent-safehouse/tools/pi.sb @@ -0,0 +1,7 @@ +;; Safehouse overrides for `safe pi`. Appended by bin/safe after common.sb. See +;; doc/adr/0064-isolation-policy-for-sandboxed-agents.md. + +;; plannotator pi extension: reads config.json, writes drafts/history/plans/sessions. +(allow file-read* file-write* + (home-subpath "/.plannotator") +) diff --git a/config/agent-safehouse/tools/prime-agent.sh b/config/agent-safehouse/tools/prime-agent.sh new file mode 100644 index 0000000..7a6495b --- /dev/null +++ b/config/agent-safehouse/tools/prime-agent.sh @@ -0,0 +1,24 @@ +# prime-agent profile, sourced by bin/safe (ADR 0064). Callers can preset any +# PRIME_AGENT_* var to keep state elsewhere. +# +# Without these prime-agent falls back to ~/.prime/agent, which safehouse denies. +: "${PRIME_AGENT_CODING_AGENT_DIR:=$SAFE_TOOL_ROOT/agent}" +: "${PRIME_AGENT_DAEMON_SOCKET:=$PRIME_AGENT_CODING_AGENT_DIR/daemon.sock}" +: "${PRIME_AGENT_KERNEL_VENV:=$PRIME_AGENT_CODING_AGENT_DIR/kernel-venv}" +mkdir -p "$PRIME_AGENT_CODING_AGENT_DIR" + +DO_NOT_TRACK=1 +# Marks agent sessions for tools that behave differently under an agent; prime-agent sets no marker itself. +AGENT_SESSION=1 +# Dotfiles scripts run inside the sandbox pick their role from this. +DOTPICKLES_ROLE=$role + +# The daemon socket lives in the state dir; session workers bind under +# $TMPDIR/prime-agent-, or /tmp when that path is too long. +uid=$(id -u) +tmp=$(cd "${TMPDIR:-/tmp}" && pwd -P) +SAFEHOUSE_ADD_DIRS="$SAFEHOUSE_ADD_DIRS:$PRIME_AGENT_CODING_AGENT_DIR" +SAFE_SOCKET_DIRS="${SAFE_SOCKET_DIRS:+$SAFE_SOCKET_DIRS:}$(dirname "$PRIME_AGENT_DAEMON_SOCKET"):$tmp/prime-agent-$uid:/private/tmp/prime-agent-$uid" + +SAFE_XDG_ISOLATE=1 +SAFE_TOOL_ENV="PRIME_AGENT_CODING_AGENT_DIR,PRIME_AGENT_DAEMON_SOCKET,PRIME_AGENT_KERNEL_VENV,DO_NOT_TRACK,AGENT_SESSION,DOTPICKLES_ROLE" diff --git a/doc/adr/0063-per-invocation-grants-for-safe.md b/doc/adr/0063-per-invocation-grants-for-safe.md new file mode 100644 index 0000000..a76a014 --- /dev/null +++ b/doc/adr/0063-per-invocation-grants-for-safe.md @@ -0,0 +1,27 @@ +# 63. per-invocation grants for safe + +Date: 2026-10-21 + +## Status + +Accepted + +## Context + +[ADR 0060](0060-agent-safehouse-wrapper.md) put dotfiles' safehouse overrides in `common.sb` and `role-.sb`. Those are per machine role. Some tools need extra grants that belong to the tool or the repo, not the role: a daemon that binds unix sockets, a toolchain that writes a cache dir, a credential profile to read. Putting those in a role file would make every `safe` run carry them, and would pull tool- and employer-specific paths into a public repo. + +Safehouse already has env knobs for most of this (`SAFEHOUSE_ADD_DIRS`, `SAFEHOUSE_ADD_DIRS_RO`, `SAFEHOUSE_ENV_PASS`), and `safe` forwards its arguments, so `--append-profile` and `--env-pass` work per call. The gap is unix sockets: safehouse has no flag for them, so a tool that runs a daemon has to hand-write a `.sb` file. + +## Decision + +`bin/safe` reads `SAFE_SOCKET_DIRS` (colon-separated). Each dir is created if missing, resolved with `pwd -P`, and granted bind, inbound and outbound for unix sockets through a temp profile appended after `common.sb` and before the role profile, so role denies still win. Without it `safe` still `exec`s safehouse; with it, `safe` stays resident to remove the temp file and passes the exit status through. + +Per-tool wrappers (a daemon-based agent, a repo-specific preset) live with the tool and set these env vars before calling `safe`. Nothing tool- or employer-specific lands in this repo. + +`bin/safe-tmux` is the tool-neutral version of driving `safe $SHELL` from a detached tmux session. + +## Consequences + +- Wrappers get sockets, write dirs, read dirs and env passthrough without editing dotfiles. +- A grant given through these knobs is only as narrow as the caller makes it. Grant a dedicated socket dir, not a state dir or `$HOME`. +- The daemon-pins-its-sandbox behavior of some tools is the tool's problem: grants don't reach a daemon started earlier under a different profile. diff --git a/doc/adr/0064-isolation-policy-for-sandboxed-agents.md b/doc/adr/0064-isolation-policy-for-sandboxed-agents.md new file mode 100644 index 0000000..6065079 --- /dev/null +++ b/doc/adr/0064-isolation-policy-for-sandboxed-agents.md @@ -0,0 +1,68 @@ +# 64. isolation policy for sandboxed agents + +Date: 2026-10-21 + +## Status + +Proposed + +## Context + +[ADR 0060](0060-agent-safehouse-wrapper.md) and [ADR 0063](0063-per-invocation-grants-for-safe.md) give `safe` its grants one at a time, as each tool hit an EPERM. That works, but it has no stated rule for what _should_ be shared with a sandboxed agent, isolated from it, or denied. Without one, every new tool re-argues the same questions (its cache dir, its config, its credentials), and the grants accrete. + +Two questions decide the treatment of any path or credential: + +1. **Execute-later:** can a write there run outside the sandbox without anyone choosing to run it? Shell rc files, git hooks and config, mise config and tasks, shims on `PATH`, LaunchAgent sources. +2. **Secret:** can a read there leak a credential? Cloud SSO token caches, Keychain-held tokens, personal ssh keys, npm tokens. + +## Decision + +Treatments, strongest first. Pick the strongest one the tool can still work under. + +1. **Deny:** not granted. +2. **Isolate:** a private per-tool copy, such as its own XDG cache and state root, or a generated config file holding only what the tool needs. +3. **Narrow:** a scoped, short-lived substitute for the real credential, passed through the environment. [ADR 0031](0031-role-scoped-agent-git-identity.md) did this for git (a dedicated agent ssh-agent and identity); other credential domains follow the same pattern. +4. **Share read-only.** +5. **Share read-write:** the workdir, by design, and the exceptions below. + +| Category | Treatment | +| ------------------------------------------------------------------------- | ----------------------------------------------- | +| Credentials (SSO caches, Keychain tokens, personal keys) | Deny the store; narrow what the agent gets | +| Execute-later config (shell rc, git hooks and config, mise config, tasks) | Read-only or deny, never writable | +| Harmless shared config (git config, editor config) | Read-only | +| Caches | Isolate per tool | +| State and IPC (daemon sockets, venvs, locks, histories) | Isolate per tool, with dedicated socket dirs | +| Installed runtimes and gems (mise installs, shims, global gem dir) | **Shared read-write, accepted for now** (below) | +| Project-local deps (`vendor/bundle`, `node_modules`, `.venv`) | Workdir, read-write | + +Tool-specific grants and env belong in per-tool profiles, not `common.sb`. Anything specific to an employer or a repo is layered on by the caller, outside this repo. + +### Tool profiles + +`bin/safe` picks the tool from `SAFE_TOOL`, else the basename of the command, and looks in `config/agent-safehouse/tools/`: + +- `.sb` is appended after `common.sb`. +- `.sh` is sourced. It sets variables, lists the ones the tool needs in `SAFE_TOOL_ENV` (passed through safehouse's env strip), and may set `SAFE_SOCKET_DIRS` and `SAFE_XDG_ISOLATE=1`. `safe` creates the tool's private root (`~/.local/share/safe/`, mode 700) and grants it read-write. +- `SAFE_XDG_ISOLATE=1` redirects `XDG_CACHE_HOME` and `XDG_STATE_HOME` into that root. Config and data stay real, because redirecting them hides mise config and installs. mise's trust state lives in its state dir, so the isolated copy is seeded one way from the real `trusted-configs` and `tracked-configs` (no-clobber, each run). Existing repos stay trusted, but a `mise trust` run by the tool lands in its own root and never in yours. On macOS mise's cache is `~/Library/Caches/mise`, not under XDG, so it stays shared. + +First profiles: `prime-agent` (state dir, daemon and worker sockets, isolated cache and state) and `pi` (the plannotator grant, moved out of `common.sb`). `pi` doesn't isolate XDG yet: it hasn't been run under that to see what it needs. mcporter's grants also stay in `common.sb` until its users are known. + +### Accepted exception: shared mise and bundler + +Agents must be able to run `mise install`, `bundle install` and dependency updates, and they do so routinely (after every new worktree, on every dependency bump). Making the shared install dirs read-only would block that, and warming them outside the sandbox would stop an agent from adding a missing tool mid-task. + +So mise's data dir and the gem dirs stay writable to sandboxed agents. This is a known gap, not a position: a sandboxed write there (a shim, an installed gem or tool) can run outside the sandbox later. It is the same execute-later class the escape guard in `role-work.sb` covers for other paths. + +### Where to improve + +- A per-tool `MISE_DATA_DIR`, with the real install dir as a read-only fallback if mise supports a shared lookup dir. Installs then land in the isolated root. +- `BUNDLE_PATH` pointed into the workdir, so gems stop going to the shared gem dir. +- Warm installs outside the sandbox for bounded jobs (crews), keeping the shared dirs read-only there. +- Seeding isolated caches from the real ones, to avoid slow first runs. + +## Consequences + +- New tools get a profile that follows the table instead of a one-off grant. The inventory of what each tool actually touches should come from running it under `safe` and collecting the sandbox's allow and deny traces, not from guessing. +- Until the shared-install exception is closed, a compromised or misbehaving agent can plant code in mise and gem dirs. Installing from the workdir keeps its effect to that worktree; installing globally does not. +- Credentials that are narrowed instead of shared expire. The failure shows up in the tool's own words, far from the cause, and a long-lived daemon pins its old env. Diagnostics help here: a way to show which grant a deny maps to, a stale-profile warning for daemons, and a doctor check for missing setup steps. +- Network access is a separate axis and isn't covered here. diff --git a/doc/adr/README.md b/doc/adr/README.md index 1bd359e..9f819a0 100644 --- a/doc/adr/README.md +++ b/doc/adr/README.md @@ -61,3 +61,5 @@ - [60. agent-safehouse-wrapper](0060-agent-safehouse-wrapper.md) - [61. hot-path-statusline-cost](0061-hot-path-statusline-cost.md) - [62. sync-taskwarrior-with-a-periodic-launchagent](0062-sync-taskwarrior-with-a-periodic-launchagent.md) +- [63. per-invocation-grants-for-safe](0063-per-invocation-grants-for-safe.md) +- [64. isolation-policy-for-sandboxed-agents](0064-isolation-policy-for-sandboxed-agents.md)