harmonize with coredesktop

This commit is contained in:
David Young
2026-10-28 14:35:07 -06:00
parent 866720c3c1
commit 9b9f1d5fdd
35 changed files with 1445 additions and 8 deletions
Executable
+127
View File
@@ -0,0 +1,127 @@
#!/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" "$@"
}
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+85
View File
@@ -0,0 +1,85 @@
{
"network": {
"allowedDomains": [
"anthropic.com",
"*.anthropic.com",
"openai.com",
"*.openai.com",
"chatgpt.com",
"*.chatgpt.com",
"openrouter.ai",
"*.openrouter.ai",
"nousresearch.com",
"*.nousresearch.com",
"generativelanguage.googleapis.com",
"api.mistral.ai",
"api.deepseek.com",
"api.x.ai",
"api.groq.com",
"api.together.xyz",
"*.githubcopilot.com",
"github.com",
"*.github.com",
"*.githubusercontent.com",
"gitea.federated.computer",
"gitea.young.computer",
"registry.npmjs.org",
"registry.yarnpkg.com",
"pypi.org",
"files.pythonhosted.org",
"crates.io",
"*.crates.io",
"rubygems.org",
"*.rubygems.org",
"proxy.golang.org",
"sum.golang.org",
"archlinux.org",
"*.archlinux.org"
],
"deniedDomains": []
},
"filesystem": {
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.password-store",
"~/.local/share/keyrings",
"~/.netrc",
"~/.git-credentials",
"~/.aws",
"~/.azure",
"~/.config/gcloud",
"~/.kube",
"~/.docker",
"~/.config/gh",
"~/.config/tea",
"~/.config/borg",
"~/.config/Bitwarden",
"~/.config/Element",
"~/.mozilla",
"~/.config/chromium",
"~/.config/google-chrome",
"~/.config/BraveSoftware",
"~/.config/net.imput.helium",
"~/.var/app",
"~/.davfs2",
"~/.claude",
"~/.claude.json",
"~/.hermes",
"~/.config/opencode",
"~/.local/share/opencode"
],
"allowRead": [],
"allowWrite": [
".",
"/tmp",
"~/.npm",
"~/.cache/pip",
"~/.cargo/registry",
"~/.cargo/git",
"~/go/pkg/mod"
],
"denyWrite": []
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"network": {
"allowedDomains": [],
"allowAllUnixSockets": true
},
"filesystem": {
"denyRead": [
"/run/user",
"/run/dbus",
"/run/docker.sock",
"/run/podman",
"/run/tailscale",
"/run/ssh-unix-local",
"/tmp/.X11-unix",
"/tmp/tmux-*",
"/tmp/ssh-*",
"/tmp/*/SingletonSocket"
],
"allowRead": [
"~/.hermes"
],
"allowWrite": [
"~/.hermes"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"network": {
"allowedDomains": [
"opencode.ai",
"*.opencode.ai",
"models.dev"
]
},
"filesystem": {
"allowRead": [
"~/.config/opencode",
"~/.local/share/opencode"
],
"allowWrite": [
"~/.config/opencode",
"~/.local/share/opencode",
"~/.local/state/opencode",
"~/.cache/opencode"
]
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
#
# Purpose : Run Hermes inside its sandbox (sandbox/agent runs this there) and
# connect its terminal to its gateway.
#
# Hermes' gateway runs the agent; `hermes` in a terminal only talks to it, over
# the gateway's control socket and then a WebSocket on 127.0.0.1:<port>. Each
# sandbox has its own network, so that port can't be reached from another one.
# The gateway's sandbox relays the port to API_SOCKET, and a terminal's sandbox
# listens on the same port and relays to API_SOCKET, so `hermes` reaches the
# gateway as if they shared a network.
#
# Usage: hermes-bridge <hermes command> [arguments]
real="$1"
shift
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
API_SOCKET="$HERMES_HOME/miniserver-api.sock"
# The gateway's answer to `identify` on its control socket, or nothing if no
# gateway is running
identify() {
local control="$HERMES_HOME/gateway.sock"
[ -S "$control" ] || control="$(cat "$HERMES_HOME/gateway.sock.path" 2> /dev/null)"
[ -S "$control" ] || return 1
printf '{"protocol":1,"verb":"identify","id":1}\n' \
| socat -t 5 - "UNIX-CONNECT:$control" 2> /dev/null | grep .
}
# The port the gateway's API listens on, once the gateway is ready. Only an
# IPv4 loopback origin is relayed, which is what Hermes uses by default.
api_port() {
local origin
origin="$(identify | jq -r 'select(.result.state == "ready") | .result.api_origin // empty' 2> /dev/null)"
[[ "$origin" =~ ^http://127\.0\.0\.1:([0-9]+)$ ]] || return 1
echo "${BASH_REMATCH[1]}"
}
if [ "$1" = "gateway" ] && [ "$2" = "run" ]; then
# A terminal finds no relay, rather than the last gateway's, until this one
# is up
rm -f "$API_SOCKET"
# The relay waits for the gateway, which runs in this process once exec'd;
# the sandbox ends both together
(
for _ in $(seq 480); do
if port="$(api_port)"; then
exec socat "UNIX-LISTEN:$API_SOCKET,unlink-early,mode=600,fork" "TCP:127.0.0.1:$port"
fi
sleep 0.25
done
echo "hermes-bridge: the gateway's API didn't come up; terminals can't reach it" >&2
) &
exec "$real" "$@"
fi
# A terminal: relay the gateway's port, if a gateway is running or its service
# is turned on, waiting for one that is still starting. Without one, hermes
# starts its own gateway inside this sandbox, which needs no relay.
if [ -e "$HOME/.config/systemd/user/default.target.wants/hermes-gateway.service" ] \
|| identify > /dev/null; then
[ -S "$API_SOCKET" ] && api_port > /dev/null || echo "Waiting for Hermes' gateway..." >&2
for _ in $(seq 240); do
[ -S "$API_SOCKET" ] && port="$(api_port)" && break
port=""
sleep 0.25
done
if [ -n "$port" ]; then
socat "TCP-LISTEN:$port,bind=127.0.0.1,reuseaddr,fork" "UNIX-CONNECT:$API_SOCKET" &
# Wait for the listener (state 0A, LISTEN, in /proc/net/tcp) so hermes'
# first connection doesn't fail
hex="$(printf ':%04X ' "$port")"
for _ in $(seq 50); do
grep -q "$hex.* 0A " /proc/net/tcp && break
sleep 0.05
done
else
echo "hermes-bridge: Hermes' gateway is running but its API isn't reachable; try: systemctl --user restart hermes-gateway" >&2
fi
fi
exec "$real" "$@"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
#
# Purpose : Set up sandboxed AI agents (see sandbox/agent): install or update
# Anthropic's sandbox runtime (srt) and Hermes Agent, and turn on
# Claude Code's built-in sandbox. Run by install.sh and on every
# update (updates.d/72-sandbox.sh).
MINISERVER="$HOME/.local/share/MiniServer"
STATUS=0
# npm's global prefix is under /usr, so install to ~/.local; srt lands in ~/.local/bin
echo "Installing the sandbox runtime (srt)..."
npm install --global --prefix "$HOME/.local" --no-fund --no-audit @anthropic-ai/sandbox-runtime || STATUS=1
# --non-interactive skips Hermes' setup questions and its background gateway
# service; `configure hermes` chooses a model provider afterwards
if [ ! -x "$HOME/.local/bin/hermes" ]; then
echo "Installing Hermes Agent..."
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive || STATUS=1
fi
# Claude Code runs every command it starts in its own sandbox, also built on
# bubblewrap and socat; it can't run inside srt because bubblewrap can't be
# nested. Anything already in the sandbox section of your settings wins over
# these defaults.
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
if [ -f "$CLAUDE_SETTINGS" ]; then
domains="$(jq '.network.allowedDomains' "$MINISERVER/sandbox/config/base.json")"
tmp="$(mktemp "$CLAUDE_SETTINGS.XXXXXX")"
if jq --argjson domains "$domains" \
'.sandbox = ({enabled: true, network: {allowedDomains: $domains}} * (.sandbox // {}))' \
"$CLAUDE_SETTINGS" > "$tmp"; then
if cmp -s "$tmp" "$CLAUDE_SETTINGS"; then
rm -f "$tmp"
else
echo "Turning on Claude Code's sandbox..."
mv -f "$tmp" "$CLAUDE_SETTINGS"
fi
else
echo "Couldn't read $CLAUDE_SETTINGS; Claude Code's sandbox was not turned on."
rm -f "$tmp"
STATUS=1
fi
fi
# Hermes' gateway acts on messages from outside this machine, so it runs in
# the sandbox too: a systemd drop-in starts it through sandbox/bin/hermes. It
# also needs to reach the Matrix server in Hermes' settings, which differs on
# each machine; for other messaging services, add their domains to
# ~/.config/miniserver/sandbox/hermes.json.
GATEWAY_UNIT="$HOME/.config/systemd/user/hermes-gateway.service"
if [ -f "$GATEWAY_UNIT" ]; then
if [ -x "$HOME/.local/bin/srt" ]; then
homeserver="$(sed -nE "s#^MATRIX_HOMESERVER=[\"']?https?://([^/:\"']+).*#\1#p" "$HOME/.hermes/.env" 2> /dev/null | head -n 1)"
dropin="$GATEWAY_UNIT.d/miniserver-sandbox.conf"
tmp="$(mktemp)"
cat > "$tmp" << EOF
# Written by MiniServer (sandbox/setup.sh): run Hermes' gateway in the sandbox
[Service]
Environment="MINISERVER_SANDBOX_DOMAINS=$homeserver"
ExecStart=
ExecStart=%h/.local/share/MiniServer/sandbox/bin/hermes gateway run
EOF
if cmp -s "$tmp" "$dropin"; then
rm -f "$tmp"
else
echo "Running Hermes' gateway in the sandbox..."
mkdir -p "$(dirname "$dropin")" && mv -f "$tmp" "$dropin" && chmod 644 "$dropin" \
&& systemctl --user daemon-reload || STATUS=1
fi
else
echo "Hermes' gateway isn't sandboxed yet: the sandbox (srt) isn't installed."
STATUS=1
fi
# A server is rarely logged in to, and user services stop at logout unless
# the user lingers
if [ "$(loginctl show-user "$USER" --property=Linger --value 2> /dev/null)" != yes ]; then
echo "Keeping Hermes' gateway running while you're logged out..."
sudo loginctl enable-linger "$USER" || STATUS=1
fi
fi
exit $STATUS