Add agent-sandbox launcher, images and CI #1
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -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/)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Executable
+10
@@ -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
|
||||
Executable
+8
@@ -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;'
|
||||
Executable
+16
@@ -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
@@ -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; }
|
||||
Reference in New Issue
Block a user