Port the launcher to Go #2

Merged
benvin merged 4 commits from benvin/go-launcher into main 2026-10-24 18:43:23 +11:00
19 changed files with 2019 additions and 389 deletions
+2
View File
@@ -1 +1,3 @@
dist/
/agent-sandbox
/agent-sandbox-*
+6
View File
@@ -0,0 +1,6 @@
version: "2"
run:
build-tags:
- remote
- exclude_graphdriver_btrfs
- containers_image_openpgp
+15 -4
View File
@@ -21,11 +21,22 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- repo: local
hooks:
- id: unit-tests
name: unit tests
entry: tests/test.sh
- id: go-vet
name: go vet
entry: make vet
language: system
pass_filenames: false
files: ^(agent-sandbox|allowed-hosts|tests/)
types: [go]
- id: go-unit-tests
name: go unit tests
entry: make test
language: system
pass_filenames: false
files: (\.go|allowed-hosts)$
+15
View File
@@ -2,6 +2,21 @@ when:
- event: pull_request
steps:
- name: agent-sandbox-bin
image: "golang:1.25"
commands:
- make bin
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:
+2 -2
View File
@@ -3,9 +3,9 @@ when:
steps:
- name: lint
image: "git.unkin.net/unkin/almalinux9-gobuilder:20260606"
image: "golangci/golangci-lint:v2.15.0"
commands:
- make lint
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
+60 -4
View File
@@ -4,9 +4,9 @@ when:
steps:
- name: test
image: "git.unkin.net/unkin/almalinux9-base:20260606"
image: "golang:1.25"
commands:
- tests/test.sh
- make test
backend_options:
kubernetes:
serviceAccountName: default
@@ -61,12 +61,29 @@ steps:
memory: 4Gi
cpu: 2
# The packaged launcher pins the images pushed above.
# The binary pins the images pushed above via main.version.
- name: build
image: "git.unkin.net/unkin/almalinux9-gobuilder:20260606"
commands:
- "make bin VERSION=${CI_COMMIT_TAG}"
- cp dist/agent-sandbox agent-sandbox-linux-amd64
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- 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]
depends_on: [build, agent-sandbox, agent-sandbox-router]
backend_options:
kubernetes:
serviceAccountName: default
@@ -105,3 +122,42 @@ steps:
limits:
memory: 2Gi
cpu: 2
- name: release
image: "git.unkin.net/unkin/almalinux9-base:20260606"
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
ASSETS="agent-sandbox-linux-amd64 $$(ls dist/*.rpm)"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt --login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+2 -2
View File
@@ -3,9 +3,9 @@ when:
steps:
- name: test
image: "git.unkin.net/unkin/almalinux9-base:20260606"
image: "golang:1.25"
commands:
- tests/test.sh
- make test
backend_options:
kubernetes:
serviceAccountName: default
+32 -12
View File
@@ -1,32 +1,52 @@
BINARY := agent-sandbox
DIST := dist
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
# dev runs the local :dev images; a release tag pins the published images.
VERSION ?= dev
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
# podman-remote's build tags: the API bindings without cgo storage drivers.
TAGS := remote,exclude_graphdriver_btrfs,containers_image_openpgp
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
.PHONY: all build test lint pre-commit rpm clean patch minor major _tag
.PHONY: all build bin images test vet lint fmt pre-commit clean rpm rpm-package patch minor major _tag
all: build
# Local images; the in-repo launcher (VERSION=dev) runs these by default.
build:
build: bin images
bin:
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -tags $(TAGS) $(GOFLAGS) -o $(DIST)/$(BINARY) .
images:
podman build --format docker -t $(IMAGE) .
podman build --format docker -t $(ROUTER_IMAGE) router
test:
tests/test.sh
go test -tags $(TAGS) -v -race ./...
vet:
go vet -tags $(TAGS) ./...
lint:
uvx --from shellcheck-py shellcheck $(SHELL_FILES)
uvx --from hadolint-py hadolint Containerfile router/Containerfile
golangci-lint run ./...
fmt:
gofmt -w .
pre-commit:
@uvx pre-commit run --all-files
rpm:
./scripts/build-rpm.sh $(VERSION)
clean:
rm -rf dist
rm -rf $(DIST)
# Build the binary then package it into an RPM via nfpm.
rpm: bin rpm-package
# Package an already-built binary into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# Bump helpers — reads the latest semver tag and creates the next one.
# If no tag exists yet, starts from v0.0.0.
+9 -4
View File
@@ -77,13 +77,18 @@ pod on that network that holds the sandbox's netns, DNS and hosts file.
## Development
`agent-sandbox` is a Go CLI built on the podman API bindings. Each session
starts its own `podman system service` on a private socket under
`$XDG_RUNTIME_DIR` (mode 0700, never mounted into a container) and stops it on
exit.
```sh
make build # local images (localhost/agent-sandbox{,-router}:dev)
make test lint pre-commit
make build # dist/agent-sandbox + local images (localhost/agent-sandbox{,-router}:dev)
make test vet 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:
A `dev` build runs the local `:dev` images. Release builds pin the images
published by the release pipeline for their tag:
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox{,-router}`.
-241
View File
@@ -1,241 +0,0 @@
#!/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
+124
View File
@@ -0,0 +1,124 @@
module git.unkin.net/unkin/agent-sandbox
go 1.25.7
require (
github.com/containers/podman/v5 v5.8.4
github.com/docker/docker v28.5.1+incompatible
github.com/opencontainers/runtime-spec v1.2.1
go.podman.io/common v0.67.1
golang.org/x/term v0.44.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.17.0 // indirect
github.com/containers/buildah v1.43.2 // indirect
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
github.com/containers/ocicrypt v1.2.1 // indirect
github.com/containers/psgo v1.9.1-0.20250826150930-4ae76f200c86 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/cyphar/filepath-securejoin v0.5.2 // indirect
github.com/disiqueira/gotree/v3 v3.0.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.4 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/go-intervals v0.0.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mistifyio/go-zfs/v3 v3.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/opencontainers/cgroups v0.0.6 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runc v1.3.4 // indirect
github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.9 // indirect
github.com/proglottis/gpgme v0.1.5 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect
github.com/sigstore/fulcio v1.7.1 // indirect
github.com/sigstore/protobuf-specs v0.4.1 // indirect
github.com/sigstore/sigstore v1.9.5 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/smallstep/pkcs7 v0.1.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect
github.com/sylabs/sif/v2 v2.22.0 // indirect
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/vbauerster/mpb/v8 v8.10.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.podman.io/image/v5 v5.39.2 // indirect
go.podman.io/storage v1.62.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/grpc v1.72.2 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
tags.cncf.io/container-device-interface v1.0.1 // indirect
)
+416
View File
@@ -0,0 +1,416 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
github.com/containerd/stargz-snapshotter/estargz v0.17.0 h1:+TyQIsR/zSFI1Rm31EQBwpAA1ovYgIKHy7kctL3sLcE=
github.com/containerd/stargz-snapshotter/estargz v0.17.0/go.mod h1:s06tWAiJcXQo9/8AReBCIo/QxcXFZ2n4qfsRnpl71SM=
github.com/containers/buildah v1.43.2 h1:KbxgDNWtbMTQtbSElh4e/n8pJa9EDF+/jtIKPr1QtZI=
github.com/containers/buildah v1.43.2/go.mod h1:dXSaAu+S9aiY0j1teX+7btxBKSZlLfWU5LXnXwPaxf8=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY=
github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM=
github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ=
github.com/containers/podman/v5 v5.8.4 h1:2RX95keVopdkWz1x9G+KhTGLySruxnBXn6oTuuLgGvE=
github.com/containers/podman/v5 v5.8.4/go.mod h1:5mMXU913/zkU+TdeLoKhIijyKjuMzBrxHCiTgvfPTjg=
github.com/containers/psgo v1.9.1-0.20250826150930-4ae76f200c86 h1:bYj0TVlkRZtMJYd6SbFOi1gjUJDJmVsYCpJla3URD7Y=
github.com/containers/psgo v1.9.1-0.20250826150930-4ae76f200c86/go.mod h1:52GX23ST30pXpeviDvNpOCXoHqr+WJDv+7BjEH+AabY=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
github.com/cyphar/filepath-securejoin v0.5.2 h1:w/T2bhKr4pgwG0SUGjU4S/Is9+zUknLh5ROTJLzWX8E=
github.com/cyphar/filepath-securejoin v0.5.2/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disiqueira/gotree/v3 v3.0.2 h1:ik5iuLQQoufZBNPY518dXhiO5056hyNBIK9lWhkNRq8=
github.com/disiqueira/gotree/v3 v3.0.2/go.mod h1:ZuyjE4+mUQZlbpkI24AmruZKhg3VHEgPLDY8Qk+uUu8=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY=
github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI=
github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM=
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
github.com/google/go-intervals v0.0.2 h1:FGrVEiUnTRKR8yE04qzXYaJMtnIYqobR5QbblK3ixcM=
github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs=
github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ=
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1CopY=
github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=
github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/opencontainers/cgroups v0.0.6 h1:tfZFWTIIGaUUFImTyuTg+Mr5x8XRiSdZESgEBW7UxuI=
github.com/opencontainers/cgroups v0.0.6/go.mod h1:oWVzJsKK0gG9SCRBfTpnn16WcGEqDI8PAcpMGbqWxcs=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.3.4 h1:+lwmPUTzbgv0JFqu8zBU2WtHYbm+JPPS9hxB/PvWd30=
github.com/opencontainers/runc v1.3.4/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4=
github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 h1:2xZEHOdeQBV6PW8ZtimN863bIOl7OCW/X10K0cnxKeA=
github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2/go.mod h1:MXdPzqAA8pHC58USHqNCSjyLnRQ6D+NjbpP+02Z1U/0=
github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE=
github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/proglottis/gpgme v0.1.5 h1:KCGyOw8sQ+SI96j6G8D8YkOGn+1TwbQTT9/zQXoVlz0=
github.com/proglottis/gpgme v0.1.5/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E=
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g=
github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sigstore/fulcio v1.7.1 h1:RcoW20Nz49IGeZyu3y9QYhyyV3ZKQ85T+FXPKkvE+aQ=
github.com/sigstore/fulcio v1.7.1/go.mod h1:7lYY+hsd8Dt+IvKQRC+KEhWpCZ/GlmNvwIa5JhypMS8=
github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc=
github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/sigstore v1.9.5 h1:Wm1LT9yF4LhQdEMy5A2JeGRHTrAWGjT3ubE5JUSrGVU=
github.com/sigstore/sigstore v1.9.5/go.mod h1:VtxgvGqCmEZN9X2zhFSOkfXxvKUjpy8RpUW39oCtoII=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU=
github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw=
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/sylabs/sif/v2 v2.22.0 h1:Y+xXufp4RdgZe02SR3nWEg7S6q4tPWN237WHYzkDSKA=
github.com/sylabs/sif/v2 v2.22.0/go.mod h1:W1XhWTmG1KcG7j5a3KSYdMcUIFvbs240w/MMVW627hs=
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM=
github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.podman.io/common v0.67.1 h1:HddYLJfkfFUmFJ0V3PVoewguFM9eHkqk0g+fOc2B9R4=
go.podman.io/common v0.67.1/go.mod h1:XVmSLtnhJwGb+ImYn6BXiozxVE3mYZJgiiBuDOiOquk=
go.podman.io/image/v5 v5.39.2 h1:EJua/pRtvgLV/a5y8/RvA+ekKukZh0UuKMvLdTmEWFk=
go.podman.io/image/v5 v5.39.2/go.mod h1:SlaR6Pra1ATIx4BcuZ16oafb3QcCHISaKcJbtlN/G/0=
go.podman.io/storage v1.62.0 h1:0QjX1XlzVmbiaulb+aR/CG6p9+pzaqwIeZPe3tEjHbY=
go.podman.io/storage v1.62.0/go.mod h1:A3UBK0XypjNZ6pghRhuxg62+2NIm5lcUGv/7XyMhMUI=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
+381
View File
@@ -0,0 +1,381 @@
// agent-sandbox runs Claude Code in a rootless podman sandbox whose only way
// out is a per-session router that resolves and forwards allowlisted hosts.
package main
import (
"context"
_ "embed"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/pkg/specgen"
spec "github.com/opencontainers/runtime-spec/specs-go"
"go.podman.io/common/libnetwork/types"
"golang.org/x/term"
)
const (
registry = "artifactapi.k8s.syd1.au.unkin.net/docker-internal"
egressNet = "agent-sandbox-egress"
)
var version = "dev"
//go:embed allowed-hosts
var defaultAllowlist []byte
func main() {
// A closed stdout must end the session through cleanup, not kill us.
signal.Ignore(syscall.SIGPIPE)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
// Every argument belongs to claude or --exec, as in the bash launcher.
code := launch(ctx, os.Args[1:])
stop()
os.Exit(code)
}
func imageNames() (string, string) {
img, router := registry+"/agent-sandbox:"+version, registry+"/agent-sandbox-router:"+version
if version == "dev" {
img, router = "localhost/agent-sandbox:dev", "localhost/agent-sandbox-router:dev"
}
return envOr("AGENT_SANDBOX_IMAGE", img), envOr("AGENT_SANDBOX_ROUTER_IMAGE", router)
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func configDir() string {
return filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(os.Getenv("HOME"), ".config")), "agent-sandbox")
}
func allowlistPath() string {
return envOr("AGENT_SANDBOX_ALLOWLIST", filepath.Join(configDir(), "allowed-hosts"))
}
// runtimeDir holds the session dirs, including the private API socket's.
func runtimeDir() string {
return envOr("XDG_RUNTIME_DIR", "/tmp")
}
func warn(format string, a ...any) {
fmt.Fprintf(os.Stderr, "agent-sandbox: "+format+"\n", a...)
}
// launch runs one session and returns the process exit code.
func launch(ctx context.Context, args []string) int {
cmd := append([]string{"claude"}, args...)
if len(args) > 0 && args[0] == "--exec" {
cmd = args[1:]
}
pwd, err := os.Getwd()
if err != nil {
warn("%v", err)
return 1
}
if strings.Contains(pwd, ":") {
warn("cannot mount a directory containing ':'")
return 1
}
allowlist := allowlistPath()
if err := checkLaunchDir(pwd, allowlist); err != nil {
warn("%v", err)
return 1
}
if err := ensureAllowlist(allowlist); err != nil {
warn("%v", err)
return 1
}
entries, err := parseAllowlist(allowlist)
if err != nil {
warn("%v", err)
return 1
}
session := fmt.Sprintf("agent-sandbox-%d-%d", time.Now().Unix(), os.Getpid())
runtimeDir := runtimeDir()
tmp, err := os.MkdirTemp(runtimeDir, session+".")
if err != nil {
warn("%v", err)
return 1
}
defer func() { _ = os.RemoveAll(tmp) }()
// The API socket lives apart from tmp, which is mounted into the router.
apiDir, err := os.MkdirTemp(runtimeDir, session+"-api.")
if err != nil {
warn("%v", err)
return 1
}
defer func() { _ = os.RemoveAll(apiDir) }()
conn, eng, stopService, err := startService(apiDir)
if err != nil {
warn("%v", err)
return 125
}
defer stopService()
defer func() {
_ = eng.PodRemove(conn, session)
_ = eng.ContainerRemove(conn, session+"-router")
_ = eng.NetworkRemove(conn, session)
}()
opCtx, cancel := context.WithCancel(conn)
defer cancel()
defer context.AfterFunc(ctx, cancel)()
code := runSession(opCtx, eng, session, tmp, pwd, entries, cmd)
if ctx.Err() != nil {
return 130
}
return code
}
// fail reports a podman API error with podman's own exit code for it.
func fail(err error) int {
warn("%v", err)
return 125
}
func runSession(ctx context.Context, eng engine, session, tmp, pwd string, entries []entry, cmd []string) int {
image, routerImage := imageNames()
homeVolume := envOr("AGENT_SANDBOX_HOME_VOLUME", "agent-sandbox-home")
router := session + "-router"
yes := true
if _, err := eng.NetworkCreate(ctx, &types.Network{Name: egressNet, Driver: "bridge", DNSEnabled: true}, true); err != nil {
return fail(err)
}
if err := eng.VolumeCreate(ctx, homeVolume); err != nil {
return fail(err)
}
n, err := eng.NetworkCreate(ctx, &types.Network{Name: session, Driver: "bridge", Internal: true}, false)
if err != nil {
return fail(err)
}
subnet := ""
for _, s := range n.Subnets {
subnet += s.Subnet.String()
}
if !strings.HasSuffix(subnet, ".0/24") {
warn("unexpected subnet %s", subnet)
return 1
}
rip := strings.TrimSuffix(subnet, ".0/24") + ".2"
dnsmasq, nginx, forwards := routerConfig(entries, rip)
for name, body := range map[string]string{"dnsmasq.conf": dnsmasq, "nginx.conf": nginx} {
f := filepath.Join(tmp, name)
err := os.WriteFile(f, []byte(body), 0o644)
if err == nil {
err = os.Chmod(f, 0o644)
}
if err != nil {
warn("%v", err)
return 1
}
}
if err := os.Chmod(tmp, 0o755); err != nil {
warn("%v", err)
return 1
}
r := newSpec(routerImage, router)
r.Remove = &yes
r.NetNS = specgen.Namespace{NSMode: specgen.Bridge}
r.Networks = map[string]types.PerNetworkOptions{
session: {StaticIPs: []net.IP{net.ParseIP(rip)}},
egressNet: {},
}
r.Mounts = []spec.Mount{{Type: "bind", Source: tmp, Destination: "/etc/router", Options: []string{"ro", "Z"}}}
if err := eng.ContainerCreate(ctx, r); err != nil {
return fail(err)
}
if err := eng.ContainerStart(ctx, router); err != nil {
return fail(err)
}
if err := waitPort(ctx, eng, router, rip, "443"); err != nil {
warn("%v", err)
return 1
}
// The pod owns the netns, DNS and hosts file; claude only joins it.
userns, _ := specgen.ParseUserNamespace("keep-id:uid=1000,gid=1000")
p := &specgen.PodSpecGenerator{}
p.Name = session
p.Hostname = "agent-sandbox"
p.NetNS = specgen.Namespace{NSMode: specgen.Bridge}
p.Networks = map[string]types.PerNetworkOptions{session: {}}
p.DNSServer = []net.IP{net.ParseIP(rip)}
p.NoManageHosts = true
p.Userns = userns
if err := eng.PodCreate(ctx, p); err != nil {
return fail(err)
}
// 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.
f := newSpec(routerImage, session+"-forwarder")
f.Pod = session
f.User = "0:0"
f.ReadOnlyFilesystem = &yes
f.ReadWriteTmpfs = &yes
f.CapDrop = []string{"all"}
f.CapAdd = []string{"NET_BIND_SERVICE"}
f.Entrypoint = []string{"/usr/local/bin/forwarder"}
f.Env = map[string]string{"FORWARDS": forwards}
if err := eng.ContainerCreate(ctx, f); err != nil {
return fail(err)
}
if err := eng.ContainerStart(ctx, f.Name); err != nil {
return fail(err)
}
if err := waitPort(ctx, eng, f.Name, "127.0.0.1", "443"); err != nil {
warn("%v", err)
return 1
}
c := newSpec(image, session+"-claude")
c.Remove = &yes
c.Stdin = &yes
c.Terminal = ptr(term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())))
c.Init = &yes
c.Pod = session
c.CapDrop = []string{"NET_ADMIN", "NET_RAW", "SYS_ADMIN"}
c.SelinuxOpts = []string{"disable"}
c.Annotations = map[string]string{define.InspectAnnotationLabel: "disable"}
c.Devices = []spec.LinuxDevice{{Path: "/dev/fuse"}, {Path: "/dev/net/tun"}}
c.Volumes = []*specgen.NamedVolume{{Name: homeVolume, Dest: "/home/claude"}}
c.Mounts = []spec.Mount{{Type: "bind", Source: pwd, Destination: pwd}}
c.WorkDir = pwd
c.Env = map[string]string{"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_TMPDIR": "/var/tmp"}
c.Command = cmd
if err := eng.ContainerCreate(ctx, c); err != nil {
return fail(err)
}
code, err := eng.ContainerRun(ctx, c.Name)
if err != nil {
warn("%v", err)
}
return code
}
func ptr[T any](v T) *T { return &v }
// newSpec mirrors `podman run` defaults the API leaves to the client.
func newSpec(image, name string) *specgen.SpecGenerator {
s := specgen.NewSpecGenerator(image, false)
s.RawImageName = image
s.Name = name
s.HTTPProxy = ptr(true)
s.SdNotifyMode = define.SdNotifyModeContainer
return s
}
func waitPort(ctx context.Context, eng engine, container, ip, port string) error {
for range 50 {
if code, err := eng.ContainerExec(ctx, container, []string{"nc", "-z", ip, port}); err == nil && code == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return fmt.Errorf("%s did not start: %s", container, eng.ContainerLogs(context.WithoutCancel(ctx), container, 5))
}
// ensureAllowlist seeds a missing allowlist from the packaged default.
func ensureAllowlist(path string) error {
if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() {
return nil
}
src := defaultAllowlist
if exe, err := os.Executable(); err == nil {
for _, p := range []string{filepath.Join(filepath.Dir(exe), "allowed-hosts"), "/usr/share/agent-sandbox/allowed-hosts"} {
if b, err := os.ReadFile(p); err == nil {
src = b
break
}
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path, src, 0o644); err != nil {
return err
}
warn("created %s from defaults", path)
return nil
}
// checkLaunchDir refuses launch dirs that would mount $HOME, the allowlist or
// the podman API socket read-write.
func checkLaunchDir(pwd, allowlist string) error {
pwd = realpath(pwd)
if inside(realpath(os.Getenv("HOME")), pwd) {
return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd)
}
for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), runtimeDir()} {
if dir = realpath(dir); inside(dir, pwd) {
return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir)
}
}
return nil
}
// inside reports whether path p is dir or lies under it.
func inside(p, dir string) bool {
return p == dir || strings.HasPrefix(p, strings.TrimSuffix(dir, "/")+"/")
}
// realpath resolves every symlink in p like `realpath -m`: missing components
// are allowed, and ".." applies to the resolved path.
func realpath(p string) string {
if !filepath.IsAbs(p) {
wd, _ := os.Getwd()
p = filepath.Join(wd, p)
}
rest := strings.Split(p, "/")
resolved := "/"
for hops := 0; len(rest) > 0; {
part := rest[0]
rest = rest[1:]
switch part {
case "", ".":
continue
case "..":
resolved = filepath.Dir(resolved)
continue
}
next := filepath.Join(resolved, part)
fi, err := os.Lstat(next)
if err != nil || fi.Mode()&os.ModeSymlink == 0 || hops > 255 {
resolved = next
continue
}
target, err := os.Readlink(next)
if err != nil {
resolved = next
continue
}
hops++
if filepath.IsAbs(target) {
resolved = "/"
}
rest = append(strings.Split(target, "/"), rest...)
}
return resolved
}
+520
View File
@@ -0,0 +1,520 @@
package main
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/containers/podman/v5/pkg/specgen"
spec "github.com/opencontainers/runtime-spec/specs-go"
"go.podman.io/common/libnetwork/types"
)
func writeList(t *testing.T, lines ...string) string {
t.Helper()
f := filepath.Join(t.TempDir(), "hosts")
if err := os.WriteFile(f, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
return f
}
func lines(s string, re string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
if regexp.MustCompile(re).MatchString(l) {
out = append(out, l)
}
}
return out
}
func check[T any](t *testing.T, name string, want, got T) {
t.Helper()
if !reflect.DeepEqual(want, got) {
t.Errorf("%s:\nwant %#v\ngot %#v", name, want, got)
}
}
func TestRouterConfig(t *testing.T) {
f := writeList(t, "# comment", "", " API.Anthropic.com # trailing", ".example.org", "git.example.net:22",
"db:5432", "db", "web:443", "web:80", "git.example.net:5432")
entries, err := parseAllowlist(f)
if err != nil {
t.Fatal(err)
}
check(t, "entries", []entry{{".example.org", ""}, {"api.anthropic.com", ""}, {"db", ""}, {"db", "5432"},
{"git.example.net", "22"}, {"git.example.net", "5432"}, {"web", ""}}, entries)
dnsmasq, nginx, forwards := routerConfig(entries, "10.89.7.2")
check(t, "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(t, "dnsmasq", "no-resolv\nno-hosts\nbind-interfaces\nlisten-address=10.89.7.2\naddress=/#/\n"+
"address=/example.org/127.0.0.2\nhost-record=api.anthropic.com,127.1.0.1\nhost-record=db,127.1.0.2\n"+
"host-record=git.example.net,127.1.0.3\nhost-record=web,127.1.0.4\n", dnsmasq)
check(t, "dnsmasq-never-forwards", []string(nil), lines(dnsmasq, `^(server|resolv-file)=`))
tlsMap := nginx[strings.Index(nginx, "map $ssl_preread_server_name"):]
httpMap := nginx[strings.Index(nginx, "map $host"):]
want := []string{" .example.org 1;", " api.anthropic.com 1;", " db 1;", " git.example.net 1;", " web 1;"}
check(t, "tls-map", want, lines(tlsMap[:strings.Index(tlsMap, "}")], ` 1;$`))
check(t, "http-map", want, lines(httpMap[:strings.Index(httpMap, "}")], ` 1;$`))
check(t, "tls-default-deny", []string{" default 127.0.0.1:9;"}, lines(nginx, `default 127`))
check(t, "http-default-deny", []string{" if ($http_allowed = 0) { return 403; }"}, lines(nginx, `return 403`))
check(t, "tcp-servers", []string{
" 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; }",
}, lines(nginx, `set \$up`))
check(t, "listens-on-router-only", len(lines(nginx, `listen `)), len(lines(nginx, `listen 10\.89\.7\.2:`)))
check(t, "resolver-placeholder", 2, strings.Count(nginx, "resolver @RESOLVER@ ipv6=off valid=30s;"))
}
func TestRouterConfigOverlap(t *testing.T) {
entries, err := parseAllowlist(writeList(t, ".example.org", "example.org", "example.org:22", "a.example.org", "example.org"))
if err != nil {
t.Fatal(err)
}
dnsmasq, nginx, _ := routerConfig(entries, "10.89.7.2")
check(t, "overlap-dnsmasq", []string{"address=/example.org/127.0.0.2", "host-record=a.example.org,127.1.0.1",
"host-record=example.org,127.1.0.2"}, lines(dnsmasq, `^(address=/e|host-record)`))
check(t, "overlap-map", []string{" .example.org 1;", " a.example.org 1;", " .example.org 1;", " a.example.org 1;"},
lines(nginx, `^ .* 1;$`))
}
func TestParseAllowlistRejects(t *testing.T) {
for _, bad := range []string{"exa mple.com", "example.com:0", "example.com:70000", "example.com:ssh", "*.example.com",
"-bad.com", "http://example.com", "a..b", ".example.com:22", "example.com:+22", "example.com:-1"} {
if _, err := parseAllowlist(writeList(t, bad)); err == nil {
t.Errorf("accepted %q", bad)
}
}
_, err := parseAllowlist(writeList(t, "ok.example.com", "bad host", "z.example.com"))
check(t, "bad-line-error", true, err != nil && strings.HasSuffix(err.Error(), ":2: one entry per line"))
}
func TestDefaultAllowlistParses(t *testing.T) {
if _, err := parseAllowlist("allowed-hosts"); err != nil {
t.Fatal(err)
}
}
func TestRealpath(t *testing.T) {
d, _ := filepath.EvalSymlinks(t.TempDir())
_ = os.MkdirAll(d+"/a/b", 0o755)
_ = os.Symlink(d+"/a/b", d+"/abs")
_ = os.Symlink("a/b", d+"/rel")
_ = os.Symlink(d+"/missing/x", d+"/dangling")
_ = os.Symlink(d+"/loop", d+"/loop")
for in, want := range map[string]string{
d + "/abs": d + "/a/b",
d + "/rel/c/d": d + "/a/b/c/d",
d + "/rel/..": d + "/a",
d + "/dangling/y": d + "/missing/x/y",
d + "/a/./b/": d + "/a/b",
"/": "/",
d + "/loop/z": d + "/loop/z",
d + "/nope/../a/b/..": d + "/a",
} {
check(t, in, want, realpath(in))
}
}
type fakeEngine struct {
calls []string
specs map[string]*specgen.SpecGenerator
pod *specgen.PodSpecGenerator
nets map[string]types.Network
apiDir string
execCode int
run func(ctx context.Context) (int, error)
}
func (f *fakeEngine) call(c string) { f.calls = append(f.calls, c) }
func (f *fakeEngine) NetworkCreate(_ context.Context, n *types.Network, ignore bool) (types.Network, error) {
f.call("network create " + n.Name)
f.nets[n.Name] = *n
sub, _ := types.ParseCIDR("10.89.7.0/24")
return types.Network{Name: n.Name, Subnets: []types.Subnet{{Subnet: sub}}}, nil
}
func (f *fakeEngine) NetworkRemove(_ context.Context, name string) error {
f.call("network rm " + name)
return nil
}
func (f *fakeEngine) VolumeCreate(_ context.Context, name string) error {
f.call("volume create " + name)
return nil
}
func (f *fakeEngine) PodCreate(_ context.Context, s *specgen.PodSpecGenerator) error {
f.call("pod create " + s.Name)
f.pod = s
return nil
}
func (f *fakeEngine) PodRemove(_ context.Context, name string) error {
f.call("pod rm " + name)
return nil
}
func (f *fakeEngine) ContainerCreate(_ context.Context, s *specgen.SpecGenerator) error {
f.call("create " + s.Name)
f.specs[s.Name] = s
return nil
}
func (f *fakeEngine) ContainerStart(_ context.Context, name string) error {
f.call("start " + name)
return nil
}
func (f *fakeEngine) ContainerRun(ctx context.Context, name string) (int, error) {
f.call("run " + name)
if f.run != nil {
return f.run(ctx)
}
return 0, nil
}
func (f *fakeEngine) ContainerExec(_ context.Context, name string, cmd []string) (int, error) {
f.call("exec " + name + " " + strings.Join(cmd, " "))
return f.execCode, nil
}
func (f *fakeEngine) ContainerLogs(_ context.Context, name string, tail int) string {
f.call("logs " + name)
return "boom"
}
func (f *fakeEngine) ContainerRemove(_ context.Context, name string) error {
f.call("rm " + name)
return nil
}
type env struct {
work string
good string
fake *fakeEngine
}
func setup(t *testing.T) *env {
work, _ := filepath.EvalSymlinks(t.TempDir())
for _, d := range []string{"home/proj", "other"} {
_ = os.MkdirAll(filepath.Join(work, d), 0o755)
}
e := &env{work: work, good: writeList(t, "api.anthropic.com")}
t.Setenv("HOME", work+"/home")
t.Setenv("XDG_RUNTIME_DIR", work)
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("AGENT_SANDBOX_ALLOWLIST", e.good)
t.Setenv("AGENT_SANDBOX_IMAGE", "")
t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "")
t.Setenv("AGENT_SANDBOX_HOME_VOLUME", "")
orig := startService
startService = func(dir string) (context.Context, engine, func(), error) {
e.fake.call("service start")
e.fake.apiDir = dir
return context.Background(), e.fake, func() { e.fake.call("service stop") }, nil
}
t.Cleanup(func() { startService = orig })
e.reset()
return e
}
func (e *env) reset() {
e.fake = &fakeEngine{specs: map[string]*specgen.SpecGenerator{}, nets: map[string]types.Network{}}
}
func (e *env) launch(t *testing.T, dir string, args ...string) int {
return e.launchCtx(t, context.Background(), dir, args...)
}
func (e *env) launchCtx(t *testing.T, ctx context.Context, dir string, args ...string) int {
t.Chdir(dir)
t.Setenv("PWD", dir)
return launch(ctx, args)
}
func TestLaunchRefusals(t *testing.T) {
cases := []struct {
name string
dir string
prep func(t *testing.T, e *env)
}{
{"bad-line", "home/proj", func(t *testing.T, e *env) {
t.Setenv("AGENT_SANDBOX_ALLOWLIST", writeList(t, "ok.example.com", "bad host"))
}},
{"home", "home", nil},
{"home-ancestor", ".", nil},
{"root", "/", nil},
{"home-symlink", "homelink", func(t *testing.T, e *env) { _ = os.Symlink(e.work+"/home", e.work+"/homelink") }},
{"config-parent", "other", func(t *testing.T, e *env) { t.Setenv("XDG_CONFIG_HOME", e.work+"/other/cfg") }},
{"allowlist-dir", "home/proj", func(t *testing.T, e *env) {
t.Setenv("AGENT_SANDBOX_ALLOWLIST", e.work+"/home/proj/hosts")
}},
{"runtime-dir", "other/run", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run")
}},
{"runtime-parent", "other", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run")
}},
{"runtime-symlink", "other", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/other/run", 0o755)
_ = os.Symlink(e.work+"/other/run", e.work+"/home/runlink")
t.Setenv("XDG_RUNTIME_DIR", e.work+"/home/runlink")
}},
{"runtime-fallback", "/tmp", func(t *testing.T, e *env) { t.Setenv("XDG_RUNTIME_DIR", "") }},
{"colon", "home/a:b", func(t *testing.T, e *env) { _ = os.Mkdir(e.work+"/home/a:b", 0o755) }},
{"symlinked-allowlist-target", "home/dotfiles", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/home/dotfiles", 0o755)
_ = os.MkdirAll(e.work+"/home/.config/agent-sandbox", 0o755)
b, _ := os.ReadFile(e.good)
_ = os.WriteFile(e.work+"/home/dotfiles/hosts", b, 0o644)
_ = os.Symlink(e.work+"/home/dotfiles/hosts", e.work+"/home/.config/agent-sandbox/allowed-hosts")
t.Setenv("XDG_CONFIG_HOME", e.work+"/home/.config")
t.Setenv("AGENT_SANDBOX_ALLOWLIST", "")
}},
{"symlinked-config-dir", "home/dotfiles", func(t *testing.T, e *env) {
_ = os.MkdirAll(e.work+"/home/dotfiles", 0o755)
_ = os.Symlink(e.work+"/home/dotfiles", e.work+"/home/cfglink")
t.Setenv("XDG_CONFIG_HOME", e.work+"/home/cfglink")
t.Setenv("AGENT_SANDBOX_ALLOWLIST", "")
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := setup(t)
if c.prep != nil {
c.prep(t, e)
}
dir := c.dir
if !filepath.IsAbs(dir) {
dir = filepath.Join(e.work, dir)
}
check(t, "exit", 1, e.launch(t, dir, "--exec", "true"))
check(t, "podman calls", []string(nil), e.fake.calls)
})
}
}
func TestCheckLaunchDirRuntimeFallback(t *testing.T) {
t.Setenv("HOME", "/nonexistent/home")
t.Setenv("XDG_CONFIG_HOME", "/nonexistent/cfg")
t.Setenv("XDG_RUNTIME_DIR", "")
err := checkLaunchDir("/tmp", "/nonexistent/hosts")
if err == nil || !strings.Contains(err.Error(), "contains "+realpath("/tmp")) {
t.Fatalf("got %v", err)
}
check(t, "subdir", nil, checkLaunchDir("/tmp/proj", "/nonexistent/hosts"))
}
func leftovers(t *testing.T, dir string) {
t.Helper()
entries, _ := os.ReadDir(dir)
for _, d := range entries {
if strings.HasPrefix(d.Name(), "agent-sandbox-") {
t.Errorf("leftover %s", d.Name())
}
}
}
func TestLaunch(t *testing.T) {
e := setup(t)
dir := e.work + "/home/proj"
var apiMode os.FileMode
e.fake.run = func(context.Context) (int, error) {
fi, err := os.Stat(e.fake.apiDir)
if err == nil {
apiMode = fi.Mode().Perm()
}
return 0, nil
}
check(t, "exit", 0, e.launch(t, dir, "--exec", "true"))
f := e.fake
session := strings.TrimPrefix(f.calls[3], "network create ")
check(t, "session", true, regexp.MustCompile(`^agent-sandbox-\d+-\d+$`).MatchString(session))
router, fwd, claude := session+"-router", session+"-forwarder", session+"-claude"
check(t, "calls", []string{
"service start",
"network create agent-sandbox-egress",
"volume create agent-sandbox-home",
"network create " + session,
"create " + router,
"start " + router,
"exec " + router + " nc -z 10.89.7.2 443",
"pod create " + session,
"create " + fwd,
"start " + fwd,
"exec " + fwd + " nc -z 127.0.0.1 443",
"create " + claude,
"run " + claude,
"pod rm " + session,
"rm " + router,
"network rm " + session,
"service stop",
}, f.calls)
check(t, "egress net", types.Network{Name: "agent-sandbox-egress", Driver: "bridge", DNSEnabled: true}, f.nets["agent-sandbox-egress"])
check(t, "session net", types.Network{Name: session, Driver: "bridge", Internal: true}, f.nets[session])
yes := true
r := f.specs[router]
tmp := r.Mounts[0].Source
check(t, "tmp under runtime dir", true, strings.HasPrefix(tmp, e.work+"/"+session+"."))
check(t, "api dir private", os.FileMode(0o700), apiMode)
check(t, "api dir not mounted", false, strings.HasPrefix(f.apiDir, tmp))
for _, d := range []string{tmp, f.apiDir} {
if _, err := os.Stat(d); !os.IsNotExist(err) {
t.Errorf("%s not removed", d)
}
}
leftovers(t, e.work)
check(t, "router image", "localhost/agent-sandbox-router:dev", r.Image)
check(t, "router rm", &yes, r.Remove)
check(t, "router netns", specgen.Bridge, r.NetNS.NSMode)
check(t, "router nets", map[string]types.PerNetworkOptions{
session: {StaticIPs: []net.IP{net.ParseIP("10.89.7.2")}},
"agent-sandbox-egress": {},
}, r.Networks)
check(t, "router mounts", []spec.Mount{{Type: "bind", Source: tmp, Destination: "/etc/router", Options: []string{"ro", "Z"}}}, r.Mounts)
p := f.pod
check(t, "pod hostname", "agent-sandbox", p.Hostname)
check(t, "pod netns", specgen.Bridge, p.NetNS.NSMode)
check(t, "pod nets", map[string]types.PerNetworkOptions{session: {}}, p.Networks)
check(t, "pod dns", []net.IP{net.ParseIP("10.89.7.2")}, p.DNSServer)
check(t, "pod no-hosts", true, p.NoManageHosts)
check(t, "pod userns", specgen.Namespace{NSMode: specgen.KeepID, Value: "uid=1000,gid=1000"}, p.Userns)
fw := f.specs[fwd]
check(t, "fwd pod", session, fw.Pod)
check(t, "fwd user", "0:0", fw.User)
check(t, "fwd ro", &yes, fw.ReadOnlyFilesystem)
check(t, "fwd caps", [][]string{{"all"}, {"NET_BIND_SERVICE"}}, [][]string{fw.CapDrop, fw.CapAdd})
check(t, "fwd entrypoint", []string{"/usr/local/bin/forwarder"}, fw.Entrypoint)
check(t, "fwd env", map[string]string{"FORWARDS": "0.0.0.0:443:10.89.7.2:443 0.0.0.0:80:10.89.7.2:80"}, fw.Env)
check(t, "fwd rm", (*bool)(nil), fw.Remove)
c := f.specs[claude]
check(t, "claude image", "localhost/agent-sandbox:dev", c.Image)
check(t, "claude flags", []*bool{&yes, &yes, &yes}, []*bool{c.Remove, c.Stdin, c.Init})
check(t, "claude tty", false, *c.Terminal)
check(t, "claude pod", session, c.Pod)
check(t, "claude cap-drop", []string{"NET_ADMIN", "NET_RAW", "SYS_ADMIN"}, c.CapDrop)
check(t, "claude cap-add", []string(nil), c.CapAdd)
check(t, "claude label", []string{"disable"}, c.SelinuxOpts)
check(t, "claude devices", []spec.LinuxDevice{{Path: "/dev/fuse"}, {Path: "/dev/net/tun"}}, c.Devices)
check(t, "claude volumes", []*specgen.NamedVolume{{Name: "agent-sandbox-home", Dest: "/home/claude"}}, c.Volumes)
check(t, "claude mounts", []spec.Mount{{Type: "bind", Source: dir, Destination: dir}}, c.Mounts)
check(t, "claude workdir", dir, c.WorkDir)
check(t, "claude env", map[string]string{"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_TMPDIR": "/var/tmp"}, c.Env)
check(t, "claude cmd", []string{"true"}, c.Command)
check(t, "claude privileged", (*bool)(nil), c.Privileged)
}
func TestLaunchClaudeArgsAndOverrides(t *testing.T) {
e := setup(t)
t.Setenv("AGENT_SANDBOX_IMAGE", "img:1")
t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "rimg:1")
t.Setenv("AGENT_SANDBOX_HOME_VOLUME", "vol")
check(t, "exit", 0, e.launch(t, e.work+"/home/proj", "--continue", "-p", "hi"))
for name, s := range e.fake.specs {
switch {
case strings.HasSuffix(name, "-claude"):
check(t, "claude cmd", []string{"claude", "--continue", "-p", "hi"}, s.Command)
check(t, "claude image", "img:1", s.Image)
check(t, "volume", "vol", s.Volumes[0].Name)
default:
check(t, name+" image", "rimg:1", s.Image)
}
}
check(t, "volume create", "volume create vol", e.fake.calls[2])
}
func TestImageNamesPinVersion(t *testing.T) {
t.Setenv("AGENT_SANDBOX_IMAGE", "")
t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "")
defer func(v string) { version = v }(version)
version = "v1.2.3"
img, router := imageNames()
check(t, "pinned", [2]string{registry + "/agent-sandbox:v1.2.3", registry + "/agent-sandbox-router:v1.2.3"}, [2]string{img, router})
}
func TestLaunchExitCode(t *testing.T) {
e := setup(t)
e.fake.run = func(context.Context) (int, error) { return 7, nil }
check(t, "exit", 7, e.launch(t, e.work+"/home/proj", "--exec", "false"))
check(t, "cleanup last", "service stop", e.fake.calls[len(e.fake.calls)-1])
}
func TestLaunchSignalCleansUp(t *testing.T) {
e := setup(t)
ctx, cancel := context.WithCancel(context.Background())
e.fake.run = func(opCtx context.Context) (int, error) {
cancel()
<-opCtx.Done()
return 125, opCtx.Err()
}
check(t, "exit", 130, e.launchCtx(t, ctx, e.work+"/home/proj", "--exec", "sleep", "60"))
n := len(e.fake.calls)
check(t, "cleanup", []string{"pod rm", "rm", "network rm", "service stop"},
[]string{e.fake.calls[n-4][:6], e.fake.calls[n-3][:2], e.fake.calls[n-2][:10], e.fake.calls[n-1]})
leftovers(t, e.work)
}
func TestLaunchServiceFails(t *testing.T) {
e := setup(t)
startService = func(string) (context.Context, engine, func(), error) { return nil, nil, nil, errors.New("no podman") }
check(t, "exit", 125, e.launch(t, e.work+"/home/proj", "--exec", "true"))
leftovers(t, e.work)
}
func TestLaunchSeedsAllowlist(t *testing.T) {
e := setup(t)
t.Setenv("AGENT_SANDBOX_ALLOWLIST", "")
t.Setenv("XDG_CONFIG_HOME", e.work+"/cfg")
check(t, "exit", 0, e.launch(t, e.work+"/home/proj", "--exec", "true"))
got, err := os.ReadFile(e.work + "/cfg/agent-sandbox/allowed-hosts")
if err != nil {
t.Fatal(err)
}
check(t, "seeded", string(defaultAllowlist), string(got))
}
func TestLaunchRouterNeverStarts(t *testing.T) {
e := setup(t)
e.fake.execCode = 1
check(t, "exit", 1, e.launch(t, e.work+"/home/proj", "--exec", "true"))
for _, c := range e.fake.calls {
if strings.HasPrefix(c, "pod create") {
t.Error("pod created after router failed")
}
}
check(t, "logs fetched", true, strings.Contains(strings.Join(e.fake.calls, "\n"), "logs "))
check(t, "service stopped", "service stop", e.fake.calls[len(e.fake.calls)-1])
}
func TestLaunchPassesArgsVerbatim(t *testing.T) {
cases := []struct{ args, want []string }{
{[]string{"--exec", "podman", "version"}, []string{"podman", "version"}},
{[]string{"--exec", "sh", "-c", "x --y"}, []string{"sh", "-c", "x --y"}},
{[]string{"help"}, []string{"claude", "help"}},
{[]string{"version"}, []string{"claude", "version"}},
{[]string{"--resume", "abc"}, []string{"claude", "--resume", "abc"}},
{[]string{"-p", "hi"}, []string{"claude", "-p", "hi"}},
{[]string{"-p", "--exec", "help"}, []string{"claude", "-p", "--exec", "help"}},
{[]string{"--exec", "--exec"}, []string{"--exec"}},
}
for _, tc := range cases {
e := setup(t)
check(t, "exit", 0, e.launch(t, e.work+"/home/proj", tc.args...))
for name, s := range e.fake.specs {
if strings.HasSuffix(name, "-claude") {
check(t, strings.Join(tc.args, " "), tc.want, s.Command)
}
}
}
}
+1 -2
View File
@@ -2,7 +2,7 @@
name: agent-sandbox
version: ${PACKAGE_VERSION}
release: 1
arch: noarch
arch: amd64
platform: linux
description: "Run Claude Code in a rootless podman sandbox with allowlisted egress"
maintainer: Ben Vincent <ben@unkin.net>
@@ -11,7 +11,6 @@ license: MIT
depends:
- podman
- bash
contents:
- src: dist/agent-sandbox
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/pkg/api/handlers"
"github.com/containers/podman/v5/pkg/bindings"
"github.com/containers/podman/v5/pkg/bindings/containers"
"github.com/containers/podman/v5/pkg/bindings/images"
"github.com/containers/podman/v5/pkg/bindings/network"
"github.com/containers/podman/v5/pkg/bindings/pods"
"github.com/containers/podman/v5/pkg/bindings/volumes"
entitiesTypes "github.com/containers/podman/v5/pkg/domain/entities/types"
"github.com/containers/podman/v5/pkg/specgen"
dockerContainer "github.com/docker/docker/api/types/container"
"go.podman.io/common/libnetwork/types"
)
// engine is the slice of the podman API a session uses.
type engine interface {
NetworkCreate(ctx context.Context, n *types.Network, ignoreIfExists bool) (types.Network, error)
NetworkRemove(ctx context.Context, name string) error
VolumeCreate(ctx context.Context, name string) error
PodCreate(ctx context.Context, s *specgen.PodSpecGenerator) error
PodRemove(ctx context.Context, name string) error
ContainerCreate(ctx context.Context, s *specgen.SpecGenerator) error
ContainerStart(ctx context.Context, name string) error
// ContainerRun attaches stdio, starts the container and returns its exit code.
ContainerRun(ctx context.Context, name string) (int, error)
ContainerExec(ctx context.Context, name string, cmd []string) (int, error)
ContainerLogs(ctx context.Context, name string, tail int) string
ContainerRemove(ctx context.Context, name string) error
}
// startService runs a private podman API service on a socket in dir and
// returns the connection context, an engine bound to it and a stop func.
var startService = func(dir string) (context.Context, engine, func(), error) {
sock := filepath.Join(dir, "podman.sock")
logPath := filepath.Join(dir, "service.log")
logf, err := os.Create(logPath)
if err != nil {
return nil, nil, nil, err
}
defer func() { _ = logf.Close() }()
// The service stops when we close lifeline, or the kernel does on SIGKILL.
// Pdeathsig can't do this: podman's userns join resets it.
lifeline, lifelineW, err := os.Pipe()
if err != nil {
return nil, nil, nil, err
}
cmd := exec.Command("sh", "-c", `podman system service --time=60 "$1" 3<&- & p=$!; { read -r _ <&3; kill $p; } & wait $p`, "sh", "unix://"+sock)
cmd.Stdout, cmd.Stderr = logf, logf
cmd.ExtraFiles = []*os.File{lifeline}
// Own process group: terminal signals must not kill it before cleanup.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
err = cmd.Start()
_ = lifeline.Close()
if err != nil {
_ = lifelineW.Close()
return nil, nil, nil, err
}
exited := make(chan struct{})
go func() { _ = cmd.Wait(); close(exited) }()
stop := func() {
_ = lifelineW.Close()
select {
case <-exited:
case <-time.After(5 * time.Second):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-exited
}
}
fail := func(err error) (context.Context, engine, func(), error) {
stop()
b, _ := os.ReadFile(logPath)
return nil, nil, nil, fmt.Errorf("podman API service: %w: %s", err, strings.TrimSpace(string(b)))
}
for deadline := time.Now().Add(30 * time.Second); ; {
if c, err := net.Dial("unix", sock); err == nil {
_ = c.Close()
break
}
select {
case <-exited:
return fail(errors.New("exited"))
case <-time.After(50 * time.Millisecond):
}
if time.Now().After(deadline) {
return fail(errors.New("timed out"))
}
}
ctx, err := bindings.NewConnection(context.Background(), "unix://"+sock)
if err != nil {
return fail(err)
}
return ctx, bindingsEngine{}, stop, nil
}
type bindingsEngine struct{}
func (bindingsEngine) NetworkCreate(ctx context.Context, n *types.Network, ignore bool) (types.Network, error) {
return network.CreateWithOptions(ctx, n, new(network.ExtraCreateOptions).WithIgnoreIfExists(ignore))
}
func (bindingsEngine) NetworkRemove(ctx context.Context, name string) error {
_, err := network.Remove(ctx, name, new(network.RemoveOptions).WithForce(true))
return err
}
func (bindingsEngine) VolumeCreate(ctx context.Context, name string) error {
_, err := volumes.Create(ctx, entitiesTypes.VolumeCreateOptions{Name: name, IgnoreIfExists: true}, nil)
return err
}
func (bindingsEngine) PodCreate(ctx context.Context, s *specgen.PodSpecGenerator) error {
_, err := pods.CreatePodFromSpec(ctx, &entitiesTypes.PodSpec{PodSpecGen: *s})
return err
}
func (bindingsEngine) PodRemove(ctx context.Context, name string) error {
_, err := pods.Remove(ctx, name, new(pods.RemoveOptions).WithForce(true).WithTimeout(0))
return err
}
// ContainerCreate pulls a missing image first, as `podman run` does.
func (bindingsEngine) ContainerCreate(ctx context.Context, s *specgen.SpecGenerator) error {
if ok, err := images.Exists(ctx, s.Image, nil); err != nil {
return err
} else if !ok {
var w io.Writer = os.Stderr
if _, err := images.Pull(ctx, s.Image, new(images.PullOptions).WithPolicy("missing").WithProgressWriter(w)); err != nil {
return err
}
}
r, err := containers.CreateWithSpec(ctx, s, nil)
if err != nil {
return err
}
for _, w := range r.Warnings {
fmt.Fprintln(os.Stderr, w)
}
return nil
}
func (bindingsEngine) ContainerStart(ctx context.Context, name string) error {
return containers.Start(ctx, name, nil)
}
// ContainerRun mirrors podman-remote's startAndAttach: attach, start, then
// wait right away so a --rm container can't be removed before we see its code.
func (bindingsEngine) ContainerRun(ctx context.Context, name string) (int, error) {
attached := make(chan error, 1)
ready := make(chan bool)
go func() {
attached <- containers.Attach(ctx, name, os.Stdin, os.Stdout, os.Stderr, ready, new(containers.AttachOptions).WithStream(true))
}()
select {
case <-ready:
case err := <-attached:
return define.ExitCode(err), err
}
if err := containers.Start(ctx, name, nil); err != nil {
return define.ExitCode(err), err
}
type result struct {
code int32
err error
}
waited := make(chan result, 1)
go func() {
code, err := containers.Wait(ctx, name, nil)
waited <- result{code, err}
}()
select {
case err := <-attached:
if errors.Is(err, define.ErrDetach) {
return 0, nil
}
if err != nil {
return 125, err
}
r := <-waited
return int(r.code), r.err
case r := <-waited:
if r.err != nil {
return 125, r.err
}
return int(r.code), <-attached
}
}
func (bindingsEngine) ContainerExec(ctx context.Context, name string, cmd []string) (int, error) {
id, err := containers.ExecCreate(ctx, name, &handlers.ExecCreateConfig{ExecOptions: dockerContainer.ExecOptions{Cmd: cmd, AttachStderr: true}})
if err != nil {
return -1, err
}
defer func() { _ = containers.ExecRemove(ctx, id, new(containers.ExecRemoveOptions).WithForce(true)) }()
if err := containers.ExecStartAndAttach(ctx, id, new(containers.ExecStartAndAttachOptions).WithAttachError(true).WithErrorStream(io.Discard)); err != nil {
return -1, err
}
s, err := containers.ExecInspect(ctx, id, nil)
if err != nil {
return -1, err
}
return s.ExitCode, nil
}
func (bindingsEngine) ContainerLogs(ctx context.Context, name string, tail int) string {
lines := make(chan string)
done := make(chan struct{})
var out []string
go func() {
for l := range lines {
out = append(out, l)
}
close(done)
}()
opts := new(containers.LogOptions).WithStdout(true).WithStderr(true).WithTail(fmt.Sprint(tail))
_ = containers.Logs(ctx, name, opts, lines, lines)
close(lines)
<-done
return strings.Join(out, "\n")
}
func (bindingsEngine) ContainerRemove(ctx context.Context, name string) error {
_, err := containers.Remove(ctx, name, new(containers.RemoveOptions).WithForce(true).WithTimeout(0))
return err
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
)
const (
// Every .domain entry resolves here; exact hosts get 127.1.x.y of their own.
wildcardIP = "127.0.0.2"
portBase = 10000
)
var hostRE = regexp.MustCompile(`^\.?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
// entry is one allowlist line; port is empty for plain web (80/443) access.
type entry struct{ host, port string }
// parseAllowlist returns the entries of allowlist file path, sorted bytewise
// on "host port" and unique.
func parseAllowlist(path string) ([]entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
seen := map[string]bool{}
var keys []string
sc := bufio.NewScanner(f)
for n := 1; sc.Scan(); n++ {
line, _, _ := strings.Cut(sc.Text(), "#")
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
if len(fields) > 1 {
return nil, fmt.Errorf("%s:%d: one entry per line", path, n)
}
line = strings.ToLower(fields[0])
host, port := line, ""
if i := strings.LastIndex(line, ":"); i >= 0 {
host, port = line[:i], line[i+1:]
}
if !hostRE.MatchString(host) {
return nil, fmt.Errorf("%s:%d: invalid host '%s'", path, n, host)
}
if port != "" {
p, err := strconv.Atoi(port)
if err != nil || strings.ContainsAny(port, "+-") || p < 1 || p > 65535 {
return nil, fmt.Errorf("%s:%d: invalid port '%s'", path, n, port)
}
if strings.HasPrefix(host, ".") {
return nil, fmt.Errorf("%s:%d: ports need an exact host, not '%s'", path, n, host)
}
port = strconv.Itoa(p)
if p == 80 || p == 443 {
port = ""
}
}
if k := host + " " + port; !seen[k] {
seen[k] = true
keys = append(keys, k)
}
}
if err := sc.Err(); err != nil {
return nil, err
}
sort.Strings(keys)
entries := make([]entry, len(keys))
for i, k := range keys {
entries[i].host, entries[i].port, _ = strings.Cut(k, " ")
}
return entries, nil
}
// routerConfig renders dnsmasq.conf and nginx.conf for the router listening on
// rip, and the sandbox's loopback forwards as "bind:port:router:router_port"
// words.
func routerConfig(entries []entry, rip string) (dnsmasq, nginx, forwards string) {
var hosts []string
isHost := map[string]bool{}
for _, e := range entries {
if !isHost[e.host] {
isHost[e.host] = true
hosts = append(hosts, e.host)
}
}
ip := map[string]string{}
i := 0
for _, h := range hosts {
if !strings.HasPrefix(h, ".") {
ip[h] = fmt.Sprintf("127.1.%d.%d", i/250, i%250+1)
i++
}
}
var tcp strings.Builder
fwd := []string{"0.0.0.0:443:" + rip + ":443", "0.0.0.0:80:" + rip + ":80"}
j := 0
for _, e := range entries {
if e.port == "" {
continue
}
fmt.Fprintf(&tcp, " server { listen %s:%d; set $up %s:%s; proxy_pass $up; }\n", rip, portBase+j, e.host, e.port)
fwd = append(fwd, fmt.Sprintf("%s:%s:%s:%d", ip[e.host], e.port, rip, portBase+j))
j++
}
var d strings.Builder
fmt.Fprintf(&d, "no-resolv\nno-hosts\nbind-interfaces\nlisten-address=%s\naddress=/#/\n", rip)
for _, h := range hosts {
if strings.HasPrefix(h, ".") {
fmt.Fprintf(&d, "address=/%s/%s\n", h[1:], wildcardIP)
} else {
fmt.Fprintf(&d, "host-record=%s,%s\n", h, ip[h])
}
}
// nginx "hostnames" rejects an exact key that a .domain key already covers.
var m strings.Builder
for _, h := range hosts {
if !strings.HasPrefix(h, ".") && isHost["."+h] {
continue
}
fmt.Fprintf(&m, " %s 1;\n", h)
}
nginx = strings.NewReplacer("@RIP@", rip, "@MAP@", m.String(), "@TCP@", tcp.String()).Replace(nginxTemplate)
return d.String(), nginx, strings.Join(fwd, " ")
}
// @RESOLVER@ is filled in by the router's start.sh.
const nginxTemplate = `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;
}
}
}
`
+15 -10
View File
@@ -1,16 +1,21 @@
#!/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.
# Package the (already built) agent-sandbox binary and the default
# allowlist into an RPM with nfpm.
# 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
VERSION="${1:-${CI_COMMIT_TAG:-v0.0.0-dev}}"
[[ $VERSION == dev ]] && VERSION=v0.0.0-dev
DIST=dist
BINARY=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
if [[ ! -f $DIST/$BINARY ]]; then
echo "ERROR: $DIST/$BINARY not found; run 'make bin' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION#v}"
envsubst <packaging/nfpm.yaml >"$DIST/nfpm.yaml"
nfpm pkg --config "$DIST/nfpm.yaml" --target "$DIST" --packager rpm
ls -1 "$DIST"/*.rpm
-108
View File
@@ -1,108 +0,0 @@
#!/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; }