diff --git a/.gitignore b/.gitignore index 849ddff..eec8bff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ dist/ +/agent-sandbox +/agent-sandbox-* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2101004 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +version: "2" +run: + build-tags: + - remote + - exclude_graphdriver_btrfs + - containers_image_openpgp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed9eb37..0dcf6f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 62aa956..259c4cd 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -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: diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index 32e99da..3ed5c62 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 909c49b..b3e153d 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index 014486e..00ddbef 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Makefile b/Makefile index eb90071..836d640 100644 --- a/Makefile +++ b/Makefile @@ -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. diff --git a/README.md b/README.md index a29c172..429f846 100644 --- a/README.md +++ b/README.md @@ -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}`. diff --git a/agent-sandbox b/agent-sandbox deleted file mode 100755 index e2a10b4..0000000 --- a/agent-sandbox +++ /dev/null @@ -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" <&2 - fi - allowlist_entries "$ALLOWLIST" >/dev/null || exit 1 - - local session router tmp subnet rip forwards - session=agent-sandbox-$(date +%s)-$$ - router=$session-router - tmp=$(mktemp -d "${XDG_RUNTIME_DIR:-/tmp}/$session.XXXX") - # shellcheck disable=SC2064 - trap "podman pod rm -f -t 0 $session >/dev/null 2>&1; podman rm -f -t 0 $router >/dev/null 2>&1; podman network rm -f $session >/dev/null 2>&1; rm -rf '$tmp'" EXIT - trap 'exit 130' INT TERM HUP - - podman network create --ignore "$EGRESS_NET" >/dev/null - podman volume create --ignore "$HOME_VOLUME" >/dev/null - podman network create --internal --disable-dns "$session" >/dev/null - subnet=$(podman network inspect -f '{{range .Subnets}}{{.Subnet}}{{end}}' "$session") - [[ $subnet == *.0/24 ]] || die "unexpected subnet $subnet" - rip=${subnet%.0/24}.2 - - forwards=$(router_config "$ALLOWLIST" "$tmp" "$rip") - chmod 755 "$tmp" - chmod 644 "$tmp"/* - - podman run -d --rm --name "$router" \ - --network "$session:ip=$rip" --network "$EGRESS_NET" \ - -v "$tmp:/etc/router:ro,Z" \ - "$ROUTER_IMAGE" >/dev/null - wait_port "$router" "$rip" 443 - - # The pod owns the netns, DNS and hosts file; claude only joins it. - podman pod create --name "$session" \ - --network "$session" --dns "$rip" --no-hosts --hostname agent-sandbox \ - --userns keep-id:uid=1000,gid=1000 >/dev/null - # Loopback forwarders live in their own container: claude can't see, signal - # or replace them. Claude runs unprivileged; podman's default bounding set - # (no NET_ADMIN/NET_RAW/SYS_ADMIN) stays only so nested containers work. - podman run -d --pod "$session" --name "$session-forwarder" \ - --user 0:0 --read-only --cap-drop all --cap-add NET_BIND_SERVICE \ - --entrypoint /usr/local/bin/forwarder -e FORWARDS="$forwards" \ - "$ROUTER_IMAGE" >/dev/null - wait_port "$session-forwarder" 127.0.0.1 443 - - local tty=() - [[ -t 0 && -t 1 ]] && tty=(-t) - podman run --rm -i "${tty[@]}" --init --pod "$session" --name "$session-claude" \ - --cap-drop NET_ADMIN,NET_RAW,SYS_ADMIN \ - --security-opt label=disable \ - --device /dev/fuse --device /dev/net/tun \ - -v "$HOME_VOLUME:/home/claude" \ - -v "$PWD:$PWD" -w "$PWD" \ - -e DISABLE_AUTOUPDATER=1 -e CLAUDE_CODE_TMPDIR=/var/tmp \ - "$IMAGE" "${cmd[@]}" -} - -wait_port() { - local i - for i in $(seq 50); do - podman exec "$1" nc -z "$2" "$3" 2>/dev/null && return - sleep 0.1 - done - die "$1 did not start: $(podman logs "$1" 2>&1 | tail -5)" -} - -if [[ ${BASH_SOURCE[0]} == "$0" ]]; then - main "$@" -fi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0bd5d74 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b2ad52 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b7684f4 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..aa25ee1 --- /dev/null +++ b/main_test.go @@ -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) + } + } + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 03c90d7..e938f76 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -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 @@ -11,7 +11,6 @@ license: MIT depends: - podman - - bash contents: - src: dist/agent-sandbox diff --git a/podman.go b/podman.go new file mode 100644 index 0000000..dd4cc4c --- /dev/null +++ b/podman.go @@ -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 +} diff --git a/router.go b/router.go new file mode 100644 index 0000000..1d5cd86 --- /dev/null +++ b/router.go @@ -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; + } + } +} +` diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index 95d3f6e..e6b65ec 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -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 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 "$DIST/nfpm.yaml" +nfpm pkg --config "$DIST/nfpm.yaml" --target "$DIST" --packager rpm +ls -1 "$DIST"/*.rpm diff --git a/tests/test.sh b/tests/test.sh deleted file mode 100755 index fd55bfb..0000000 --- a/tests/test.sh +++ /dev/null @@ -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; }