Files
home-network-k3s-impl/platform/pm-agent/pm-agent-credentials.sops.yaml.example
T
jakman85andClaude Opus 5 c5b5b8672a
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests
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]>
2026-10-05 22:28:10 -05:00

118 lines
7.0 KiB
Plaintext

# =============================================================================
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (`.yaml.example` is ignored by kustomize and is
# never auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
#
# WHAT THIS IS: the two credentials the PM agent service holds, and the only two. The
# dispatcher and every worker read both by name (R-IDP-04); nothing else in this repository
# references them.
#
# THE COMMITTED `pm-agent-credentials.sops.yaml` HOLDS PLACEHOLDERS. It is encrypted to the
# repository's age recipient so the kustomization builds and Flux reconciles, but its values
# are the obviously-impossible strings below. Until the operator replaces them the service
# is INERT and says so: the dispatcher authenticates to Gitea at startup, fails, and
# CrashLoops with `GET /user returned no login — is GITEA_TOKEN valid?` in its log. That is
# deliberate — a service that quietly did nothing would be worse than one that visibly
# cannot start.
#
# =============================================================================
# HAND-OFF STEPS (operator)
# =============================================================================
#
# (1) CREATE A DEDICATED GITEA ACCOUNT FOR THE AGENT. Do NOT reuse `jakman85`, and do NOT
# reuse the existing `gitea-admin` agent account. Both hold `owner` permission on this
# repository, and `infra/gitea/approval-policy.json` authorises `/approve-override` by
# LIVE PERMISSION ALONE — so a token belonging to either would let this service clear
# the sensitive-path human review on its own pull requests. CLAUDE.md §1 forbids an agent
# doing that in the strongest terms it has, and the cheapest way to make it impossible
# rather than forbidden is for the token's account not to have the permission.
#
# Gitea -> Site Administration -> Identity & Access -> User Accounts -> Create
# username: pm-agent (any name; it appears as the author of every comment)
# NOT an administrator
# Then add it to THIS REPOSITORY as a collaborator with permission `Write`:
# Repository -> Settings -> Collaborators -> add `pm-agent`, access `Write`
#
# Verify, do not assume (CLAUDE.md §2b) — this must print `write`, never `admin` or
# `owner`:
#
# curl -s -H "Authorization: token <the token from step 2>" \
# https://gitea.home.keithjackson.dev/api/v1/repos/Home-Network/home-network-k3s-impl/collaborators/pm-agent/permission \
# | python3 -c 'import json,sys; print(json.load(sys.stdin).get("permission"))'
#
# HONEST LIMIT, read it before deciding this is enough: `Write` in Gitea includes the
# ability to MERGE a pull request, and no token scope separates "push a branch and open a
# pull request" from "merge it". The service never issues a merge call — pm-worker's code
# is the whole set of API calls it makes, and there is no merge among them — but that is
# a property of the code, not of the credential. What the permission level DOES buy
# structurally is the override: a non-admin account cannot clear the sensitive-path
# review, so a sensitive-path pull request from this agent correctly sits unmergeable
# until a human reviews it.
#
# (2) MINT THE GITEA TOKEN as that user (log in as `pm-agent`, or use the admin API to
# create a token for it): Settings -> Applications -> Generate New Token.
#
# Scopes: `write:repository` and `write:issue`. Nothing else — in particular NOT
# `write:admin`, NOT `write:organization`, NOT `write:user`.
#
# `write:repository` covers the clone, the branch push and the pull-request create;
# `write:issue` covers reading and posting issue comments. (The repository's OTHER CI
# token, `SETTINGS_API_TOKEN`, is a separate credential with `read:admin` — do not
# conflate them, and do not give this service that one. The 2026-10-02 measurement on
# PR #38 recorded in infra/gitea/approval-policy.json is about that token, not this one.)
#
# (3) MINT THE ANTHROPIC SUBSCRIPTION TOKEN. This service authenticates with a Claude
# SUBSCRIPTION (Pro, Max, Team or Enterprise), not with a Console API key. On a machine
# where you can complete a browser login:
#
# claude setup-token
#
# It opens the normal authorisation flow, prints a ONE-YEAR OAuth token, and saves it
# nowhere. Copy it into `claude-code-oauth-token` below.
#
# THREE THINGS ABOUT THIS TOKEN:
# * It EXPIRES after a year, with no in-cluster warning: Claude Code's expiry notice
# only appears for an interactive `/login` credential, never for this variable. Put
# the date in the handoff entry and in runbooks/pm-agent.md; when it lapses, every
# session fails at once and the only symptom is worker Jobs failing to authenticate.
# * It must NOT be accompanied by an `ANTHROPIC_API_KEY`. Claude Code's credential
# precedence puts an API key ABOVE this variable, so a stray key would silently
# take over and bill elsewhere (or fail). pm-worker unsets both API-key variables
# before it starts a session, and the image sets neither.
# * Usage counts against the subscription seat's own limits, shared with every other
# place that seat is used. That is the real reason PM_MAX_ACTIVE_WORKERS defaults to
# 2 — several concurrent Claude Code sessions on one seat contend for the same
# account limits, and a rate-limited worker fails mid-assignment.
#
# (4) COPY, FILL, ENCRYPT, COMMIT:
#
# cp platform/pm-agent/pm-agent-credentials.sops.yaml.example \
# platform/pm-agent/pm-agent-credentials.sops.yaml
# $EDITOR platform/pm-agent/pm-agent-credentials.sops.yaml # the two real values
# sops -e -i platform/pm-agent/pm-agent-credentials.sops.yaml
#
# The root `.sops.yaml` creation rule matches this path and encrypts only `data`/
# `stringData`, so the diff stays reviewable. NEVER `sops -d` this file into a terminal,
# a log or a transcript (CLAUDE.md §6), and never print the Secret's `.data` when
# checking that it exists — list key names only.
#
# It is already listed under `resources:` in platform/pm-agent/kustomization.yaml, so
# replacing the placeholder file is the whole change.
#
# (5) ROTATION. Both values are independent. To rotate either, repeat its step, re-encrypt,
# and merge — Flux updates the Secret and the dispatcher's pod must then be restarted to
# re-read it (a Secret change does not roll a Deployment by itself). The worker Jobs pick
# the new value up on their next run with no action at all.
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: pm-agent-credentials
namespace: pm-agent
type: Opaque
stringData:
# Gitea token for the dedicated, NON-ADMIN `pm-agent` account.
# Scopes: write:repository, write:issue.
gitea-token: REPLACE-ME-gitea-token
# Output of `claude setup-token` on a subscription account. One year's validity.
claude-code-oauth-token: REPLACE-ME-claude-code-oauth-token