Files
home-network-k3s-impl/platform/kyverno/admission/pm-agent-job-shape.yaml
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

365 lines
19 KiB
YAML

# Serves: R-SEC-02 (least privilege for workload identities)
#
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in platform/pm-agent/rbac.yaml is not.
#
# Kubernetes places NO `use` check on a Pod's `serviceAccountName`. Any principal that can
# `create jobs` in namespace `pm-agent` can therefore create a Job that runs as any
# ServiceAccount in that namespace, with an ARBITRARY command — so the dispatcher's Role,
# however narrow it looks, IS whatever the most privileged identity in the namespace can do,
# unless something constrains the SHAPE of what it may create. This is the same finding
# conformance/incluster/rbac.yaml records for the conformance trigger, and the model here is
# deliberately the same one.
#
# Two things make it bite harder for this service than for that one:
#
# 1. THE CREATOR IS DRIVEN BY A LANGUAGE MODEL READING UNTRUSTED TEXT. The dispatcher asks
# the model what to do about an issue thread written by whoever can reach the
# repository. The model holds no tools and the dispatcher accepts only a validated
# vocabulary (an action from a fixed set, a branch matching CLAUDE.md §4) — but those
# are both one program away from the API server. This policy is the layer that does not
# depend on that program being correct.
# 2. THE WORKER RUNS MODEL-AUTHORED CODE WITH CREDENTIALS. Each worker holds a Gitea write
# token and an Anthropic subscription token. What stops a worker Job from being pointed
# at a different command, a different image, a mounted hostPath or a projected
# ServiceAccount token is this file.
#
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
# ${KYVERNO_VALIDATION_ACTION} like the app-scoped policies: a security boundary an
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
# Kyverno outage blocks worker Jobs rather than silently admitting unconstrained ones.
# Blocking an agent's work is the correct failure direction; admitting arbitrary code that
# holds a git write token is not.
#
# SCOPE: it constrains ONLY Jobs created by the `pm-agent` ServiceAccount in the `pm-agent`
# namespace. Jobs created by humans, by Flux, or by any other SA are untouched — so a broken
# rule here cannot wedge the rest of the cluster. A human debugging this service can still
# create a one-off Job by hand, which is deliberate: the boundary exists to bound the
# AUTOMATED creator, not to lock the operator out of their own namespace.
#
# IMAGE PINNING — HONEST LIMIT, read before relying on this. The rules below require the
# image to live under the agent-runner repository path AND to be digest-pinned
# (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that registry
# path could publish a different digest. TIGHTEN IT once the image exists by replacing the
# wildcard with the literal digest — a one-line change, reviewed in a pull request, which is
# exactly where a change to the security boundary belongs. (Same limit, same wording, as
# conformance-trigger-job-shape.yaml: that is not copy-paste, it is the same unresolved
# tradeoff in both places.)
#
# EVERY RULE BELOW EXISTS BECAUSE OF A FIELD THAT WAS ONCE UNREAD. The conformance policy
# shipped with three rules and then needed four more — `initContainers`, then
# `ephemeralContainers`, then `env`, then `envFrom` — because pinning `command` and `args`
# only moves the injection to whatever adjacent field nobody enumerated. This policy starts
# with all of them, and `trigger`-shaped rules are named `worker-…` for the same reason.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: pm-agent-job-shape
annotations:
policies.kyverno.io/title: Constrain what the PM agent dispatcher may create
policies.kyverno.io/category: Supply chain
policies.kyverno.io/severity: critical
homelab.keithjackson.dev/requirements: R-SEC-02
homelab.keithjackson.dev/scope: admission-only
spec:
validationFailureAction: Enforce
failurePolicy: Fail
background: false
rules:
# =====================================================================================
# Rule 1 — the committed worker shape, as a pattern.
#
# `resources` AND `subjects` in the SAME filter entry: that is how Kyverno ANDs them. As
# two separate `all` entries it is a schema error ("match resources not specified") and
# the WHOLE policy loads as INVALID — which the CLI reports as a SKIP, i.e. a boundary
# that silently is not there. That mistake has already been made once in this repository.
# =====================================================================================
- name: worker-must-be-the-committed-shape
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
The PM dispatcher may create ONLY the committed worker shape: a single container,
the digest-pinned agent-runner image, serviceAccountName pm-agent-worker with NO
mounted token, command ["/usr/local/bin/pm-worker"], no host namespaces, non-root
with privilege escalation disabled and a read-only root filesystem. See
infra/agent/worker-job-template.yaml and
platform/kyverno/admission/pm-agent-job-shape.yaml.
pattern:
spec:
template:
spec:
# The powerless identity. A Job that ran as `pm-agent` instead could create
# more Jobs — i.e. fork itself without limit — which is why this is pinned
# rather than merely defaulted.
serviceAccountName: pm-agent-worker
# A worker needs no Kubernetes API access at all, so it gets no token. This
# is the field that makes the empty `pm-agent-worker` Role moot rather than
# merely narrow.
automountServiceAccountToken: false
# A retried agent would duplicate its push, its pull request and its issue
# comment, from a tree the first attempt had already changed.
restartPolicy: Never
=(hostNetwork): "false"
=(hostPID): "false"
=(hostIPC): "false"
containers:
- image: "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:*"
command:
- "/usr/local/bin/pm-worker"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
=(privileged): "false"
# =====================================================================================
# Rule 2 — exactly one container, and no args on it.
#
# Rule 1's `containers` pattern is an ARRAY pattern, and a Kyverno array pattern asserts
# "at least one element matches". So rule 1 alone would admit the compliant worker
# container PLUS a second container with an arbitrary image and command, sharing the
# pod's network namespace and its two credential env vars. The count has to be asserted
# separately.
#
# `args` is the other half: the command is pinned, so caller-supplied code moves to the
# arguments. pm-worker refuses arguments itself (the image build asserts it), and this
# refuses them one layer earlier.
# =====================================================================================
- name: worker-must-be-one-container-with-no-args
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
A worker Job must declare EXACTLY ONE container and give it NO args. A second
container shares the pod's namespaces and its credentials; args on a pinned command
are caller-supplied code.
deny:
conditions:
any:
- key: "{{ length(request.object.spec.template.spec.containers) }}"
operator: NotEquals
value: 1
- key: "{{ length(not_null(request.object.spec.template.spec.containers[0].args, `[]`)) }}"
operator: GreaterThan
value: 0
# =====================================================================================
# Rule 3 — only the `workspace` and `tmp` volumes.
#
# foreach, NOT a `pattern` list, for the reason in rule 2: an array pattern is satisfied
# by one matching element, so it would happily admit `workspace` PLUS a hostPath, a
# secret projection, or another SA's token. This asserts EVERY volume is one of the two,
# and that each is an emptyDir — a volume named `workspace` whose source is a hostPath
# would otherwise pass a name-only check.
# =====================================================================================
- name: worker-volumes-must-be-workspace-or-tmp-emptydirs
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
A worker Job may mount only the `workspace` and `tmp` volumes, and both must be
emptyDir. An extra volume — or a hostPath wearing an allowed name — is how a
constrained pod reaches the host, a Secret, or another ServiceAccount's token.
foreach:
- list: "request.object.spec.template.spec.volumes"
deny:
conditions:
any:
- key: "{{ element.name }}"
operator: AnyNotIn
value: ["workspace", "tmp"]
# The SOURCE must be emptyDir, asserted over the volume's KEY SET rather
# than over emptyDir's contents: `emptyDir: {}` is a perfectly valid
# volume with no sizeLimit, and a length check on it denies the good case
# (measured — this is what row 5 of the test caught). Checking the keys
# also catches every other source type without naming them, so a future
# `csi:` or `projected:` is denied by default rather than by enumeration.
- key: "{{ keys(element) }}"
operator: AnyNotIn
value: ["name", "emptyDir"]
# =====================================================================================
# Rule 4 — no initContainers and no ephemeralContainers.
#
# These are the second and third container lists on a PodSpec, and every rule above reads
# only `containers`. On the conformance policy this exact gap was MEASURED: a Job with a
# fully compliant containers[0] and a privileged initContainer running an arbitrary
# command was ADMITTED (pass 3, fail 0) before the equivalent rule existed.
#
# Unlike the conformance run, a worker needs NO init container at all — pm-worker does its
# own clone, inside the one pinned entrypoint — so this is a flat prohibition rather than
# a second pinned shape. That is strictly simpler to reason about, and it is the whole
# reason the clone lives in the entrypoint instead of in an init step.
#
# `ephemeralContainers` is believed to be rejected by upstream Kubernetes in a Job's pod
# template (the field is settable only via the pods/ephemeralcontainers subresource).
# THAT HAS NOT BEEN VERIFIED on this cluster, and "probably rejected upstream" is not a
# boundary. The condition costs nothing and is a no-op when the field is absent.
# =====================================================================================
- name: worker-may-not-declare-init-or-ephemeral-containers
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
A worker Job may not declare initContainers or ephemeralContainers. pm-worker does
its own clone inside the pinned entrypoint, so neither has a legitimate purpose
here — and either is a second container list running with the worker's credentials
under a command this policy does not read.
deny:
conditions:
any:
- key: "{{ length(not_null(request.object.spec.template.spec.initContainers, `[]`)) }}"
operator: GreaterThan
value: 0
- key: "{{ length(not_null(request.object.spec.template.spec.ephemeralContainers, `[]`)) }}"
operator: GreaterThan
value: 0
# =====================================================================================
# Rule 5 — only the committed environment variable NAMES.
#
# The pattern from the conformance policy, applied before it has to be learned again:
# pin `command` and `args`, and injection moves to the environment. It is not
# hypothetical here either —
# * pm-worker keys its whole behaviour on this environment: PM_BRANCH decides which ref
# is created and pushed, PM_GITEA_URL decides which host is cloned from and pushed to
# (it validates that one against a baked-in allowlist, which is the other half of
# this defence), PM_TASK_MODE decides whether an existing branch is reused;
# * `claude` reads ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN AHEAD of the subscription
# token, so one injected variable redirects every model call to a different
# credential and a different endpoint (pm-worker unsets them, which is defence in
# depth, not a reason to omit this);
# * and bash itself reads BASH_ENV, which it sources before a non-interactive script —
# so `command: ["/usr/local/bin/pm-worker"]` plus one env var would be arbitrary code
# execution holding a git write token.
#
# NOT CONSTRAINED, deliberately: the VALUES. They are legitimately per-assignment (a
# branch, a brief, an issue number), and pm-worker validates every one it acts on. Names
# are the vector that matters, because a name is what a program keys its behaviour on.
# The `valueFrom` SOURCES are likewise unconstrained here and constrained in the one place
# that can: the committed Job template holds them and pm-dispatch refuses to overwrite an
# entry that has a `valueFrom`.
#
# This list must stay equal to WORKER_ENV_ALLOWED in infra/agent/pm-dispatch.py. The
# duplication is deliberate — the policy must decide without trusting that program — and
# platform/kyverno/tests/pm-agent/ pins them together with the real template as a fixture.
# =====================================================================================
- name: worker-env-names-must-be-the-committed-set
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
A worker Job may set only the committed environment variables (PM_TASK_MODE,
PM_ISSUE_NUMBER, PM_ISSUE_TITLE, PM_BRANCH, PM_BASE_BRANCH, PM_BRIEF, PM_GITEA_URL,
PM_GITEA_OWNER, PM_GITEA_REPO, PM_GITEA_USER, PM_MODEL, PM_MAX_TURNS,
PM_MAX_BUDGET_USD, PM_PUSH_ON_RED_GATE, GITEA_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, HOME,
TMPDIR). An arbitrary env var changes what the pinned command does — BASH_ENV alone
is arbitrary code execution, and ANTHROPIC_API_KEY silently outranks the
subscription token. Widening this list is a reviewed one-line change, in two files.
foreach:
- list: "request.object.spec.template.spec.containers[0].env"
deny:
conditions:
all:
- key: "{{ element.name }}"
operator: AnyNotIn
value:
- PM_TASK_MODE
- PM_ISSUE_NUMBER
- PM_ISSUE_TITLE
- PM_BRANCH
- PM_BASE_BRANCH
- PM_BRIEF
- PM_GITEA_URL
- PM_GITEA_OWNER
- PM_GITEA_REPO
- PM_GITEA_USER
- PM_MODEL
- PM_MAX_TURNS
- PM_MAX_BUDGET_USD
- PM_PUSH_ON_RED_GATE
- GITEA_TOKEN
- CLAUDE_CODE_OAUTH_TOKEN
- HOME
- TMPDIR
# =====================================================================================
# Rule 6 — no envFrom.
#
# It injects an entire ConfigMap or Secret as environment variables whose KEYS are not
# visible in the Job at all, which would make rule 5 unenforceable by inspection. The
# dispatcher itself uses envFrom (it is a Deployment, not a Job, and not matched by this
# policy); a worker must not.
# =====================================================================================
- name: worker-may-not-use-envfrom
match:
all:
- resources:
kinds:
- Job
namespaces:
- pm-agent
subjects:
- kind: ServiceAccount
name: pm-agent
namespace: pm-agent
validate:
message: >-
A worker Job may not use envFrom. It injects a whole ConfigMap or Secret as
environment variables whose keys the Job does not name, which would make the
env-name allowlist unenforceable by inspection.
deny:
conditions:
any:
- key: "{{ length(not_null(request.object.spec.template.spec.containers[0].envFrom, `[]`)) }}"
operator: GreaterThan
value: 0
- key: "{{ length(not_null(request.object.spec.template.spec.initContainers[].envFrom[], `[]`)) }}"
operator: GreaterThan
value: 0