Chio/Docs
LOGIN · JOIN

PlatformAuthoring & Portability

Kernel

Custom WASM Guards

Author a guard as a WebAssembly module, then hand it to a host that decides what every import returns and when the fuel ends.

The host side is chio-wasm-guards. It detects the module format, compiles under Wasmtime, meters fuel per evaluation, serves the four host imports, verifies the manifest and its signature sidecar, and turns a guest verdict into a kernel Verdict. The guest side is chio-guard-sdk and chio-guard-sdk-macros, both published, so a guard crate builds without a checkout of Chio. The chio guard subcommands in chio-cli drive the lifecycle between the two.

Why WASM

Native Rust guards (covered in Custom guards) compile into the kernel and have full access to the address space. WASM guards trade direct process access for three properties:

  • Sandboxing. A WASM guest cannot read host memory it was not handed, cannot open files, cannot make syscalls, and runs out of fuel deterministically. The host decides what every host import returns.
  • Language portability. The host accepts any module that exports the documented functions. Rust is the primary toolchain through chio-guard-sdk, but AssemblyScript, Go (TinyGo), and C can target the same ABI.
  • Hot reload. Modules are loaded from a bundle store and can be swapped at runtime through the DebouncedReload component. A failed canary restores the previous module without restarting the kernel.

Module formats

The host supports two WASM formats and decides which one a binary is by reading its header through wasmparser, not by being told:

FormatBackendContract
Core moduleWasmtimeBackendRaw ABI: evaluate(ptr, len) -> i32
Component ModelComponentBackendWIT world chio:guard/guard@0.2.0

detect_wasm_format answers the question and create_backend loads the matching backend, so no caller passes a format flag. Bytes that are neither refuse with WasmGuardError::UnrecognizedFormat rather than being guessed at. Both backends share the same fuel meter, memory limit, host imports and manifest verification path.


The core module ABI

A core module exports a single evaluation entry point:

text
evaluate(request_ptr: i32, request_len: i32) -> i32

The host serializes a GuardRequest as JSON, copies it into guest linear memory at request_ptr with length request_len, calls evaluate, and reads the return code:

Return valueMeaning
0Allow (constant VERDICT_ALLOW)
1Deny (constant VERDICT_DENY)
any negative valueError. Treated as deny (fail-closed).

On a deny, the host probes for an optional export to retrieve a structured deny reason:

text
chio_deny_reason(buf_ptr: i32, buf_len: i32) -> i32

When the export is present, the host calls it with a fixed buffer region at offset 65536 (64 KiB) and length 4096. The guest writes a JSON { "reason": "..." } document into that buffer and returns the number of bytes written; a return of 0, a negative value, or a length past the buffer yields no reason. Bytes that do not parse as that JSON are read again as a plain UTF-8 string from the same buffer, trailing NULs trimmed, so a guest that writes a bare message still gets a reason through.

If chio_deny_reason is absent, the host does not fall back to a generic message. Instead it reads a NUL-terminated UTF-8 string the guest may have written starting at the same fixed offset 65536, up to 4096 bytes. That is the older core-module deny-reason convention. Only if that region is empty too does the denial carry no reason. A guard author on a non-Rust toolchain who skips chio_deny_reason should write the reason string at offset 65536 instead.

Fuel out, traps, and unexpected returns are all deny

Anything other than 0 or 1 from evaluate, or a guest trap, or fuel exhaustion, becomes Verdict::Deny. There is no third state.

The memory model

The guest owns its linear memory. The host writes the request bytes into that memory before calling evaluate and reads the deny reason out of it after. To avoid clobbering the guest's own data structures, the host probes for an optional allocator export:

text
chio_alloc(size: i32) -> i32     // returns a pointer, or 0 on failure
chio_free(ptr: i32, size: i32)   // guest-side release; the host never calls this

If the module exports chio_alloc, the host calls chio_alloc(request_len), validates that the returned pointer plus length stays in bounds, copies the JSON bytes there, and calls evaluate(ptr, len). If the module does not export it, the host writes the request at offset 0 and calls evaluate(0, len), the raw core-module placement.

