# ============================================================================= # agent-runner — the image behind the PM agent service (platform/pm-agent/). # # ONE image, TWO entrypoints, because they must agree on everything that matters: # # /usr/local/bin/pm-dispatch the long-running PROGRAM MANAGER. Polls Gitea issues, # asks the model what to do about each thread, posts the # answer as an issue comment, and creates a worker Job # when the answer is "implement" or "revise". # /usr/local/bin/pm-worker one ASSIGNMENT. Clones the repo, branches, runs a full # Claude Code session against CLAUDE.md, runs ./ci/gate.sh, # pushes the branch and opens the PR. # # WHY IT IS LAYERED ON THE DEV TOOLBOX, NOT BUILT FROM SCRATCH. # A worker's last act before pushing is `./ci/gate.sh`, and CLAUDE.md §3 says in terms # that only the PINNED TOOLBOX result counts — gating on a host without promtool and the # rest produces a false rc 3. The toolbox (infra/devcontainer/) already pins that whole # toolchain in one place. Re-listing those pins here would create a second source of truth, # and the first time the two drifted a worker's "gate green" would stop meaning the gate's # green while still reading as a pass. So the base IS the toolbox, and this file adds only # what the toolbox lacks: node, the Claude Code CLI, and the two entrypoints. # # The cost is honest and worth naming: the toolbox is a full Fedora with ansible and # OpenTofu in it, so this image is large (~2 GiB) for a workload that needs neither. The # alternative — a slim image with a hand-copied subset of the toolchain — buys size and # pays with exactly the drift the paragraph above describes. Size is the cheaper problem. # # THE ADMISSION CONTRACT THIS IMAGE MUST SATISFY. # platform/kyverno/admission/pm-agent-job-shape.yaml is a fail-closed Enforce ClusterPolicy # and it is the security boundary — not the dispatcher's RBAC. Kubernetes places no `use` # check on a Pod's `serviceAccountName`, so any principal that can `create jobs` in the # pm-agent namespace can create a Job with an arbitrary command; the policy is what stops # that. Three consequences are baked in below: # # 1. `USER` is NUMERIC. With `runAsNonRoot: true` and no explicit `runAsUser`, the # kubelet REJECTS an image whose USER is a name it cannot resolve to a uid # ("container has runAsNonRoot and image has non-numeric user"). So: USER 65532:65532. # 2. The worker pod is `readOnlyRootFilesystem: true` and the policy admits only the # `workspace` and `tmp` volumes. So HOME and every cache path point INTO /workspace, # never at the read-only root — that is why HOME=/workspace below, and why # CLAUDE_CONFIG_DIR is set (Claude Code writes its config and credentials under it, # and would otherwise try $HOME/.claude on a path that may not be writable). # 3. `command` is pinned to one of the two absolute paths above, with NO args. Both # scripts therefore take NO arguments at all and read their whole input from the # environment, which they validate themselves. An argument surface on a pinned # command is caller-supplied code; there is none here. # ============================================================================= # The pinned dev toolbox. Passed in by build-image.sh from infra/agent/versions.env; the # default is deliberately EMPTY so a build that forgets it fails loudly instead of # resolving some other image named `agent-base`. ARG AGENT_BASE_IMAGE= # Stage `agent-runner` is the image that ships; stage `verify` (end of file) is a TEST stage built # FROM it that only runs assertions and is never pushed. build-image.sh and the CI pipeline # build `--target verify` to prove the image and `--target agent-runner` for the image itself. FROM ${AGENT_BASE_IMAGE} AS agent-runner # Re-declared AFTER the FROM: an ARG from before the first FROM is not in scope in the # build stage, so the provenance layer below would otherwise record an empty base. ARG AGENT_BASE_IMAGE= ARG NODEJS_MAJOR= ARG CLAUDE_CODE_VERSION= ARG CLAUDE_CODE_MIN_VERSION= ARG AGENT_MODEL= # Nonroot uid/gid. 65532 is the distroless `nonroot` convention; the value is arbitrary # but it MUST be numeric and non-zero — see "THE ADMISSION CONTRACT" above. ARG RUNNER_UID=65532 ARG RUNNER_GID=65532 LABEL org.opencontainers.image.title="agent-runner" \ org.opencontainers.image.description="pinned dev toolbox + Claude Code CLI: the PM dispatcher and the per-assignment worker agent" \ org.opencontainers.image.source="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl" \ org.opencontainers.image.documentation="platform/pm-agent/README.md" \ homelab.keithjackson.dev/requirements="R-SEC-02,R-GITOPS-02" # Fail the BUILD — not a worker Job three weeks later — if a pin was not passed in. RUN set -eux; \ : "${NODEJS_MAJOR:?pass --build-arg NODEJS_MAJOR from infra/agent/versions.env}"; \ : "${CLAUDE_CODE_VERSION:?pass --build-arg CLAUDE_CODE_VERSION from infra/agent/versions.env}"; \ : "${AGENT_MODEL:?pass --build-arg AGENT_MODEL from infra/agent/versions.env}" # --- node + the Claude Code CLI ---------------------------------------------- # The CLI is a node program; the Fedora base carries a nodejs module stream, which is the # reproducible way to get node inside an already-pinned base (no second distro, no curl | # bash installer). `npm i -g` with an EXACT version — a floating tag would make the image # non-reproducible, and the in-image autoupdater is disabled at runtime anyway. # # The version is then ASSERTED, twice: that the binary runs at all, and that it is at or # above CLAUDE_CODE_MIN_VERSION. Both entrypoints pass `--permission-prompts none`, which # requires >= 2.1.259 (docs/en/cli-reference). Below that floor the flag is unknown and an # unattended run waits for a permission answer nobody can give — a hang, not an error, which # is the worst failure mode available. Catch it here. RUN set -eux; \ dnf -y module enable "nodejs:${NODEJS_MAJOR}" || true; \ dnf -y install --setopt=install_weak_deps=False nodejs npm; \ dnf clean all; \ npm install -g --no-fund --no-audit "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"; \ npm cache clean --force; \ claude --version; \ installed="$(claude --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"; \ [ -n "${installed}" ] || { echo "cannot read claude --version" >&2; exit 1; }; \ lowest="$(printf '%s\n%s\n' "${installed}" "${CLAUDE_CODE_MIN_VERSION}" | sort -V | head -1)"; \ [ "${lowest}" = "${CLAUDE_CODE_MIN_VERSION}" ] || { \ echo "claude ${installed} is below the ${CLAUDE_CODE_MIN_VERSION} floor needed for --permission-prompts none" >&2; \ exit 1; } # --- the two entrypoints ----------------------------------------------------- # Copied as files at FIXED paths with NO argument surface, because the admission policy # pins `command` and reads nothing else: a container with `command: ["/bin/sh","-c"]` and # an arbitrary script would otherwise be admitted and run with whatever the worker holds. # Pinning a path is only worth anything if the thing at that path cannot be replaced, so # ownership and mode are ASSERTED below rather than assumed, and both scripts are # syntax-checked at build time. COPY pm-dispatch.py /usr/local/bin/pm-dispatch COPY pm-worker /usr/local/bin/pm-worker COPY worker-job-template.yaml /etc/pm-agent/worker-job-template.yaml COPY pm-persona.md /etc/pm-agent/pm-persona.md COPY worker-rules.md /etc/pm-agent/worker-rules.md RUN set -eux; \ chown root:root /usr/local/bin/pm-dispatch /usr/local/bin/pm-worker; \ chmod 0755 /usr/local/bin/pm-dispatch /usr/local/bin/pm-worker; \ chown -R root:root /etc/pm-agent; \ chmod 0644 /etc/pm-agent/*; \ python3 -c 'import ast,sys; ast.parse(open("/usr/local/bin/pm-dispatch").read())'; \ bash -n /usr/local/bin/pm-worker; \ test "$(stat -c '%U:%G %a' /usr/local/bin/pm-worker)" = 'root:root 755'; \ test "$(stat -c '%U:%G %a' /usr/local/bin/pm-dispatch)" = 'root:root 755' # Both entrypoints refuse arguments. Prove it here, in the layer, so a rule that pins # `command` with no args is backed by a program that would reject them anyway. RUN set -eux; \ /usr/local/bin/pm-worker --anything 2>&1 | grep -q 'takes no arguments'; \ /usr/local/bin/pm-dispatch --anything 2>&1 | grep -q 'takes no arguments' # --- the nonroot account ----------------------------------------------------- # Written directly rather than pulling in shadow-utils to run useradd once. HOME is # /workspace because at runtime that emptyDir is the only writable path. RUN set -eux; \ echo "agent:x:${RUNNER_UID}:${RUNNER_GID}:pm agent:/workspace:/bin/bash" >> /etc/passwd; \ echo "agent:x:${RUNNER_GID}:" >> /etc/group; \ install -d -m 0775 -o "${RUNNER_UID}" -g "${RUNNER_GID}" /workspace # SYSTEM-level git config, because HOME is an emptyDir that starts empty on every run and # the root filesystem is read-only — a runtime `git config --global` write would fail. # # safe.directory=*: the clone and the checks that walk it can run under different uids in # the same volume, and git aborts with "detected dubious ownership" on a mismatch. The repo # is cloned fresh per run into an emptyDir, so there is no pre-existing hostile checkout # this relaxes a check against. # # The identity is NOT set here. A commit's author is per-assignment (it names the issue the # work came from), so pm-worker passes it in the environment; see the script. RUN set -eux; \ printf '%s\n' \ '[safe]' \ ' directory = *' \ '[advice]' \ ' detachedHead = false' \ '[init]' \ ' defaultBranch = main' \ > /etc/gitconfig; \ chmod 0644 /etc/gitconfig # Provenance: what is actually in this layer, readable from a running pod. RUN set -eux; \ { echo "base=${AGENT_BASE_IMAGE}"; \ echo "nodejs=$(node --version)"; \ echo "npm=$(npm --version)"; \ echo "claude-code=$(claude --version)"; \ echo "claude-code-pin=${CLAUDE_CODE_VERSION}"; \ echo "model-default=${AGENT_MODEL}"; \ echo "python3=$(python3 --version)"; \ echo "git=$(git --version)"; \ echo "pm-dispatch=$(sha256sum /usr/local/bin/pm-dispatch | cut -d' ' -f1)"; \ echo "pm-worker=$(sha256sum /usr/local/bin/pm-worker | cut -d' ' -f1)"; \ } > /etc/agent-runner-versions; \ chmod 0444 /etc/agent-runner-versions # Every writable path points into the `workspace` emptyDir — the root filesystem is # read-only in the worker pod and the admission policy admits no third volume for scratch. # # CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC turns off auto-updates, telemetry, error # reporting AND feature-flag fetching in one variable (docs/en/env-vars). That is not a # privacy gesture: this namespace's egress policy allows tcp/443 to the world and # gitea only, so a background call to any other host is a connection that hangs until it # times out, inside a Job with a deadline. Turning the traffic off is how the deadline # stays about the work. # # ANTHROPIC_API_KEY is deliberately NOT set and MUST NOT be: Claude Code's credential # precedence puts an API key ABOVE CLAUDE_CODE_OAUTH_TOKEN, so an empty or stale key in # the environment would silently take over from the subscription token and bill (or fail) # somewhere else entirely. See platform/pm-agent/README.md. ENV HOME=/workspace \ XDG_CACHE_HOME=/workspace/.cache \ XDG_CONFIG_HOME=/workspace/.config \ TMPDIR=/workspace/.tmp \ CLAUDE_CONFIG_DIR=/workspace/.claude \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ NPM_CONFIG_CACHE=/workspace/.cache/npm \ PYTHONDONTWRITEBYTECODE=1 \ PM_AGENT_MODEL=${AGENT_MODEL} \ PM_WORKER_JOB_TEMPLATE=/etc/pm-agent/worker-job-template.yaml \ PM_PERSONA_FILE=/etc/pm-agent/pm-persona.md \ PM_WORKER_RULES_FILE=/etc/pm-agent/worker-rules.md USER ${RUNNER_UID}:${RUNNER_GID} WORKDIR /workspace # No ENTRYPOINT on purpose. The admission policy pins `command` to one of the two absolute # paths above; an ENTRYPOINT here would either be ignored or prepend something the policy # cannot see. `bash` is the interactive fallback for a human debugging the image locally. CMD ["/bin/bash"] # ============================================================================= # verify — a TEST stage, never shipped. Every assertion infra/agent/build-image.sh used to make # with `run --user 65532 --read-only --tmpfs /workspace --tmpfs /tmp --network none` now runs # here, at build time, because CI builds on rootless BuildKit where no container runtime exists. # # WHAT A BUILD STEP CAN AND CANNOT REPRODUCE, stated so nobody over-reads a green verify: # * uid 65532 ............ exact: USER is inherited from the agent-runner stage. # * --network none ....... exact: RUN --network=none, and the first check proves it. # * --read-only rootfs ... NOT reproducible: a RUN step always has a writable layer. It is # APPROXIMATED by permission: as uid 65532, every path the process can # write is inventoried and must lie under /workspace, /tmp or /var/tmp # (the pod mounts emptyDirs on the first two; /var/tmp is read-only # there, and TMPDIR points into /workspace so nothing should use it). # The pod's readOnlyRootFilesystem is enforced by admission # (platform/kyverno/admission/pm-agent-job-shape.yaml), not by this. # * --tmpfs /workspace ... approximated by the image's own /workspace (owned 65532), which is # what the pod's emptyDir replaces. # The probe script is COPYed into THIS stage only, so it never ships in the image. # ============================================================================= FROM agent-runner AS verify COPY probe-validation.sh /opt/verify/probe-validation.sh RUN --network=none /bin/bash -euo pipefail <<'VERIFY' echo "--- no network (the build-time stand-in for run --network none) ---" if getent hosts registry.npmjs.org >/dev/null 2>&1 || curl -sS --max-time 3 -o /dev/null https://1.1.1.1 2>/dev/null; then echo "UNEXPECTED: network reachable in the verify stage"; exit 1 fi echo " confirmed: no network" 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 ---" # Each must REFUSE. (The old docker-run version wrote `cmd | grep -q ... && echo`, which under # `set -e` + pipefail could never fail the script; these are real assertions.) for e in pm-worker pm-dispatch; do out="$(/usr/local/bin/$e --anything 2>&1 || true)" grep -q "takes no arguments" <<<"$out" || { echo "UNEXPECTED: $e accepted an argument: $out"; exit 1; } echo " $e: rejected" done 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 "--- the root filesystem is not writable by the runtime uid (expected: a failure) ---" if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: / is writable by uid $(id -u)"; exit 1; fi echo " confirmed: / is not writable" echo "--- everything uid $(id -u) CAN write lies under /workspace, /tmp or /var/tmp ---" stray="$(find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -writable ! -type l -print 2>/dev/null \ | grep -vE '^/(workspace|tmp|var/tmp)(/|$)' || true)" if [ -n "${stray}" ]; then echo "UNEXPECTED: writable outside the allowed paths:"; printf ' %s\n' ${stray} | head -20; exit 1; fi echo " confirmed: no writable path outside /workspace, /tmp, /var/tmp" echo "--- the two writable paths really are writable ---" touch "${HOME}/.probe" /tmp/.probe || { echo "UNEXPECTED: ${HOME} or /tmp not writable"; exit 1; } echo " confirmed: ${HOME} and /tmp are writable" echo "--- uid is non-zero ---" [ "$(id -u)" -ne 0 ] || { echo "UNEXPECTED: running as root"; exit 1; } 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 "UNEXPECTED: an Anthropic key is baked in"; exit 1; } echo " confirmed: neither is set" echo "--- pm-worker's input validation, with the interpreter and script that run in the cluster ---" PM_WORKER_PATH=/usr/local/bin/pm-worker /bin/bash /opt/verify/probe-validation.sh echo "AGENT-RUNNER VERIFIED" VERIFY