The image allowlist

How c8s decides what may run — the two-layer data model, per-container argv policy, entry-level secret grants, what an operator key authorizes and what it does not, seeding, and the three points that enforce.

The allowlist decides what may run. Nothing on it, nothing starts. It has two layers: a floor of image digests admitted by digest alone, and named workload entries that additionally pin the command line each image may run with.

Every decision keys on the image digest, never the image reference. The reference a pod presents (docker.io/vllm/vllm-openai:v0.6.3) is chosen by the untrusted host; the digest is bound to the bytes that execute. References in the allowlist are labels for humans.

The allowlist is owned and served by the CDS, enforced at container creation by the nri-image-policy plugin (node-as-CVM) and the in-guest policy-monitor (pod-as-CVM), and managed with the c8s allowlist CLI against an operator-authorized API. This page is the model; the CLI page is the task. The wire surface is in the CDS HTTP API.

Data model

{
  "schema": "c8s.allowlist/v1",
  "digests": {
    "sha256:<cds>": "ghcr.io/confidential-dot-ai/cds",
    "sha256:<get-cert>": "ghcr.io/confidential-dot-ai/get-cert"
  },
  "workloads": {
    "vllm-llama": {
      "label": "docker.io/vllm/vllm-openai:v0.6.3",
      "initContainers": [],
      "containers": [
        {
          "digest": "sha256:<vllm>",
          "image": "docker.io/vllm/vllm-openai:v0.6.3",
          "command": { "policy": "exact", "argv": ["python3"] },
          "args": { "policy": "exact", "argv": ["-m", "vllm.entrypoints.openai.api_server"] }
        }
      ],
      "secrets": { "policy": "allow", "read": ["/tenant-a/**"] }
    }
  }
}
  • schema must be c8s.allowlist/v1. It is the first field of the canonical form, so a foreign or malformed document fails loudly instead of parsing as an empty (deny-everything) allowlist.
  • digests is the floor: digest → image reference. A floor image runs by digest alone, whatever its command line. The platform's own component images and the measured guest seed live here, because their argv is per-pod and must not be argv-policed.
  • workloads maps an operator-chosen name to one entry. The name must match [A-Za-z0-9][A-Za-z0-9._-]* — it is a URL path segment. Each entry lists initContainers and containers; each container binds a digest to its command and args policy. The entry label and each container's image are informational.
  • secrets is an optional grant carried by the whole entry, not by a container: the secret-store paths the workload it names may read and write. policy is allow or deny — there is no any. Paths are absolute and clean, and the only wildcard is a trailing /**, matching strictly beneath its base, so /tenant-a/** does not grant /tenant-a itself. A grant carrying write paths must carry at least one read path. An entry with no grant releases nothing, and a deny grant normalizes away entirely, so it never reaches the canonical document. See Secrets.

Digests are validated as sha256:<64 hex chars> (the OCI image-digest format). The store is a SQLite database (--allowlist-db) — a floor table, a table of canonical workload entries, and a digest index over them. Both layers share one version counter, bumped on every mutation and served as the ETag.

The serialization is canonical: fixed field order, sorted map keys, sorted container lists. Any holder of an equivalent document reproduces the same bytes, so what c8s allowlist export writes round-trips as an upload or as CDS's --allowlist-seed.

Process policy: command and args

A digest already pins the image's baked ENTRYPOINT/CMD — they are in the OCI config the digest covers. A workload policy constrains what a pod may run for those bytes. Without it, an image with an overridable entrypoint can be pointed at an arbitrary command — credential extraction, a reverse shell — while still presenting an allowlisted digest.

The two fields mirror the Kubernetes container fields: command overrides the image ENTRYPOINT, args overrides CMD. The enforcers do not see the override as an override — they see the container's effective argv (the OCI process.args, already merged from the image config and any pod-spec override) and match policy against that:

  • command is matched as an exact prefix of the argv. It may be several tokens (/bin/sh -c, /docker-entrypoint.sh nginx, python3).
  • args governs the remainder of the argv after that prefix.
policycommand (a prefix)args (the remainder)
exactargv must start with its argvthe remainder must equal its argv
anyno prefix constraintthe remainder is unconstrained
denythe whole argv must be emptythere must be no remainder

exact requires a non-empty argv; any and deny take none. Every combination is well-defined: command exact + args any pins the executable and lets flags vary, exact + exact pins the whole argv, args deny means "no arguments beyond the command".

An absent policy normalizes to deny, so a minimally specified container is maximally restrictive. That also means command: deny requires an empty argv and can therefore never start — write command: any if you mean "any argv". c8s allowlist lint flags it.

A digest may run several ways

One digest can appear under several containers, within an entry or across entries. At the per-container gate, admission is the union: the container runs if its effective argv satisfies some listing container's policy. This is deliberate — a shared base image (busybox, a distroless runtime) is legitimately invoked with different command lines by different workloads.

Two consequences to know, both of which lint reports:

  • One entry widening a shared digest to any becomes that digest's effective container-level policy everywhere, because the host chooses which pod pairs a digest with which argv.
  • A floor digest short-circuits every workload policy written for it. The floor admits by digest alone, so the argv policy is silently not enforced. Remove it from the floor to make the policy bite.

Authorizing mutations

The allowlist is the source of truth for what may run, so writes are authorized by an operator key: an EC keypair whose public half you pin into the CDS at install time (c8s install --operator-keyscds.operatorKeys) and whose private half never leaves your side. There is no server-side session and no static bearer secret to hand out — for every write, the c8s allowlist CLI signs a fresh, single-purpose token locally with the operator private key.

  ┌─────────────────────────────┐          ╔═════════════════════════════╗
  │          Operator           │          ║             CDS             ║
  │   holds the EC private key  │          ║  pins operator public keys  ║
  │      (--operator-key)       │          ║      (cds.operatorKeys)     ║
  └──────────────┬──────────────┘          ╚══════════════╤══════════════╝
                 │                                        │
                 │  attested dial · verify the endpoint   │
                 │  against --measurements                │
                 │────────────────────────────────────────►
                 │                                        │
                 │  mint token: pbh = SHA-256(body) ·     │
                 │  htm/htu = method/path · 60s TTL       │
                 │                                        │
                 │  POST /allowlist/digests               │
                 │  {digest, image} + Bearer token        │
                 │────────────────────────────────────────►
                 │                                        │
                 │                          ┌─────────────┴─────────────┐
                 │                          │ signature ∈ pinned keys · │
                 │                          │ exp−iat ≤ 5m · htm/htu    │
                 │                          │ match request · pbh =     │
                 │                          │ SHA-256(received body)    │
                 │                          └─────────────┬─────────────┘
                 │                                        │
                 │  204 · version bumped                  │
                 ◄────────────────────────────────────────│
                 │                                        │
                 ▼                                        ▼

A write is bound to the operator key, the HTTP method and path, and the exact body bytes — a captured token cannot be replayed against a different change.

The token is a JSON Web Token (JWT) signed with the operator's ECDSA key (ES256, ES384, or ES512, matching the key's curve), minted fresh by the CLI for each write with a 60-second lifetime. Three claims bind it to that one write:

  • pbh — the SHA-256 hash of the exact request body,
  • htm — the HTTP method,
  • htu — the URL path.

The CDS accepts a mutation only when all of it checks out: the signature verifies against a pinned key; the token carries issue and expiry times no more than five minutes apart (a server-side cap, so no client tooling can mint a long-lived token); the method and path match the request it is actually handling; and the body hash matches a re-hash of the body the CDS actually received, compared in constant time.

Verification happens at the application layer — the listener stays plain RA-TLS, so mesh clients are unaffected. Writes fail closed: with no pinned keys, every mutation is rejected while reads keep serving. The same keys authorize floor and workload writes alike.

Know the boundaries of this design before relying on it:

  • A pinned operator key is the image-integrity control. Anyone holding the private key can rewrite what may run. Keep it in a vault, HSM, or hardware token, and supply it to the CLI per invocation.
  • Revocation is coarse. Operator keys are long-lived, and revoking one means removing its public key from cds.operatorKeys and re-installing — there is no CRL/OCSP-style revocation to lean on.
  • A captured token is briefly replayable — against the same change. The body/method/path binding stops cross-payload replay, but the token carries no cluster (aud) binding: two clusters pinning the same operator key would accept each other's tokens within the validity window. Pin distinct keys per cluster.
  • The pinned-key list is host-supplied config, detected rather than prevented. The CDS serving certificate commits its key and launch measurement — not the operator-key set, and not the applied seed. A control plane can restart the CDS with a different bundle. What closes the loop is c8s cds verify --operator-keys, which fetches the served set over the attested serving certificate and fails closed on a mismatch. It protects only the verifier that runs it, so run it continuously in CI rather than once at bootstrap. The key set is covered by attestation on the CDS /handoff and /attest-key paths, so a replacement replica cannot adopt the mesh CA under a different write policy.

Planned, not yet shipped.

A CA issuing short-lived operator certificates — giving delegated issuance and real revocation instead of editing a pinned-key list. See Limitations.

Seeding and bootstrap

  • At CDS startup, --allowlist-seed <file> loads a full document (floor and workloads) into the store before the server serves its first request. Seeding is additive: it inserts only what is missing and leaves existing entries untouched. Any seed error halts startup (fail-closed) — the CDS must not serve a partial allowlist. The chart renders the seed from nriImagePolicy.bootstrapAllowlist (its digests and workloads maps). With the default --resolve-digests=true, c8s install resolves each c8s component image to its digest and derives it into the floor, so the platform's own images are covered from first boot.
  • Under pod-as-CVM, the guest image bakes /etc/c8s/bootstrap-allowlist.json — a flat sha256_digests list that is part of the guest's launch measurement. policy-monitor enforces against it from t=0, before the first container starts and with no network at all — so there is no boot-path fetch a host could stall to open a window.

The in-guest CDS refresh that would layer operator writes on top of that baked seed is gated on a pinned CDS measurement (C8S_CDS_MEASUREMENTS alongside C8S_CDS_URL): with no pin, policy-monitor disables the refresh rather than accept any attested CDS, because the host can boot its own CVM from the same guest image. No shipping path delivers that pin today, so on a default Kata install the refresh is off — a c8s allowlist write reaches the CDS and the host-side enforcer, but not running guests, which keep enforcing the measured seed alone. Budget a guest-image rebuild for pod-as-CVM allowlist changes. See Limitations.

Enforcement

Three independent points enforce, at different strengths:

  • Host NRI plugin (nri-image-policy), on the CreateContainer hook, per container. It resolves the image digest and checks it plus the effective argv against the allowlist index. It is fail-closed before the allowlist first loads — with no list available, nothing runs. nriImagePolicy.policy.mode is fail-closed by default; audit logs the would-be denial and admits, which is a bring-up setting, not a production one. For kata pods the plugin sits on the untrusted side of the TEE boundary, so it is defense-in-depth there and the primary gate for base-mode pods.
  • In-guest policy-monitor (pod-as-CVM), watching each new container bundle's config.json for the digest and process.args. This is the load-bearing gate for confidential pods: the host is untrusted, guest-pull is forced, and a violation kills the container's whole cgroup as a unit through the kernel's cgroup.kill interface — never a PID picked out of cgroup.procs, which the kernel does not order and which a recycled PID can turn into the wrong kill. A kill that cannot be delivered is reported as a failure, never as a success. Because the monitor is baked into the launch measurement, the host cannot disable or bypass it.
  • CDS at certificate issuance. Before signing a leaf for a pod, the CDS asks that pod's own admission inventory which images its sandbox is running — a report of (digest, argv) pairs for everything ever admitted there. Every reported digest must be allowlisted, in the floor or in some workload entry. This is a membership check: issuance lands mid-lifecycle, when the running set is a strict subset of the declared one, so requiring a whole entry would refuse ordinary pods. See CDS.

The honest guarantee is per-container digest plus argv, everywhere — and no combination gating. Nothing today enforces "only this set of images may run together": NRI and policy-monitor see containers one at a time and cannot detect a missing one, and the CDS sees the whole reported set only at issuance, where it can check membership but not composition. Env, mounts, capabilities, and the rest of the pod spec are likewise outside the allowlist. See Limitations.

The two layers also refresh differently. The floor is additive — a digest, once served, is never dropped by a consumer, so a CDS outage or a stale read degrades to "the same set or larger", never to "open". The workload overlay swaps wholesale, gated on the version counter: a consumer applies a pulled overlay only if its version is greater than the last one it applied. Workload policy can tighten, and a plain additive merge would let a host that withholds an update keep a laxer policy live forever. The high-water mark is process-local, so this rejects rollback only within a consumer's lifetime; after a restart the first version seen is trusted.

See also