The host never calls chio_free. Each evaluation builds a fresh Wasmtime Store, sets the fuel on it, and instantiates into it, so the guest's linear memory starts empty every call and is dropped when the call returns. What the host keeps warm between calls is the InstancePre, a pre-linked template, not a live instance or its memory. There is nothing for the host to free. chio-guard-sdk still exports chio_free alongside chio_alloc, because its thread-local Vec-based allocator satisfies both signatures for guest-side symmetry. The export exists for the guest's own use, not because the host invokes it.

Linear-memory size is capped per guard by the max_memory_bytes field in WasmGuardConfig (default 16 MiB). Module size on disk is also capped via max_module_size (default 10 MiB) so an oversized binary is rejected before compilation.


Host imports

The guest calls the host through a fixed import API, and every call runs inside a tracing span. The set is not the same on both backends. A core module gets the three functions register_host_functions puts on its linker, all under the chio module. A component additionally gets fetch-blob from chio:guard/host@0.2.0, which is a WIT interface a core module cannot reach:

ImportSignatureEffect
chio.log(level: i32, ptr: i32, len: i32)Append a UTF-8 message to the host's log buffer at the given level.
chio.get_config(key_ptr, key_len, val_ptr, val_len) -> i32Look up a string config value. Returns bytes written, or -1 if missing.
chio.get_time_unix_secs() -> i64Wall-clock time as Unix seconds.
chio:guard/host@0.2.0 fetch-blob(handle: u32, offset: u64, len: u32) -> result<list<u8>, string>Read a byte range from a host-owned content bundle. Component Model only.

Log levels match the host's tracing levels: trace=0, debug=1, info=2, warn=3, error=4. Out-of-range levels are silently dropped.get_config uses a fixed 4096-byte scratch buffer on the guest side. A value longer than that is silently truncated to its first 4096 bytes, not reported as None: the host copies only what fits but returns the value's true length, and the SDK wrapper hands back the truncated string. The wrapper yields None only when the cut lands mid-codepoint and the retained bytes fail UTF-8 validation. Keep config values under 4096 bytes to avoid truncation.

No filesystem, no network, and the loader enforces it

This is the complete host interface. A WASM guard cannot open a file, make a network request, or read an environment variable. The rule is checked rather than assumed: on the core-module path load_module walks module.imports() and refuses anything outside the chio namespace with WasmGuardError::ImportViolation, before compilation finishes and long before instantiation. A guard needing external data takes it through chio.get_config, or, as a component, stages it as a content bundle and reads it with fetch-blob.

The Component Model backend

The Component Model variant uses the WIT world chio:guard/guard@0.2.0. The world declares the same four host imports as typed functions, plus a policy-context.bundle-handle resource for streaming reads of large content blobs. The host loader verifies the declared world fail-closed: a manifest that omits wit_world or declares a version other than chio:guard/guard@0.2.0 is rejected at load time. Migrating from the 0.1.x WIT world is documented at docs/guards/MIGRATION-0.1-to-0.2.md.

Component-model bindings are generated by wasmtime::component::bindgen! on the host side. Guest authors writing in Rust still depend on chio-guard-sdk, which targets the same WIT version and exposes a PolicyContext wrapper for the bundle-handle resource.


The Rust SDK

chio-guard-sdk is the guest-side toolkit. It packages the data types, host bindings, ABI glue, and allocator that every Rust guard needs. The crate compiles for both wasm32-unknown-unknown (production target) and the host's native target, where the host imports become fallbacks so unit tests run without a WASM runtime. The fallbacks are not neutral: log does nothing, get_config returns None for every key, and get_time returns 0. A guard whose behavior depends on config therefore takes its default path under a native unit test, which is a reason to give that default a tested meaning.

rust
use chio_guard_sdk::prelude::*;
// Re-exports:
//   GuardRequest, GuardVerdict, VERDICT_ALLOW, VERDICT_DENY
//   read_request, encode_verdict
//   log, log_level, get_config, get_time, fetch_blob, PolicyContext

The pieces:

