Manage the allowlist

Read and change the c8s image allowlist with the c8s allowlist CLI — create the operator credential, reach the API through the front door or a direct CDS URL, change the digest floor and workload policy, grant secret paths, and get an image's digest.

c8s allowlist is the operator tool for the image allowlist: unauthenticated reads (list, export, diff, workload list, workload get, lint, inspect-image) and operator-signed writes (add, remove, upload, workload apply, workload edit, workload delete). Add every image a confidential workload needs — the workload image and any init containers — before you deploy, or the enforcer will refuse the container at start.

For what the two layers mean and what an operator key does and does not protect, see The image allowlist. Every flag is in the CLI reference.

c8s allowlist is the operator tool for the API above — unauthenticated reads (list, export, diff, workload list, workload get, lint, inspect-image) and operator-signed writes (add, remove, upload, workload apply, workload edit, workload delete). Add every image a confidential workload needs (the workload image and any init containers) before you deploy, or the enforcer will refuse the container at start. Every flag is in the CLI Reference.

Create the operator credential

Generate an EC keypair (P-256, P-384, and P-521 are accepted) and pin the public half at install time — the private key stays with you:

# the operator private key — keep it in a vault/HSM; it never leaves your machine
openssl ecparam -name prime256v1 -genkey -noout -out operator.key

# the public half — this is what the CDS pins
openssl ec -in operator.key -pubout -out operator.pub

c8s install --operator-keys operator.pub   # plus your other install flags

To authorize several operators, concatenate their .pub files into one bundle — and pin distinct keys per cluster. Installing without --operator-keys leaves allowlist writes disabled (reads still work), so c8s install refuses the default path unless you acknowledge with --force.

cds.operatorKeys is the PEM content, never a file path. If you set the chart value yourself — a values file, a Flux HelmRelease, or helm --set-file — paste the PEM block; a path from the machine that rendered the values is meaningless in-cluster, and the chart fails the render if the value doesn't look like PEM. c8s install --operator-keys and c8s render-values --operator-keys both read the file and embed its content for you.

Reach the allowlist API

By default the chart publishes the complete /allowlist API through the tls-lb front door — the same host your application traffic uses. Point --url at it and pin the tls-lb launch digest:

URL=https://<TLS_LB_HOST>
DIGEST=<TLS_LB_LAUNCH_DIGEST>
  ┌───────────────────────────────┐
  │        c8s allowlist          │
  │  --url https://<TLS_LB_HOST>  │
  │  --measurements <TLS_LB_...>  │
  └───────────────┬───────────────┘
                  │  1. GET /v1/discovery over one TLS connection;
                  │     verify the evidence against --measurements;
                  │     require the attested cert to be this handshake's leaf

  ╔═══════════════════════════════╗
  ║            tls-lb             ║  nginx + an in-pod RA-TLS-verifying proxy
  ║          /allowlist           ║  (and /allowlist/)
  ╚═══════════════╤═══════════════╝
                  │  2. verify the CDS's RA-TLS evidence against
                  │     cds.measurements, then forward the method,
                  │     URI, body, and Authorization header unchanged

  ╔═══════════════════════════════╗
  ║             CDS               ║  owns the store; checks the operator token
  ╚═══════════════════════════════╝

Two attested hops. Because the front door forwards the request bytes untouched, the operator token's method, path, and body bindings still match what the CDS receives.

The CLI picks its verification path by probing the target: if it serves a discovery document it is treated as a front door and verified through it; if it serves none — a direct CDS URL — the CLI falls back to verifying the CDS's RA-TLS serving certificate. A discovery document that is present but fails verification is a hard error, never a fallback.

The CLI refuses a WebPKI front door.

When tlsLb.publicTLS.secretName is set, tls-lb serves your own public certificate and reports public_tls.mode=webpki in its discovery document. That certificate is not cryptographically bound to the discovery attestation, so the CLI rejects the endpoint rather than trust it. Use a direct CDS RA-TLS URL and pin the CDS launch digest instead.

The same applies when the public route is gone: tlsLb.allowlist.enabled=false, or a tlsLb.routes entry of your own at /allowlist or /allowlist/, removes the built-in route.

For the direct path, pin the CDS launch digest, and port-forward the CDS if it is not otherwise routable — the CLI verifies the CDS's attestation, so the localhost hop is fine:

kubectl port-forward -n c8s-system svc/c8s-cds 8443:8443 &

URL=https://localhost:8443
DIGEST=<CDS_LAUNCH_DIGEST>

This works under Pod-as-CVM too: the guest image exempts port 8443 from the in-guest mesh's inbound mTLS redirect, so a port-forward reaches the CDS's RA-TLS listener. What the locked guest policy denies is kubectl exec and kubectl logs, not port-forward.

