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]>
139 lines
6.0 KiB
YAML
139 lines
6.0 KiB
YAML
---
|
|
# Default-deny baseline (R-NET-01): deny all ingress and all egress, allow only DNS.
|
|
#
|
|
# `pm-agent` is a PLATFORM namespace, so Kyverno does NOT auto-generate this (its policies
|
|
# match homelab.keithjackson.dev/app-namespace=true — see namespace.yaml for why this
|
|
# namespace deliberately lacks that label). It is shipped explicitly here, the same shape as
|
|
# the Kyverno-generated app baseline and as headlamp/homepage/crafty.
|
|
#
|
|
# Cilium enforces the UNION of this NetworkPolicy and the CiliumNetworkPolicies below (allow
|
|
# if ANY policy allows), so those add the three egress paths on top of deny-all. Nothing adds
|
|
# an ingress path: see the comment at the ingress policy that is not here.
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: default-deny
|
|
namespace: pm-agent
|
|
labels:
|
|
homelab.keithjackson.dev/baseline: default-deny
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress: []
|
|
egress:
|
|
- to:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: kube-system
|
|
ports:
|
|
- protocol: UDP
|
|
port: 53
|
|
- protocol: TCP
|
|
port: 53
|
|
---
|
|
# EGRESS for the DISPATCHER. Three destinations, and the list is the whole answer to "what
|
|
# can this thing reach".
|
|
#
|
|
# 1. the in-cluster Gitea (:3000) — read issues and comments, post comments. By pod
|
|
# identity, not by hostname: the dispatcher dials the ClusterIP directly, which is also
|
|
# why the matching INGRESS allow has to exist on Gitea's side (catalog/gitea.yaml
|
|
# inClusterClients) — under Gitea's own namespace default-deny the connection would
|
|
# otherwise be dropped there, exactly as it was for Flux's source-controller in the
|
|
# 2026-09-13 incident recorded in that file.
|
|
# 2. the kube-apiserver — `kubectl create job` / `kubectl get jobs`. The `kube-apiserver`
|
|
# entity is the right selector here: on k3s the API server listens on the node's host
|
|
# network, so `toEndpoints` cannot express it and `host` would be far wider.
|
|
# 3. tcp/443 to the world — the Anthropic API.
|
|
#
|
|
# ON (3), HONESTLY: `toEntities: world` on 443 permits reaching ANY external host on 443,
|
|
# not just api.anthropic.com. Cilium can express the tighter rule (`toFQDNs`), but nothing in
|
|
# this repository uses FQDN policy today and it depends on Cilium's DNS proxy being enabled,
|
|
# which has not been measured here — putting an unverified mechanism in a security-relevant
|
|
# position is worse than a documented wide rule. So the narrowing of this rule is a tracked
|
|
# follow-up with a prerequisite: verify the DNS proxy, then replace the `world` entry with
|
|
# `toFQDNs: [{matchName: api.anthropic.com}]` and re-measure that the model still answers.
|
|
# Until then, what bounds the egress of model-authored code is the PAIR of this rule and
|
|
# pm-worker's own baked-in allowlist of git hosts, which refuses to clone from or push to
|
|
# anywhere but the two addresses of this Gitea.
|
|
apiVersion: cilium.io/v2
|
|
kind: CiliumNetworkPolicy
|
|
metadata:
|
|
name: pm-agent-allow-egress
|
|
namespace: pm-agent
|
|
labels:
|
|
app.kubernetes.io/name: pm-agent
|
|
spec:
|
|
endpointSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: pm-agent
|
|
egress:
|
|
- toEndpoints:
|
|
- matchLabels:
|
|
io.kubernetes.pod.namespace: gitea
|
|
app.kubernetes.io/name: gitea
|
|
toPorts:
|
|
- ports:
|
|
- { port: "3000", protocol: TCP }
|
|
- toEntities:
|
|
- kube-apiserver
|
|
- toEntities:
|
|
- world
|
|
toPorts:
|
|
- ports:
|
|
- { port: "443", protocol: TCP }
|
|
---
|
|
# EGRESS for a WORKER. The same two outward paths as the dispatcher — git over :3000 and
|
|
# tcp/443 for the model — and pointedly NOT the kube-apiserver.
|
|
#
|
|
# A worker holds no ServiceAccount token (rbac.yaml, and the Job template sets
|
|
# automountServiceAccountToken: false), so an API call would fail on authentication anyway.
|
|
# Denying the network path as well is the second of the two: the worker runs model-authored
|
|
# code, and "it could reach the API server but had no credential" is a worse place to stand
|
|
# than "it could not reach the API server". `kubectl` is on its PATH — it is in the pinned
|
|
# toolbox the gate needs — and this is what makes it inert.
|
|
apiVersion: cilium.io/v2
|
|
kind: CiliumNetworkPolicy
|
|
metadata:
|
|
name: pm-agent-worker-allow-egress
|
|
namespace: pm-agent
|
|
labels:
|
|
app.kubernetes.io/name: pm-agent-worker
|
|
spec:
|
|
endpointSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: pm-agent-worker
|
|
egress:
|
|
- toEndpoints:
|
|
- matchLabels:
|
|
io.kubernetes.pod.namespace: gitea
|
|
app.kubernetes.io/name: gitea
|
|
toPorts:
|
|
- ports:
|
|
- { port: "3000", protocol: TCP }
|
|
- toEntities:
|
|
- world
|
|
toPorts:
|
|
- ports:
|
|
- { port: "443", protocol: TCP }
|
|
|
|
# THERE IS NO INGRESS POLICY, AND THAT IS THE DESIGN.
|
|
#
|
|
# The default-deny baseline above denies all ingress and nothing adds an allow back. No
|
|
# Service, no HTTPRoute, no webhook receiver, no metrics port: nothing outside this namespace
|
|
# can open a connection to anything inside it. The one component that might have wanted
|
|
# inbound reach — a Gitea webhook receiver — was deliberately not built; the dispatcher polls
|
|
# instead (infra/agent/pm-dispatch.py explains why at length).
|
|
#
|
|
# Keeping this as a comment rather than an empty policy is intentional: a reader asking "how
|
|
# is this thing reached?" should find the answer where they look for it, and the answer is
|
|
# "it isn't". If a future change adds a listener, it adds its allow HERE, scoped to one
|
|
# client namespace and one port, and says why in this comment block.
|
|
#
|
|
# One consequence worth naming for whoever wires observability later: there is no
|
|
# ServiceMonitor and no metrics endpoint, so a scrape from the `monitoring` namespace would
|
|
# be dropped by this baseline — adding metrics means adding the port AND the additive
|
|
# ingress allow for it, which is the same lesson as the oauth2-proxy sidecar scrape
|
|
# (decisions/2026-10-04-oauth2-sidecar-metrics-scrape-netpol.md).
|