SymbolRole
GuardRequestThe request, read-only: tool_name, server_id, agent_id, arguments, scopes, the host-extracted action_type, extracted_path and extracted_target, plus filesystem_roots and matched_grant_index from the kernel context. Field order and serde annotations match the host's own definition.
GuardVerdictAllow or Deny { reason }. Construct via GuardVerdict::allow() / GuardVerdict::deny(reason).
read_requestUnsafe helper that deserializes the JSON request from a host-supplied (ptr, len) pair.
encode_verdictReturns the ABI integer for a verdict and stores any deny reason for chio_deny_reason to retrieve.
chio_alloc / chio_freeThread-local allocator exports. The host probes chio_alloc via get_typed_func::<i32, i32>; chio_free is a guest-side release the host never calls.
chio_deny_reasonWrites the JSON deny payload into a host-provided buffer.

The #[chio_guard] macro

chio-guard-sdk-macros provides a proc-macro that wires the ABI exports for you. Annotate a function fn evaluate(req: GuardRequest) -> GuardVerdict and the macro generates the extern "C" entry point, the allocator re-exports, and the deny-reason export.

rust
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;

#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
    if req.tool_name == "dangerous_tool" {
        GuardVerdict::deny("tool is blocked by policy")
    } else {
        GuardVerdict::allow()
    }
}

The SDK crate doc shows the manual wiring only

The chio-guard-sdk crate-level quick-start still shows the hand-wired, macro-free style: a plain fn evaluate(req: GuardRequest) -> GuardVerdict annotated only with the note that guard authors "wire the pieces together manually". It does not mention #[chio_guard] at all. The macro crate, chio-guard-sdk-macros, is shipped and works today: prefer #[chio_guard] over hand-rolling the entry point. The manual route below is documented as a reference for non-Rust toolchains and for anyone porting an existing module.

The manifest

Every WASM guard ships with a guard-manifest.yaml sitting next to the .wasm binary. The loader reads it before instantiation and rejects the guard if anything fails to validate. The manifest is YAML, not TOML. The two hex fields below are placeholders: a digest is read out of the binary that was actually built, never copied from a page, and step 6 of the worked example shows the command that reads one.

guard-manifest.yamlyaml
name: pii-scanner
version: "1.0.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: pii.wasm
wasm_sha256: "<64 hex, sha256sum of pii.wasm>"
config:
  threshold: "0.8"
  mode: strict
signer_public_key: "<64 hex, Ed25519 verifying key>"   # optional
allow_unsigned: false                                  # opt-out, dev only
FieldRequiredDescription
nameYesHuman-readable guard identifier. Recorded in logs and receipts.
versionYesSemantic version of the guard binary.
abi_versionYesABI the guard targets. Must be in SUPPORTED_ABI_VERSIONS (currently "1").
wit_worldYesMust equal "chio:guard/guard@0.2.0". verify_wit_world runs on every manifest the loader reads, core module included, and an absent field refuses with <missing> exactly as an older world does.
wasm_pathYesPath to the binary, relative to the manifest or absolute.
wasm_sha256YesHex-encoded SHA-256 of the binary. Mismatch is a load-time error.
configNoString key-value pairs returned to the guest by chio.get_config.
signer_public_keyNoHex-encoded Ed25519 public key. When set, a .wasm.sig sidecar is required.
allow_unsignedNoDefaults to false, and that default refuses: a manifest with neither a pinned key nor allow_unsigned: true fails with "is not signed: no .sig sidecar found and allow_unsigned is false". It cannot override a pinned key, only the absence of one.

The signature sidecar

When signer_public_key is set, the loader looks for <wasm_path>.sig next to the binary. The sidecar is a JSON envelope:

pii.wasm.sigjson
{
  "module_hash":       "<sha256 hex of pii.wasm>",
  "module_name":       "pii-scanner",
  "version":           "1.0.0",
  "signer_public_key": "<32-byte Ed25519 key, hex>",
  "signature":         "<64-byte detached signature, hex>"
}

The signature covers a canonical envelope built by signed_module_message: the literal domain separator chio-wasm-guard-v1, followed by newline-separated module hash, module name, version, and signer public key. Binding all five fields prevents replaying a signature across modules or versions.

The loader verifies, in order: trusted key matches sidecar key, hash of the actual bytes matches the sidecar hash, signature is well formed, and the Ed25519 verification passes under verify_strict. Any mismatch on the sidecar's name or version against the manifest is also rejected, so a sidecar from a different module cannot be swapped in.

