Add agent-sandbox launcher, images and CI #1

Merged
benvin merged 9 commits from benvin/scaffold into main 2026-10-24 16:19:58 +11:00
19 changed files with 866 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
dist/
+31
View File
@@ -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/)
+42
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+107
View File
@@ -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
+18
View File
@@ -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
+37
View File
@@ -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"]
+52
View File
@@ -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)
+87 -1
View File
@@ -1,3 +1,89 @@
# agent-sandbox
Rootless podman sandbox for Claude Code with allowlisted egress
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`).
- 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
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.
- Never hard-link the allowlist into a directory you launch from; hardlinks
can't be detected by path.
## 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}`.
Executable
+241
View File
@@ -0,0 +1,241 @@
#!/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"
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))
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 <<<"$entries"
{
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"
# nginx "hostnames" rejects an exact key that a .domain key already covers.
local map=""
for host in $hosts; do
[[ $host != .* && " ${hosts//$'\n'/ } " == *" .$host "* ]] && continue
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"
}
# 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")" "$(dirname "$(realpath -m "$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
shift
cmd=("$@")
fi
[[ $PWD == *:* ]] && die "cannot mount a directory containing ':'"
check_launch_dir
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
allowlist_entries "$ALLOWLIST" >/dev/null || exit 1
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" \
-v "$tmp:/etc/router:ro,Z" \
"$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
+17
View File
@@ -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"
+24
View File
@@ -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 <ben@unkin.net>
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
+9
View File
@@ -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"]
+10
View File
@@ -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
+8
View File
@@ -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;'
+16
View File
@@ -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 <packaging/nfpm.yaml >dist/nfpm.yaml
nfpm pkg --config dist/nfpm.yaml --target dist --packager rpm
ls -1 dist/*.rpm
Executable
+108
View File
@@ -0,0 +1,108 @@
#!/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:')"
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
check "rejects '$bad'" 1 "$?"
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")"
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; }