feat(conformance): build the runner image the in-cluster L2/L3 Job could never have run #18

Merged
jakman85 merged 4 commits from feat/conformance-runner-image into main 2026-09-30 03:59:26 +00:00
Owner

Rebased and unblocked — the handoff moved out of DECISIONS-LOG.md

This PR was mergeable: false, and the reason was not the code. Its handoff was a
~236-line append to the tail of DECISIONS-LOG.md. Every concurrent branch appended to that
same last hunk, so git could not auto-merge any two of them: measured against the live Gitea on
2026-09-29, four of the five open PRs were unmergeable and DECISIONS-LOG.md was the only
conflicting path in all four.

What changed here:

  • The DECISIONS-LOG.md append is dropped from this branch entirely — the branch no longer
    touches that file at all.
  • The same handoff now lives in its own file, decisions/2026-09-29-conformance-runner-image.md,
    in the one-entry-per-file shape PR #24 introduced and ci/decisions-log.sh enforces.
  • The branch is rebased onto current main (c59fe9f, which already contains #24) and
    merges cleanly on its own: git merge-tree --write-tree origin/main HEAD reports no
    CONFLICT, and the API now reports mergeable: true.
  • The code is byte-identical to the pre-rework tip. git diff <old tip> HEAD -- conformance/
    is empty, file modes included (build-image.sh and conformance-clone both 100755).
    Nothing this PR claims to have proven was changed, and no image was rebuilt as part of the
    rework. Every UNVERIFIED below is still UNVERIFIED.

git diff origin/main...HEAD --name-only:

conformance/incluster/Containerfile
conformance/incluster/README.md
conformance/incluster/build-image.sh
conformance/incluster/conformance-clone
conformance/versions.env
decisions/2026-09-29-conformance-runner-image.md

Gate re-run from the worktree in the pinned toolbox, on the exact pushed commit b105c76:
rc 3, MERGE GATE: UNDETERMINED, nothing FAILED. spec-immutability, decisions-log,
lint, l0, render-golden and l1 all green; gitea-settings is the only stage that did
not run
(SETTINGS_API_TOKEN unwired repo-wide), and conformance skips with
CLUSTER_AVAILABLE != true, so R-SEC-02 and R-TEST-01..04 remain L2/L3-UNVERIFIED.
That is rc 3, not rc 0 — it is not green and is not offered as green.

The sections below are the PR's original description, unchanged. Note that the "Gate" numbers
in them predate this rebase, and the CI result quoted in the CORRECTION section was read from
run 128 against the old head — the post-force-push CI run has not been read.


What this is

docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1: the pinned container image the
in-cluster L2/L3 conformance Job runs.

The bug this closes

conformance/incluster/chainsaw-job.yaml pins ghcr.io/kyverno/chainsaw:v0.2.12 and
command: ["./run.sh"]. Those two lines cannot both be true. That image carries the
chainsaw binary and essentially nothing else — no bash, no python3, no kubectl, no
git — so the Job would have failed at exec of ./run.sh, before a single test ran.
Nothing in the tree said so, and nothing would have until someone read a pod log.

Changes

File What
conformance/incluster/Containerfile new — the conformance-runner image: bash + python3 + chainsaw + kubectl + git-core + tar/gzip/coreutils/diffutils
conformance/incluster/build-image.sh new — builds it, proves the toolchain by running it, prints the digest, never pushes
conformance/versions.env CONFORMANCE_RUNNER_IMAGE pinned, digest deliberately empty until the operator pushes
conformance/incluster/README.md documents both new files and flags the Job manifest's stale image:

Versions are not invented here. infra/devcontainer/versions.env stays the single source
of truth: build-image.sh sources it and passes CHAINSAW_VERSION, KUBECTL_VERSION and
FEDORA_VERSION in as --build-arg, so the chainsaw and kubectl in the runner are the same
pins the toolbox gate runs with. The ARG defaults in the Containerfile are empty and the
first RUN asserts them, so a build that forgets a pin fails loudly instead of silently
baking a stale version.

Two-stage build: a full Fedora fetches the release binaries (and verifies kubectl's published
sha256); the runtime layer is fedora-minimal and carries no downloader.

What the admission boundary forced

platform/kyverno/admission/conformance-trigger-job-shape.yaml is Enforce +
failurePolicy: Fail and it is the R-SEC-02 security boundary. It is not modified here.
Two of its clauses are load-bearing on the image itself:

  • runAsNonRoot: true with no explicit runAsUser means the kubelet rejects an image
    whose USER is a name it cannot resolve to a uid. So USER is numeric — 65532:65532,
    not a friendly name. This is the failure mode that would otherwise have surfaced as
    CreateContainerConfigError on the first real run.
  • The pod is readOnlyRootFilesystem: true and the policy admits only the workspace and
    results volumes — there is no writable /tmp and one cannot be added without editing the
    boundary. So HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME and TMPDIR all point into
    /workspace, and git's safe.directory config is written to /etc/gitconfig at build
    time because a runtime --global write would fail.

Verified by running it, not by a successful build

As uid 65532 with --read-only:

--- id ---
uid=65532(runner) gid=65532(runner) groups=65532(runner)
--- bash ---
GNU bash, version 5.3.9(1)-release (x86_64-redhat-linux-gnu)
--- python3 ---
Python 3.14.7
--- git ---
git version 2.55.0
--- kubectl ---
Client Version: v1.36.4
Kustomize Version: v5.8.1
--- chainsaw ---
Version: 0.2.12
Time: 2024-12-04T13:06:58Z
Git commit ID: 6459cd84a78f13d842e0ff0ad2f6a76c321fd579
--- rootfs is read-only (expected: a failure) ---
confirmed: / is read-only
--- HOME is writable (the workspace emptyDir) ---
confirmed: /workspace is writable
--- uid is non-zero ---
confirmed: running as uid 65532, not root

Then the actual entrypoint, in the same shape (uid 65532, --read-only, /workspace as a
tmpfs standing in for the emptyDir), against this worktree:

===== run.sh --lint =====            rc=0
===== run.sh --coverage-only =====   rc=0
===== run.sh --layer l2 --no-cluster =====
[run.sh] No L0/L1 offline checks match the selection (layer=l2 reqs='').
[junit] wrote skipped JUnit for 55 scaffolded test(s) -> _artifacts/report.junit.xml
rc=0
===== artifacts produced =====
_artifacts/coverage/coverage.json
_artifacts/coverage/coverage.md
_artifacts/report.junit.xml

Local build: 408 MiB, local manifest digest
sha256:cb672739026f35e02d0fe92deaf7a070ff2e1b73cecde638dbea52be3a26c689 (informational —
the registry digest after the operator's push is the authoritative one).

Known limit, flagged rather than papered over

run.sh runs the selected offline L0/L1 checks (run_offline_checks) before it touches
the cluster, for any selection that is not offline-only. Those checks shell out to flux,
promtool, kyverno and kustomize, which this image deliberately does not carry — baking
the whole devcontainer toolchain into the runner would roughly double its size to re-run work
ci/l0.sh and ci/l1.sh already did offline in the same CI run.

Consequence for T-6.1b: the Job must select the cluster-gated layers only —
args: ["--layer", "l2"] / ["--layer", "l3"], verified above to resolve the offline
selection to nothing. The plan's --layer all --strict-coverage phase-exit shape and a bare
--requirements <IDs> would both drag the L0 checks in and fail on a missing tool. Documented
in the Containerfile header and the incluster README rather than left to be discovered from a
pod log. Choosing the Job's args is T-6.1b's call, not this image's.

chainsaw-job.yaml's image: is left stale on purpose — there is no digest to put there
yet, and the trigger renders that template per run (plan §3 item 4). The README now says so
instead of letting the placeholder read as a pin.

Not done, by design — this is operator work

The image is not pushed. No registry credential, and publishing the artifact that a
fail-closed policy then trusts is an operator action. build-image.sh prints the exact
commands; conformance/versions.env repeats them. CONFORMANCE_RUNNER_IMAGE_DIGEST is
empty, not a placeholder: empty fails visibly in whatever renders the Job, whereas a
guessed digest would be an ImagePullBackOff dressed up as a cluster fault.

podman login gitea.home.keithjackson.dev
podman push --digestfile /tmp/conformance-runner.digest \
  gitea.home.keithjackson.dev/home-network/conformance-runner:chainsaw0.2.12-kubectl1.36.4-fc44

Then a follow-up PR sets CONFORMANCE_RUNNER_IMAGE_DIGEST from that digestfile. And a
separate, human-reviewed PR should do what the policy's own header asks: replace its
@sha256:* wildcard with the literal digest, closing the residual "anyone who can push to
this path" gap.

Gate

DEV_KUBECONFIG=/dev/null ENGINE=<worktree .git shim> infra/devcontainer/dev.sh run bash -c './ci/gate.sh'

GATE_RC=3
[skip] stage spec-immutability: SKIPPED (not run)
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[skip] stage gitea-settings: SKIPPED (not run)
[skip] stage conformance: SKIPPED (not run)

MERGE GATE: UNDETERMINED (rc 3). Nothing FAILED. touched requirements resolved to
R-SEC-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04. The three skips are environmental, not
results: spec-immutability needs the sibling spec repo checked out (CLAUDE.md §3 tolerates
exactly this one), gitea-settings needs SETTINGS_API_TOKEN, and conformance L2/L3 needs
CLUSTER_AVAILABLE=true. This is not a green run and is not offered as one — L2/L3 remains
UNVERIFIED for every touched requirement above, which is the same coverage hole this PR's image
is a prerequisite for closing.

CORRECTION: there is no approval-policy check, and this PR's CI is green

I was told to expect CI to fail on an approval-policy stage because conformance/** is a
security-relevant path requiring human approval, and to report that as expected. I checked
instead, and it is not true:

Claim Measured
an approval-policy CI stage exists No. grep -rn 'approval-policy|approval_policy' .gitea/ ci/ → nothing. gate.yml has jobs gate + conformance; merge-gate.yaml has spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate.
conformance/** is a protected path No. GET /branch_protections → protected_file_patterns: ''. No path rule of any kind, and no CODEOWNERS in the repo.
required status checks status_check_contexts: ['gate / gate (pull_request)'] — one context, not an approval stage.

What this PR's CI actually did, read out of the job log (run 128, job 0) rather than off the
tick — because a skipped Gitea job reports success:

[skip] stage spec-immutability: SKIPPED (not run)
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[ ok ] stage gitea-settings: pass
[skip] stage conformance: SKIPPED (not run)
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.

gate / gate (pull_request) = success in 1m36s, which means rc 3 tolerated by
./ci/gate.sh || [ "$?" -eq 3 ], not rc 0. gate / conformance also reports success — with
the description "Has been skipped", which is exactly the trap CLAUDE.md §2 warns about.

Note gitea-settings ran and passed in CI with REQUIRE_GITEA_SETTINGS=true, so CI is more
complete here than the local toolbox run, where that stage can only skip.

What blocks this PR is the missing human approval (required_approvals: 1,
block_admin_merge_override: true, and gitea-admin authors it and cannot self-approve) — not a
failed check. Please review and merge; nothing needs working around.


The init container now has no injection surface

Added at the coordinator's request to close half of the admission gap a sibling agent found: the
policy pins the main container's command but never reads initContainers, so a Job
from conformance-trigger can carry an init container with command: ["/bin/sh","-c"] and an
arbitrary script, running as the privileged conformance-runner SA. Reproduced by the policy's
author — pass: 3, fail: 0 on a privileged alpine:latest init container running
kubectl get secrets -A.

The image now bakes /usr/local/bin/conformance-clone, 0755 root:root, so uid 65532 cannot
replace the one executable the policy will come to trust. Clone logic taken from PR #20's
clone-suite-at-commit, with both load-bearing properties preserved:

  • the SHA must match ^[0-9a-f]{40}$ — an unrendered placeholder is a hard failure, never a
    silent run of the branch tip;
  • after checkout, rev-parse HEAD is asserted equal to the requested SHA.

It accepts no arguments.

Deviation from the brief, and why

I was asked to take SHA, repo URL and target dir from the environment. The repo URL and clone
target are compiled in instead.
The policy contains no env, valueFrom or secretKeyRef
clause at all — I checked before writing. So a script that trusted CONFORMANCE_REPO_URL would
merely move the injection one field over: the trigger points it at a repo it controls, this
fetches that, and ./run.sh from the attacker's tree runs under the privileged SA. "No args"
would not have stopped it — it is the same hole as initContainers, one field further along.
The env vars are still read so the Job template stays self-documenting, but a value that
disagrees with the baked literal is a hard failure, never a redirect. The SHA is the only
genuinely trusted per-run input.

This is half a fix — please don't read it as the whole one

What makes the fixed path required rather than merely available is a 4th policy rule
demanding command: ["/usr/local/bin/conformance-clone"] with no args on every
initContainer. That edits the security boundary, so it belongs in its own reviewed PR and is
not in this branch. Until it lands, the hole is open. Said so in the script header, the
Containerfile, versions.env and the incluster README.

Verified by running it

/usr/local/bin/conformance-clone root:root 755
confirmed: not writable by uid 65532

Seven negative cases, each asserted to exit non-zero rather than merely to print something:

--- rejects arguments ---                FATAL: takes no arguments (got 2: --layer l2)…   (exit 1)
--- rejects a missing SHA ---            CONFORMANCE_GIT_SHA is not set…                  (exit 1)
--- rejects a branch name ---            FATAL: … not a 40-hex commit id: 'main'          (exit 1)
--- rejects an UNRENDERED placeholder -- FATAL: … not a 40-hex commit id: '@@…@@'          (exit 1)
--- rejects HOME off the volume ---      FATAL: HOME must be /workspace or under it…      (exit 1)
--- refuses a REDIRECTED repo URL ---    FATAL: CONFORMANCE_REPO_URL does not match…      (exit 1)
--- refuses a target off the volume ---  FATAL: CONFORMANCE_SRC does not match…           (exit 1)

And the positive case, which is the one that matters — a real clone at a real SHA:

requesting commit c8899368e76cf734f9717417167dd683e5bed8bd
==> no GITEA_USERNAME/GITEA_TOKEN in the environment — cloning ANONYMOUSLY
==> shallow clone https://gitea.home.keithjackson.dev/…/home-network-k3s-impl.git -> /workspace/src
==> c889936… is not in the shallow tip; fetching that commit
==> suite ready at c8899368e76cf734f9717417167dd683e5bed8bd
--- assert rev-parse HEAD == the requested SHA ---
requested: c8899368e76cf734f9717417167dd683e5bed8bd
HEAD:      c8899368e76cf734f9717417167dd683e5bed8bd
confirmed: the working tree is at the requested commit
confirmed: run.sh present and executable

Two bugs of my own that only running it could find, both fixed: the workspace-path guard
matched /workspace/* and so rejected the image's own HOME=/workspace; and the negative checks
were written as pipelines, which under pipefail aborted the verify at the first expected
failure and silently skipped the other six. A verify that stops early looks identical to a verify
that passed.

The real clone is behind build-image.sh --verify-clone because it needs the Gitea host
reachable — the default verify stays offline so an air-gapped build is fully checked rather than
half-checked.

Also measured: the clone path works anonymously

Restored here because my own earlier body edit truncated it away (see below). From inside the
image as uid 65532, against the stock ca-certificates: python3 completes a verifying TLS
handshake to gitea.home.keithjackson.dev (issuer Let's Encrypt YR2, notAfter
Dec 12 02:16:57 2026 GMT), and ls-remote returns HEAD with no credential. So no
internal CA bundle is needed, and the read-only deploy token of plan §3 item 2 buys
authorisation hygiene rather than reachability. The coordinator has independently confirmed
(private: false, owner visibility public) and escalated it as a decision for the operator.

Gate, after this change

GATE_RC=3 — lint, l0, render-golden, l1 all pass; spec-immutability,
gitea-settings, conformance skipped; nothing FAILED. MERGE GATE: UNDETERMINED, which is
expected and is not offered as green.

Process note

Three parallel agents shared one scratchpad; two siblings collided on a helper path and one
PATCHed a PR belonging to neither, getting a plausible 201. My helpers now use per-agent paths,
this PR was audited by reading it back (head.ref: feat/conformance-runner-image, no sibling
content), and this edit appends only — my previous one truncated at a marker and silently
deleted my own anonymous-read section, which is exactly the class of bug a 2xx hides.

🤖 Generated with Claude Code

## Rebased and unblocked — the handoff moved out of `DECISIONS-LOG.md` **This PR was `mergeable: false`, and the reason was not the code.** Its handoff was a ~236-line append to the tail of `DECISIONS-LOG.md`. Every concurrent branch appended to that same last hunk, so git could not auto-merge any two of them: measured against the live Gitea on 2026-09-29, four of the five open PRs were unmergeable and `DECISIONS-LOG.md` was the **only** conflicting path in all four. What changed here: - The `DECISIONS-LOG.md` append is **dropped from this branch entirely** — the branch no longer touches that file at all. - The same handoff now lives in its own file, **`decisions/2026-09-29-conformance-runner-image.md`**, in the one-entry-per-file shape PR #24 introduced and `ci/decisions-log.sh` enforces. - The branch is **rebased onto current `main` (`c59fe9f`, which already contains #24)** and merges cleanly on its own: `git merge-tree --write-tree origin/main HEAD` reports no `CONFLICT`, and the API now reports `mergeable: true`. - **The code is byte-identical to the pre-rework tip.** `git diff <old tip> HEAD -- conformance/` is empty, file modes included (`build-image.sh` and `conformance-clone` both `100755`). Nothing this PR claims to have proven was changed, and no image was rebuilt as part of the rework. Every `UNVERIFIED` below is still `UNVERIFIED`. `git diff origin/main...HEAD --name-only`: conformance/incluster/Containerfile conformance/incluster/README.md conformance/incluster/build-image.sh conformance/incluster/conformance-clone conformance/versions.env decisions/2026-09-29-conformance-runner-image.md Gate re-run from the worktree in the pinned toolbox, on the exact pushed commit `b105c76`: **rc 3, `MERGE GATE: UNDETERMINED`, nothing FAILED.** `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden` and `l1` all green; **`gitea-settings` is the only stage that did not run** (`SETTINGS_API_TOKEN` unwired repo-wide), and `conformance` skips with `CLUSTER_AVAILABLE != true`, so `R-SEC-02` and `R-TEST-01..04` remain **L2/L3-UNVERIFIED**. That is rc 3, not rc 0 — it is not green and is not offered as green. > The sections below are the PR's original description, unchanged. Note that the "Gate" numbers > in them predate this rebase, and the CI result quoted in the CORRECTION section was read from > run 128 against the old head — **the post-force-push CI run has not been read.** --- ## What this is `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 1: the pinned container image the in-cluster L2/L3 conformance Job runs. ## The bug this closes `conformance/incluster/chainsaw-job.yaml` pins `ghcr.io/kyverno/chainsaw:v0.2.12` **and** `command: ["./run.sh"]`. Those two lines cannot both be true. That image carries the chainsaw binary and essentially nothing else — no `bash`, no `python3`, no `kubectl`, no `git` — so the Job would have failed at exec of `./run.sh`, before a single test ran. Nothing in the tree said so, and nothing would have until someone read a pod log. ## Changes | File | What | |---|---| | `conformance/incluster/Containerfile` | **new** — the `conformance-runner` image: bash + python3 + chainsaw + kubectl + git-core + tar/gzip/coreutils/diffutils | | `conformance/incluster/build-image.sh` | **new** — builds it, proves the toolchain by *running* it, prints the digest, **never pushes** | | `conformance/versions.env` | `CONFORMANCE_RUNNER_IMAGE` pinned, digest deliberately empty until the operator pushes | | `conformance/incluster/README.md` | documents both new files and flags the Job manifest's stale `image:` | **Versions are not invented here.** `infra/devcontainer/versions.env` stays the single source of truth: `build-image.sh` sources it and passes `CHAINSAW_VERSION`, `KUBECTL_VERSION` and `FEDORA_VERSION` in as `--build-arg`, so the chainsaw and kubectl in the runner are the same pins the toolbox gate runs with. The `ARG` defaults in the Containerfile are **empty** and the first `RUN` asserts them, so a build that forgets a pin fails loudly instead of silently baking a stale version. Two-stage build: a full Fedora fetches the release binaries (and verifies kubectl's published `sha256`); the runtime layer is `fedora-minimal` and carries no downloader. ## What the admission boundary forced `platform/kyverno/admission/conformance-trigger-job-shape.yaml` is `Enforce` + `failurePolicy: Fail` and it is the R-SEC-02 security boundary. **It is not modified here.** Two of its clauses are load-bearing on the image itself: - `runAsNonRoot: true` with no explicit `runAsUser` means the kubelet **rejects** an image whose `USER` is a name it cannot resolve to a uid. So `USER` is **numeric** — `65532:65532`, not a friendly name. This is the failure mode that would otherwise have surfaced as `CreateContainerConfigError` on the first real run. - The pod is `readOnlyRootFilesystem: true` and the policy admits only the `workspace` and `results` volumes — there is no writable `/tmp` and one cannot be added without editing the boundary. So `HOME`, `XDG_CACHE_HOME`, `XDG_CONFIG_HOME` and `TMPDIR` all point into `/workspace`, and git's `safe.directory` config is written to `/etc/gitconfig` at **build** time because a runtime `--global` write would fail. ## Verified by running it, not by a successful build As uid 65532 with `--read-only`: ``` --- id --- uid=65532(runner) gid=65532(runner) groups=65532(runner) --- bash --- GNU bash, version 5.3.9(1)-release (x86_64-redhat-linux-gnu) --- python3 --- Python 3.14.7 --- git --- git version 2.55.0 --- kubectl --- Client Version: v1.36.4 Kustomize Version: v5.8.1 --- chainsaw --- Version: 0.2.12 Time: 2024-12-04T13:06:58Z Git commit ID: 6459cd84a78f13d842e0ff0ad2f6a76c321fd579 --- rootfs is read-only (expected: a failure) --- confirmed: / is read-only --- HOME is writable (the workspace emptyDir) --- confirmed: /workspace is writable --- uid is non-zero --- confirmed: running as uid 65532, not root ``` Then the actual entrypoint, in the same shape (uid 65532, `--read-only`, `/workspace` as a tmpfs standing in for the emptyDir), against this worktree: ``` ===== run.sh --lint ===== rc=0 ===== run.sh --coverage-only ===== rc=0 ===== run.sh --layer l2 --no-cluster ===== [run.sh] No L0/L1 offline checks match the selection (layer=l2 reqs=''). [junit] wrote skipped JUnit for 55 scaffolded test(s) -> _artifacts/report.junit.xml rc=0 ===== artifacts produced ===== _artifacts/coverage/coverage.json _artifacts/coverage/coverage.md _artifacts/report.junit.xml ``` Local build: 408 MiB, local manifest digest `sha256:cb672739026f35e02d0fe92deaf7a070ff2e1b73cecde638dbea52be3a26c689` (informational — the registry digest after the operator's push is the authoritative one). ## Known limit, flagged rather than papered over `run.sh` runs the selected **offline L0/L1 checks** (`run_offline_checks`) before it touches the cluster, for **any** selection that is not offline-only. Those checks shell out to `flux`, `promtool`, `kyverno` and `kustomize`, which this image deliberately does not carry — baking the whole devcontainer toolchain into the runner would roughly double its size to re-run work `ci/l0.sh` and `ci/l1.sh` already did offline in the same CI run. **Consequence for T-6.1b:** the Job must select the cluster-gated layers only — `args: ["--layer", "l2"]` / `["--layer", "l3"]`, verified above to resolve the offline selection to nothing. The plan's `--layer all --strict-coverage` phase-exit shape and a bare `--requirements <IDs>` would both drag the L0 checks in and fail on a missing tool. Documented in the Containerfile header and the incluster README rather than left to be discovered from a pod log. Choosing the Job's args is T-6.1b's call, not this image's. `chainsaw-job.yaml`'s `image:` is left stale **on purpose** — there is no digest to put there yet, and the trigger renders that template per run (plan §3 item 4). The README now says so instead of letting the placeholder read as a pin. ## Not done, by design — this is operator work **The image is not pushed.** No registry credential, and publishing the artifact that a fail-closed policy then trusts is an operator action. `build-image.sh` prints the exact commands; `conformance/versions.env` repeats them. `CONFORMANCE_RUNNER_IMAGE_DIGEST` is **empty**, not a placeholder: empty fails visibly in whatever renders the Job, whereas a guessed digest would be an `ImagePullBackOff` dressed up as a cluster fault. podman login gitea.home.keithjackson.dev podman push --digestfile /tmp/conformance-runner.digest \ gitea.home.keithjackson.dev/home-network/conformance-runner:chainsaw0.2.12-kubectl1.36.4-fc44 Then a follow-up PR sets `CONFORMANCE_RUNNER_IMAGE_DIGEST` from that digestfile. And a separate, human-reviewed PR should do what the policy's own header asks: replace its `@sha256:*` wildcard with the **literal** digest, closing the residual "anyone who can push to this path" gap. ## Gate `DEV_KUBECONFIG=/dev/null ENGINE=<worktree .git shim> infra/devcontainer/dev.sh run bash -c './ci/gate.sh'` ``` GATE_RC=3 [skip] stage spec-immutability: SKIPPED (not run) [ ok ] stage lint: pass [ ok ] stage l0: pass [ ok ] stage render-golden: pass [ ok ] stage l1: pass [skip] stage gitea-settings: SKIPPED (not run) [skip] stage conformance: SKIPPED (not run) ``` `MERGE GATE: UNDETERMINED` (rc 3). **Nothing FAILED.** touched requirements resolved to `R-SEC-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04`. The three skips are environmental, not results: `spec-immutability` needs the sibling spec repo checked out (CLAUDE.md §3 tolerates exactly this one), `gitea-settings` needs `SETTINGS_API_TOKEN`, and `conformance` L2/L3 needs `CLUSTER_AVAILABLE=true`. **This is not a green run and is not offered as one** — L2/L3 remains UNVERIFIED for every touched requirement above, which is the same coverage hole this PR's image is a prerequisite for closing. ## CORRECTION: there is no `approval-policy` check, and this PR's CI is green I was told to expect CI to fail on an `approval-policy` stage because `conformance/**` is a security-relevant path requiring human approval, and to report that as expected. **I checked instead, and it is not true:** | Claim | Measured | |---|---| | an `approval-policy` CI stage exists | **No.** `grep -rn 'approval-policy\|approval_policy' .gitea/ ci/` → nothing. `gate.yml` has jobs `gate` + `conformance`; `merge-gate.yaml` has `spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate`. | | `conformance/**` is a protected path | **No.** `GET /branch_protections` → `protected_file_patterns: ''`. No path rule of any kind, and no `CODEOWNERS` in the repo. | | required status checks | `status_check_contexts: ['gate / gate (pull_request)']` — one context, not an approval stage. | What this PR's CI **actually** did, read out of the job log (run 128, job 0) rather than off the tick — because a skipped Gitea job reports `success`: ``` [skip] stage spec-immutability: SKIPPED (not run) [ ok ] stage lint: pass [ ok ] stage l0: pass [ ok ] stage render-golden: pass [ ok ] stage l1: pass [ ok ] stage gitea-settings: pass [skip] stage conformance: SKIPPED (not run) [fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED. ``` `gate / gate (pull_request)` = **success in 1m36s**, which means **rc 3 tolerated** by `./ci/gate.sh || [ "$?" -eq 3 ]`, not rc 0. `gate / conformance` also reports `success` — with the description **"Has been skipped"**, which is exactly the trap CLAUDE.md §2 warns about. Note `gitea-settings` **ran and passed** in CI with `REQUIRE_GITEA_SETTINGS=true`, so CI is more complete here than the local toolbox run, where that stage can only skip. **What blocks this PR is the missing human approval** (`required_approvals: 1`, `block_admin_merge_override: true`, and `gitea-admin` authors it and cannot self-approve) — not a failed check. Please review and merge; nothing needs working around. --- ## The init container now has no injection surface Added at the coordinator's request to close half of the admission gap a sibling agent found: the policy pins the **main** container's `command` but **never reads `initContainers`**, so a Job from `conformance-trigger` can carry an init container with `command: ["/bin/sh","-c"]` and an arbitrary script, running as the privileged `conformance-runner` SA. Reproduced by the policy's author — `pass: 3, fail: 0` on a privileged `alpine:latest` init container running `kubectl get secrets -A`. The image now bakes **`/usr/local/bin/conformance-clone`, 0755 root:root**, so uid 65532 cannot replace the one executable the policy will come to trust. Clone logic taken from PR #20's `clone-suite-at-commit`, with both load-bearing properties preserved: - the SHA must match `^[0-9a-f]{40}$` — an unrendered placeholder is a hard failure, never a silent run of the branch tip; - after checkout, `rev-parse HEAD` is **asserted** equal to the requested SHA. It accepts **no arguments**. ### Deviation from the brief, and why I was asked to take SHA, repo URL and target dir from the environment. **The repo URL and clone target are compiled in instead.** The policy contains no `env`, `valueFrom` or `secretKeyRef` clause at all — I checked before writing. So a script that trusted `CONFORMANCE_REPO_URL` would merely move the injection one field over: the trigger points it at a repo it controls, this fetches that, and `./run.sh` from the attacker's tree runs under the privileged SA. "No args" would not have stopped it — it is the *same* hole as `initContainers`, one field further along. The env vars are still read so the Job template stays self-documenting, but a value that disagrees with the baked literal is a hard failure, never a redirect. The SHA is the only genuinely trusted per-run input. ### This is half a fix — please don't read it as the whole one What makes the fixed path **required** rather than merely available is a 4th policy rule demanding `command: ["/usr/local/bin/conformance-clone"]` with no `args` on every `initContainer`. That edits the security boundary, so it belongs in its own reviewed PR and is **not in this branch**. Until it lands, the hole is open. Said so in the script header, the Containerfile, `versions.env` and the incluster README. ### Verified by running it ``` /usr/local/bin/conformance-clone root:root 755 confirmed: not writable by uid 65532 ``` Seven negative cases, each asserted to **exit non-zero** rather than merely to print something: ``` --- rejects arguments --- FATAL: takes no arguments (got 2: --layer l2)… (exit 1) --- rejects a missing SHA --- CONFORMANCE_GIT_SHA is not set… (exit 1) --- rejects a branch name --- FATAL: … not a 40-hex commit id: 'main' (exit 1) --- rejects an UNRENDERED placeholder -- FATAL: … not a 40-hex commit id: '@@…@@' (exit 1) --- rejects HOME off the volume --- FATAL: HOME must be /workspace or under it… (exit 1) --- refuses a REDIRECTED repo URL --- FATAL: CONFORMANCE_REPO_URL does not match… (exit 1) --- refuses a target off the volume --- FATAL: CONFORMANCE_SRC does not match… (exit 1) ``` And the positive case, which is the one that matters — a real clone at a real SHA: ``` requesting commit c8899368e76cf734f9717417167dd683e5bed8bd ==> no GITEA_USERNAME/GITEA_TOKEN in the environment — cloning ANONYMOUSLY ==> shallow clone https://gitea.home.keithjackson.dev/…/home-network-k3s-impl.git -> /workspace/src ==> c889936… is not in the shallow tip; fetching that commit ==> suite ready at c8899368e76cf734f9717417167dd683e5bed8bd --- assert rev-parse HEAD == the requested SHA --- requested: c8899368e76cf734f9717417167dd683e5bed8bd HEAD: c8899368e76cf734f9717417167dd683e5bed8bd confirmed: the working tree is at the requested commit confirmed: run.sh present and executable ``` **Two bugs of my own that only running it could find**, both fixed: the workspace-path guard matched `/workspace/*` and so rejected the image's own `HOME=/workspace`; and the negative checks were written as pipelines, which under `pipefail` aborted the verify at the first expected failure and silently skipped the other six. A verify that stops early looks identical to a verify that passed. The real clone is behind `build-image.sh --verify-clone` because it needs the Gitea host reachable — the default verify stays offline so an air-gapped build is fully checked rather than half-checked. ## Also measured: the clone path works anonymously Restored here because my own earlier body edit truncated it away (see below). From inside the image as uid 65532, against the stock `ca-certificates`: `python3` completes a **verifying** TLS handshake to `gitea.home.keithjackson.dev` (issuer Let's Encrypt `YR2`, notAfter `Dec 12 02:16:57 2026 GMT`), and `ls-remote` returns `HEAD` with **no credential**. So no internal CA bundle is needed, and the read-only deploy token of plan §3 item 2 buys authorisation hygiene rather than reachability. The coordinator has independently confirmed (`private: false`, owner visibility `public`) and escalated it as a decision for the operator. ## Gate, after this change `GATE_RC=3` — `lint`, `l0`, `render-golden`, `l1` all **pass**; `spec-immutability`, `gitea-settings`, `conformance` skipped; **nothing FAILED**. `MERGE GATE: UNDETERMINED`, which is expected and is not offered as green. ## Process note Three parallel agents shared one scratchpad; two siblings collided on a helper path and one PATCHed a PR belonging to neither, getting a plausible `201`. My helpers now use per-agent paths, this PR was audited by reading it back (`head.ref: feat/conformance-runner-image`, no sibling content), and this edit **appends only** — my previous one truncated at a marker and silently deleted my own anonymous-read section, which is exactly the class of bug a `2xx` hides. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-09-30 00:12:19 +00:00
feat(conformance): build the runner image the in-cluster Job could never have run
gate / gate (pull_request) Successful in 1m38s
gate / conformance (pull_request) Has been skipped
526a38d137
The committed L2/L3 Job pins `ghcr.io/kyverno/chainsaw:v0.2.12` with
`command: ["./run.sh"]`. Those two lines cannot both be true. That image carries
the chainsaw binary and essentially nothing else -- no bash, no python3, no
kubectl, no git -- so the Job would have failed at exec of `./run.sh`, before a
single test ran. Nothing in the tree said so.

This adds the image that makes the Job runnable (plan
docs/plans/l2-l3-conformance-ci-wiring.md section 3 item 1):

* conformance/incluster/Containerfile -- bash + python3 + chainsaw + kubectl +
  git-core + tar/gzip/coreutils (the base64-tar results channel of item 3).
  Two-stage: a full Fedora fetches the release binaries and verifies kubectl's
  published sha256; the runtime layer is fedora-minimal and carries no
  downloader. Versions are --build-arg-injected from
  infra/devcontainer/versions.env (CHAINSAW_VERSION, KUBECTL_VERSION,
  FEDORA_VERSION) -- the single source of truth -- so the chainsaw and kubectl in
  the runner are the same pins the toolbox gate runs with. No new pin is invented
  here, and the ARG defaults are EMPTY so a build that forgets one fails loudly
  instead of baking a stale version.

* conformance/incluster/build-image.sh -- builds it, PROVES the toolchain by
  executing it inside the image, and prints the digest. It never pushes: the
  registry credential is not an agent's to hold and publishing the artifact a
  fail-closed policy then trusts is an operator action. It prints the exact
  `podman push --digestfile ...` line instead.

* conformance/versions.env -- CONFORMANCE_RUNNER_IMAGE, with the digest
  deliberately EMPTY and a loud block saying the operator fills it in after the
  push. Empty is a visible failure in whatever renders the Job; a guessed digest
  would be an ImagePullBackOff dressed up as a cluster fault.

WHAT THE ADMISSION BOUNDARY FORCED, AND WHY THE IMAGE OBEYS IT

platform/kyverno/admission/conformance-trigger-job-shape.yaml is Enforce +
failurePolicy: Fail and it is the R-SEC-02 boundary. It is NOT modified here.
Two of its clauses are load-bearing on the image itself:

* `runAsNonRoot: true` with no explicit runAsUser means the kubelet rejects an
  image whose USER is a name it cannot resolve to a uid. So USER is NUMERIC
  (65532), not a friendly name.
* The pod is readOnlyRootFilesystem and the policy admits only the `workspace`
  and `results` volumes -- there is no writable /tmp and one cannot be added
  without editing the boundary. So HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME and
  TMPDIR all point into /workspace, and git's safe.directory config is written
  to /etc/gitconfig at BUILD time because a runtime --global write would fail.

VERIFIED BY RUNNING IT, not by a successful build

As uid 65532 with --read-only: bash 5.3.9, Python 3.14.7, git 2.55.0,
kubectl v1.36.4 (client), chainsaw 0.2.12; / confirmed read-only, /workspace
writable, uid confirmed non-zero. Then the real thing -- `./run.sh --lint`,
`--coverage-only` and `--layer l2 --no-cluster` all rc 0 inside the image
against this worktree, emitting coverage.md/.json and report.junit.xml.

KNOWN LIMIT, FLAGGED NOT PAPERED OVER

run.sh runs the selected offline L0/L1 checks before it touches the cluster for
ANY selection that is not offline-only, and those checks shell out to flux,
promtool, kyverno and kustomize, which this image does not carry. Baking the
whole devcontainer toolchain into the runner would roughly double its size to
re-run work ci/l0.sh and ci/l1.sh already did offline in the same CI run. So the
Job must select the cluster-gated layers only (`--layer l2` / `--layer l3`);
`--layer all` and a bare `--requirements <IDs>` would drag the L0 checks in and
fail on a missing tool. `--layer l2` is verified above to resolve the offline
selection to nothing. Choosing the Job's args belongs to T-6.1b; the constraint
is documented in the Containerfile header and the incluster README rather than
left for someone to discover from a pod log.

chainsaw-job.yaml's `image:` is left stale ON PURPOSE -- there is no digest to
put there yet, and the trigger renders that template per run (plan item 4). The
README now says so instead of letting the placeholder read as a pin.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-09-30 00:15:47 +00:00
docs(decisions-log): handoff for the conformance-runner image (PR #18)
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
ff4892f795
CLAUDE.md section 7. Records what was built, what was VERIFIED by running it, and —
more usefully — what was not:

* the image has never run in the cluster; every proof above is local podman
* it is not pushed, so CONFORMANCE_RUNNER_IMAGE_DIGEST is empty on purpose
* L2/L3 still do not run in CI; this image is a prerequisite, not the closure

And one constraint the plan does not account for, which the next agent would
otherwise hit at runtime: run.sh runs the offline L0/L1 checks for any selection
that is not offline-only, and those need flux/promtool/kyverno/kustomize. So BOTH
of the plan's documented invocation shapes -- `--layer all --strict-coverage` and
a bare `--requirements <IDs>` -- fail on this image. `--layer l2` / `--layer l3`
work, and are verified to. That is a decision for the plan owner, not a detail.

Also records a measurement rather than an assumption: from inside the image as uid
65532 the Gitea host verifies against the stock ca-certificates (Let's Encrypt
YR2) and an anonymous `ls-remote` succeeds, so the init-container clone needs no
internal CA bundle -- and anonymous read is currently open, which means the plan's
read-only deploy token buys authorisation hygiene, not reachability.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-09-30 00:20:34 +00:00
docs(decisions-log): PR #18 has no approval-policy check -- measured, not assumed
gate / gate (pull_request) Successful in 1m37s
gate / conformance (pull_request) Has been skipped
b27254dfee
My brief said this PR's CI would fail on an `approval-policy` stage because
`conformance/**` is a security-relevant path requiring human approval, and to
report that as expected. I wrote that down before checking it. It is not true:

  * `grep -rn 'approval-policy|approval_policy' .gitea/ ci/` returns nothing.
    gate.yml has jobs `gate` and `conformance`; merge-gate.yaml has
    spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate.
  * GET /branch_protections -> protected_file_patterns: '' . No path rule of any
    kind, and no CODEOWNERS file in the repo.
  * status_check_contexts: ['gate / gate (pull_request)'] -- one context, not an
    approval stage.

What blocks this PR is what blocked #12-#15: required_approvals: 1 with
block_admin_merge_override: true, and gitea-admin authors every agent PR and
cannot self-approve. That is a MERGE block, not a CI failure. The distinction is
not pedantry -- "expect a red X" and "expect a green run an agent still cannot
merge" send a reviewer to different places.

The correction is left IN the log rather than quietly deleted. This is the fourth
consecutive entry where a confidently-stated control turned out not to exist, and
the pattern is more useful than a tidy paragraph.

Also records what the CI run actually did, read out of the job log rather than off
the tick (a skipped Gitea job reports success): lint, l0, render-golden, l1 and
gitea-settings all pass; spec-immutability and conformance SKIPPED; MERGE GATE
UNDETERMINED. So `gate / gate` = success means rc 3 tolerated, not rc 0. Note that
gitea-settings RAN AND PASSED with REQUIRE_GITEA_SETTINGS=true -- the detector
armed in #15 works, and CI is more complete than the local toolbox run, where that
stage can only skip.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-09-30 00:23:23 +00:00
fix(conformance): state the chainsaw supply-chain gap accurately
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
c8899368e7
The Containerfile said chainsaw "publishes no per-asset checksum file at a stable
URL". Literally true, and misleading: it reads as "no integrity option exists".
Checked the release assets via the GitHub API -- v0.2.12 ships per-asset cosign
`.sig` + `.pem` and an SBOM alongside each tarball. So verification IS available,
it just needs cosign in the builder stage and the right keyless identity for
kyverno's release workflow.

Comment now says what is actually true: kubectl is sha256-verified, chainsaw rests
on tag + TLS, and the cosign path is named as a TODO rather than implied away.
Deliberately not implemented here -- picking a trusted signer identity is a
supply-chain decision that belongs in its own reviewed change, not a silent
addition to a runner image. Noted that infra/devcontainer/Containerfile has the
same gap for the same binary.

Rebuilt after the edit; the in-image verification still passes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin added 1 commit 2026-09-30 00:34:40 +00:00
feat(sec): bake conformance-clone so the init container has no injection surface
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
fbe3126d3f
Closes half of the admission gap a sibling agent found in PR #13's policy: it pins
the MAIN container's `command` but never reads `initContainers`, so a Job from
`conformance-trigger` can carry an init container with `command: ["/bin/sh","-c"]`
and an arbitrary script, running as the privileged `conformance-runner` SA.
Reproduced by the policy's author: pass 3, fail 0 on a privileged alpine:latest
init container running `kubectl get secrets -A`.

The image now carries /usr/local/bin/conformance-clone, 0755 root:root, so uid
65532 cannot replace the one executable the policy will come to trust. Clone logic
taken from PR #20's `clone-suite-at-commit`, with both load-bearing properties
preserved: the SHA must match ^[0-9a-f]{40}$, and `rev-parse HEAD` is ASSERTED
equal to it after checkout, so an unrendered placeholder is a hard failure instead
of a silent run of the branch tip. No arguments are accepted.

DEVIATION FROM THE BRIEF, DELIBERATE: the repo URL and clone target are COMPILED
IN rather than read from the environment. I was asked to take SHA, repo URL and
target dir from env. The policy contains no `env`/`valueFrom`/`secretKeyRef` clause
at all -- so trusting CONFORMANCE_REPO_URL would move the injection one field over:
the trigger points it at a repo it controls, this fetches that, and ./run.sh from
the attacker's tree runs under the privileged SA. "No args" would not have stopped
it. The env vars are still read so the Job template stays self-documenting, but a
value disagreeing with the baked literal is a hard failure, never a redirect. The
SHA is the only genuinely trusted per-run input.

THIS IS HALF A FIX. What makes the fixed path REQUIRED rather than merely available
is a 4th policy rule demanding command: ["/usr/local/bin/conformance-clone"] with
no args on every initContainer. That edits the security boundary and belongs in its
own reviewed PR -- it is NOT here. Until it lands the hole is open, and the README,
the Containerfile and the script header all say so.

VERIFIED by running it (uid 65532, --read-only): root:root 755 and confirmed not
writable by the runner; seven negative cases each asserted to exit non-zero
(arguments, missing SHA, a branch name, an unrendered @@placeholder@@, HOME off the
workspace volume, a REDIRECTED repo URL, a clone target off the workspace volume);
and the positive case -- a real clone of this repo at a real 40-hex SHA with
rev-parse HEAD proven equal to it.

Two bugs of mine that only running it could find, both fixed: the workspace-path
guard matched /workspace/* and so rejected the image's own HOME=/workspace; and the
negative checks were pipelines, which under pipefail aborted the verify at the
first expected failure and silently skipped the other six. A verify that stops
early looks exactly like a verify that passed.

The anonymous-clone fallback is deliberate and logged: read access to this repo
needs no credential today, the integrity guarantee is the SHA assertion rather than
the credential, and requiring the Secret would make the script untestable without
one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin force-pushed feat/conformance-runner-image from fbe3126d3f to b105c764fc 2026-09-30 03:52:44 +00:00 Compare
jakman85 approved these changes 2026-09-30 03:59:17 +00:00
jakman85 merged commit cac5c9f2a3 into main 2026-09-30 03:59:26 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#18