Add launcher and default allowlist

This commit is contained in:
2026-10-23 22:29:01 +11:00
parent 3d0563a12c
commit c3b7a32bd1
3 changed files with 303 additions and 0 deletions
Executable
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Run Claude Code in a rootless podman sandbox whose only way out is a
# per-session router that resolves and forwards allowlisted hosts only.
# Usage: agent-sandbox [--exec CMD...] [claude args...]
set -euo pipefail
VERSION=dev
REGISTRY=artifactapi.k8s.syd1.au.unkin.net/docker-internal
if [[ $VERSION == dev ]]; then
IMAGE=${AGENT_SANDBOX_IMAGE:-localhost/agent-sandbox:dev}
ROUTER_IMAGE=${AGENT_SANDBOX_ROUTER_IMAGE:-localhost/agent-sandbox-router:dev}
else
IMAGE=${AGENT_SANDBOX_IMAGE:-$REGISTRY/agent-sandbox:$VERSION}
ROUTER_IMAGE=${AGENT_SANDBOX_ROUTER_IMAGE:-$REGISTRY/agent-sandbox-router:$VERSION}
fi
CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/agent-sandbox
ALLOWLIST=${AGENT_SANDBOX_ALLOWLIST:-$CONFIG_DIR/allowed-hosts}
HOME_VOLUME=${AGENT_SANDBOX_HOME_VOLUME:-agent-sandbox-home}
EGRESS_NET=agent-sandbox-egress
# Every .domain entry resolves here; exact hosts get 127.1.x.y of their own.
WILDCARD_IP=127.0.0.2
PORT_BASE=10000
die() {
echo "agent-sandbox: $*" >&2
exit 1
}
# Print "host port" per allowlist entry, sorted and unique; port is empty for
# plain web (80/443) access.
allowlist_entries() {
local line extra host port n=0
local label='[a-z0-9]([a-z0-9-]*[a-z0-9])?'
while IFS= read -r line || [[ -n $line ]]; do
n=$((n + 1))
line=${line%%#*}
read -r line extra <<<"$line" || true
[[ -z $line ]] && continue
[[ -n $extra ]] && die "$1:$n: one entry per line"
line=${line,,}
host=${line%:*}
port=
[[ $line == *:* ]] && port=${line##*:}
[[ $host =~ ^\.?$label(\.$label)*$ ]] || die "$1:$n: invalid host '$host'"
if [[ -n $port ]]; then
if ! [[ $port =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then
die "$1:$n: invalid port '$port'"
fi
[[ $host == .* ]] && die "$1:$n: ports need an exact host, not '$host'"
[[ $port == 80 || $port == 443 ]] && port=
fi
echo "$host $port"
done <"$1" | LC_ALL=C sort -u
}
# Write the router config (dnsmasq.conf, nginx.conf) for allowlist $1 into dir
# $2, listening on router IP $3. Prints the sandbox's loopback forwards as
# "bind:port:router:router_port" words.
router_config() {
local allowlist=$1 dir=$2 rip=$3 host port i=0 j=0
local -A ip=()
local hosts tcp="" forwards="0.0.0.0:443:$rip:443 0.0.0.0:80:$rip:80"
hosts=$(allowlist_entries "$allowlist" | awk '{print $1}' | uniq)
for host in $hosts; do
[[ $host == .* ]] && continue
ip[$host]=127.1.$((i / 250)).$((i % 250 + 1))
i=$((i + 1))
done
while read -r host port; do
[[ -z $port ]] && continue
tcp+=" server { listen $rip:$((PORT_BASE + j)); set \$up $host:$port; proxy_pass \$up; }"$'\n'
forwards+=" ${ip[$host]}:$port:$rip:$((PORT_BASE + j))"
j=$((j + 1))
done < <(allowlist_entries "$allowlist")
{
printf '%s\n' no-resolv no-hosts bind-interfaces "listen-address=$rip" "address=/#/"
for host in $hosts; do
if [[ $host == .* ]]; then
echo "address=/${host#.}/$WILDCARD_IP"
else
echo "host-record=$host,${ip[$host]}"
fi
done
} >"$dir/dnsmasq.conf"
local map=""
for host in $hosts; do map+=" $host 1;"$'\n'; done
cat >"$dir/nginx.conf" <<EOF
load_module /usr/lib/nginx/modules/ngx_stream_module.so;
worker_processes 1;
pid /tmp/nginx.pid;
error_log stderr warn;
events { worker_connections 1024; }
stream {
resolver @RESOLVER@ ipv6=off valid=30s;
map \$ssl_preread_server_name \$tls_allowed {
hostnames;
default 0;
$map
}
map \$tls_allowed \$tls_upstream {
1 \$ssl_preread_server_name:443;
default 127.0.0.1:9;
}
server {
listen $rip:443;
ssl_preread on;
proxy_pass \$tls_upstream;
}
$tcp}
http {
resolver @RESOLVER@ ipv6=off valid=30s;
access_log off;
map \$host \$http_allowed {
hostnames;
default 0;
$map
}
server {
listen $rip:80;
if (\$http_allowed = 0) { return 403; }
location / {
proxy_pass http://\$host;
proxy_set_header Host \$host;
}
}
}
EOF
echo "$forwards"
}
main() {
local cmd=(claude "$@")
if [[ ${1:-} == --exec ]]; then
shift
cmd=("$@")
fi
[[ $PWD == *:* ]] && die "cannot mount a directory containing ':'"
if [[ ! -f $ALLOWLIST ]]; then
local default
for default in "$(dirname "$(readlink -f "$0")")/allowed-hosts" /usr/share/agent-sandbox/allowed-hosts; do
[[ -f $default ]] && break
done
mkdir -p "$(dirname "$ALLOWLIST")"
cp "$default" "$ALLOWLIST"
echo "agent-sandbox: created $ALLOWLIST from defaults" >&2
fi
local session router tmp subnet rip forwards
session=agent-sandbox-$(date +%s)-$$
router=$session-router
tmp=$(mktemp -d "${XDG_RUNTIME_DIR:-/tmp}/$session.XXXX")
# shellcheck disable=SC2064
trap "podman pod rm -f -t 0 $session >/dev/null 2>&1; podman rm -f -t 0 $router >/dev/null 2>&1; podman network rm -f $session >/dev/null 2>&1; rm -rf '$tmp'" EXIT
trap 'exit 130' INT TERM HUP
podman network create --ignore "$EGRESS_NET" >/dev/null
podman volume create --ignore "$HOME_VOLUME" >/dev/null
podman network create --internal --disable-dns "$session" >/dev/null
subnet=$(podman network inspect -f '{{range .Subnets}}{{.Subnet}}{{end}}' "$session")
[[ $subnet == *.0/24 ]] || die "unexpected subnet $subnet"
rip=${subnet%.0/24}.2
forwards=$(router_config "$ALLOWLIST" "$tmp" "$rip")
chmod 755 "$tmp"
chmod 644 "$tmp"/*
podman run -d --rm --name "$router" \
--network "$session:ip=$rip" --network "$EGRESS_NET" \
--security-opt label=disable \
-v "$tmp:/etc/router:ro" \
"$ROUTER_IMAGE" >/dev/null
wait_port "$router" "$rip" 443
# The pod owns the netns, DNS and hosts file; claude only joins it.
podman pod create --name "$session" \
--network "$session" --dns "$rip" --no-hosts --hostname agent-sandbox \
--userns keep-id:uid=1000,gid=1000 >/dev/null
# Loopback forwarders live in their own container: claude can't see, signal
# or replace them. Claude runs unprivileged; podman's default bounding set
# (no NET_ADMIN/NET_RAW/SYS_ADMIN) stays only so nested containers work.
podman run -d --pod "$session" --name "$session-forwarder" \
--user 0:0 --read-only --cap-drop all --cap-add NET_BIND_SERVICE \
--entrypoint /usr/local/bin/forwarder -e FORWARDS="$forwards" \
"$ROUTER_IMAGE" >/dev/null
wait_port "$session-forwarder" 127.0.0.1 443
local tty=()
[[ -t 0 && -t 1 ]] && tty=(-t)
podman run --rm -i "${tty[@]}" --init --pod "$session" --name "$session-claude" \
--cap-drop NET_ADMIN,NET_RAW,SYS_ADMIN \
--security-opt label=disable \
--device /dev/fuse --device /dev/net/tun \
-v "$HOME_VOLUME:/home/claude" \
-v "$PWD:$PWD" -w "$PWD" \
-e DISABLE_AUTOUPDATER=1 -e CLAUDE_CODE_TMPDIR=/var/tmp \
"$IMAGE" "${cmd[@]}"
}
wait_port() {
local i
for i in $(seq 50); do
podman exec "$1" nc -z "$2" "$3" 2>/dev/null && return
sleep 0.1
done
die "$1 did not start: $(podman logs "$1" 2>&1 | tail -5)"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi
+22
View File
@@ -0,0 +1,22 @@
# agent-sandbox egress allowlist: one entry per line.
# example.com exactly this host
# .example.com the domain and every subdomain
# example.com:22 also any other TCP port on this exact host
# Ports 80 and 443 are open for every entry.
# Claude Code (https://code.claude.com/docs/en/network-config)
api.anthropic.com
claude.ai
claude.com
platform.claude.com
sso.claude.com
mcp-proxy.anthropic.com
downloads.claude.ai
code.claude.com
http-intake.logs.us5.datadoghq.com
browser-intake-us5-datadoghq.com
# unkin
git.unkin.net
artifactapi.k8s.syd1.au.unkin.net
vault.service.consul:8200
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Unit tests for the launcher's allowlist parsing and router config generation.
# Usage: tests/test.sh
# shellcheck disable=SC2016 # nginx variables are literal here
set -uo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=agent-sandbox
source ./agent-sandbox
set +e
fails=0
check() {
local name=$1 want=$2 got=$3
if [[ $want == "$got" ]]; then
echo "ok $name"
else
echo "FAIL $name"
diff <(echo "$want") <(echo "$got")
fails=$((fails + 1))
fi
}
list() {
local f
f=$(mktemp)
printf '%s\n' "$@" >"$f"
echo "$f"
}
f=$(list '# comment' '' ' API.Anthropic.com # trailing' '.example.org' 'git.example.net:22' \
'db:5432' 'db' 'web:443' 'web:80' 'git.example.net:5432')
check entries "$(printf '%s\n' '.example.org ' 'api.anthropic.com ' 'db ' 'db 5432' \
'git.example.net 22' 'git.example.net 5432' 'web ')" "$(allowlist_entries "$f")"
dir=$(mktemp -d)
forwards=$(router_config "$f" "$dir" 10.89.7.2)
check forwards "0.0.0.0:443:10.89.7.2:443 0.0.0.0:80:10.89.7.2:80 127.1.0.2:5432:10.89.7.2:10000 127.1.0.3:22:10.89.7.2:10001 127.1.0.3:5432:10.89.7.2:10002" "$forwards"
check dnsmasq "$(printf '%s\n' no-resolv no-hosts bind-interfaces listen-address=10.89.7.2 'address=/#/' \
'address=/example.org/127.0.0.2' 'host-record=api.anthropic.com,127.1.0.1' 'host-record=db,127.1.0.2' \
'host-record=git.example.net,127.1.0.3' 'host-record=web,127.1.0.4')" "$(cat "$dir/dnsmasq.conf")"
check dnsmasq-never-forwards "" "$(grep -E '^(server|resolv-file)=' "$dir/dnsmasq.conf")"
nginx=$(cat "$dir/nginx.conf")
check tls-map "$(printf ' %s 1;\n' .example.org api.anthropic.com db git.example.net web)" \
"$(sed -n '/map \$ssl_preread_server_name/,/}/p' <<<"$nginx" | grep ' 1;$')"
check http-map "$(printf ' %s 1;\n' .example.org api.anthropic.com db git.example.net web)" \
"$(sed -n '/map \$host/,/}/p' <<<"$nginx" | grep ' 1;$')"
check tls-default-deny " default 127.0.0.1:9;" "$(grep 'default 127' <<<"$nginx")"
check http-default-deny " if (\$http_allowed = 0) { return 403; }" "$(grep 'return 403' <<<"$nginx")"
check tcp-servers "$(printf '%s\n' \
' server { listen 10.89.7.2:10000; set $up db:5432; proxy_pass $up; }' \
' server { listen 10.89.7.2:10001; set $up git.example.net:22; proxy_pass $up; }' \
' server { listen 10.89.7.2:10002; set $up git.example.net:5432; proxy_pass $up; }')" \
"$(grep 'set \$up' <<<"$nginx")"
check listens-on-router-only "" "$(grep -E 'listen ' <<<"$nginx" | grep -v 'listen 10\.89\.7\.2:')"
for bad in 'exa mple.com' 'example.com:0' 'example.com:70000' 'example.com:ssh' '*.example.com' \
'-bad.com' 'http://example.com' 'a..b' '.example.com:22'; do
(allowlist_entries "$(list "$bad")") >/dev/null 2>&1
check "rejects '$bad'" 1 "$?"
done
check default-allowlist-parses 0 "$( (allowlist_entries allowed-hosts >/dev/null) 2>&1; echo $?)"
((fails == 0)) || { echo "$fails failed"; exit 1; }