--measurements (or --measurements-file, one hex digest per line) always names the trusted build of the endpoint you connected to — never a third party's. Get the value from Obtaining launch measurements, and check the front door on its own with c8s verify --kind lb. Empty measurements accept any attested endpoint and warn UNSAFE; a plaintext http:// URL is refused outright unless you pass --insecure (dev/test only, skips attestation entirely).

Two operational notes for the front-door path. The session is bound to the single TLS connection whose handshake was attested and is never redialed, because a second handshake could reach a different tls-lb replica holding a different certificate — if the connection drops, the command fails closed and asks you to re-run it. And the route is rate-limited before nginx collapses callers onto the in-pod proxy: mutations at 1 r/s with burst 5 per client and 8 r/s with burst 15 across all clients, reads at 20 r/s with burst 40 per client, over-limit requests answered 429. A direct CDS URL is accounted under your own source address instead.

Read it

# the floor and the workload entries (text; -o json for machine-readable)
c8s allowlist list --url $URL --measurements $DIGEST

# back it up — canonical JSON that round-trips as an upload or a seed
c8s allowlist export allowlist.json --url $URL --measurements $DIGEST

# preview what a changed file would do; --exit-code makes any difference non-zero
c8s allowlist diff allowlist.json --url $URL --measurements $DIGEST

diff reports the floor as + (added), - (removed), and ~ (image reference changed), and workload entries the same way, with a field-level breakdown of every changed entry. Run it before any upload.

Change the floor

Writes additionally take the operator private key, by flag or the C8S_OPERATOR_KEY environment variable (the flag wins). add, remove, and upload all support --dry-run:

# add one digest (see "Getting an image's digest" below)
c8s allowlist add <IMAGE_DIGEST> <IMAGE_REF> \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

# remove one or more digests
c8s allowlist remove <IMAGE_DIGEST> \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

# replace the whole document from a file — lints it, prints the diff, then writes
c8s allowlist upload allowlist.json \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

<IMAGE_DIGEST> is the image's sha256: manifest digest and <IMAGE_REF> the image reference it maps to. remove warns when the digest you dropped is a c8s component image: removing it from the CDS does not lock it out, because the plugin's always_allow set and the in-guest baked seed still admit it — replace a compromised component digest by rolling the chart instead.

upload refuses a file whose image labels name none of the core c8s components (cds, ratls-mesh, nri-image-policy, attestation-api, nginx) — a cluster missing them cannot pull its own control plane. Override with --force, or change the required set with --require. It also runs the offline lint first: a lint error blocks the write regardless of --force, and --strict makes warnings block it too.

Change workload policy

Workload entries are managed as whole entries. Nothing field-merges, so no command can silently clobber a sibling field, and every mutating path shows the field diff first:

# what is there
c8s allowlist workload list --url $URL --measurements $DIGEST
c8s allowlist workload get vllm-llama --url $URL --measurements $DIGEST

# upsert entries from a file (or '-' for stdin); --dry-run stops before the write
c8s allowlist workload apply entries.json \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

# fetch → $EDITOR → lint → diff → confirm → write
c8s allowlist workload edit vllm-llama \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

c8s allowlist workload delete vllm-llama \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

apply accepts either a full allowlist document or a bare name-keyed map of entries; floor digests in the file are ignored (use add or upload for those). It lints the file and cross-checks it against the served allowlist, because the entry a new one collides with is usually one already there.

Check a file before you write it with lint, which reads a file or stdin and never contacts the CDS:

c8s allowlist lint entries.json --strict

