Add launcher and default allowlist
This commit is contained in:
Executable
+216
@@ -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
|
||||
@@ -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
@@ -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; }
|
||||
Reference in New Issue
Block a user