#!/usr/bin/env bash ############################################################################### # infra/agent/build-image.sh — build the pinned `agent-runner` image and print its digest. # # ./build-image.sh # build + verify + print the digest and push command # ./build-image.sh --no-verify # build only (skip the in-image proof) # ENGINE=docker ./build-image.sh # build with docker instead of podman # # THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is not # an agent's to hold, and the pushed digest is what a fail-closed admission policy then # trusts. So this builds locally, proves the toolchain and both entrypoints work INSIDE the # image as the non-root uid with the read-only root filesystem the policy forces, and prints # the exact push line for a human. # # VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the four pins this image # adds (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds # the platform toolchain the base already carries. Nothing in this file invents a version. # # THE DIGEST CAVEAT — read it before pasting a digest into anything. The digest printed here # is the LOCAL manifest digest. It is normally identical to the registry digest, but that is # a property of the push rather than a guarantee, so the push command below uses # `--digestfile`: the digest the REGISTRY returns is the authoritative one, and that is the # one that goes into platform/pm-agent/configmap.yaml. ############################################################################### set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${HERE}/../.." && pwd)" ENGINE="${ENGINE:-podman}" VERIFY=1 for arg in "$@"; do case "$arg" in --no-verify) VERIFY=0 ;; -h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $arg" >&2; exit 2 ;; esac done log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)" # --- pins: sourced, never invented ------------------------------------------- AGENT_ENV="${HERE}/versions.env" [ -f "${AGENT_ENV}" ] || die "missing ${AGENT_ENV} — the pins live there, not here" # shellcheck disable=SC1090 source "${AGENT_ENV}" : "${AGENT_BASE_IMAGE:?AGENT_BASE_IMAGE not set in ${AGENT_ENV}}" : "${NODEJS_MAJOR:?NODEJS_MAJOR not set in ${AGENT_ENV}}" : "${CLAUDE_CODE_VERSION:?CLAUDE_CODE_VERSION not set in ${AGENT_ENV}}" : "${CLAUDE_CODE_MIN_VERSION:?CLAUDE_CODE_MIN_VERSION not set in ${AGENT_ENV}}" : "${AGENT_MODEL:?AGENT_MODEL not set in ${AGENT_ENV}}" : "${AGENT_IMAGE_REPO:?AGENT_IMAGE_REPO not set in ${AGENT_ENV}}" # --- the base must already exist --------------------------------------------- # Deliberately NOT built on demand. The toolbox build is its own reviewed artefact with its # own pins, and silently building it here would mean this script could change the toolchain # a worker gates with as a side effect of rebuilding the agent image. if ! "${ENGINE}" image exists "${AGENT_BASE_IMAGE}" 2>/dev/null; then die "base image '${AGENT_BASE_IMAGE}' is not present. Build the pinned toolbox first: ${REPO_ROOT}/infra/devcontainer/dev.sh build" fi IMAGE_TAG="claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}" IMAGE_REF="${AGENT_IMAGE_REPO}:${IMAGE_TAG}" log "Building ${IMAGE_REF}" info "engine ${ENGINE} ($(${ENGINE} --version 2>/dev/null | head -1))" info "pins from ${AGENT_ENV}" info " base ${AGENT_BASE_IMAGE}" info " node ${NODEJS_MAJOR}" info " claude ${CLAUDE_CODE_VERSION} (floor ${CLAUDE_CODE_MIN_VERSION})" info " model ${AGENT_MODEL}" "${ENGINE}" build \ --file "${HERE}/Containerfile" \ --tag "${IMAGE_REF}" \ --build-arg "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}" \ --build-arg "NODEJS_MAJOR=${NODEJS_MAJOR}" \ --build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \ --build-arg "CLAUDE_CODE_MIN_VERSION=${CLAUDE_CODE_MIN_VERSION}" \ --build-arg "AGENT_MODEL=${AGENT_MODEL}" \ "${HERE}" # --- verify: the point of the whole exercise --------------------------------- # A build that merely succeeded is not evidence this image is usable. Prove the tools # EXECUTE, as the NON-ROOT uid, with a READ-ONLY root filesystem and only the two volumes # the admission policy admits — the exact shape a worker Job runs in. Anything that works # here but not there is a 3am discovery. if [ "${VERIFY}" -eq 1 ]; then log "Verifying inside the image (uid 65532, read-only rootfs, workspace+tmp only)" "${ENGINE}" run --rm \ --user 65532:65532 \ --read-only \ --tmpfs /workspace:rw,mode=1777 \ --tmpfs /tmp:rw,mode=1777 \ --network none \ --entrypoint "" \ "${IMAGE_REF}" \ /bin/bash -euo pipefail -c ' echo "--- id ---"; id echo "--- HOME ---"; echo "HOME=${HOME} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" echo "--- baked versions ---"; cat /etc/agent-runner-versions echo "--- the gate toolchain the worker needs (CLAUDE.md §3) ---" for t in git python3 bash yamllint check-jsonschema kyverno promtool chainsaw flux sops age kubectl jq diff; do if command -v "$t" >/dev/null 2>&1; then printf " ok %s\n" "$t"; else printf " MISSING %s\n" "$t"; missing=1; fi done [ -z "${missing:-}" ] || { echo "the gate cannot run in this image"; exit 1; } echo "--- python can import the renderer dependency ---" python3 -c "import yaml; print(\"PyYAML\", yaml.__version__)" echo "--- claude ---"; claude --version echo "--- the two entrypoints refuse arguments ---" /usr/local/bin/pm-worker --anything 2>&1 | grep -q "takes no arguments" && echo " pm-worker: rejected" /usr/local/bin/pm-dispatch --anything 2>&1 | grep -q "takes no arguments" && echo " pm-dispatch: rejected" echo "--- the dispatcher selftest (branch vocabulary, result envelopes, Job template, persona) ---" PM_SELFTEST=1 /usr/local/bin/pm-dispatch echo "--- the entrypoints are root-owned and NOT writable by the runtime uid ---" for f in /usr/local/bin/pm-worker /usr/local/bin/pm-dispatch /etc/pm-agent/worker-job-template.yaml; do stat -c " %n %U:%G %a" "$f" if [ -w "$f" ]; then echo "UNEXPECTED: uid $(id -u) can write $f"; exit 1; fi done echo " confirmed: not writable by uid $(id -u)" echo "--- rootfs is read-only (expected: a failure) ---" if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: rootfs is writable"; exit 1; fi echo " confirmed: / is read-only" echo "--- the two writable paths really are writable ---" touch "${HOME}/.probe" /tmp/.probe && echo " confirmed: ${HOME} and /tmp are writable" echo "--- uid is non-zero ---" [ "$(id -u)" -ne 0 ] && echo " confirmed: uid $(id -u), not root" echo "--- no Anthropic API key is baked into the image (it would outrank the subscription token) ---" [ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] && echo " confirmed: neither is set" ' # The worker's input guards, exercised in the image, with the interpreter and the script # that will actually run in the cluster. The probe is mounted read-only rather than baked # in: it is a build-time check, not something a worker Job has any business carrying. log "Probing pm-worker's input validation inside the image" "${ENGINE}" run --rm \ --user 65532:65532 \ --read-only \ --tmpfs /workspace:rw,mode=1777 \ --tmpfs /tmp:rw,mode=1777 \ --network none \ --volume "${HERE}/probe-validation.sh:/probe-validation.sh:ro,Z" \ --entrypoint "" \ "${IMAGE_REF}" \ /bin/bash /probe-validation.sh log "Image verified" fi # --- digest ------------------------------------------------------------------ DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")" SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")" log "Built ${IMAGE_REF}" info "local manifest digest ${DIGEST}" info "size $(( SIZE / 1024 / 1024 )) MiB" cat < sha256:.... THIS is the authoritative digest, not the local one above. # platform/pm-agent/configmap.yaml PM_WORKER_IMAGE: "${AGENT_IMAGE_REPO}@\$(cat /tmp/agent-runner.digest)" WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/pm-agent-job-shape.yaml is a fail-closed Enforce policy. It DENIES any worker Job whose image is not ${AGENT_IMAGE_REPO}@sha256: — a tag reference is denied, and so is any other repository path. The dispatcher also refuses to start if PM_WORKER_IMAGE carries no digest, so the failure names its own cause. THE SAME IMAGE RUNS THE DISPATCHER. platform/pm-agent/deployment.yaml pins it too, so a new digest is a two-line change in one pull request: the Deployment's image and PM_WORKER_IMAGE. They must be the same digest — the dispatcher and its workers share the committed Job template and the env-name contract, and a mismatched pair would disagree about it. TIGHTEN THE BOUNDARY AFTERWARDS: the policy admits the wildcard \`${AGENT_IMAGE_REPO}@sha256:*\`, so anyone who can push to that registry path can publish a different digest. Replacing the wildcard with the LITERAL digest closes that gap; it is a one-line change, reviewed in a pull request, which is exactly where a change to the security boundary belongs. ================================================================================ EOF