feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests #57

Merged
jakman85 merged 7 commits from feat/pm-agent-issue-to-pr into main 2026-10-06 04:12:31 +00:00
Owner

An in-cluster AI Program Manager that reads this repository's Gitea issues, decides what
to do about each thread as the PM 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 considered, threat model: docs/plans/pm-agent-issue-to-pr.md
  • Operating surface and what it can/cannot do: platform/pm-agent/README.md
  • Stopping it, reading it, rotating its tokens: runbooks/pm-agent.md

Three choices carry the design

  • It polls; it does not receive webhooks. Gitea's webhook SSRF guard 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 purpose is running model-authored
    code. This namespace takes no ingress at all. Cost: up to 60s of latency.
  • It keeps no state. The trigger is act iff the newest thing in this thread was not
    written by me
    , so the Gitea thread is the state: no PVC, no cursor file, no recovery
    path to get wrong, no R-STATE-03 obligation — and the operator reads the agent's reasoning
    where they are already looking. The Job is created before the comment is posted,
    because the other order can silently abandon an issue holding a promise.
  • The model holds no tools. The PM runs claude --print --tools "" against a JSON
    schema; its reply is one object that this code validates field by field (action from a
    fixed set, branch against CLAUDE.md §4, revise against the branch that actually carries
    the issue). Nothing in the path executes text.

The security boundary is the admission policy, 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". Answered twice: the worker
identity is bound to nothing and mounts no token (there is nothing to escalate to), and
pm-agent-job-shape (Enforce, failurePolicy: Fail) admits only the committed shape —
starting with all six rules the sibling conformance policy needed four revisions to reach.
28/28 offline Kyverno assertions, including the two a careless policy admits: a second
container while containers[0] is compliant, and a volume named workspace whose source
is a hostPath.

Nothing has run

The image has never been built or pushed, and both the image digest and the two
credentials are deliberate placeholders
— so on merge the service CrashLoops visibly
rather than doing anything. Two operator steps bring it up; they are in the README and the
.example hand-off. One of them matters more than the rest: the Gitea bot account must be
a non-admin collaborator
, because infra/gitea/approval-policy.json authorises
/approve-override on live permission alone, and a non-admin bot therefore cannot clear
its own sensitive-path review.

Gate

./ci/gate.sh in the pinned toolbox → exit 3 (UNDETERMINED), with gitea-settings the
one stage that did not run (no SETTINGS_API_TOKEN on the workstation) and every other
stage green, spec-immutability included. rc 3 is not a pass and is not reported as one.
The gate refused two earlier revisions of this change, both times correctly — see the
handoff entry; one of them was a comment warning about the envsubst trap that was itself the
trap.

