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.
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 namedworkspace 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-overrideposted by the operator — not by an agent.
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)
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]>
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]>
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]>
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]>
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]>
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]>
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
mainand never merges.docs/plans/pm-agent-issue-to-pr.mdplatform/pm-agent/README.mdrunbooks/pm-agent.mdThree choices carry the design
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.
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.
claude --print --tools ""against a JSONschema; its reply is one object that this code validates field by field (action from a
fixed set, branch against CLAUDE.md §4,
reviseagainst the branch that actually carriesthe issue). Nothing in the path executes text.
The security boundary is the admission policy, not the RBAC
Kubernetes places no
usecheck onserviceAccountName, so "may create Jobs" is "may runarbitrary 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 namedworkspacewhose sourceis 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
.examplehand-off. One of them matters more than the rest: the Gitea bot account must bea non-admin collaborator, because
infra/gitea/approval-policy.jsonauthorises/approve-overrideon live permission alone, and a non-admin bot therefore cannot clearits own sensitive-path review.
Gate
./ci/gate.shin the pinned toolbox → exit 3 (UNDETERMINED), withgitea-settingstheone stage that did not run (no
SETTINGS_API_TOKENon the workstation) and every otherstage green,
spec-immutabilityincluded. 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'sapproval-policy stage will require a non-author review or an administrator's
/approve-overrideposted by the operator — not by an agent.🤖 Generated with Claude Code
New commits pushed, approval review dismissed automatically according to repository settings