128 lines
4.7 KiB
Bash
Executable File
128 lines
4.7 KiB
Bash
Executable File
#!/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/<agent> links here and shell/bashrc puts sandbox/bin first on
|
|
# PATH, so typing an agent's name runs it sandboxed. `unsandboxed <agent>` runs
|
|
# the agent's real command instead.
|
|
#
|
|
# The config is sandbox/config/base.json, then sandbox/config/<agent>.json,
|
|
# then your own ~/.config/miniserver/sandbox/base.json and <agent>.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 <agent> [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" "$@"
|
|
}
|