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]>
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 |
| Operator runbook — stopping it, reading its logs, rotating its tokens | runbooks/pm-agent.md |
| The image, the two entrypoints, the PM persona | infra/agent/ |
| The security boundary | platform/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:
- The image does not exist.
platform/pm-agent/deployment.yamlandPM_WORKER_IMAGEinconfigmap.yamlcarry an all-zero digest placeholder. Build it withinfra/agent/build-image.sh(which verifies it and refuses to push), push it as the operator, then update both places with the digestpodman push --digestfilereturned, in one pull request. Until then:ImagePullBackOff. - The credentials are placeholders.
pm-agent-credentials.sops.yamlis encrypted to the repository's age recipient but holdsREPLACE-ME-…strings. The hand-off — which Gitea account to create, which scopes, and why it must not be an administrator — is inpm-agent-credentials.sops.yaml.example. Until then the dispatcher CrashLoops withGET /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.
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 any host but this Gitea: pm-worker validates PM_GITEA_URL against an allowlist baked into the image |
| 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_TOKENandCLAUDE_CODE_OAUTH_TOKEN. There is no isolation between them and none is claimed. What bounds it: the Gitea token is scopedwrite:repository, write:issueon one repository with no admin permission, and egress reaches only this Gitea and tcp/443. world:443is wider thanapi.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
writecollaborator 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 ofpm-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:
- There is nothing to escalate to.
pm-agent-workeris bound to no Role at all, and the Job template mounts no token. The prize is empty. - The shape is constrained at the API server.
platform/kyverno/admission/pm-agent-job-shape.yamlisEnforce+failurePolicy: Failand admits only: the digest-pinnedagent-runnerimage, exactly one container, the pinnedcommandwith no args, that powerless SA, no mounted token,restartPolicy: Never, non-root with no privilege escalation and a read-only root filesystem, only theworkspace/tmpemptyDir volumes, no init or ephemeral containers, only the committed env names, and noenvFrom.
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 policy is applied by
platform-config/kustomization.yaml, and it has to be: adding a ClusterPolicy only toplatform/kyverno/admission/kustomization.yamlis 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, 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.