From 4122e3592b39a9808084866e84efd2d7a05cca59 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 1/9] Add router image for allowlisted egress --- router/Containerfile | 9 +++++++++ router/forwarder | 10 ++++++++++ router/start.sh | 8 ++++++++ 3 files changed, 27 insertions(+) create mode 100644 router/Containerfile create mode 100755 router/forwarder create mode 100755 router/start.sh diff --git a/router/Containerfile b/router/Containerfile new file mode 100644 index 0000000..bf9a4b7 --- /dev/null +++ b/router/Containerfile @@ -0,0 +1,9 @@ +FROM artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/alpine:3.22 + +# hadolint ignore=DL3018 +RUN apk add --no-cache dnsmasq nginx nginx-mod-stream socat + +COPY start.sh /usr/local/bin/router-start +COPY forwarder /usr/local/bin/forwarder +# The launcher mounts the per-session config at /etc/router. +ENTRYPOINT ["/usr/local/bin/router-start"] diff --git a/router/forwarder b/router/forwarder new file mode 100755 index 0000000..c0564f1 --- /dev/null +++ b/router/forwarder @@ -0,0 +1,10 @@ +#!/bin/sh +# Loopback forwarders in the sandbox netns. FORWARDS holds +# "bind:port:router:router_port" words. +set -eu +for fwd in $FORWARDS; do + # shellcheck disable=SC2046 + set -- $(echo "$fwd" | tr : ' ') + socat "TCP-LISTEN:$2,bind=$1,fork,reuseaddr" "TCP:$3:$4" & +done +wait diff --git a/router/start.sh b/router/start.sh new file mode 100755 index 0000000..5e72ba9 --- /dev/null +++ b/router/start.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Start the per-session router from the launcher's config in /etc/router. +set -eu +resolver=$(awk '/^nameserver/ {print $2; exit}' /etc/resolv.conf) +sed "s/@RESOLVER@/$resolver/" /etc/router/nginx.conf >/tmp/nginx.conf +dnsmasq --conf-file=/etc/router/dnsmasq.conf +# ponytail: no supervisor; if dnsmasq dies the session loses DNS, restart the session. +exec nginx -c /tmp/nginx.conf -g 'daemon off;' -- 2.47.3 From 3d0563a12cc7be0265c80a9cfa09c2bddf1c8183 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 2/9] Add sandbox image with nested podman --- Containerfile | 37 +++++++++++++++++++++++++++++++++++++ containers.conf | 17 +++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 Containerfile create mode 100644 containers.conf diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..4b598c1 --- /dev/null +++ b/Containerfile @@ -0,0 +1,37 @@ +FROM artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/fedora:44 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Internal CA over plain HTTP, pinned by the repodata pkgid; HTTPS after this. +ARG CA_RPM_URL="http://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-vendor-f44/Packages/unkin-ca-certificates-2026.10.2-1.fc44.x86_64.rpm" +ARG CA_RPM_SHA256="19929d13dd7a0542af3bf1dce736568b87159914b0261d7a9965edfd58dfc9cd" +# hadolint ignore=DL3041 +RUN curl -fsSLo /tmp/ca.rpm "$CA_RPM_URL" \ + && echo "$CA_RPM_SHA256 /tmp/ca.rpm" | sha256sum -c - \ + && dnf install -y /tmp/ca.rpm \ + && rm /tmp/ca.rpm \ + && dnf clean all + +# hadolint ignore=DL3041 +RUN printf '%s\n' '[claude-code]' 'name=claude-code' \ + 'baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/claude-code/' \ + 'gpgcheck=0' >/etc/yum.repos.d/claude-code.repo \ + && dnf install -y --setopt=install_weak_deps=False \ + bind-utils claude-code curl fuse-overlayfs git-core iproute jq less \ + openssh-clients passt podman procps-ng \ + && dnf clean all + +# No setuid binaries; nested rootless podman needs only newuidmap's file caps +# (lost in image layers) and subordinate ids inside keep-id's namespace. +RUN find / -xdev -type f -perm /6000 -exec chmod ug-s {} + \ + && rpm --setcaps shadow-utils \ + && useradd -u 1000 -m claude \ + && echo 'claude:1:999' >/etc/subuid \ + && echo 'claude:1001:64535' >>/etc/subuid \ + && cp /etc/subuid /etc/subgid + +COPY containers.conf /etc/containers/containers.conf + +USER 1000 +WORKDIR /home/claude +CMD ["claude"] diff --git a/containers.conf b/containers.conf new file mode 100644 index 0000000..89baaa6 --- /dev/null +++ b/containers.conf @@ -0,0 +1,17 @@ +# Nested podman inside the sandbox. Nested containers share the sandbox's +# netns by default so they see its DNS and loopback forwarders; either way they +# have no route out. +[containers] +netns = "host" +# The sandbox's /proc is masked and /proc/sys read-only, and it may not set +# hostnames or cgroups, so nested containers share those with it. +default_sysctls = [] +volumes = ["/proc:/proc"] +utsns = "host" +ipcns = "host" +cgroupns = "host" +cgroups = "disabled" + +[engine] +cgroup_manager = "cgroupfs" +events_logger = "file" -- 2.47.3 From c3b7a32bd1bfafb12844121d97883e32359e9506 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 3/9] Add launcher and default allowlist --- agent-sandbox | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++ allowed-hosts | 22 +++++ tests/test.sh | 65 +++++++++++++++ 3 files changed, 303 insertions(+) create mode 100755 agent-sandbox create mode 100644 allowed-hosts create mode 100755 tests/test.sh 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; } -- 2.47.3 From 0144546973a9c87f3c82e0e4c6bf1ef0a178d0e5 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 4/9] Add Makefile and RPM packaging --- .gitignore | 1 + Makefile | 52 ++++++++++++++++++++++++++++++++++++++++++++ packaging/nfpm.yaml | 24 ++++++++++++++++++++ scripts/build-rpm.sh | 16 ++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 packaging/nfpm.yaml create mode 100755 scripts/build-rpm.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eb90071 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +IMAGE := localhost/agent-sandbox:dev +ROUTER_IMAGE := localhost/agent-sandbox-router:dev +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +SHELL_FILES := agent-sandbox router/start.sh router/forwarder tests/test.sh scripts/build-rpm.sh + +.PHONY: all build test lint pre-commit rpm clean patch minor major _tag + +all: build + +# Local images; the in-repo launcher (VERSION=dev) runs these by default. +build: + podman build --format docker -t $(IMAGE) . + podman build --format docker -t $(ROUTER_IMAGE) router + +test: + tests/test.sh + +lint: + uvx --from shellcheck-py shellcheck $(SHELL_FILES) + uvx --from hadolint-py hadolint Containerfile router/Containerfile + +pre-commit: + @uvx pre-commit run --all-files + +rpm: + ./scripts/build-rpm.sh $(VERSION) + +clean: + rm -rf dist + +# Bump helpers — reads the latest semver tag and creates the next one. +# If no tag exists yet, starts from v0.0.0. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..03c90d7 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,24 @@ +# Rendered through envsubst by scripts/build-rpm.sh. +name: agent-sandbox +version: ${PACKAGE_VERSION} +release: 1 +arch: noarch +platform: linux +description: "Run Claude Code in a rootless podman sandbox with allowlisted egress" +maintainer: Ben Vincent +homepage: https://git.unkin.net/unkin/agent-sandbox +license: MIT + +depends: + - podman + - bash + +contents: + - src: dist/agent-sandbox + dst: /usr/bin/agent-sandbox + file_info: + mode: 0755 + - src: allowed-hosts + dst: /usr/share/agent-sandbox/allowed-hosts + file_info: + mode: 0644 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..95d3f6e --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Package the launcher and default allowlist into an RPM with nfpm. The +# packaged launcher defaults to the published images for this version. +# Usage: scripts/build-rpm.sh [version] (defaults to $CI_COMMIT_TAG) +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +TAG="${1:-${CI_COMMIT_TAG:-v0.0.0-dev}}" +mkdir -p dist +sed "s/^VERSION=dev$/VERSION=$TAG/" agent-sandbox >dist/agent-sandbox +grep -q "^VERSION=$TAG$" dist/agent-sandbox + +export PACKAGE_VERSION="${TAG#v}" +envsubst dist/nfpm.yaml +nfpm pkg --config dist/nfpm.yaml --target dist --packager rpm +ls -1 dist/*.rpm -- 2.47.3 From 7a6cb93fdb3f5161052282f4904830c677c8b407 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 5/9] Add pre-commit and Woodpecker pipelines --- .pre-commit-config.yaml | 31 +++++++++++ .woodpecker/build.yaml | 42 ++++++++++++++ .woodpecker/lint.yaml | 18 ++++++ .woodpecker/pre-commit.yaml | 18 ++++++ .woodpecker/release.yaml | 107 ++++++++++++++++++++++++++++++++++++ .woodpecker/test.yaml | 18 ++++++ 6 files changed, 234 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/lint.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yaml create mode 100644 .woodpecker/test.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ed9eb37 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: check-executables-have-shebangs + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/AleksaC/hadolint-py + rev: v2.15.1 + hooks: + - id: hadolint + files: Containerfile$ + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + + - repo: local + hooks: + - id: unit-tests + name: unit tests + entry: tests/test.sh + language: system + pass_filenames: false + files: ^(agent-sandbox|allowed-hosts|tests/) diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..62aa956 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,42 @@ +when: + - event: pull_request + +steps: + - name: agent-sandbox + image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest" + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox" + dockerfile: Containerfile + platforms: "linux/amd64" + dry_run: true + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 + + - name: agent-sandbox-router + image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest" + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox-router" + context: router + dockerfile: router/Containerfile + platforms: "linux/amd64" + dry_run: true + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 0000000..32e99da --- /dev/null +++ b/.woodpecker/lint.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: lint + image: "git.unkin.net/unkin/almalinux9-gobuilder:20260606" + commands: + - make lint + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..e4446ad --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: "git.unkin.net/unkin/almalinux9-gobuilder:20260606" + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..909c49b --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,107 @@ +when: + - event: tag + ref: "refs/tags/v*" + +steps: + - name: test + image: "git.unkin.net/unkin/almalinux9-base:20260606" + commands: + - tests/test.sh + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: agent-sandbox + image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest" + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox" + dockerfile: Containerfile + platforms: "linux/amd64" + tags: + - "${CI_COMMIT_TAG}" + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 + + - name: agent-sandbox-router + image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest" + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox-router" + context: router + dockerfile: router/Containerfile + platforms: "linux/amd64" + tags: + - "${CI_COMMIT_TAG}" + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 + + # The packaged launcher pins the images pushed above. + - name: package + image: "git.unkin.net/unkin/almalinux9-rpmbuilder:latest" + commands: + - "./scripts/build-rpm.sh ${CI_COMMIT_TAG}" + depends_on: [agent-sandbox, agent-sandbox-router] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: upload-rpm + image: "git.unkin.net/unkin/almalinux9-base:20260606" + commands: + - | + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true) + if [ "$$code" = "200" ]; then + echo "$$FILE already exists in $$REPO; skipping upload" + continue + fi + curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \ + -H "Content-Type: application/x-rpm" --data-binary @"$$rpm" + done + depends_on: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..014486e --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: test + image: "git.unkin.net/unkin/almalinux9-base:20260606" + commands: + - tests/test.sh + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 -- 2.47.3 From 4ee52cb615105abc6d1ae8017f5759bc6a0f8a90 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:29:01 +1100 Subject: [PATCH 6/9] Add README --- README.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e402efb..fc7c433 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,85 @@ # agent-sandbox -Rootless podman sandbox for Claude Code with allowlisted egress \ No newline at end of file +Runs Claude Code in a rootless podman sandbox. The only thing visible from the +host is the directory you launch it from, and the only way out of the sandbox +is a per-session router that serves the hostnames on your allowlist. + +## Usage + +```sh +cd ~/src/some-repo +agent-sandbox [claude args...] # e.g. agent-sandbox --continue +agent-sandbox --exec bash # a shell in the sandbox instead of claude +``` + +- `$PWD` is mounted read-write at the same path. Files you create there are + owned by your host user. Nothing else from the host is mounted, and SELinux + labels on your files are never changed (`label=disable`). +- Claude's home is the `agent-sandbox-home` podman volume (login, config and + nested container images live here). All sessions share it. +- `podman` works inside the sandbox (nested rootless). The host podman socket is + never exposed. +- You can run several sessions at once, from different directories. +- Environment overrides: `AGENT_SANDBOX_IMAGE`, `AGENT_SANDBOX_ROUTER_IMAGE`, + `AGENT_SANDBOX_ALLOWLIST`, `AGENT_SANDBOX_HOME_VOLUME`. + +## Allowlist + +`~/.config/agent-sandbox/allowed-hosts` is copied from the packaged default on +first run. It is read at the start of each session. + +``` +api.anthropic.com exactly this host (ports 80 and 443) +.example.com example.com and every subdomain (ports 80 and 443) +db.example.com:5432 this exact host on TCP 5432 too (ssh, postgres, ...) +``` + +You don't need a proxy setting. Allowlisted names resolve to loopback +addresses inside the sandbox, and all other names return NXDOMAIN. + +## How it works + +Each session gets its own `--internal` podman network with no route out, and a +pod on that network that holds the sandbox's netns, DNS and hosts file. + +- **router** (on the internal net and the shared egress net): dnsmasq answers + for allowlisted names only and never forwards a query. nginx routes 443 by + SNI and 80 by Host header to allowlisted names only. Each `host:port` entry + has its own router port, which forwards to that host and port only. +- **forwarder** (in the pod, read-only, no capabilities except + NET_BIND_SERVICE): socat listeners on the loopback IPs that forward to the + router. +- **claude** (in the pod, non-root keep-id user, no setuid binaries, no + NET_ADMIN, NET_RAW or SYS_ADMIN): has no capability over the netns. It can't + change addresses, routes, nftables, `/etc/hosts` or `/etc/resolv.conf`, and + can't see or signal the forwarders. +- Nested containers share claude's netns by default. They get the same DNS and + forwarders, and they can't change the netns either. A nested container on its + own network (`--network pasta|bridge`) has no egress. + +## Threat model + +- Confined: direct IP egress, other hostnames, DNS exfiltration (dnsmasq never + forwards), SNI or Host spoofing (the destination is always the name the + client sent, and it must be on the allowlist), the host filesystem beyond + `$PWD`, and the host podman socket. +- Not confined: anything you allowlist is still an exfiltration channel. + `git.unkin.net` and `api.anthropic.com` can carry your data out. `$PWD` is + writable, so anything there, including `.git/hooks`, runs later on the host + if you trust it. +- Router-side DNS resolves allowlisted names on the host's resolvers, so + internal names like `vault.service.consul` work. +- The home volume persists across sessions and is shared by all of them. + +## Development + +```sh +make build # local images (localhost/agent-sandbox{,-router}:dev) +make test lint pre-commit +make rpm # dist/agent-sandbox-*.rpm +make patch|minor|major +``` + +The in-repo launcher runs the local `:dev` images. The RPM's launcher pins the +images published by the release pipeline for its tag: +`artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox{,-router}`. -- 2.47.3 From edbe3faeb02d488c8f2934237d099437bddf748b Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:37:51 +1100 Subject: [PATCH 7/9] Skip exact hosts covered by a .domain in nginx maps; confine router with SELinux --- agent-sandbox | 9 ++++++--- tests/test.sh | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/agent-sandbox b/agent-sandbox index be8210f..00d1149 100755 --- a/agent-sandbox +++ b/agent-sandbox @@ -85,8 +85,12 @@ router_config() { done } >"$dir/dnsmasq.conf" + # nginx "hostnames" rejects an exact key that a .domain key already covers. local map="" - for host in $hosts; do map+=" $host 1;"$'\n'; done + for host in $hosts; do + [[ $host != .* && " ${hosts//$'\n'/ } " == *" .$host "* ]] && continue + map+=" $host 1;"$'\n' + done cat >"$dir/nginx.conf" </dev/null wait_port "$router" "$rip" 443 diff --git a/tests/test.sh b/tests/test.sh index a7cd413..c203474 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -54,6 +54,14 @@ check tcp-servers "$(printf '%s\n' \ "$(grep 'set \$up' <<<"$nginx")" check listens-on-router-only "" "$(grep -E 'listen ' <<<"$nginx" | grep -v 'listen 10\.89\.7\.2:')" +f=$(list '.example.org' 'example.org' 'example.org:22' 'a.example.org' 'example.org') +dir=$(mktemp -d) +router_config "$f" "$dir" 10.89.7.2 >/dev/null +check overlap-dnsmasq "$(printf '%s\n' 'address=/example.org/127.0.0.2' 'host-record=a.example.org,127.1.0.1' \ + 'host-record=example.org,127.1.0.2')" "$(grep -E '^(address=/e|host-record)' "$dir/dnsmasq.conf")" +check overlap-map "$(printf ' %s 1;\n' .example.org a.example.org .example.org a.example.org)" \ + "$(grep '^ .* 1;$' "$dir/nginx.conf")" + 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 -- 2.47.3 From 71c1999e27be3b8557fda9b264c73c2c8ba387a3 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:46:24 +1100 Subject: [PATCH 8/9] Abort on invalid allowlist lines; refuse launch dirs exposing $HOME or the allowlist --- README.md | 2 ++ agent-sandbox | 26 ++++++++++++++++++++++++-- tests/test.sh | 28 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fc7c433..8f29076 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ agent-sandbox --exec bash # a shell in the sandbox instead of claude - `$PWD` is mounted read-write at the same path. Files you create there are owned by your host user. Nothing else from the host is mounted, and SELinux labels on your files are never changed (`label=disable`). +- It refuses to launch from `$HOME`, any parent of it, or any directory + containing the allowlist. - Claude's home is the `agent-sandbox-home` podman volume (login, config and nested container images live here). All sessions share it. - `podman` works inside the sandbox (nested rootless). The host podman socket is diff --git a/agent-sandbox b/agent-sandbox index 00d1149..3a12b5b 100755 --- a/agent-sandbox +++ b/agent-sandbox @@ -61,7 +61,9 @@ router_config() { 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) + local entries + entries=$(allowlist_entries "$allowlist") || return + hosts=$(awk '{print $1}' <<<"$entries" | uniq) for host in $hosts; do [[ $host == .* ]] && continue ip[$host]=127.1.$((i / 250)).$((i % 250 + 1)) @@ -72,7 +74,7 @@ router_config() { 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") + done <<<"$entries" { printf '%s\n' no-resolv no-hosts bind-interfaces "listen-address=$rip" "address=/#/" @@ -137,6 +139,24 @@ EOF echo "$forwards" } +# True if path $1 is $2 or lies under it. +inside() { + [[ $1 == "$2" || $1 == "${2%/}"/* ]] +} + +# Refuse launch dirs that would mount $HOME or the allowlist read-write. +check_launch_dir() { + local pwd home dir + pwd=$(realpath "$PWD") + home=$(realpath -m "$HOME") + inside "$home" "$pwd" && die "refusing to mount $pwd: it is or contains \$HOME" + for dir in "$CONFIG_DIR" "$(dirname "$ALLOWLIST")"; do + dir=$(realpath -m "$dir") + inside "$dir" "$pwd" && die "refusing to mount $pwd: it contains $dir" + done + return 0 +} + main() { local cmd=(claude "$@") if [[ ${1:-} == --exec ]]; then @@ -144,6 +164,7 @@ main() { cmd=("$@") fi [[ $PWD == *:* ]] && die "cannot mount a directory containing ':'" + check_launch_dir if [[ ! -f $ALLOWLIST ]]; then local default @@ -154,6 +175,7 @@ main() { cp "$default" "$ALLOWLIST" echo "agent-sandbox: created $ALLOWLIST from defaults" >&2 fi + allowlist_entries "$ALLOWLIST" >/dev/null || exit 1 local session router tmp subnet rip forwards session=agent-sandbox-$(date +%s)-$$ diff --git a/tests/test.sh b/tests/test.sh index c203474..4480537 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -70,4 +70,32 @@ done check default-allowlist-parses 0 "$( (allowlist_entries allowed-hosts >/dev/null) 2>&1; echo $?)" +f=$(list 'ok.example.com' 'bad host' 'z.example.com') +check router-config-fails-on-bad-line 1 "$( (router_config "$f" "$(mktemp -d)" 10.89.7.2) >/dev/null 2>&1; echo $?)" + +# Launch through main with a stub podman that logs its calls. +work=$(mktemp -d) +mkdir -p "$work/bin" "$work/home/proj" "$work/other" +printf '%s\n' '#!/bin/sh' "echo \"\$*\" >>$work/podman.log" \ + 'case "$*" in "network inspect"*) echo 10.89.7.0/24 ;; esac' >"$work/bin/podman" +chmod +x "$work/bin/podman" +launch() { + local dir=$1 + shift + rm -f "$work/podman.log" + (cd "$dir" && env PATH="$work/bin:$PATH" HOME="$work/home" XDG_RUNTIME_DIR="$work" "$@" \ + "$OLDPWD/agent-sandbox" --exec true) >/dev/null 2>&1 + echo "$? $([[ -s $work/podman.log ]] && echo podman || echo none)" +} +good=$(list 'api.anthropic.com') +check launch-from-home-subdir "0 podman" "$(launch "$work/home/proj" AGENT_SANDBOX_ALLOWLIST="$good")" +check launch-refuses-bad-line "1 none" "$(launch "$work/home/proj" AGENT_SANDBOX_ALLOWLIST="$(list 'ok.example.com' 'bad host')")" +check launch-refuses-home "1 none" "$(launch "$work/home" AGENT_SANDBOX_ALLOWLIST="$good")" +check launch-refuses-home-ancestor "1 none" "$(launch "$work" AGENT_SANDBOX_ALLOWLIST="$good")" +check launch-refuses-root "1 none" "$(launch / AGENT_SANDBOX_ALLOWLIST="$good")" +ln -s "$work/home" "$work/homelink" +check launch-refuses-home-symlink "1 none" "$(launch "$work/homelink" AGENT_SANDBOX_ALLOWLIST="$good")" +check launch-refuses-config-parent "1 none" "$(launch "$work/other" XDG_CONFIG_HOME="$work/other/cfg" AGENT_SANDBOX_ALLOWLIST="$good")" +check launch-refuses-allowlist-dir "1 none" "$(launch "$work/home/proj" AGENT_SANDBOX_ALLOWLIST="$work/home/proj/hosts")" + ((fails == 0)) || { echo "$fails failed"; exit 1; } -- 2.47.3 From 07d93b0fe19f5d9c8caf5dac72cfe591bee932b2 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:51:11 +1100 Subject: [PATCH 9/9] Refuse launch dirs containing the allowlist's resolved location --- README.md | 2 ++ agent-sandbox | 2 +- tests/test.sh | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f29076..a29c172 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ pod on that network that holds the sandbox's netns, DNS and hosts file. - Router-side DNS resolves allowlisted names on the host's resolvers, so internal names like `vault.service.consul` work. - The home volume persists across sessions and is shared by all of them. +- Never hard-link the allowlist into a directory you launch from; hardlinks + can't be detected by path. ## Development diff --git a/agent-sandbox b/agent-sandbox index 3a12b5b..e2a10b4 100755 --- a/agent-sandbox +++ b/agent-sandbox @@ -150,7 +150,7 @@ check_launch_dir() { pwd=$(realpath "$PWD") home=$(realpath -m "$HOME") inside "$home" "$pwd" && die "refusing to mount $pwd: it is or contains \$HOME" - for dir in "$CONFIG_DIR" "$(dirname "$ALLOWLIST")"; do + for dir in "$CONFIG_DIR" "$(dirname "$ALLOWLIST")" "$(dirname "$(realpath -m "$ALLOWLIST")")"; do dir=$(realpath -m "$dir") inside "$dir" "$pwd" && die "refusing to mount $pwd: it contains $dir" done diff --git a/tests/test.sh b/tests/test.sh index 4480537..fd55bfb 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -97,5 +97,12 @@ ln -s "$work/home" "$work/homelink" check launch-refuses-home-symlink "1 none" "$(launch "$work/homelink" AGENT_SANDBOX_ALLOWLIST="$good")" check launch-refuses-config-parent "1 none" "$(launch "$work/other" XDG_CONFIG_HOME="$work/other/cfg" AGENT_SANDBOX_ALLOWLIST="$good")" check launch-refuses-allowlist-dir "1 none" "$(launch "$work/home/proj" AGENT_SANDBOX_ALLOWLIST="$work/home/proj/hosts")" +mkdir -p "$work/home/dotfiles" "$work/home/.config/agent-sandbox" +cp "$good" "$work/home/dotfiles/hosts" +ln -s "$work/home/dotfiles/hosts" "$work/home/.config/agent-sandbox/allowed-hosts" +check launch-refuses-symlinked-allowlist-target "1 none" "$(launch "$work/home/dotfiles" XDG_CONFIG_HOME="$work/home/.config")" +rm "$work/home/.config/agent-sandbox/allowed-hosts" +ln -s "$work/home/dotfiles" "$work/home/cfglink" +check launch-refuses-symlinked-config-dir "1 none" "$(launch "$work/home/dotfiles" XDG_CONFIG_HOME="$work/home/cfglink")" ((fails == 0)) || { echo "$fails failed"; exit 1; } -- 2.47.3