The reverse case refuses too. A sidecar sitting next to a manifest that pins no key is not treated as a signature to trust: unless allow_unsigned is set, the loader rejects it as "has an unpinned .sig sidecar but manifest does not declare signer_public_key". Trust flows from the manifest to the sidecar, never the other way.


The digest blocklist

GuardDigestBlocklist in chio-wasm-guards is a persistent set of SHA-256 digests that must never load, even when every other check passes. The reload and pull paths consult it, and a module whose digest matches refuses with E_GUARD_DIGEST_BLOCKLISTED rather than being instantiated. It is the lever for a module that would otherwise verify cleanly: a leaked signer key, or a release withdrawn after it shipped.

Guard supply chain covers the persisted file behind the refusal: where it lives, the two digest domains it mixes, and what writes to it.


What a reload means for your module

A running node can replace the bytes behind a registered guard id without restarting. Three consequences land on the module author. An evaluation already in flight finishes on the module it started with, so a swap never produces a verdict from two versions. On the canary entry point a candidate is replayed against a frozen corpus whose length must equal CANARY_FIXTURE_COUNT before it goes live, and comparison is over serialized verdict bytes, so changing a deny reason string aborts the swap exactly as a flipped decision does. And a candidate that fails to compile leaves the live module serving. Guard supply chain owns the mechanism behind all three: the four entry points and what each one does and does not check, the epoch reservation, the corpus digest checks, the rollback watchdog, and the incident record. The reload engine is a library surface in chio-wasm-guards that a host drives; a deployment that never calls it never swaps a module.

The reload state machine has three terminal outcomes. All three are emitted under a single tracing span, chio.guard.reload (from guard_reload_span), distinguished by its outcome field. Operators query traces by filtering that one span name on the outcome value, not by three different span names:

Outcomeoutcome valueEffect
AppliedappliedNew module is live; old module is dropped.
Canary failedcanary_failedNew module never serves traffic. Old module continues.
Rolled backrolled_backA live failure tripped the watchdog; reverted to the previous module.

The RELOAD_APPLIED, RELOAD_CANARY_FAILED, and RELOAD_ROLLED_BACK constants in observability.rs hold these lowercase strings; they are the field values, not span names.

A rolled_back outcome also writes an incident record holding redacted trace summaries, never request arguments. Its contents, its path, and the streak policy that triggers it are in Guard supply chain.


Metrics

GUARD_METRIC_FAMILIES in chio-wasm-guards declares the families a WASM guard reports, each with the exact label set it carries. Every family is keyed by guard_id; the second label, where there is one, is what a dashboard breaks the family down by.

FamilyKindLabelsUnit
chio_guard_eval_duration_secondsHistogramguard_id, verdictseconds
chio_guard_fuel_consumed_totalCounterguard_idfuel units
chio_guard_verdict_totalCounterguard_id, verdictcount
chio_guard_deny_totalCounterguard_id, reason_classcount
chio_guard_reload_totalCounterguard_id, outcomecount
chio_guard_host_call_duration_secondsHistogramguard_id, host_fnseconds
chio_guard_module_bytesGaugeguard_id, epochbytes

Cardinality is capped at MAX_GUARD_METRIC_CARDINALITY per family; exceeding the cap emits a E_GUARD_METRIC_CARDINALITY_EXCEEDED warning and drops the new label set.


A worked example: a tool denylist guard

A guard that denies any tool whose name appears in a config-supplied denylist. The eight steps below scaffold the crate, write the guard, compile it to wasm32-unknown-unknown, check its exports, pin both edges of the decision with fixtures, read the fuel it burns, digest the binary, and pack the result. Every block showing output is a capture of that command against a chio binary built at the pinned commit, so the digests and sizes are that run's, not a transcription. A fresh run produces its own digest.

The chio guard subcommands wrap the workflow; the manual equivalents shown alongside support a toolchain that does not use them:

the whole lifecyclebash
# Scaffold Cargo.toml, src/lib.rs, and guard-manifest.yaml
chio guard new tool-denylist-guard
cd tool-denylist-guard

# Compile the current directory to wasm32-unknown-unknown
chio guard build

# Print exports, ABI compatibility, and memory config
chio guard inspect target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm

