An in-cluster consumer reaches a service by its *.svc.cluster.local name, which is by design a different path from how a client outside the cluster reaches the same service. pm-worker's host allowlist accepted TWO values: the cluster-local name and the external hostname gitea.home.keithjackson.dev. The second is removed. It was never needed — nothing sets it — it would route in-cluster traffic out through the Gateway and back for no reason, and as an ACCEPTED value it widened the allowlist to a host reachable over the namespace's world:443 egress rule, which is precisely the injection shape the allowlist exists to refuse. probe-validation.sh now pins that: 20 refusals, including the external hostname, the cluster-local name over https, and the cluster-local name on the wrong port. Also corrects a remark in the handoff entry: I cited cluster-vars' GATEWAY_VIP (192.168.1.53) as suspicious next to the nodes' 192.168.2.x. That was noise — those base cluster-vars values are dev-era leftovers for a decommissioned cluster and say nothing about prod. None of the measured facts depended on it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
207 lines
14 KiB
Markdown
207 lines
14 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) |
|
|
|
|
---
|
|
|
|
## 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 must be able to verify the registry's TLS certificate.** containerd
|
|
currently fails with `x509: certificate is not valid for any names, but wanted to match
|
|
gitea.home.keithjackson.dev`, while the same name from the LAN serves a healthy
|
|
Let's Encrypt `*.home.keithjackson.dev`. Correcting the digest will not clear this.
|
|
- **The pull must be authenticated.** An unauthenticated client gets nothing from this
|
|
Gitea's registry (its anonymous token carries no access scope; every path 401s), and
|
|
there is no `imagePullSecret` anywhere in the tree. Either add one — a
|
|
`dockerconfigjson`, SOPS-encrypted like `pm-agent-credentials`, referenced from the
|
|
Deployment *and* from the committed worker Job template — or allow anonymous reads of
|
|
that package in Gitea, which makes the image readable to anything that can reach the
|
|
host. That is a posture decision, so it is yours rather than an agent's.
|
|
|
|
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).
|