The registry had two blockers. #59 fixed the first (nodes resolved the hostname to the non-hairpinned WAN and failed TLS against the router's CN=%D cert). This is the second: the pull is unauthenticated, and this registry refuses unauthenticated clients outright — measured 2026-10-05, its anonymous token carries access: null and every path 401s. pm-agent-registry is a dockerconfigjson Secret (SOPS, placeholders) referenced by imagePullSecrets on the dispatcher Deployment and on the committed worker Job template. It holds the SAME Gitea token as pm-agent-credentials, because the two consumers need two formats: the application reads a raw token from env, the kubelet reads a dockerconfigjson before any container starts. Same one-value-in-two-files pattern as the Keycloak client secret, documented in both .example files. The operator's part is the value plus one new scope: read:package. ADMISSION GAINED A SEVENTH RULE. imagePullSecrets is a pod-spec field no container references, which is exactly why it is easy to leave unread — the same shape of omission as initContainers, ephemeralContainers, env and envFrom before it. The kubelet reads the named Secret, in this namespace, before anything runs. Rule 1's pattern now REQUIRES the committed entry; rule 7 forbids any other and any second one. Both halves are needed: a foreach over an absent list yields no result. The leverage is small and the policy says so: the image is digest-pinned, so a different pull credential cannot change WHAT runs. Pinned anyway — 'only the committed shape' is the premise, and the rule costs four lines. kyverno test: 33 passed, 0 failed (was 28). Three new fixtures, each a real path: the committed entry plus a second one, a different Secret, and none at all. The gate also caught me shipping the Secret in plaintext — I never ran sops -e -i on the placeholder. Recorded in the handoff entry, because the file looked finished. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
249 lines
17 KiB
Markdown
249 lines
17 KiB
Markdown
# pm-agent — an AI Program Manager that turns Gitea issues into pull requests
|
|
|
|
A service that runs in the cluster, reads the issues of
|
|
`Home-Network/home-network-k3s-impl`, decides what to do about each thread **as the Program
|
|
Manager persona this repository already works under**, and runs one worker agent per
|
|
assignment that authors the change, runs the merge gate and opens a pull request.
|
|
|
|
It reacts to issue creation and to comments on issues. Every assignment and every piece of
|
|
correspondence travels through the issue thread and the pull request, so the whole record of
|
|
what the agent did, and why, is in the same place a human is already looking.
|
|
|
|
It **never pushes to `main`** and **never merges a pull request**. The merge gate remains the
|
|
approver, and a sensitive-path change still needs the human review `ci/approval-policy.sh`
|
|
requires.
|
|
|
|
| | |
|
|
|---|---|
|
|
| Design, alternatives considered, threat model | [`docs/plans/pm-agent-issue-to-pr.md`](../../docs/plans/pm-agent-issue-to-pr.md) |
|
|
| Operator runbook — stopping it, reading its logs, rotating its tokens | [`runbooks/pm-agent.md`](../../runbooks/pm-agent.md) |
|
|
| The image, the two entrypoints, the PM persona | [`infra/agent/`](../../infra/agent/) |
|
|
| The security boundary | [`platform/kyverno/admission/pm-agent-job-shape.yaml`](../kyverno/admission/pm-agent-job-shape.yaml) |
|
|
|
|
---
|
|
|
|
## Why the agent authenticates to Gitea with a local account and a PAT
|
|
|
|
Not Keycloak, and that is a decision rather than an omission (operator, 2026-10-06): the
|
|
agents only ever talk to Gitea, so a local Gitea account with a scoped personal access token
|
|
is the whole requirement, and it is drastically simpler than federating a machine identity.
|
|
|
|
It is also what Gitea can actually do. Keycloak service accounts were considered and are not
|
|
available for this: Gitea's API accepts basic auth, PATs, SSH signatures and **its own**
|
|
OAuth2 bearer tokens, and as an OAuth2 provider Gitea implements **only the Authorization
|
|
Code Grant** — there is no client-credentials grant, and nothing in Gitea validates a token
|
|
issued by a third-party OIDC provider. So a Keycloak service-account token cannot
|
|
authenticate to Gitea's API or to git-over-HTTP at all. Keycloak remains what it is for human
|
|
access: the browser login source registered on Gitea (`catalog/gitea.yaml`).
|
|
|
|
What still matters, and is enforced by the hand-off rather than by Keycloak: **the account
|
|
must not be an administrator** — `infra/gitea/approval-policy.json` authorises
|
|
`/approve-override` on live repository permission alone, so an admin-permission bot could
|
|
waive the sensitive-path human review on its own pull request.
|
|
|
|
## It does nothing at all until an operator does two things
|
|
|
|
Both are deliberate, and both fail **visibly** rather than silently:
|
|
|
|
1. **The image does not exist.** `platform/pm-agent/deployment.yaml` and
|
|
`PM_WORKER_IMAGE` in `configmap.yaml` carry an all-zero digest placeholder. Build it with
|
|
`infra/agent/build-image.sh` (which verifies it and refuses to push), push it as the
|
|
operator, then update **both** places with the digest `podman push --digestfile` returned,
|
|
in one pull request. Until then: `ImagePullBackOff`.
|
|
|
|
**Two things have to be true before any digest will pull, and neither is declared in this
|
|
repository.** Both were measured after PR #57 merged — this service is the first workload
|
|
in the cluster ever to reference the in-cluster registry, so nothing had exercised the
|
|
path. Details and the exact commands:
|
|
[`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`](../../decisions/2026-10-05-pm-agent-registry-pull-prereq.md).
|
|
|
|
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
|
|
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
|
|
*node* process, so it resolves through the node's public upstreams
|
|
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
|
|
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
|
|
IP — which this router does not hairpin; it answers :443 with its own placeholder
|
|
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
|
|
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
|
|
and the digest were never implicated. The fix is the pin this repository already uses
|
|
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
|
|
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
|
|
inventory, never typed on a node. Apply it scoped to that one task:
|
|
`cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod
|
|
--tags hosts`.
|
|
- **The pull must be authenticated** once the node reaches the right endpoint: an
|
|
unauthenticated client gets nothing from this registry. **Now wired** —
|
|
`pm-agent-registry` is a `dockerconfigjson` Secret referenced by both the Deployment and
|
|
the committed worker Job template, and the admission policy pins it to exactly that
|
|
name. What is left to you is the value: the Gitea token must carry **`read:package`** in
|
|
addition to `write:repository` and `write:issue`, and the dockerconfigjson is built from
|
|
it. Steps:
|
|
[`pm-agent-registry.sops.yaml.example`](./pm-agent-registry.sops.yaml.example).
|
|
|
|
The same two apply to `conformance-runner`: same registry path, same push-then-pin model,
|
|
never pulled either.
|
|
2. **The credentials are placeholders — two Secrets, one token.** Both are encrypted to the
|
|
repository's age recipient and both hold `REPLACE-ME-…` strings:
|
|
|
|
| Secret | type | read by | hand-off |
|
|
|---|---|---|---|
|
|
| `pm-agent-credentials` | Opaque | the application, as env | [`.example`](./pm-agent-credentials.sops.yaml.example) |
|
|
| `pm-agent-registry` | dockerconfigjson | the **kubelet**, before any container starts | [`.example`](./pm-agent-registry.sops.yaml.example) |
|
|
|
|
They hold the **same** Gitea token in two formats, because a Secret cannot be both — the
|
|
same one-value-in-two-files pattern the Keycloak client secret already uses here. So mint
|
|
it once, with all three scopes: `read:package` (the pull), `write:repository` (clone,
|
|
branch push, pull request) and `write:issue` (issue comments). The first `.example`
|
|
explains which account to create and why it must *not* be an administrator.
|
|
|
|
Until they are filled in: the pull fails `401`, and if you fix only the pull, the
|
|
dispatcher CrashLoops with `GET /user returned no login — is GITEA_TOKEN valid?`.
|
|
|
|
Its Flux Kustomization is `wait: false` precisely because of those two states: a workload
|
|
that is *expected* to be unhealthy for a while must never be able to gate anything.
|
|
|
|
---
|
|
|
|
## How it works
|
|
|
|
```
|
|
┌──────────────────────── pm-agent namespace ─────────────────────────┐
|
|
│ │
|
|
Gitea issue ───▶│ pm-dispatch (1 replica, Recreate) │
|
|
(poll, 60s) │ 1. list open issues │
|
|
│ 2. act iff the NEWEST thing in the thread is not mine │
|
|
│ 3. ask the model, under the PM persona, with NO tools │
|
|
│ → {action, branch, brief, comment} │
|
|
│ 4. validate it, then: create the Job, then post the comment │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ pm-worker (one Job per assignment, no SA token, read-only rootfs) │
|
|
│ clone → branch → read the live thread → Claude Code session → │
|
|
│ ./ci/gate.sh → commit → push → open the PR → comment on the issue│
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
egress: gitea:3000 · kube-apiserver (dispatcher only) · tcp/443 · DNS. ingress: none.
|
|
```
|
|
|
|
**There is no database and no PVC.** The trigger is a question asked of the issue thread
|
|
itself — *act iff the newest thing in this thread was not written by me* — and since the PM
|
|
always answers in the thread, acting makes the thread quiet again until a human (or a worker)
|
|
says something else. The Gitea thread **is** the state. It survives restarts with no recovery
|
|
path to get wrong, it is readable by the operator in the place they already look, and it
|
|
creates no R-STATE-03 obligation for irreplaceable state.
|
|
|
|
**It polls; it does not receive webhooks.** Gitea's webhook SSRF guard
|
|
(`[webhook] ALLOWED_HOST_LIST`) does not by default permit calling a private in-cluster
|
|
address, a missed delivery is simply lost, and a receiver would need inbound reach into a
|
|
namespace whose entire purpose is running model-authored code. The cost is up to 60s of
|
|
latency. The full argument is in the header of
|
|
[`infra/agent/pm-dispatch.py`](../../infra/agent/pm-dispatch.py).
|
|
|
|
**The PM holds no tools.** It runs `claude --print --tools ""` against a JSON schema: text
|
|
in, one JSON object out, no Bash, no file access, no network of its own. The dispatcher then
|
|
validates every field — the action against a fixed set, the branch against `CLAUDE.md` §4's
|
|
pattern, `revise` against the branch that actually carries the issue's work — and builds the
|
|
Job from a committed template. **The model's output is data, never a command.**
|
|
|
|
---
|
|
|
|
## What the worker can and cannot do
|
|
|
|
| | |
|
|
|---|---|
|
|
| **can** | change any file in its own clone; run any command in the container; run the gate; read the issue thread and the pull requests |
|
|
| **can** | push **one** branch (the one the PM named, validated twice against `^(feat\|fix\|docs\|chore)/…`) and open/update its pull request |
|
|
| **can** | post comments on its own issue |
|
|
| **cannot** | push to `main` — refused by `pm-worker` before anything runs, and by branch protection regardless |
|
|
| **cannot** | merge anything — the gate is the approver, and the service makes no merge call |
|
|
| **cannot** | reach the Kubernetes API: no ServiceAccount token is mounted, the `pm-agent-worker` identity is bound to nothing, and the egress policy does not include the API server. `kubectl` is on its `PATH` (the gate's toolchain needs it) and is inert |
|
|
| **cannot** | reach anything except this Gitea and tcp/443 — no package registry, no GitHub |
|
|
| **cannot** | clone from or push to anywhere but `gitea.gitea.svc.cluster.local:3000`: `pm-worker` validates `PM_GITEA_URL` against a one-entry allowlist baked into the image. In-cluster consumers reach a service by its cluster-local name — a different path by design from how a client outside the cluster reaches it — so the external hostname is not an accepted value |
|
|
| **cannot** | authenticate a `git push` **during the model session**: `GIT_ASKPASS` is unset for its duration, so the restriction is structural rather than a request in a prompt |
|
|
| **cannot** | be created in any shape but the committed one — see the admission policy |
|
|
| **cannot** | have the kubelet read any Secret but `pm-agent-registry` on its behalf: `imagePullSecrets` is pinned to exactly that one name |
|
|
|
|
### What is *not* defended, stated plainly
|
|
|
|
- **The worker's own credentials are inside the model's reach.** The session runs as the same
|
|
uid in the same container as the wrapper, so it can read the environment, including its
|
|
`GITEA_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`. There is no isolation between them and none is
|
|
claimed. What bounds it: the Gitea token is scoped `write:repository, write:issue` on **one
|
|
repository** with no admin permission, and egress reaches only this Gitea and tcp/443.
|
|
- **`world:443` is wider than `api.anthropic.com`.** Cilium can express the tighter rule
|
|
(`toFQDNs`) but nothing in this repository uses FQDN policy and its prerequisite (Cilium's
|
|
DNS proxy) has not been measured here. Narrowing it is a tracked follow-up with that
|
|
measurement as its first step, not a one-line edit.
|
|
- **A `write` collaborator in Gitea can merge a pull request.** No token scope separates
|
|
"push a branch and open a PR" from "merge it". The service issues no merge call — that is
|
|
all of `pm-worker`'s API calls, readable in one file — but it is a property of the code, not
|
|
of the credential. What the non-admin permission *does* buy structurally is that the bot
|
|
**cannot** post `/approve-override`, so a sensitive-path pull request from the agent
|
|
correctly sits unmergeable until a human reviews it.
|
|
- **The PM cannot see the repository.** It triages from the thread alone, so its brief is a
|
|
belief about where the work lives, not a verified fact — which is why the persona requires
|
|
it to say so, and why the worker is told to investigate before changing anything.
|
|
- **Nothing here has run.** Everything in this directory is authored and gate-checked
|
|
offline. No part of this service has been built, pushed, deployed or observed; see the
|
|
handoff entry's "What I did NOT verify".
|
|
|
|
---
|
|
|
|
## The escalation this service is built around
|
|
|
|
Kubernetes places **no `use` check** on a Pod's `serviceAccountName`. So "may create Jobs in
|
|
namespace X" is equivalent to "may run arbitrary code as the most privileged identity in
|
|
namespace X" — no matter how narrow the creator's own Role looks. The dispatcher creates
|
|
Jobs. That is the whole problem, and this repository has met it before
|
|
(`conformance/incluster/rbac.yaml`).
|
|
|
|
Two things answer it, and neither is sufficient alone:
|
|
|
|
1. **There is nothing to escalate to.** `pm-agent-worker` is bound to no Role at all, and the
|
|
Job template mounts no token. The prize is empty.
|
|
2. **The shape is constrained at the API server.**
|
|
`platform/kyverno/admission/pm-agent-job-shape.yaml` is `Enforce` + `failurePolicy: Fail`
|
|
and admits only: the digest-pinned `agent-runner` image, exactly one container, the pinned
|
|
`command` with no args, that powerless SA, no mounted token, `restartPolicy: Never`,
|
|
non-root with no privilege escalation and a read-only root filesystem, only the
|
|
`workspace`/`tmp` **emptyDir** volumes, no init or ephemeral containers, only the
|
|
committed env **names**, no `envFrom`, and exactly one `imagePullSecret` — the committed
|
|
one.
|
|
|
|
Seven rules, because pinning `command` only moves the injection to whatever adjacent field
|
|
nobody enumerated — on the sibling policy that was `initContainers` (measured: a Job with a
|
|
compliant `containers[0]` and a privileged init container was **admitted**, pass 3 fail 0),
|
|
then `ephemeralContainers`, then `env`, then `envFrom`. All of them are closed here from the
|
|
start, and `platform/kyverno/tests/pm-agent/` proves each refusal offline: **33 assertions,
|
|
33 passing** under the pinned `kyverno` CLI. The worker's own input guards have their own
|
|
offline probe — `infra/agent/probe-validation.sh`, **17 refusals** — because a guard nothing
|
|
exercises is a claim rather than a control.
|
|
|
|
> The policy is applied by `platform-config/kustomization.yaml`, and it has to be: adding a
|
|
> ClusterPolicy only to `platform/kyverno/admission/kustomization.yaml` is a **silent
|
|
> no-op**. That is exactly how the conformance boundary shipped green and never reached the
|
|
> cluster on 2026-09-29.
|
|
|
|
---
|
|
|
|
## Operating it
|
|
|
|
Every knob is in [`configmap.yaml`](./configmap.yaml), with the reasoning next to it. The
|
|
five that matter:
|
|
|
|
| knob | default | what it is for |
|
|
|---|---|---|
|
|
| `PM_REQUIRE_LABEL` | `""` (every open issue) | set it to `agent` and only issues a human has labelled are in scope. **Recommended for the first weeks** — it bounds the agent to work somebody deliberately handed it, with no code change |
|
|
| `PM_DRY_RUN` | `false` | still triages and still posts what it would have done; creates no worker. The honest way to watch it for a day |
|
|
| `PM_MAX_ACTIVE_WORKERS` | `2` | concurrent workers. RAM is the binding constraint (2Gi limit each) and the subscription seat's rate limits are shared with every other use of that seat |
|
|
| `PM_MAX_NEW_WORKERS_PER_CYCLE` | `1` | drains a backlog one issue at a time instead of in one stampede the first minute the credentials work |
|
|
| `PM_PUSH_ON_RED_GATE` | `false` | a change the gate refused is not pushed; the issue gets the gate log instead |
|
|
|
|
Changing a knob requires bumping the `config-generation` annotation in `deployment.yaml` in
|
|
the same commit — the process reads its configuration once, at startup, so without the bump
|
|
the ConfigMap changes and nothing does. (Same class of defect as the inert merged
|
|
`cilium-config`: `decisions/2026-10-05-cilium-roll-out-on-config-change.md`.)
|
|
|
|
**To stop it**, in increasing severity: `PM_DRY_RUN`, scale to zero, suspend the
|
|
Kustomization, or revoke the Gitea token. Step-by-step in
|
|
[`runbooks/pm-agent.md`](../../runbooks/pm-agent.md).
|