Files
home-network-k3s-impl/infra/agent/Containerfile
T
jakman85andClaude Opus 5 c5b5b8672a
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests
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]>
2026-10-05 22:28:10 -05:00

211 lines
12 KiB
Docker

# =============================================================================
# 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=
FROM ${AGENT_BASE_IMAGE}
# 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"]