Guard Supply Chain
Name guard code by its digest, admit it only on verified signatures, and replace it while calls are in flight.
On this page
A guard decides what agents may do, so the node pins guard code to exact bytes. An artifact is named by its manifest digest and loads only after the signatures its policy requires verify. A replacement swaps in between calls, and a call in flight finishes on the module it started with.
A guard artifact travels from publish to a registry, through a digest-pinned pull into the node's cache, past the load gate, and into the reload engine, which moves one guard's module pointer across a replacement.
Each call keeps the module it acquired
The engine builds the replacement backend first, then stores one new pointer under the reload mutex. Call A acquired epoch 0 before the store and finishes on it. Call B starts after it and acquires epoch 1. Guard Supply Chain.
A guard artifact is named by the digest of its OCI manifest
An artifact is an OCI image manifest with artifactType application/vnd.chio.guard.v1+wasm, one JSON config blob, and exactly three layers in a fixed order: the WIT interface, the WASM module, and the guard manifest. The manifest's digest is the artifact's name.
The two directions take different reference shapes, and each refuses the other's while parsing the string. Publish takes a tag, oci://ghcr.io/example/tool-gate:v1. Pull takes a digest, oci://ghcr.io/example/tool-gate@sha256:<64 hex>, so the bytes a node fetches are fixed before it contacts the registry. Transport is HTTPS, and referrer, blob, and token reads leave the process through the Node Egress Contract.
Publish packages the module and records a signer key in the config blob; it creates no signature. Signatures come from two places: chio guard sign writes an Ed25519 .wasm.sig envelope beside the module, and the release signer attaches a Sigstore bundle to the artifact as an OCI referrer.
Read the layout in Artifact layout and Publish below.
A pull refuses before it writes a byte to the cache
pull_guard_to_cache runs in a fixed order, and every step can refuse:
- Manifest. Fetch the raw manifest and compare the registry-reported digest with the pinned one; a mismatch is
ManifestDigestMismatch. - Layers. Pull the three layers and compare the digest the image pull reported. This second check runs when the client returns a digest for the pulled image.
- Bundle. Use a caller-supplied Sigstore bundle, or discover one through OCI 1.1 referrers, filtered by the Sigstore bundle artifact type and sorted by digest so the pick is deterministic. The referrer must name the pinned digest as its subject, and its blob must match media type, size, and digest.
- Verify.
chio guard pull --sigstore-identity-regex ... --sigstore-oidc-issuer ...sets a Sigstore policy. With one, a verifier without an expected identity, or the reverse, isInvalidClientConfig, and a policy with no bundle anywhere isSigstoreBundleNotFound, refused before the verifier runs. - Admit to cache.
validate_cache_admissionrecomputes the manifest digest, requires the OCI image manifest media type, the Chio artifact type, and exactly three layers, and checks media type, digest, and size for the config and every payload.
The registry must serve the OCI 1.1 referrers API, or the caller passes --sigstore-bundle: discovery runs on every pull that supplies no bundle, and a 404 from /referrers fails the pull with ReferrersStatus.
A pull without a Sigstore policy succeeds and reports sigstore_verified: false, caching any discovered bundle unverified. A cache entry is therefore not evidence of verification, and the load gate verifies again rather than trusting the entry's existence.
The load gate admits a module only on the verification its policy requires
Three verification kinds exist, and the admission rule differs by kind:
pub fn admits_guard_load(&self) -> bool {
match self.kind {
GuardVerificationKind::Ed25519Only => true,
GuardVerificationKind::SigstoreOnly | GuardVerificationKind::DualVerified => {
self.rekor_inclusion_verified == Some(true)
}
}
}A Sigstore or dual-mode load needs the verifier to report Rekor inclusion verified; a bundle without it is denied, with VerifyMissingRekorProof from an admission API. Ed25519Only is the local-file loader's mode: chio-wasm-guards::manifest reads a .wasm and .wasm.sig pair beside a local guard-manifest.yaml, and a registry-pulled artifact is admitted through the Sigstore path.
Before the verifier runs, the gate re-reads the five artifact files and revalidates them against the pinned manifest, so an entry tampered with after admission is denied as cache-integrity-failed. Every decision carries one GuardLoadEvent named chio.guard.verify, with the result, verification kind, source, pinned digest, and a stable reason on a deny.
Read every mode and deny reason in Verification modes below.
A replacement is built before the pointer moves
A guard registered with the engine holds its module behind an ArcSwap<LoadedModule>. An evaluation takes one load_full() snapshot before executing and uses it for the whole call. The reload path compiles the replacement first, and only then stores a new pointer:
let module_sha256 = hex::encode(Sha256::digest(new_module_bytes));
self.ensure_digest_not_blocklisted(&module_sha256)?;
let backend = self
.backend_factory
.build_backend_for_guard(guard_id, new_module_bytes)
.map_err(|source| HotReloadError::ReplacementLoad {
guard_id: guard_id.to_string(),
source,
})?;
let epoch_id = guard
.replace_loaded_module(backend, Some(module_sha256))
.ok_or_else(|| HotReloadError::EpochCounterExhausted {
guard_id: guard_id.to_string(),
})?;Three properties follow from that order. A blocklisted digest is refused before a compiler runs. A module that fails to compile leaves the live epoch untouched. And the guard always has a module: the swap is a single store of an already-built value under a reload mutex. Engine::new installs the default blocklist, and without_blocklist() removes it.
Epochs come from a monotonic u64 counter reserved through fetch_update; exhausting it returns EpochCounterExhausted instead of wrapping. A rollback makes the previous epoch current again by restoring a held snapshot, without allocating a new epoch, and the last completed evaluation's stored evidence keeps its own epoch and module digest across a swap. The engine offers four entry points:
| Entry point | Blocklist | Canary | Backends built | Rollback |
|---|---|---|---|---|
reload | Yes | No | 1 | No; the previous module is dropped |
reload_with_canary | Yes | 32 frozen fixtures | 2 | No |
reload_with_watchdog | Yes | No | 1 | A ReloadWatchdog holds the previous module |
reload_debounced | Yes | No | 1 | No |
The reload engine is a library in chio-wasm-guards that the host drives: it decides which entry point to call, feeds the watchdog, and wires file or registry triggers to reloads.
Read the details in Reload entry points, Rollback watchdog, and Incident record below.
Module bytes reload in place, and configuration takes a restart
A reload replaces the module bytes behind an already-registered guard id. The name, the advisory flag, and the pipeline position belong to the WasmGuard handed over at registration, and the limits come from whatever the ReloadBackendFactory closure captured when the engine was built. An unregistered guard cannot be reloaded into existence: GuardNotFound returns before anything else runs.
The chio.yaml schema for WASM guards is WasmGuardEntry, under the wasm_guards key, with five fields: name, path, fuel_limit (default 10,000,000), priority (default 1000), and advisory. Kernel startup loads the entries in priority order. The guest memory cap is MAX_MEMORY_BYTES, a 16 MiB constant in chio-wasm-guards/src/host.rs, which embedders override through WasmtimeBackend::with_limits. All of these are read once at startup, so adding, removing, or retuning a guard is a restart; Node Lifecycle covers what a restart costs.
The artifact cache, the blocklist, and the incident record are files on each node, so each node pulls and blocks its own guards. Cluster replication carries budgets, tool and child receipts, lineage, and revocations; Replication & Convergence lists what crosses node boundaries.
Artifact, verification, and reload reference
Try wit_world, VerifyMissingRekorProof, canary, or blocklist. Each entry has its own link.
Artifact layout
| Position | Media type | Role annotation | Cache file |
|---|---|---|---|
config | application/ | n/a | config.json |
layer 0 | application/ | wit | wit.bin |
layer 1 | application/ | wasm | module.wasm |
layer 2 | application/ | manifest | guard- |
The config blob carries six fields: a schema_version of chio.guard.config.v1, the pinned wit_world, an ed25519:<base64> signer key, fuel_limit, memory_limit_bytes, and an operator-supplied epoch_id_seed. The CLI defaults the two limits to 5000000 and 16777216 and requires --epoch-id-seed. GuardArtifactConfig is built at publish; the pull path checks the config descriptor's media type, digest, and size and writes the bytes to config.json. At runtime, fuel comes from the node's wasm_guards entry and the memory cap from MAX_MEMORY_BYTES.
Reference shapes
| Direction | Accepted | Refused |
|---|---|---|
GuardPublishRef | oci://ghcr. | A digest ( Publish) or no tag (Publish) |
GuardOciRef | oci://ghcr. | Neither tag nor digest ( MissingDigest); any tag, with or without a digest (Tagged); uppercase or short hex (Invalid) |
Both shapes require the oci:// scheme and an explicit registry: a first path component that is localhost or contains a dot or a colon. Transport is HTTPS unless a host is named in --allow-http-registry, and accept_invalid_certificates is hard-coded false.
Referrer, blob, and bearer-token reads go through an HttpEgressContract built per URL, so a denied egress is a typed ReferrersEgress error. The contract allows one scheme and one authority, denies link-local and IPv6 ULA addresses, denies loopback unless the host is HTTP-allowlisted, and caps a read at three redirects and 16 MiB of response. The bulk manifest and layer pulls go through oci-distribution, outside that contract.
On-disk roots
| Root | Default path | Written by |
|---|---|---|
Artifact cache | ${XDG_CACHE_HOME:-~/.cache}/chio/guards/<sha256:digest>/ | Guard |
Digest blocklist | ${XDG_STATE_HOME:-~/.local/state}/chio/ | Guard |
Reload incidents | ${XDG_STATE_HOME}/chio/incidents/<utc>-<guard_id>-<reload_seq>/ | Incident |
With no XDG variable and no HOME, the cache refuses with CacheRootUnavailable and chio guard pull exits. The blocklist's fallback is in Digest blocklist. The incident root is whatever the caller passes in WatchdogConfig.
Publish
GuardPublishArtifact::build performs no network I/O. It serializes the config, wraps the three layer payloads with their media types and org.chio.layer.role annotations, and stamps the manifest with the Chio artifact type, the org.chio.guard.wit_world annotation, and an optional org.chio.guard.signer_subject. Pushing is a separate call.
Before that, the CLI runs a preflight. It parses guard-manifest.yaml, rejects any wit_world other than chio:guard/guard@0.2.0 (an absent wit_world defaults to it), requires wasm_sha256 to be 64 lowercase hex characters, and refuses to publish when the digest recomputed from the module on disk differs from the declared one. That digest comparison is the integrity check publish performs.
Publish requires a signer key, from --signer-public-key or signer_public_key in the manifest, and normalizes bare hex into ed25519:<base64>. --signer-subject becomes a manifest annotation. The .wasm.sig envelope from chio guard sign sits beside the module, outside the three layers.
Pull details
Layers are located by media type, not by position, and a media type that appears twice is rejected (descriptor_for_layer). The role annotations written at publish are provenance for people; resolution never reads them. Cache writes are fs::write calls under the process umask, so an entry left partial by a crash fails its digest check at the next load.
Verification modes and deny reasons
Verification is delegated. GuardSigstoreVerifier forwards bytes to the shared AttestVerifier trait in chio-attest-verify and maps every failure to a distinct typed error, from VerifySignatureMismatch and VerifyWrongOidcIssuer through a catch-all VerifyFailedClosed. Sigstore-mode admission requires rekor_inclusion_verified to be Some(true), and the verifier reports it true only for a bundle whose Rekor inclusion it checked.
A deny arrives by two routes with two reasons. An admission API (verify_bundle, verify_cached_layout_report) raises VerifyMissingRekorProof inside the closure, so the gate records signature-verification-failed with no identity. A report-only API returns rekor_inclusion_verified: Some(false), so the gate re-checks admits_guard_load itself and records rekor-inclusion-unverified with the identity and subject digest the bundle carried. Report-only lets an operator read that metadata without the API acting as an admission decision, and routing it through the gate still denies.
- Ed25519 only.
admits_guard_loadreturns true forEd25519Only, and the cache layout has no slot for a.wasm.sigfile. Ed25519 sidecar verification lives inchio-wasm-guards::manifest, the local-file loader. - Dual. Dual mode reconciles the two signing modes:
verify_dual_modeinvokes both closures before inspecting either result, then requires Rekor inclusion, then agreeing artifact digests, then matching normalized identities. Digest and identity disagreements are distinctVerifyFailedClosedmessages.
GuardLoadEvent (chio.guard.verify) is attached to the returned value or to the error. It carries the result, the verification kind, the source (network or offline-cache), the pinned digest, a stable reason on a deny, and the identity and subject digest when verification got that far. The crate builds the event and returns it; the caller emits it. The gate does no network I/O, so an OnlineCacheIncomplete deny means the fetch into cache did not finish. The cached Sigstore bundle has no descriptor in the OCI manifest, so in the two Sigstore modes the gate checks that its file exists.
Reload entry points
reload_with_canary builds one backend, replays every fixture in CANARY_FIXTURE_COUNT through it, drops it, and builds a second from the same bytes to publish, so the published module has evaluated nothing. The corpus is content-addressed: a MANIFEST.sha256 must name exactly the set of .json files present, each fixture digest must match its line, names must be single non-traversing components, and the count must be exactly 32. Comparison is over serialized verdict bytes, so a changed deny reason fails the canary as a flipped decision does.
Debouncing is per guard. The first request claims the slot and sleeps; requests arriving during the window overwrite the pending bytes and return None. When the window closes, the executor publishes the latest bytes: registry_poll_burst_does_not_double_swap fires 100 requests into one window and asserts a single applied reload at sequence 1 carrying the digest of the last submission. A Drop guard releases the in-flight flag when the executor future is canceled.
Triggers and reloads are separate. The engine can watch a path and poll a registry digest, and both send a ReloadTrigger down an mpsc channel for the host to act on. The file watcher is a non-recursive PollWatcher on a 100 ms interval with content comparison enabled, and it ignores read-only access events. The registry poll task uses the awaiting send and exits with TriggerReceiverClosed when nobody is listening.
Rollback watchdog
reload_with_watchdog keeps the previous Arc<LoadedModule> alive. The default policy trips after five error-class verdicts inside a 60-second sliding window, and a recorded success clears the streak. Error classes include traps, fuel exhaustion, serialization failures, and other fail-closed backend errors; an advisory guard's errors count toward the streak as well. The holder of the ReloadWatchdog records each one with record_error.
On a trip it calls restore_loaded_module with the module it already holds, so rollback needs no compilation and cannot itself fail. The rolled-back counter increments right after the restore and before the incident write, so an I/O failure while writing evidence never hides a rollback from alerting; if that write fails, record_error returns IncidentWrite and the rollback stands. A watchdog fires once: afterward record_error returns Ok(None), and re-arming means taking a new watchdog from the next reload.
Incident record
A rollback writes one directory named <utc-iso8601>-<guard_id>-<reload_seq>, holding incident.json (guard id, reload sequence, rolled-back epoch, and a reason naming the error count and the window) and last_5_eval_traces.ndjson. Each trace is an EvalTrace of exactly three fields: request_id, verdict_class (for example trap or fuel_exhausted), and a detail string.
The shape has no room for call arguments, and the trace vector is trimmed to its last five entries on every push. A recorded success clears the traces with the error streak, so an incident can hold fewer than five lines. detail is a plain String whose contract keeps request arguments out of it; callers that build EvalTrace::new format only redacted summaries.
Digest blocklist
The blocklist is a JSON file holding a sorted set of sha256:<64 lower hex> strings, and it outranks every other check: a reload tests it before the backend factory runs, and a pull tests it before the registry client is constructed. A hit is E_GUARD_DIGEST_BLOCKLISTED.
The two paths test different digests. chio guard pull tests the OCI manifest digest from the reference; Engine::reload tests the SHA-256 of the WASM module bytes. normalize_digest accepts sha256:<hex> and bare hex, so both land in one set without a namespace, and withdrawing a release from both paths takes two entries. engine_reload_denies_blocklisted_digest pins the round trip: block, watch the reload fail while the guard stays at EpochId::INITIAL, unblock, watch the same bytes publish as epoch 1.
An entry goes in by writing blocklist.json, as Failure & Recovery shows, and chio guard blocklist remove takes one out. When neither XDG_STATE_HOME nor HOME is set, the engine's default_blocklist reads <temp_dir>/chio-guard-state/chio/guards/blocklist.json, while the CLI calls from_environment directly and exits on StateRootUnavailable.
Preflight probe
chio doctor runs a cosign probe that checks a bundle file's modification time against a 90-day limit. Signature verification happens at pull and at the load gate. The probe's resolution order and severity mapping are in Node Preflight.
Contracts and their tests
- A pull is refused before any byte reaches the cache when the manifest digest, the artifact type, the layer count, or any descriptor's media type, digest, or size disagrees with the pinned manifest.
pull.rs,cache.rs::validate_cache_admission;rejects_descriptor_mismatch_before_cache_write,rejects_manifest_with_wrong_top_level_artifact_type. - A discovered Sigstore bundle must name the pinned artifact as its subject and match its blob descriptor, or the pull fails.
sigstore_bundle_descriptor_from_manifest;rejects_sigstore_referrer_manifest_for_wrong_subject. - An in-flight evaluation completes on the epoch it snapshotted. At about 100 requests per second across four worker threads, with a reload landing partway, all 100 requests complete, and sorted by dispatch index every request before the boundary used the old epoch and every one after it the new. The test drives a byte-keyed test backend, so it pins the swap discipline.
hot_reload_under_100rps_drops_no_requests,engine_reload_publishes_replacement_module_epoch_atomically. - A replacement that fails to compile, or whose digest is blocklisted, leaves the live epoch and its verdicts unchanged.
engine_reload_failure_leaves_current_epoch_unchanged,engine_reload_denies_blocklisted_digest. - A rollback increments
chio_guard_reload_total{guard_id,outcome="rolled_back"}even when the incident write fails.rollback_counts_even_when_incident_write_fails. - Sigstore-mode admission denies a bundle without verified Rekor inclusion.
offline_sigstore_load_denies_unverified_rekor_inclusion,offline_policy_denies_report_only_sigstore_without_rekor_inclusion. - A registry without the referrers API fails the pull unless the caller supplies a bundle.
oci.rsRegistryNotFoundBehavior::Error;rejects_registry_without_referrers_api. - A pull without a Sigstore policy claims no verification.
pull_without_sigstore_policy_does_not_claim_verification.