#!/bin/bash # # Purpose : Run an AI agent inside Anthropic's sandbox runtime (srt). srt uses # bubblewrap to limit which files the agent can read and write, and # removes its network; socat links it to a proxy outside the sandbox # that only lets through the domains in its config. # # sandbox/bin/ links here and shell/bashrc puts sandbox/bin first on # PATH, so typing an agent's name runs it sandboxed. `unsandboxed ` runs # the agent's real command instead. # # The config is sandbox/config/base.json, then sandbox/config/.json, # then your own ~/.config/miniserver/sandbox/base.json and .json, in # the same format. Later files add to the lists in earlier ones. Domains in # MINISERVER_SANDBOX_DOMAINS (space-separated) are allowed too; setup.sh uses # it for Hermes' gateway, whose messaging server differs on each machine. # Wrapped in { ...; exit; } so bash reads the whole script up front; update # replaces this file (git pull) while agents are running. { SANDBOX="$HOME/.local/share/MiniServer/sandbox" USER_CONFIG="$HOME/.config/miniserver/sandbox" name="$(basename "$0")" # The agent's real command: the first one on PATH outside sandbox/bin real_command() { local dir bin_dir IFS=: bin_dir="$(realpath "$SANDBOX/bin")" for dir in $PATH; do [ -n "$dir" ] && [ "$(realpath "$dir" 2> /dev/null)" != "$bin_dir" ] || continue if [ -x "$dir/$1" ] && [ ! -d "$dir/$1" ]; then echo "$dir/$1" return 0 fi done return 1 } if [ "$name" = "unsandboxed" ]; then if [ $# -eq 0 ]; then echo "Usage: unsandboxed [arguments]" exit 2 fi agent="$1" shift real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; } exec "$real" "$@" fi agent="$name" real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; } # Commands that share another agent's sandbox config case "$agent" in hermes-agent) profile=hermes ;; *) profile="$agent" ;; esac srt="$(command -v srt || echo "$HOME/.local/bin/srt")" if [ ! -x "$srt" ]; then echo "The sandbox (srt) isn't installed; run update. To run $agent without it: unsandboxed $agent" exit 1 fi # The agent can change anything in the directory it starts in case "$PWD" in "$HOME"|/) echo "Start $agent from a project directory. In the sandbox it can change anything in the directory it starts in, and this is $PWD." exit 1 ;; esac configs=("$SANDBOX/config/base.json") [ -f "$SANDBOX/config/$profile.json" ] && configs+=("$SANDBOX/config/$profile.json") ours=("${configs[@]}") for file in "$USER_CONFIG/base.json" "$USER_CONFIG/$profile.json"; do [ -f "$file" ] && configs+=("$file") done # Objects are merged and lists joined, so later files only add to earlier ones MERGE=' def merge(a; b): reduce (b | to_entries[]) as $e (a; .[$e.key] |= ( if type == "object" and ($e.value | type) == "object" then merge(.; $e.value) elif type == "array" and ($e.value | type) == "array" then . + $e.value | unique else $e.value end)); reduce .[] as $config ({}; merge(.; $config))' # srt runs in place of this script (exec below), so a stop signal, e.g. from # systemd, reaches it and it passes it on to the agent. That leaves nothing to # remove the settings file afterwards, so it's named after this process, and # files whose process has ended are removed here. SETTINGS_DIR="${XDG_RUNTIME_DIR:-/tmp}/miniserver-sandbox" mkdir -p -m 700 "$SETTINGS_DIR" || exit 1 for old in "$SETTINGS_DIR"/*.json; do pid="${old%.json}" pid="${pid##*.}" [[ "$pid" =~ ^[0-9]+$ ]] && ! kill -0 "$pid" 2> /dev/null && rm -f "$old" done # Outside the sandbox's writable paths, so the agent can't change its own rules settings="$SETTINGS_DIR/$agent.$$.json" if ! jq -s --arg domains "$MINISERVER_SANDBOX_DOMAINS" \ "$MERGE"' | .network.allowedDomains += ($domains | split(" ") | map(select(. != "")))' \ "${configs[@]}" > "$settings"; then echo "Couldn't read the sandbox config: ${configs[*]}" exit 1 fi # Bubblewrap can only make writable a directory that exists, and the agent # can't create one itself in a parent it may not write to while IFS= read -r path; do path="${path/#\~/$HOME}" case "$path" in "$HOME"/*) mkdir -p "$path" ;; esac done < <(jq -rs "$MERGE | .filesystem.allowWrite[]? | strings" "${ours[@]}") # srt sets TMPDIR to /tmp/claude, which nothing creates, unless told otherwise; # temp files then fail, and GTK aborts. /tmp is writable in every profile. export CLAUDE_CODE_TMPDIR=/tmp # Hermes' terminal reaches its gateway, in another sandbox, through a relay case "$profile" in hermes) exec "$srt" --settings "$settings" -- bash "$SANDBOX/hermes-bridge" "$real" "$@" ;; esac exec "$srt" --settings "$settings" -- "$real" "$@" }