It reports entries with no containers, a command: deny container that can never start, a shared digest whose union has been widened to any, a digest that is floor-listed while also carrying a workload policy, tag-form image labels (mutable, so TOCTOU), an entry granting the root secret subtree /** (every secret in the store), and a count of the any policies the document carries. Two entries declaring the same containers with the same argv policy are an error, not a warning: nothing a running pod can do tells them apart, so both are refused forever. Errors exit non-zero on their own; --strict makes warnings do the same, which is what you want in CI. --online additionally checks each digest resolves in its registry via crane.

Grant secret paths

A grant belongs to the entry, so it takes the same path as everything else in it: a whole-entry, operator-signed write. There is no separate command and no second credential.

{
  "vllm-llama": {
    "initContainers": [],
    "containers": [
      {
        "digest": "sha256:<vllm>",
        "command": { "policy": "exact", "argv": ["python3"] },
        "args": { "policy": "any" }
      }
    ],
    "secrets": {
      "policy": "allow",
      "read": ["/tenant-a/**"],
      "write": ["/tenant-a/session"]
    }
  }
}
c8s allowlist workload apply entries.json \
  --url $URL \
  --measurements $DIGEST \
  --operator-key operator.key

Writing the grant is what turns release on: an entry carrying none releases nothing, and the CDS refuses a workload that resolves to it. Revoking is the same write in reverse — set policy: deny, or drop the field, and apply. Because the entry is replaced whole, the grant and the container policy it rides on always move together.

A grant is only as reachable as its entry. Release resolves a pod's running containers to exactly one workload entry — not to membership, as certificate issuance does — so an entry nothing resolves to hands out nothing, and neither does either half of a pair no running set can tell apart (lint reports that pair as an error). See Secrets for the release decision and how to debug one.

Two tooling notes. The field diff that apply and edit print covers the label and the container policies but not the grant: workload edit therefore reports no changes and writes nothing when the grant is your only edit, while workload apply prints = <name> (unchanged) and writes it anyway — so use apply for a grant-only change. And the workload table printed by list and workload list summarizes each entry's grant in its last column (still headed PATHS): deny, or allow(r=…,w=…) with the number of read and write paths it carries.

Verify who can write

Which operator keys the CDS actually pins is a verification task, and c8s cds verify --operator-keys is where it lives — it fetches GET /operator-keys over a connection pinned to the attested serving certificate and fails closed on a key set that does not match your bundle. See Check the operator keys CDS pins.

Run it on a schedule in CI rather than once at bootstrap — the pinned-key list is host-supplied config, so it is detected, not prevented.

Getting an image's digest

Every allowlist entry is keyed by an image's sha256: manifest digest — the immutable content address, not a tag. A few ways to get one, no full pull required:

With c8s allowlist inspect-image — resolves the digest and prints the image's baked Entrypoint and Cmd, which is what you need to write an exact argv policy. It reads the registry only and never contacts the CDS:

c8s allowlist inspect-image ghcr.io/example/inference:v3
ref:        ghcr.io/example/inference:v3
digest:     sha256:9f2c…
entrypoint: /usr/bin/inference
cmd:        --port 8080

With crane (c8s install requires it on PATH unless you pass --resolve-digests=false, and inspect-image and lint --online shell out to it) — works against any OCI registry: ghcr.io, Docker Hub, an internal mirror:

crane digest ghcr.io/example/inference:v3
# sha256:9f2c…

With Dockerbuildx imagetools reads it straight from the registry without pulling:

docker buildx imagetools inspect ghcr.io/example/inference:v3 --format '{{.Manifest.Digest}}'

…or, if you've already pulled the image, read the digest it resolved to:

docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/example/inference:v3
# ghcr.io/example/inference@sha256:9f2c…

With skopeo — handy in CI where Docker isn't running:

skopeo inspect docker://ghcr.io/example/inference:v3 | jq -r .Digest

Registry UIs show it too — ghcr.io lists the digest on a package's version page, and Docker Hub shows it per tag.

Multi-arch images.

A tag like …:v3 can point to a manifest list with one entry per platform. crane digest returns the index digest by default, but a node resolves that tag to its platform-specific manifest when it pulls — and the enforcer matches the …@sha256:… digest the runtime actually resolved. Pin that one, e.g. crane digest --platform linux/amd64 ghcr.io/example/inference:v3, or read it back from a running pod:

kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].imageID}'

Pin the index digest of a multi-arch image and the check will reject the very image you meant to allow.

Automating the allowlist

A write is a single CLI call authorized by a key file, so it scripts cleanly — but automation here holds a live credential for the image-integrity control. Treat the operator key like a release-signing key, not a CI variable: vault-managed, one key per cluster and per pipeline, and removed from cds.operatorKeys when retired.

In CI. A pipeline that builds a workload image knows the resulting digest at build time. Give the release job its own operator key and make the final step:

c8s allowlist add "$(crane digest "$IMAGE")" "$IMAGE" \
  --url "$URL" \
  --measurements "$DIGEST" \
  --operator-key /run/secrets/operator.key

Pair it with two gates in the same pipeline: c8s allowlist lint --strict on any policy file you keep in source control, and c8s cds verify --operator-keys so a swapped write policy fails the build rather than going unnoticed.

The token minted for that call expires in 60 seconds and can authorize only that exact change, so what transits the pipeline is narrow; the key file is the thing to protect. The CDS logs every write it accepts — the floor digest and image, or the workload entry name — and every one it rejects, but never which pinned key signed it.

With kettle (planned). kettle, Confidential AI's attested-builds system, produces images whose digest is tied to a cryptographic attestation of how they were built — reproducible source → measured artifact.

The goal is to let a kettle-attested build feed the allowlist directly: a digest admitted because the build proved itself, not because a key holder vouched for it. That closes the loop — source → reproducible build → measured image → allowed to run in the TEE — with nobody hand-curating digests.

None of this is wired up yet. It needs an attested-writer path — a launch measurement, rather than a pinned key, authorizing the write. See Limitations.

See also