A service that reads this repository's Gitea issues, decides what to do about each thread as the Program Manager persona, and runs one worker agent per assignment that authors a change, runs the merge gate and opens a pull request. It reacts to issue creation and to comments; the assignment and all correspondence travel through the issue thread and the PR. It authenticates with an Anthropic subscription token. It never pushes to main and never merges. Three choices carry the design, with alternatives recorded in docs/plans/: - it POLLS rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no inbound reach needed at all); - it keeps NO state: the trigger is "the newest thing in this thread was not written by me", so the issue thread is the state — no PVC, no backup obligation; - the model holds NO tools: its reply is one JSON object this code validates, so nothing in the path executes text. The boundary is platform/kyverno/admission/pm-agent-job-shape.yaml (Enforce, failurePolicy: Fail), not the RBAC: Kubernetes places no `use` check on serviceAccountName, so "may create Jobs" is "may run arbitrary code as the namespace's most privileged identity". Six rules admit only the committed shape, and the worker identity is bound to nothing with no token mounted, so there is nothing to escalate to. 28/28 offline Kyverno assertions. Nothing has run: the image has never been built or pushed, and both the image digest and the two credentials are deliberate placeholders. See the handoff entry's "What I did NOT verify". Refs CLAUDE.md §1/§3/§6, R-SEC-02, R-GITOPS-02. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
186 lines
9.4 KiB
Bash
Executable File
186 lines
9.4 KiB
Bash
Executable File
#!/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"
|
|
'
|
|
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 <<EOF
|
|
|
|
================================================================================
|
|
NOT PUSHED. Publishing is an operator action — this script holds no registry
|
|
credential and will never acquire one.
|
|
|
|
OPERATOR: run these, then put the digest from the digestfile into
|
|
platform/pm-agent/configmap.yaml (PM_WORKER_IMAGE) via a pull request:
|
|
|
|
${ENGINE} login gitea.home.keithjackson.dev
|
|
|
|
${ENGINE} push --digestfile /tmp/agent-runner.digest ${IMAGE_REF}
|
|
|
|
cat /tmp/agent-runner.digest
|
|
# -> 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:<digest> — 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
|