--- # 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).