Node configuration in this project is Ansible's — declared in the inventory, reviewed,
version-controlled — and for prod it is the ONLY surface: infra/PROD-PROVISIONING.md
records prod as 3x bare metal with the dev OpenTofu -> Proxmox -> cloud-init path
explicitly not applying, so there is no tofu/cloud-init half to keep in parity for an
/etc/hosts pin.
The pin was already in the right place; what the docs lacked was the command. All four
places now carry the scoped, idempotent one:
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
-e k3s_hosts=k3s_prod --tags hosts
--tags hosts runs ONLY the /etc/hosts task, so applying this pin cannot drift any other
baseline setting.
Also verified the mechanism is not inert, which is the failure mode this repo keeps
meeting: roles/node_baseline/tasks/main.yml renders node_baseline_hosts_pins into a marked
blockinfile with backup: true, and state: absent when the list empties — so a removed
entry really does leave the node.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
235 lines
16 KiB
Markdown
235 lines
16 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 is still unauthenticated** once the node reaches the right endpoint. An
|
|
unauthenticated client gets nothing from this Gitea's registry, and there is no
|
|
`imagePullSecret` anywhere in the tree. Consistent with the agent credential decision
|
|
(local Gitea accounts with PATs), the fix is a `dockerconfigjson` for a local Gitea
|
|
account whose token carries `read:package`, referenced from the Deployment and the
|
|
committed worker Job template — not built yet, because minting that token is yours.
|
|
|
|
The same two apply to `conformance-runner`: same registry path, same push-then-pin model,
|
|
never pulled either.
|
|
2. **The credentials are placeholders.** `pm-agent-credentials.sops.yaml` is encrypted to the
|
|
repository's age recipient but holds `REPLACE-ME-…` strings. The hand-off —
|
|
which Gitea account to create, which scopes, and why it must *not* be an administrator —
|
|
is in [`pm-agent-credentials.sops.yaml.example`](./pm-agent-credentials.sops.yaml.example).
|
|
Until then 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 |
|
|
|
|
### 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**, and no `envFrom`.
|
|
|
|
Six 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: **28 assertions,
|
|
28 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).
|