This branch touches sensitive paths (clusters/**, platform/kyverno/**,
platform-config/kustomization.yaml, **/rbac.yaml, **/*.sops.yaml), so CI's
approval-policy stage will require a non-author review or an administrator's
/approve-override posted by the operator — not by an agent.

🤖 Generated with Claude Code

An in-cluster AI **Program Manager** that reads this repository's Gitea issues, decides what to do about each thread as the PM 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 considered, threat model: `docs/plans/pm-agent-issue-to-pr.md` - Operating surface and what it can/cannot do: `platform/pm-agent/README.md` - Stopping it, reading it, rotating its tokens: `runbooks/pm-agent.md` ## Three choices carry the design - **It polls; it does not receive webhooks.** Gitea's webhook SSRF guard 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 purpose is running model-authored code. This namespace takes **no ingress at all**. Cost: up to 60s of latency. - **It keeps no state.** The trigger is *act iff the newest thing in this thread was not written by me*, so the Gitea thread **is** the state: no PVC, no cursor file, no recovery path to get wrong, no R-STATE-03 obligation — and the operator reads the agent's reasoning where they are already looking. The Job is created **before** the comment is posted, because the other order can silently abandon an issue holding a promise. - **The model holds no tools.** The PM runs `claude --print --tools ""` against a JSON schema; its reply is one object that this code validates field by field (action from a fixed set, branch against CLAUDE.md §4, `revise` against the branch that actually carries the issue). Nothing in the path executes text. ## The security boundary is the admission policy, 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". Answered twice: the worker identity is bound to **nothing** and mounts no token (there is nothing to escalate to), and `pm-agent-job-shape` (`Enforce`, `failurePolicy: Fail`) admits only the committed shape — starting with all six rules the sibling conformance policy needed four revisions to reach. **28/28 offline Kyverno assertions**, including the two a careless policy admits: a second container while `containers[0]` is compliant, and a volume *named* `workspace` whose source is a `hostPath`. ## Nothing has run The image has never been built or pushed, and **both the image digest and the two credentials are deliberate placeholders** — so on merge the service CrashLoops visibly rather than doing anything. Two operator steps bring it up; they are in the README and the `.example` hand-off. One of them matters more than the rest: **the Gitea bot account must be a non-admin collaborator**, because `infra/gitea/approval-policy.json` authorises `/approve-override` on live permission alone, and a non-admin bot therefore *cannot* clear its own sensitive-path review. ## Gate `./ci/gate.sh` in the pinned toolbox → **exit 3 (UNDETERMINED)**, with `gitea-settings` the one stage that did not run (no `SETTINGS_API_TOKEN` on the workstation) and every other stage green, `spec-immutability` included. **rc 3 is not a pass** and is not reported as one. The gate refused two earlier revisions of this change, both times correctly — see the handoff entry; one of them was a comment warning about the envsubst trap that was itself the trap. **This branch touches sensitive paths** (`clusters/**`, `platform/kyverno/**`, `platform-config/kustomization.yaml`, `**/rbac.yaml`, `**/*.sops.yaml`), so CI's approval-policy stage will require a non-author review or an administrator's `/approve-override` **posted by the operator** — not by an agent. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-10-06 03:29:11 +00:00
feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
c5b5b8672a
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]>
gitea-admin added 1 commit 2026-10-06 03:30:47 +00:00
fix(pm-agent): take the newest comment by list order, not by comparing timestamp strings
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
54418363aa
last_activity() is the whole trigger: the PM acts iff the newest thing in a thread was
not written by it. It found that newest comment by comparing Gitea's RFC 3339
created_at STRINGS, which is only correct while every offset is identical — true today,
silently wrong after a server timezone change, and the failure would be the PM answering
its own last comment forever.

The comments endpoint returns them in creation order, so the last element IS the newest.
Using that removes the dependency entirely. Found by self-review, before anything ran.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-10-06 03:34:48 +00:00
test(pm-agent): a committed negative-path probe for the worker's input guards
gate / gate (pull_request) Failing after 1m52s
gate / conformance (pull_request) Has been skipped
8b5e950baa
pm-worker's command is pinned by admission with no args, so everything a caller could
influence arrives in the environment — and the guards at the top of that script are what
stand between an injected variable and a clone from the wrong host or a push to the
wrong ref. Nothing exercised them, which makes them a claim rather than a control.

probe-validation.sh asserts 17 refusals, each on BOTH a non-zero exit and the specific
message, so a guard that stopped firing cannot pass by failing somewhere else. It covers
a redirected git host, a real and reachable WRONG one (the egress policy permits tcp/443,
so that case is not hypothetical), the right host with a smuggled path, 'main' and five
other bad branch shapes, a malformed owner/repo, a bad mode, a bad issue number, and each
missing credential. build-image.sh runs it inside the built image too.

Writing it found three things:
 - the PM_BRANCH != main guard is unreachable while the pattern check above it holds, so
   the comment claiming it as the defence was wrong about which line does the work. Kept
   as an assertion independent of the regex; the comment now says so.
 - the EXIT trap ended in a failing test, which under set -e can replace the script's
   real exit code with that failure.
 - 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.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-10-06 03:38:57 +00:00
docs(pm-agent): record the PR's own CI result, read from the log not the status tick
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
005e80d38a
The PR gate ran and BLOCKED on approval-policy alone — the sensitive-path rule, with no
non-author review — while every technical stage passed, gitea-settings included (the one
stage a workstation cannot run). That is the gate working, and it is the one thing an
agent must not clear for itself.

Also recorded: the commit-status API reported 'pending — Waiting to run' for a run that
had already completed, so the log is the source of truth here. §2b again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jakman85 approved these changes 2026-10-06 03:49:12 +00:00
Dismissed
gitea-admin added 2 commits 2026-10-06 04:00:53 +00:00
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, with main's
vpn-ingress first — it is a platform-config-tier leaf, while pm-agent dependsOn
apps-wave-0, which is later.

Nothing else conflicted. runbooks/kustomization.yaml auto-merged and carries both the
vpn-remote-access and pm-agent entries; the handoff entries are separate files and had
nothing to merge, which is the whole point of decisions/ being a directory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
docs(pm-agent): CLAUDE.md §0 is already satisfied in a worker — tell it not to make a worktree
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
e4a9b02b00
PR #56 added §0 (work in your own worktree, never the shared checkout) while this branch
was open. A worker agent reads CLAUDE.md first, and §0's instruction is to create a git
worktree outside the shared checkout — which does not exist in a worker's container. It
holds a fresh clone of its own, made for one assignment and thrown away with the pod,
which is what §0 is actually asking for.

A worker that obeyed §0 literally would 'git worktree add', edit somewhere the wrapper
that started it does not look, and have its work committed by nobody while the issue got
a 'changed nothing' report. worker-rules.md now says §0 is satisfied and not to do that.

Also records in the handoff entry: the merge and its one conflict, and that this push
dismisses the operator's approving review — measured, dismiss_stale_approvals=true — so
the approval-policy stage needs them to re-approve. That is theirs to do, not an agent's.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin dismissed jakman85's review 2026-10-06 04:00:53 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

gitea-admin added 1 commit 2026-10-06 04:03:30 +00:00
docs(pm-agent): correct the handoff — a STALE approval still satisfies the gate
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
867a82d8ad
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]>
jakman85 merged commit 95597bd688 into main 2026-10-06 04:12:31 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#57