diff --git a/agent-sandbox b/agent-sandbox new file mode 100755 index 0000000..be8210f --- /dev/null +++ b/agent-sandbox @@ -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" <&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 diff --git a/allowed-hosts b/allowed-hosts new file mode 100644 index 0000000..5bfcd06 --- /dev/null +++ b/allowed-hosts @@ -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 diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 0000000..a7cd413 --- /dev/null +++ b/tests/test.sh @@ -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; }