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 flagsTo 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 $DIGESTdiff 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.keyapply 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 --strictIt 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.keyWriting 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:v3ref: ghcr.io/example/inference:v3
digest: sha256:9f2c…
entrypoint: /usr/bin/inference
cmd: --port 8080With 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 Docker — buildx 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 .DigestRegistry 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.keyPair 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
- The image allowlist — the data model, argv policy, and enforcement.
- CDS HTTP API → Allowlist — the endpoints these commands drive.
- Verify a deployment — cross-check which keys the CDS actually pins.