Files
home-network-k3s-impl/platform/pm-agent/rbac.yaml
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

127 lines
6.0 KiB
YAML

---
# =============================================================================
# Two identities for the PM agent service, and the whole point is how little each holds.
# Serves R-SEC-02 (least privilege for workload identities).
#
# pm-agent the dispatcher. Needs to create and observe worker Jobs in its OWN
# namespace. Nothing else, anywhere.
# pm-agent-worker the per-assignment worker. Holds NO Kubernetes rights at all — no
# Role, no RoleBinding, not even a mounted token.
#
# THE ESCALATION THIS LAYOUT EXISTS TO BOUND, and why RBAC alone cannot close it.
#
# Kubernetes places NO `use` check on a Pod's `serviceAccountName`. So any principal that can
# `create jobs` in a namespace can create a Job that runs as ANY ServiceAccount in that
# namespace, with an arbitrary command. "Can create Jobs" is therefore equivalent to "can run
# arbitrary code as the most privileged SA in the namespace", no matter how narrow the
# creator's own Role looks. That is exactly the finding recorded in
# conformance/incluster/rbac.yaml for the conformance trigger, and it applies here verbatim.
#
# This service answers it in two ways at once:
#
# 1. There is no privileged SA in the namespace to escalate TO. `pm-agent-worker` is bound
# to nothing, so even a Job with a shell as its command and that SA attached gets no API
# access — and `automountServiceAccountToken: false` in the committed Job template means
# no token is projected into the pod either. The prize is empty.
# 2. platform/kyverno/admission/pm-agent-job-shape.yaml constrains the SHAPE of what
# `pm-agent` may create: the digest-pinned image, the pinned command with no args, that
# SA, no token, no extra volume, no init or ephemeral container, and only the committed
# env names. That policy is the security boundary; this file is the necessary half.
#
# DO NOT grant this Role without that policy reconciled and ENFORCING, and do not bind
# anything to `pm-agent-worker`. If a worker ever genuinely needs to read something from the
# API, that is a reviewed change to the boundary — and it should be questioned hard, because
# a worker's job is to write git, not to touch the cluster.
#
# Namespaced Roles only. There is no ClusterRole here, deliberately: nothing this service
# does is cluster-scoped, and the absence is the control.
# =============================================================================
apiVersion: v1
kind: ServiceAccount
metadata:
name: pm-agent
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent
app.kubernetes.io/part-of: pm-agent
annotations:
pm-agent.homelab.keithjackson.dev/audit: >-
The dispatcher identity. It may create worker Jobs in this namespace and watch them.
Its blast radius is bounded by the admission policy pm-agent-job-shape, NOT by this
Role — widen neither without the other, and review both.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pm-agent
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent
app.kubernetes.io/part-of: pm-agent
rules:
# Create one worker Job per assignment, and read back which Jobs are still running.
#
# `create` cannot be constrained by resourceNames (the name is not known until the object
# exists), which is the structural reason the admission policy has to exist.
#
# `get`/`list` are load-bearing, not conveniences: the dispatcher's concurrency cap and its
# "a worker is already on this issue" check are both answered by listing Jobs, and its
# issue -> branch memory is the annotation it reads back off the Job it created. Without
# them it would duplicate work on every poll.
- apiGroups: [batch]
resources: [jobs]
verbs: [create, get, list, watch]
# NOTHING ELSE, and three absences are deliberate:
# no `delete` on jobs — a dispatcher that can delete can hide a failed run. Finished
# Jobs are reaped by ttlSecondsAfterFinished in the template, by
# the Job controller, on a clock nothing here can influence.
# no `patch`/`update` — the shape admission approved must stay the shape that runs.
# no pods, pods/log,
# pods/exec, secrets,
# configmaps — the dispatcher reads its own config from its own env and the
# thread from Gitea. An execution channel into a worker pod, or a
# read over Secrets, would serve nothing it actually does.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pm-agent
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent
app.kubernetes.io/part-of: pm-agent
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
---
# THE WORKER IDENTITY — deliberately bound to NOTHING.
#
# It exists only because a Pod must name a ServiceAccount, and because naming a dedicated
# one is what lets the admission policy require it: a Job that tried to run as `pm-agent`
# (which CAN create Jobs, and so could fork itself without limit) is DENIED.
#
# `automountServiceAccountToken: false` here as well as in the Job template. The template's
# setting is what actually takes effect; this one is the fallback for any pod in this
# namespace that forgets, and it documents the intent at the identity rather than only at
# one use of it.
apiVersion: v1
kind: ServiceAccount
metadata:
name: pm-agent-worker
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent-worker
app.kubernetes.io/part-of: pm-agent
annotations:
pm-agent.homelab.keithjackson.dev/audit: >-
INTENTIONALLY POWERLESS. No Role and no RoleBinding reference this ServiceAccount, and
no token is mounted into the pods that use it. A worker runs model-authored code
against a git repository; it holds a Gitea token and an Anthropic subscription token,
and nothing else. Binding any Role to this SA is a change to the security boundary.
automountServiceAccountToken: false