#!/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" "$@"
}
