Rebased #61 onto the CI image pipeline (#81). This commit makes the remaining in-git blockers to going live go away: - deployment.yaml and PM_WORKER_IMAGE pin images run 340's digest (sha256:c4eba5b3..., tag sha-523cd9118f95 = main), read from the registry. Renovate markers unchanged. - PM_DRY_RUN=true and PM_REQUIRE_LABEL=agent for the first run. Dry run is required, not just cautious: the run-340 image's baked worker template predates imagePullSecrets, so admission would deny its Jobs until CI re-publishes after this merge and Renovate bumps. - Token scopes: Gitea 1.24.3 scopes GET /api/v1/user (the dispatcher's first call) under read:user. The documented list was missing it. Now read:user, write:repository, write:issue, read:package everywhere. - Org-package read documented as a team (Packages=Read, no repos), not just a scope. - sops is toolbox-only; the .examples say so and give the in-place edit. - runbook: key-name check no longer emits Secret values before the pipe. Co-Authored-By: Claude Opus 5.5 <[email protected]>
261 lines
18 KiB
Markdown
261 lines
18 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 is published by CI, not by a person.** `platform/pm-agent/deployment.yaml` and
|
|
`PM_WORKER_IMAGE` in `configmap.yaml` carry the digest of the **first** publish (images
|
|
run 340, `sha-523cd9118f95`), pinned once on 2026-10-30 so going live did not wait on
|
|
Renovate's first run; every later bump is Renovate's. That digest predates the worker
|
|
template's `imagePullSecrets` entry, so worker Jobs built from it are refused by
|
|
admission — which is why `PM_DRY_RUN` starts `true` (see `configmap.yaml`). `.gitea/workflows/images.yaml` builds `agent-runner` on rootless BuildKit
|
|
(`platform/buildkit/`) — the same Containerfile, and the same `verify` stage, that
|
|
`infra/agent/build-image.sh` builds locally — and on a push to `main` publishes it to
|
|
`gitea.home.keithjackson.dev/home-network/agent-runner` under `sha-<commit>` and `main`.
|
|
Renovate then follows the `main` tag through the `# renovate-digest:` marker above **both**
|
|
lines and moves them together in one pull request. Nobody builds-and-pushes this image from
|
|
a workstation. The one operator prerequisite on the publishing side is the
|
|
`REGISTRY_PUSH_TOKEN` Actions secret (see the header of `.gitea/workflows/images.yaml`);
|
|
without it the publish job fails loudly rather than skipping.
|
|
|
|
**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 `read:user`, `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 publish-then-Renovate
|
|
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 four scopes: `read:package` (the pull), `read:user` (the dispatcher's
|
|
startup `GET /user` — Gitea 1.24.3 scopes the whole `/user` group), `write:repository`
|
|
(clone, branch push, pull request) and `write:issue` (issue comments). The account also
|
|
needs package *permission*, not just the scope: a `pm-agent-pull` org team with Packages
|
|
= Read and no repositories (steps in the registry `.example`). 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 `read:user, write:repository, write:issue, read:package` 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).
|