harmonize with coredesktop
This commit is contained in:
Executable
+127
@@ -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" "$@"
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../agent
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../agent
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../agent
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../agent
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+81
@@ -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" "$@"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user