diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ 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 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/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/README.md b/README.md index e402efb..a29c172 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,89 @@ # 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`). +- 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}`. diff --git a/agent-sandbox b/agent-sandbox new file mode 100755 index 0000000..e2a10b4 --- /dev/null +++ b/agent-sandbox @@ -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" <&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 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/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" 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/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;' 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 diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 0000000..fd55bfb --- /dev/null +++ b/tests/test.sh @@ -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; }