# Replay YAML fixtures, then benchmark fuel and latency
chio guard test --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
  ./fixtures/*.yaml --fuel-limit 1000000
chio guard bench target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
  --iterations 100

# Package into a distributable .arcguard archive
chio guard pack

# Publish as a signed OCI artifact, or pull one by digest
chio guard publish . --ref oci://ghcr.io/acme/tool-denylist:v1 --epoch-id-seed prod-1
chio guard pull --ref oci://ghcr.io/acme/tool-denylist@sha256:<digest>

See Custom guards for other subcommands, including chio guard sign/verify, install, and blocklist.

Step 1: the crate the scaffold writes

chio guard new writes three files and prints the next two commands.

wasm-guard · newtranscript
$ chio guard new tool-denylist-guard
created guard project at ./tool-denylist-guard

Next steps:
  cd tool-denylist-guard
  chio guard build
  chio guard inspect target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
exit 0

The generated manifest depends on the two published SDK crates, so a guard builds without a checkout of Chio. The cdylib crate type is what produces a .wasm module rather than a Rust rlib, and the two clippy lints are there because a panic inside a guard is a WASM trap, which the host counts as a deny.

Cargo.tomltoml
[package]
name = "tool-denylist-guard"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
chio-guard-sdk = "0.1"
chio-guard-sdk-macros = "0.1"

[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"

Step 2: the guard itself

The scaffold writes a stub that denies everything. Replace it with the denylist. The list comes from manifest config, and the constant is the fallback for a host that supplies none, so an absent config cannot silently widen what the guard allows:

src/lib.rsrust
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;

// Used when the host supplies no denylist config, so an absent
// config cannot silently widen what the guard allows.
const DEFAULT_DENYLIST: &str = "delete_file,execute_command_as_root,wipe_database";

#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
    log(log_level::DEBUG, &format!("evaluating {}", req.tool_name));

    // The host exposes manifest config[] entries as strings.
    // We accept a comma-separated list under "denylist".
    let raw = get_config("denylist").unwrap_or_else(|| DEFAULT_DENYLIST.to_string());
    let denied: Vec<&str> = raw
        .split(',')
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .collect();

    if denied.iter().any(|t| *t == req.tool_name.as_str()) {
        return GuardVerdict::deny(format!(
            "tool '{}' is on the denylist", req.tool_name
        ));
    }

    GuardVerdict::allow()
}

The fallback matters because the two subcommands that replay a guard locally, chio guard test and chio guard bench, both call WasmtimeBackend::new() in crates/products/chio-cli/src/guard/verify.rs, which starts from an empty config map. A guard written as get_config("denylist").unwrap_or_default() has an empty denylist under both, and its deny fixtures allow instead.

Step 3: build it

chio guard build shells out to cargo for the wasm32-unknown-unknown target and then prints two lines of its own: where the module landed and how large it is. Cargo's own progress goes to stderr; the capture below shows both.

wasm-guard · buildtranscript
$ chio guard build
build complete: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
binary size: 189.6 KiB
   Compiling tool-denylist-guard v0.1.0 (~/chio/tool-denylist-guard)
    Finished `release` profile [optimized] target(s) in 1.22s
exit 0in tool-denylist-guard

Because it is a wrapper, the two commands underneath work on their own and support a toolchain that does not use chio guard. The binary size line is the only thing lost:

the same build, by handbash
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown

For Component Model output, use cargo component build --release from the cargo-component toolchain and target the chio:guard/guard@0.2.0 world.

Step 4: inspect the module

chio guard inspect reads the module and checks it against the three ABI exports the host calls. Run it before anything downstream trusts the binary:

wasm-guard · inspecttranscript
$ chio guard inspect target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
=== WASM Guard Inspection ===

File: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
Size: 189.6 KiB

Exported functions:
  memory           (memory)
  evaluate         (function)
  chio_alloc       (function)
  chio_deny_reason (function)
  chio_free        (function)
  __data_end       (global)
  __heap_base      (global)

ABI compatibility: COMPATIBLE
  [+] evaluate
  [+] chio_alloc
  [+] chio_deny_reason

Memory:
  initial=17 pages (1088 KiB), max=unbounded pages
exit 0in tool-denylist-guard

The three names under ABI compatibility are the exports the host probes. chio_free is in the export list above but not in that check, because the host never calls it. The memory line reports the module's declared minimum rather than a ceiling; the ceiling is max_memory_bytes, and it lives on the host side.

Step 5: watch it refuse

Two fixtures are enough to pin both edges of the decision. The fixture schema is on Testing guards and policies:

./fixtures/tool-denylist.yamlyaml
- name: allows a tool that is not on the denylist
  request:
    tool_name: read_file
    server_id: srv-files
    agent_id: agent-1
    arguments:
      path: "./workspace/README.md"
  expected_verdict: allow

- name: denies a tool on the denylist
  request:
    tool_name: delete_file
    server_id: srv-files
    agent_id: agent-1
    arguments:
      path: "./workspace/output.txt"
  expected_verdict: deny
  deny_reason_contains: "denylist"
wasm-guard · testtranscript
$ chio guard test \
    --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
    ./fixtures/tool-denylist.yaml --fuel-limit 1000000
[PASS] allows a tool that is not on the denylist
[PASS] denies a tool on the denylist

2 passed, 0 failed out of 2 total
exit 0in tool-denylist-guard

A fixture that expects the wrong verdict prints the guard's own refusal, which is how the deny reason becomes a tested string rather than a comment. Assert expected_verdict: allow on wipe_database, a tool the default denylist covers, and the runner reports what the guard actually said, exits 1, and names the error on stderr:

wasm-guard · test-refusestranscript
$ chio guard test \
    --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
    ./fixtures/wrong.yaml --fuel-limit 1000000
[FAIL] wipe_database is still allowed: expected allow, got deny: tool 'wipe_database' is on the denylist

0 passed, 1 failed out of 1 total
error [urn:chio:error:guard:denied]: 1 test(s) failed
context: {"domain":"guard","severity":"error","stability":"stable","string_code":"CHIO-KERNEL-GUARD-DENIED"}
suggested fix: Inspect the guard verdict and adjust the prompt, tool input, policy, or output before retrying.
exit 1in tool-denylist-guard

The bracketed identifier is a registry URN and the string_code beside it is its stable spelling. 4 codes carry the guard domain, and a script branching on a refusal branches on these rather than on the message:

URNstring_codeSeverityStability
urn:chio:error:guard:deniedCHIO-KERNEL-GUARD-DENIEDerrorstable
urn:chio:error:guard:input-redactedCHIO-GUARD-INPUT-REDACTEDwarningunstable
urn:chio:error:guard:output-redactedCHIO-GUARD-OUTPUT-REDACTEDwarningunstable
urn:chio:error:guard:wasm-trapCHIO-GUARD-WASM-TRAPfatalunstable

chio guard bench gives the fuel number the manifest budget has to clear. The sample request it drives is a fixed bench_tool call, so the fuel figure is the guard's floor rather than its worst case. Most of that floor is JSON deserialization of the request: rerun chio guard test with --fuel-limit 1000 and the module traps inside serde_json, before any denylist comparison runs.

wasm-guard · benchtranscript
$ chio guard bench \
    target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
    --iterations 100
=== Guard Benchmark ===

File: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
Iterations: 100
Fuel limit: 1,000,000

Latency:
  p50:  336.40 us
  p99:  31294.91 us
  min:  269.84 us
  max:  31294.91 us
  mean: 1219.19 us

Fuel consumed:
  p50:  20,272
  p99:  20,272
  min:  20,272
  max:  20,272
  mean: 20,272
exit 0in tool-denylist-guard

Fuel is identical across all five percentiles because the sample request never varies, which is what makes it a floor rather than a distribution. Latency spreads because the host machine does; read the fuel column, not the microseconds.

Step 6: the manifest

The manifest binds a name and a digest to the module. Take the digest from the binary you just built; it changes with the toolchain and the SDK version, so read it rather than copying one:

wasm-guard · digesttranscript
$ sha256sum target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
33993d036cab4429affccf15c5a3bc6fc83850268c182b4f8a6b71cad3eafe6c  target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
exit 0in tool-denylist-guard

Edit the scaffolded manifest to carry that digest, the name the archive should take, and the config the guest reads back through chio.get_config. wasm_path is resolved relative to the project directory, so it stays pointed at the build output:

guard-manifest.yamlyaml
name: tool-denylist
version: "1.0.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
wasm_sha256: "<the digest sha256sum just printed>"
config:
  denylist: "delete_file,execute_command_as_root,wipe_database"
allow_unsigned: true   # development only

Add wit_world by hand; the scaffold omits it

The manifest chio guard new writes carries name, version, abi_version, wasm_path, and a wasm_sha256 placeholder, and no wit_world. chio guard pack parses that manifest and archives it happily, but load_manifest refuses it, so a guard packed straight off the scaffold fails when a node loads it from chio.yaml. Add the line before packing.

chio guard pack then writes one gzipped tar holding the manifest and the module. The archive name is {name}-{version}.arcguard read out of the manifest, not out of the crate: rename the guard in the manifest and the archive is renamed with it. Inside, the module is stored under its filename alone, and chio guard install rewrites wasm_path to match when it unpacks, so the build-output path above does not have to survive the trip.

wasm-guard · packtranscript
$ chio guard pack
packed: tool-denylist-1.0.0.arcguard (76.0 KiB)
exit 0in tool-denylist-guard

Step 7: register it in chio.yaml

chio.yamlyaml
kernel:
  signing_key: "${CHIO_SIGNING_KEY}"

adapters:
  - id: petstore
    protocol: openapi
    upstream: "http://petstore.example/api"

wasm_guards:
  - name: tool-denylist
    path: /etc/chio/guards/tool-denylist/tool_denylist_guard.wasm
    fuel_limit: 5000000
    priority: 100

The loader finds the manifest by taking the parent directory of path, exactly one level, and reading guard-manifest.yaml there. It does not search ancestors, so the module and its manifest sit in the same directory or the load fails. See chio.yaml configuration for the schema.

priority ordering depends on the load path

priority is meant to order WASM guards low-to-high before evaluation, but two load paths treat it differently. WasmGuardRuntime carries a struct doc-comment promising priority ordering yet never sorts: add_guard, load_guard, and into_guards all preserve insertion order, so priority is parsed and then ignored. The newer wiring path, load_wasm_guards, does sort entries by (priority, advisory). Priority ascends, and advisory guards are placed after non-advisory ones at equal priority. Whether priority actually reorders evaluation therefore depends on which path your build wires the wasm_guards[] list through. Check the active loading path in your deployment before relying on priority ordering.

Step 8: reload behind a canary

Canary fixtures live alongside the manifest. They are JSON documents matching the CanaryFixture shape and cover the most important verdicts the guard should preserve across a reload (one allow, one deny, edge cases). The corpus must contain exactly CANARY_FIXTURE_COUNT entries; a corpus of any other size fails to load with HotReloadError::CanaryFixtureCount, which carries the guard id, the expected count and the actual one.

A fixture pairs a GuardRequest with expected_verdict_bytes, and the comparison is over those bytes rather than over a decoded decision. A candidate that reaches the same allow or deny by a different reason string does not match, so a reason edit aborts the swap the way a flipped verdict does.

When the bundle store reports a new module digest, the reload component loads the candidate, replays the canary fixtures, compares verdicts to the recorded baseline, and either swaps the live module or aborts. A live failure later trips the watchdog and rolls back, in which case the next request is served by the prior module while the operator inspects the incident log.


Other language targets

Rust is the supported toolchain. Any language that produces a core WASM module exporting evaluate(i32, i32) -> i32 will load. The host also probes for an optional chio_alloc(i32) -> i32 (falling back to writing the request at offset 0 when it is absent) and an optional chio_deny_reason(i32, i32) -> i32 (falling back to a NUL-terminated string at offset 65536). Because the host never calls chio_free, a non-Rust module does not need to export it.

Keep every import in the chio module. A core module that imports anything else, a WASI function or the chio:guard/host@0.2.0 interface included, is refused at load with ImportViolation rather than failing later at a call site. A toolchain that emits WASI shims by default has to be told not to.

  • AssemblyScript: wire the ABI by hand using @external declarations for the host imports and exporting your own allocator.
  • Go (TinyGo): use //go:wasmexport for the entry points and //go:wasmimport for the host functions. TinyGo's default GC is acceptable inside the fuel limit.
  • C / C++: compile with the WASI SDK targeting wasm32-unknown-unknown, not wasm32-wasip1, whose imports the loader refuses, and use __attribute__((export_name("..."))).

See also

Custom WASM Guards · Chio Docs