I recorded that merging main in would dismiss the operator's approval and that approval-policy would therefore go red until they re-approved. Measured: wrong. dismiss_stale_approvals is true, so the review did go stale — but ci/approval-policy.sh selects on .state == "APPROVED" and a non-author login and reads nothing about staleness, and required_approvals is 0 so Gitea never required one either. CI run #257 on the merged head: "approved by 'jakman85' (author 'gitea-admin') — sensitive change is human-reviewed", approval-policy pass, gitea-settings pass, required check green, PR mergeable. The 'push again and you must override again' rule applies to the /approve-override directive, which IS staleness-checked against the head commit — not to a review. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
260 lines
19 KiB
Markdown
260 lines
19 KiB
Markdown
---
|
|
date: 2026-10-05
|
|
branch: feat/pm-agent-issue-to-pr
|
|
title: An in-cluster AI Program Manager that turns Gitea issues into pull requests — authored, nothing deployed
|
|
---
|
|
|
|
## What changed
|
|
|
|
A new service, `pm-agent`, that runs in the cluster, reads the issues of this Gitea
|
|
repository, 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 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.
|
|
|
|
Design, alternatives and threat model: `docs/plans/pm-agent-issue-to-pr.md`. Operating
|
|
surface: `platform/pm-agent/README.md`. Operator procedures:
|
|
`runbooks/pm-agent.md`.
|
|
|
|
**New — the agent itself** (`infra/agent/`): one image (`agent-runner`) layered on the pinned
|
|
dev toolbox plus node and the Claude Code CLI, with two fixed entrypoints and no argument
|
|
surface. `pm-dispatch` is the PM; `pm-worker` is one assignment. `pm-persona.md` is the PM
|
|
persona, assembled from `CLAUDE.md`, the frozen log's PM guardrails and the spec repo's
|
|
`agents/roles.md`. `worker-job-template.yaml` is the only Job shape the dispatcher may
|
|
create, baked into the image rather than mounted, because that template *is* the shape the
|
|
admission policy protects. `versions.env` pins only what the toolbox lacks — deliberately not
|
|
a second copy of the toolchain pins. `build-image.sh` builds, verifies in-image as uid 65532
|
|
on a read-only rootfs, and **never pushes**.
|
|
|
|
**New — the deployment** (`platform/pm-agent/`): namespace (no `app-namespace` label — this
|
|
is CI plumbing like `gitea-actions`; PodSecurity `restricted` is enforced), `rbac.yaml` (the
|
|
dispatcher may `create/get/list/watch jobs` in its own namespace and nothing else; the worker
|
|
identity is bound to **nothing** and mounts no token), the dispatcher Deployment (1 replica,
|
|
`Recreate` — two dispatchers would double-trigger every thread), `configmap.yaml` (every knob,
|
|
with its reasoning), `networkpolicy.yaml` (default-deny; egress to gitea:3000,
|
|
kube-apiserver for the dispatcher only, tcp/443; **no ingress at all**), and
|
|
`pm-agent-credentials.sops.yaml` holding **placeholders**.
|
|
|
|
**New — the boundary** (`platform/kyverno/admission/pm-agent-job-shape.yaml`, `Enforce` +
|
|
`failurePolicy: Fail`): 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. It is listed in `platform-config/kustomization.yaml`,
|
|
which is the only list that actually applies ClusterPolicies — adding it to
|
|
`platform/kyverno/admission/kustomization.yaml` alone is a silent no-op, which is exactly how
|
|
the conformance boundary shipped green and never reached the cluster on 2026-09-29.
|
|
|
|
**Wiring:** `clusters/base/pm-agent.yaml` (dependsOn `apps-wave-0` — what it needs is Gitea,
|
|
not Keycloak or the Gateway; `wait: false`, because the pod is *expected* to be unhealthy
|
|
while its credentials and image digest are placeholders), its line in
|
|
`clusters/base/kustomization.yaml`, a SOPS decryption patch in **both** overlays, and an
|
|
`inClusterClients` entry for the `pm-agent` namespace on `catalog/gitea.yaml` with
|
|
`apps/wave-0/gitea.yaml` re-rendered by the canonical command.
|
|
|
|
Three design choices worth naming, with the alternatives recorded in the plan: it **polls**
|
|
rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no need for
|
|
inbound reach); it keeps **no state** — the trigger is "the newest thing in this thread was
|
|
not written by me", so the thread is the state and there is no PVC and no backup obligation;
|
|
and the **model holds no tools** — its reply is one JSON object that this code validates, so
|
|
nothing in the path executes text.
|
|
|
|
## What I verified
|
|
|
|
All offline, in the pinned toolbox (`./infra/devcontainer/dev.sh run`), never on the host.
|
|
|
|
- **`kyverno test platform/kyverno/tests/pm-agent` → 28 passed, 0 failed** under the pinned
|
|
CLI (v1.19.0). The first run was **27 passed, 1 failed** and caught a real defect: the
|
|
volume rule asserted `length(emptyDir) != 0`, which denies the valid `emptyDir: {}`. Fixed
|
|
by asserting the volume's **key set** instead, which also denies every other source type
|
|
without enumerating them. The suite includes the two cases a careless policy admits — a
|
|
second container while `containers[0]` is compliant, and a volume *named* `workspace` whose
|
|
source is a `hostPath`.
|
|
- **`./ci/gate.sh` → exit 3, MERGE GATE: UNDETERMINED. Not green, and not reported as
|
|
green.** Per-stage, quoted from the run: spec-immutability **pass** (the spec repo is a
|
|
sibling of this checkout and is clean at the pinned SHA), decisions-log **pass**, lint
|
|
**pass**, l0 **pass**, render-golden **pass** (10 services reproduce), l1 **pass**,
|
|
approval-policy **pass** (N/A off a pull-request event), conformance **declared-skip** (no
|
|
cluster — Phase 0), and `gitea-settings` **SKIPPED**: `SETTINGS_API_TOKEN` is not set on
|
|
this workstation, so branch protection and runner registration are UNVERIFIED by this run.
|
|
The gate names that single stage as the reason for rc 3 ("Stages that did not run:
|
|
gitea-settings"), so nothing in the change is unexamined — but rc 3 is not a pass and this
|
|
entry does not call it one.
|
|
- **The PR's OWN CI run, read from its log rather than from the status tick** (PR #57, head
|
|
`8b5e950b`, `.gitea/workflows/gate.yml` with `GATE_STRICT_SKIPS=true` and
|
|
`REQUIRE_GITEA_SETTINGS=true`): decisions-log **pass**, lint **pass**, l0 **pass**,
|
|
render-golden **pass**, l1 **pass**, **gitea-settings pass** — including
|
|
`block_admin_merge_override = true`, which is the one stage my workstation cannot run at
|
|
all — spec-immutability **SKIPPED** (the spec repo is absent in CI, as its own step
|
|
tolerates), conformance **SKIPPED**, and **approval-policy FAIL → MERGE GATE: BLOCKED**.
|
|
The failure is *only* the sensitive-path rule: "This PR changes the pipeline or the
|
|
security posture it rests on, and has NO approving review from anyone other than its author
|
|
('gitea-admin')", over eleven paths — `clusters/{base,dev,prod}/*`,
|
|
`platform-config/kustomization.yaml`, `platform/kyverno/{admission,tests}/pm-agent*`,
|
|
`platform/pm-agent/rbac.yaml` and `platform/pm-agent/pm-agent-credentials.sops.yaml`.
|
|
**That is the gate working, not a defect in the change**, and it is the one thing an agent
|
|
must not clear for itself: the operator posts `/approve-override <reason>` as a PR *review*,
|
|
or a non-author reviews it. CI is therefore red and will stay red until a human acts.
|
|
- **The commit-status API did not reflect any of that.** While the run completed and its log
|
|
carried the verdict above, `GET /commits/{sha}/status` still reported
|
|
`gate / gate (pull_request): pending — Waiting to run`. Worth knowing before anyone builds
|
|
automation on that endpoint: on this Gitea the status tick lagged the run indefinitely, so
|
|
the log is the source of truth. A fresh instance of §2b — "a green tick is not proof
|
|
anything ran"; here the tick was not even wrong, it was absent.
|
|
- **The gate caught two real defects in this change before I did**, which is worth recording
|
|
because both were in the parts I was most confident about:
|
|
1. `flux-postbuild-substitution` → 1 UNDEFINED + 1 FATAL-PARSE. My ConfigMap's *comment*
|
|
explaining the envsubst hazard contained two dollar-brace tokens, and envsubst runs over
|
|
comment lines too — so the paragraph warning about the trap was itself the trap. Rewritten
|
|
without braces.
|
|
2. `no-plaintext-secrets` → the unencrypted `.example` template was read as REFERENCED by a
|
|
kustomization, because the kustomization's comment named it by filename. Correctly
|
|
refused: the distance between a filename in a comment and a line under `resources:` is
|
|
one careless edit. The comment now points at it in prose.
|
|
- **`infra/agent/probe-validation.sh` → 17 refusals, 0 failures.** A new, committed
|
|
negative-path probe for `pm-worker`'s input guards — the things that stand between an
|
|
injected environment variable and a clone from the wrong host or a push to the wrong ref.
|
|
Each case asserts a non-zero exit AND the specific message, so a guard that stopped firing
|
|
cannot pass by failing somewhere else. It refuses: an argument on the pinned command;
|
|
`main`, a wrong prefix, `feat/x;id`, `../../etc/passwd`, too-short slugs, and a branch equal
|
|
to the base; a redirected git host, a real-and-reachable wrong one (`github.com` — the
|
|
egress policy permits tcp/443, so this one is genuinely reachable), and the right host with
|
|
a smuggled path; a malformed owner or repository; an unknown mode; a non-numeric and a zero
|
|
issue number; and each missing credential. `build-image.sh` also runs it inside the built
|
|
image, where the interpreter is the one that will run in the cluster.
|
|
- **Writing that probe corrected an overclaim in the code.** The `PM_BRANCH != "main"` guard
|
|
is UNREACHABLE while the pattern check above it holds (`main` has no `feat|fix|docs|chore/`
|
|
prefix), so the comment claiming it as the defence against pushing to main was wrong about
|
|
which line does the work. It is kept — as an assertion that does not depend on the regex
|
|
being right — and the comment now says so.
|
|
- **Two more defects found by self-review of the unattended paths, both fixed:** the worker's
|
|
`EXIT` trap ended in a failing test, which under `set -e` can replace the script's real exit
|
|
code with that failure; and a provenance *log line* read a file through a redirect that
|
|
`set -o pipefail` would have turned into a dead worker if the file were absent.
|
|
- **A self-review of the trigger found and fixed one real defect** before any of this ran:
|
|
`last_activity()` compared RFC 3339 timestamp STRINGS to find the newest comment, which is
|
|
only correct while every offset Gitea returns is identical — and the failure mode is the
|
|
worst available one, the PM answering its own last comment in a loop. It now takes the last
|
|
element of the comments list, which is the order the endpoint returns, so there is nothing
|
|
about timezones to be right about.
|
|
- **`check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/gitea.yaml`
|
|
→ ok.**
|
|
- **The golden reproduces:** `python3 catalog/render.py --all catalog -o apps --wave-layout
|
|
--no-image-automation` leaves `apps/wave-0/gitea.yaml` byte-identical to what is committed,
|
|
and the only diff versus `main` is the new `gitea-allow-pm-agent-ingress`
|
|
CiliumNetworkPolicy.
|
|
- **Syntax:** `bash -n infra/agent/pm-worker` and `ast.parse` on `pm-dispatch.py` both clean.
|
|
`yaml.safe_load_all` parses every new manifest.
|
|
- **The spec repo has no git remote** — `git remote -v` in `../home-network-k3s` prints
|
|
nothing. That is *why* a worker's gate can never run `spec-immutability` in-cluster, and it
|
|
is measured rather than assumed. The worker's acceptance rule names that stage and
|
|
`gitea-settings` explicitly and refuses any other skip.
|
|
- **`PM_SELFTEST=1 python3 infra/agent/pm-dispatch.py` → exit 0, 26 assertions, all ok**
|
|
(run in the toolbox against the repository's own files, with `PM_WORKER_JOB_TEMPLATE` and
|
|
`PM_PERSONA_FILE` pointed at `infra/agent/`). It covers the branch vocabulary in both
|
|
directions (`feature/x`, `main`, `feat/AB`, `feat/x;rm -rf /` and `../../etc/passwd` all
|
|
refused), the five result-envelope shapes `extract_decision` has to survive plus three it
|
|
must reject, the committed Job template against its whole contract (pinned command, no
|
|
args, the powerless SA, no mounted token, the volume set, the security context, and every
|
|
env name inside the dispatcher's allowlist), and that the persona still says the four
|
|
things it must. `build-image.sh` runs the same selftest inside the image; that invocation
|
|
is the one that has not happened, because the image has not been built.
|
|
- **Claude Code facts were read from the current documentation, not from memory:**
|
|
`claude setup-token` mints a one-year subscription OAuth token consumed via
|
|
`CLAUDE_CODE_OAUTH_TOKEN`; `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` rank **above** it in
|
|
credential precedence (hence the unsets and the env-name allowlist); `--bare` does **not**
|
|
read it (hence neither entrypoint uses that flag); `--permission-prompts none` requires
|
|
>= 2.1.259, which is why the image asserts a version floor. The pin `2.1.285` is the npm
|
|
registry's `stable` dist-tag, measured on 2026-10-05.
|
|
|
|
## Merged `main` in, after PR #55 and #56 landed
|
|
|
|
One conflict, in `clusters/base/kustomization.yaml`: PR #55 (vpn-ingress) and this branch
|
|
both appended a resource line to the same tail. Resolved by keeping both, main's first —
|
|
`vpn-ingress` is a platform-config-tier leaf and `pm-agent` dependsOn `apps-wave-0`, which
|
|
is later. Nothing else conflicted; `runbooks/kustomization.yaml` auto-merged with both
|
|
entries, and the handoff entries are separate files with nothing to merge, which is exactly
|
|
what `decisions/` being a directory buys.
|
|
|
|
**PR #56 added `CLAUDE.md` §0 — work in your own worktree, never the shared checkout —
|
|
while this branch was open, and it named this run's uncommitted directories as its
|
|
measured example.** Two consequences were handled rather than noted:
|
|
|
|
- This merge, and everything after it, was done in a worktree outside the repository root
|
|
(`/var/home/keith/source/wt-pm-agent`), per §0.
|
|
- **§0 would have misled a worker agent, so `infra/agent/worker-rules.md` now says so
|
|
explicitly.** A worker reads `CLAUDE.md` first, and §0's instruction is to create a
|
|
worktree outside the shared checkout. There is no shared checkout in a worker's container
|
|
— it holds a fresh clone of its own, which is what §0 is actually asking for — and a
|
|
worker that obeyed §0 literally would `git worktree add`, edit somewhere the wrapper does
|
|
not look, and have its work committed by nobody while the issue got a "changed nothing"
|
|
report. The rules file now tells it §0 is already satisfied and not to make a worktree.
|
|
|
|
**The operator's approving review goes STALE on each new head, and the gate does not care.**
|
|
I first wrote here that it would need re-approving. That was wrong, and the correction is
|
|
worth keeping because it is a fact about this repository's gate rather than about this
|
|
change:
|
|
|
|
- Measured on the live branch protection: `dismiss_stale_approvals = true`,
|
|
`ignore_stale_approvals = false` — so pushing the merge did stale `jakman85`'s review
|
|
(confirmed: `GET /pulls/57/reviews` → `APPROVED`, `stale: true`).
|
|
- But `ci/approval-policy.sh` selects reviews on `.state == "APPROVED"` and
|
|
`.user.login != author` and reads **nothing about staleness** (line 147). So a stale
|
|
approval still satisfies the sensitive-path rule.
|
|
- And `required_approvals = 0`, so Gitea's own merge button never required one either.
|
|
|
|
Measured, not inferred: CI run **#257** on the merged head printed
|
|
`approved by 'jakman85' (author 'gitea-admin') — sensitive change is human-reviewed.` →
|
|
`stage approval-policy: pass`, with `gitea-settings: pass` as well. The job's own exit was
|
|
`MERGE GATE: UNDETERMINED` (rc 3 — `spec-immutability` and `conformance` skipped, as the
|
|
workflow tolerates with `|| [ $? -eq 3 ]`), so the required check
|
|
`gate / gate (pull_request)` is **green** and PR #57 reports `mergeable: true`.
|
|
|
|
Nothing is therefore waiting on the operator except **the merge itself**, which is theirs
|
|
by CLAUDE.md §1 and never an agent's. Worth noting for whoever reads this later: the rule
|
|
"push again and you must override again" applies to the `/approve-override` **directive**,
|
|
which is staleness-checked against the head commit — not to an ordinary approving review,
|
|
which this gate accepts stale.
|
|
|
|
## What I did NOT verify
|
|
|
|
**Nothing in this change has run.** That is the headline, and everything below is a
|
|
consequence of it.
|
|
|
|
- **The image has never been built.** The nodejs module-stream install, the `npm i -g
|
|
@anthropic-ai/claude-code@2.1.285` layer, the version-floor assertion, the `COPY`
|
|
ownership assertions and `build-image.sh`'s whole in-image verification (including
|
|
`PM_SELFTEST=1 pm-dispatch`) are **unexercised**. Expect the build to need iteration.
|
|
- **It has never been pushed,** so the committed image digests are the deliberate all-zero
|
|
placeholder. Nothing has pulled this image.
|
|
- **No pod has started.** The dispatcher has never authenticated to Gitea, never polled,
|
|
never triaged an issue, never created a Job. No worker has cloned, branched, run a session,
|
|
run the gate in-cluster, pushed a branch, opened a pull request or commented on an issue.
|
|
Every claim about runtime behaviour is a design claim.
|
|
- **The admission policy has not met a live API server.** 28 offline assertions prove the
|
|
policy *refuses*; nothing proves the API server *enforces* it here. That is an L2 test and
|
|
it needs a cluster. Note also that `CLAUDE.md` §2b applies to me as much as to anyone: I
|
|
did not query whether Kyverno is healthy, whether `pm-agent-job-shape` reconciles, or
|
|
whether `platform-config` is Ready.
|
|
- **The network policies have not been exercised.** Whether `toEntities: kube-apiserver`
|
|
actually permits `kubectl` from this namespace on k3s, and whether `world:443` reaches
|
|
`api.anthropic.com` through this cluster's egress, are both unmeasured.
|
|
- **Neither credential exists.** The Gitea bot account has not been created, so its
|
|
permission has not been measured — and *that measurement is the control* that keeps the
|
|
agent from clearing its own sensitive-path review. Do it before the first real run.
|
|
- **Gitea's webhook `ALLOWED_HOST_LIST` was not queried.** It is cited as a reason webhooks
|
|
were rejected; it is not a measured fact about this Gitea.
|
|
- **The `write`-can-merge gap is real and unclosed.** No Gitea token scope separates "push a
|
|
branch and open a pull request" from "merge it". The service issues no merge call, which is
|
|
a property of the code, not of the credential.
|
|
- **The worker's credentials are inside the model session's reach.** Same container, same
|
|
uid; no isolation exists and none is claimed. Bounded by token scope and egress only.
|
|
- **This branch touches sensitive paths** — `clusters/**`, `platform/kyverno/**`,
|
|
`platform-config/kustomization.yaml`, `**/rbac.yaml`, `**/*.sops.yaml` — so it needs an
|
|
approving review from a non-author, or an administrator's `/approve-override` **posted by
|
|
the operator**. An agent must not clear this one on its own PR; that is precisely the
|
|
self-approval the stage exists to prevent.
|
|
- **No observability.** No metrics endpoint, no ServiceMonitor, no alert, and the namespace's
|
|
default-deny would drop a scrape from `monitoring` if one were added. Follow-ups are listed
|
|
in the plan, in the order they are worth doing.
|