Chio/Docs
LOGIN · JOIN

WASM Guard Runtime

Write a guard as a WebAssembly module, then look up the ABI, imports, limits, manifest, and signing rules its host applies.

Reference chio@5f63e6e

On this page

A WASM guard is a policy rule compiled to WebAssembly that the kernel runs during admission. The host hands the module one request, caps its memory and fuel, grants it a fixed set of imports, and reads back an allow or a deny before the call is dispatched.

Teams ship such a rule independently of the host binary while the operator keeps its imports and resource limits. The document application uses that contract to limit documents / count_words to eight words: Building Custom Guards runs that rule, and Custom guards signs and installs it.

Each evaluation runs in a fresh, bounded guest

A native extension implements the public Guard trait inside the host process. A WASM guard runs behind a narrower interface: the host supplies the request and configuration, grants imports, caps linear memory, and assigns fuel.

One guest evaluationChoose how the guest ends.
mInstancePreinstantiatefresh Store per call · fuel · ≤ 16 MiB memorylinear memoryrequestreason @ 65536gcopyevaluate(ptr, len)returns 0rKdispatchdtoolρreceiptρreceiptmInstancePreinstantiatefresh Store · fuel · ≤ 16 MiBlinear memoryrequestreason @ 65536gcopyevaluate(ptr, len)returns 0rKdispatchdtool

A zero lets the call continue to dispatch

The guest reads the request from its own memory and returns 0, VERDICT_ALLOW. The host drops the store and its memory, and the rest of the mediated call continues toward the tool. Core module ABI.

One core-module evaluation under the raw ABI. The memory strip is not to scale. The Component Model backend, content-bundle reads, and the reload engine are left out.

The module decides only its own rule. The kernel runs the rest of the mediated call around it, from the capability check before the guard to the receipt after the decision, and treats every ending other than a returned 0 as a refusal.

The document rule admits six words and refuses eleven

The signed deployment of the document application configures its guard with this complete policy entry. The captured run beneath it sends two requests through the configured host.

guard-policy.json · complete configured applicationjson
{
  "modules": [
    {
      "name": "word-limit",
      "version": "0.1.0",
      "module_path": "installed-guards/word-limit/word_limit.wasm",
      "capabilities": [
        "chio.get_config"
      ],
      "config": {
        "max_words": "8"
      },
      "fuel_limit": 1000000,
      "priority": 100,
      "advisory": false,
      "allow_unsigned": false,
      "signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
    }
  ]
}
Captured configured application resulttext
Evidence: runs/a348e5c2-5bec-4a0e-a0ef-a4a7137571d5
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: e380bbac0029d0dc581a7a98528d6ff7154f6ec24d07aa0a0d0d3b64369aa994 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Receipt: ad7e0e17668a6d8e50ff1f4a41a0db28489f98dfa6c5f68586449315d1c7eabd (receipt-2.json)

The six-word request reaches the tool. The eleven-word request is refused before dispatch. The application saves both signed receipts and records one tool invocation. The signed deployment walkthrough supplies the build, key, module, sidecar, and host setup behind this result.

Module formats

Chio loads two module formats, each through its own backend. The backend factory detects the format from module bytes; each deployment loader below names the formats it accepts.

FormatBackendContract
Core module
WasmtimeBackend
Raw ABI: evaluate(ptr, len) -> i32
Component Model
ComponentBackend
WIT world chio:guard/guard@0.2.0

Loaders

Entry pointFormats and configurationVerification and ordering
load_guards_from_policy
Core modules. PolicyCustomGuards carries configuration and allowed host-function capabilities.
Requires the configured signer for path modules, or explicit unsigned permission. Checks imports against granted capabilities. Sorts by priority, then advisory.
load_wasm_guards
Core modules. Each chio.yaml entry locates an adjacent guard-manifest.yaml, which supplies guest configuration.
Checks WIT world, supported ABI, module hash, and signing policy. Sorts by priority, then advisory.
load_signed_guard
Core or component. The caller supplies the module path and GuardManifest; the factory detects the format.
Checks WIT world, signing policy, and hash. The caller runs verify_abi_version separately.
create_backend
Core or component, detected from bytes. The caller supplies engine, fuel, and configuration.
Compiles and loads. Does not read a manifest or verify a release signature. The caller owns those checks.
WasmGuardRuntime::load_guard
Format set by the caller’s backend factory. Reads bytes from WasmGuardConfig.path.
Checks module size, then invokes the factory. Keeps insertion order, with no priority sort or signature check of its own.

The document example uses load_guards_from_policy, and its policy entry has a different shape from the chio.yaml entry in the walkthrough below. Both deployment loaders load core modules. A component loads through create_backend or ComponentBackend in the embedding host, which supplies the verification and bundle store that component needs.

Core module ABI

A core module exports one evaluation entry point. 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.

Core module exporttext
evaluate(request_ptr: i32, request_len: i32) -> i32
Return valueConstantVerdict
0
VERDICT_ALLOW
Allow
1
VERDICT_DENY
Deny
any other value
none
Deny (fail closed)

For a blocking guard, a guest trap and fuel exhaustion also become Verdict::Deny. An advisory guard records the failure without blocking dispatch.

On a deny, the backend probes for an optional export that returns a structured reason. The kernel receipt records the named guard’s decision, and the guest’s message stays in the guard’s diagnostics; the document application shows both in its captured diagnostics and complete receipts.

Optional deny-reason exporttext
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 fail to 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.

When chio_deny_reason is absent, the host reads a NUL-terminated UTF-8 string the guest may have written at the same offset 65536, up to 4096 bytes. That fallback convention serves guard authors on other toolchains: write the reason string at offset 65536. When that region is empty too, the denial carries no reason.

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 place the request without overwriting the guest’s own data structures, the host probes for an optional allocator export:

Optional allocator exportstext
chio_alloc(size: i32) -> i32     // returns a pointer checked by the host
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). Otherwise the host writes the request at offset 0 and calls evaluate(0, len), the raw core-module placement.

Each evaluation builds a fresh Wasmtime Store, sets the fuel on it, and instantiates into it, so each call receives fresh guest memory with the module’s data segments initialized. Mutations from previous calls are gone, and the store and live instance are dropped when the call returns. Between calls the host keeps the InstancePre warm: a pre-linked template, with no live instance or memory. The host therefore has nothing to free and never calls chio_free. chio-guard-sdk exports it beside chio_alloc because its thread-local Vec-based allocator satisfies both signatures for the guest’s own use.

  • Linear memory. The core and component backends default to a 16 MiB limit. The store limiter applies it during instantiation and memory growth.
  • Module bytes. Both backends default to a 10 MiB limit. Oversized module bytes fail before compilation. Backend builders expose with_limits for a host that needs different values.

WasmGuardConfig also declares those two fields, but its generic load_guard wrapper forwards only bytes and fuel to the factory and checks module size directly. Set memory limits in the backend you construct. The chio.yaml WasmGuardEntry schema exposes fuel, priority, and advisory fields, and leaves both size limits to the backend.

Host imports

The guest calls the host through a fixed import API, and every call runs inside a tracing span. A core module gets the three functions register_host_functions puts on its linker, all under the chio module. A component also gets fetch-blob from chio:guard/host@0.2.0, a WIT interface outside a core module’s reach.

ImportSignatureEffect
chio.log
(level: i32, ptr: i32, len: i32)
Appends 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) -> i32
Looks up a string config value. Returns the full value length and copies at most the buffer size; -1 for a missing key or invalid input.
chio.get_time_unix_secs
() -> i64
Wall-clock time as Unix seconds.
fetch-blob
chio:guard/host@0.2.0
(handle: u32, offset: u64, len: u32) -> result<list<u8>, string>
Reads 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 dropped without an error. get_config uses a fixed 4096-byte scratch buffer on the guest side, so a longer value arrives truncated to its first 4096 bytes: the host copies what fits and returns the true length, and the SDK wrapper hands back the truncated string. The wrapper returns None for a missing value or invalid UTF-8, including a truncation that splits a codepoint. Keep config values under 4096 bytes.

This is the complete host interface: a guard has no file, network, or environment-variable access. On the core-module path, load_module walks module.imports() after compilation and refuses anything outside the chio namespace with WasmGuardError::ImportViolation, before pre-linking or instantiation. A guard that needs external data reads it through chio.get_config, or, as a component, stages it as a content bundle and reads it with fetch-blob.

Component Model backend

The Component Model variant uses the WIT world chio:guard/guard@0.2.0. The world declares four typed host functions, plus a policy-context.bundle-handle resource for streaming reads of large content blobs. Manifest-backed loading checks the declared world fail-closed: a manifest that omits wit_world or declares any other version is rejected at load time.

ComponentBackend uses generated WIT bindings for typed calls. Its default bundle store is empty, and an embedding host supplies the content it exposes through with_bundle_store. The attribute macro compiles a Rust guard to a core module; a component starts from one of the Component Model SDK examples.

Rust SDK

chio-guard-sdk is the guest-side toolkit: the data types, host bindings, ABI glue, and allocator a Rust guard needs. It compiles for wasm32-unknown-unknown, the production target, and for the host’s native target, where the host imports become fallbacks so unit tests run without a WASM runtime. Under those fallbacks log does nothing, get_config returns None for every key, and get_time returns 0, so a config-dependent guard takes its default path in a native unit test. Give that default a tested meaning.

src/lib.rs · preluderust
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
  • GuardRequest: the read-only request, with 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.
  • GuardVerdict: Allow or Deny { reason }, built with GuardVerdict::allow() or GuardVerdict::deny(reason).
  • read_request: an unsafe helper that deserializes the JSON request from a host-supplied (ptr, len) pair.
  • encode_verdict: returns the ABI integer for a verdict and stores any deny reason for chio_deny_reason to retrieve.
  • chio_alloc and chio_free: thread-local allocator exports. The host probes chio_alloc through get_typed_func::<i32, i32>; chio_free is the guest-side release.
  • chio_deny_reason: writes the JSON deny payload into the host-provided buffer.

The #[chio_guard] macro

chio-guard-sdk-macros provides a proc-macro that wires the ABI exports. 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.

src/lib.rs · smallest guardrust
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()
    }
}

Use the macro for a Rust core module. The manual signatures above serve a custom toolchain.

Manifest

The chio.yaml loader reads guard-manifest.yaml beside the selected module. The policy loader takes identity, configuration, and signing requirements from its policy entry instead. This is the complete manifest from the signed document application; regenerate the digest and public key for your own build.

guard-manifest.yaml · captured signed releaseyaml
name: word-limit
version: "0.1.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: word_limit.wasm
wasm_sha256: "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c"
signer_public_key: "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
allow_unsigned: false
config:
  max_words: "8"
FieldRequiredDescription
name
Yes
Human-readable guard identifier, recorded in logs and receipts.
version
Yes
Semantic version of the guard binary.
abi_version
Yes
ABI the guard targets. Explicit validation accepts values in SUPPORTED_ABI_VERSIONS, which holds "1".
wit_world
Yes
Must equal "chio:guard/guard@0.2.0". verify_wit_world runs on every manifest the loader reads, core modules included; an absent field refuses as <missing>.
wasm_path
Yes
Locates the binary for packaging tools. Runtime loaders read the module path from their caller or deployment entry.
wasm_sha256
Yes
Hex-encoded SHA-256 of the binary. A mismatch is a load-time error.
config
No
String key-value pairs returned to the guest by chio.get_config.
signer_public_key
No
Hex-encoded Ed25519 public key. When set, a .wasm.sig sidecar is required.
allow_unsigned
No
Defaults to false. Without a pinned key, loading requires this explicit permission. A pinned key still requires a valid matching signature.

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:

word_limit.wasm.sig · complete captured envelopejson
{
  "module_hash": "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c",
  "module_name": "word-limit",
  "version": "0.1.0",
  "signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516",
  "signature": "cd28937edd1f1a93d1301e43233828260d2bdcfd43b24019f32e74bc2e23fe8f35c045daff178b16d085102b8ae0e816b8bf7be0597ba049d8f62882e517fb0f"
}

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

The loader checks, in order: the trusted key matches the sidecar key, the hash of the actual bytes matches the sidecar hash, the signature is well formed, and Ed25519 verification passes under verify_strict. A sidecar whose name or version differs from the manifest is rejected, so a sidecar from a different module cannot be swapped in.

A sidecar next to a manifest that pins no key is refused too, 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.

Digest blocklist

GuardDigestBlocklist stores withdrawn SHA-256 digests. With a blocklist configured, the reload engine checks module bytes before calling its backend factory, and chio guard pull checks the OCI manifest digest before fetching. A match returns E_GUARD_DIGEST_BLOCKLISTED. The two are different digest domains, so withdrawing a guard from both paths takes both entries.

The standalone loaders above leave the withdrawal check to the embedding host, and a reload engine built with without_blocklist skips it. Guard supply chain covers the file, state-directory behavior, and how to populate it.

Reload

A host integrated with the reload library replaces the bytes behind a registered guard id without restarting. Three consequences reach the module author:

  1. In-flight calls finish on their module. An evaluation already running completes on the module it started with, so a swap never produces a verdict from two versions.
  2. The canary compares bytes. On the canary entry point a candidate is replayed against a frozen corpus whose length must equal CANARY_FIXTURE_COUNT before it goes live. Comparison is over serialized verdict bytes, so a changed deny reason string aborts the swap exactly as a flipped decision does.
  3. A broken candidate never serves. 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 checks, the epoch reservation, the corpus digest checks, the rollback watchdog, and the incident record. The reload engine is a library in chio-wasm-guards that a host drives; a deployment that never calls it keeps its modules fixed.

The reload state machine has three terminal outcomes, all emitted under one tracing span, chio.guard.reload (from guard_reload_span), and distinguished by its outcome field. Query traces by that one span name filtered on the outcome value.

Outcomeoutcome valueEffect
Applied
applied
New module is live; old module is dropped.
Canary failed
canary_failed
New module never serves traffic. Old module continues.
Rolled back
rolled_back
A live failure tripped the watchdog; the previous module is restored.

The RELOAD_APPLIED, RELOAD_CANARY_FAILED, and RELOAD_ROLLED_BACK constants in observability.rs hold these lowercase strings as field values. A rolled_back outcome also writes an incident record holding redacted trace summaries, never request arguments; its contents, path, and triggering streak policy are in Guard supply chain.

Metrics

GUARD_METRIC_FAMILIES declares metric names, kinds, units, and label sets for a host exporter. The embedding host collects and exports them beside the fuel measurements and tracing spans the runtime exposes.

FamilyKindLabelsUnit
chio_guard_eval_duration_seconds
Histogram
guard_id, verdict
seconds
chio_guard_fuel_consumed_total
Counter
guard_id
fuel units
chio_guard_verdict_total
Counter
guard_id, verdict
count
chio_guard_deny_total
Counter
guard_id, reason_class
count
chio_guard_reload_total
Counter
guard_id, outcome
count
chio_guard_host_call_duration_seconds
Histogram
guard_id, host_fn
seconds
chio_guard_module_bytes
Gauge
guard_id, epoch
bytes

GuardMetricRegistry accepts at most 1,024 distinct registered guard IDs by default; registering another returns E_GUARD_METRIC_CARDINALITY_EXCEEDED. The registry caps guard IDs, and the collector you wire handles that error and any exporter label combinations.

Standalone verifier walkthrough

A guard that denies any tool named in a config-supplied denylist runs here under the guard CLI, outside the kernel, so no tool is dispatched and no receipt is signed. The steps 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. The transcripts are captured CLI runs; a fresh build produces its own digest. For a configured application with signed receipts, use the document project above.

The chio guard subcommands below form a command index. Write the rule, fixtures, and manifest in the steps that follow before running each command.

Guard CLI command indexbash
# 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/tool-denylist.yaml --fuel-limit 1000000
chio guard bench target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
  --iterations 100

# Package the manifest and module
chio guard pack

Custom guards covers the other subcommands, including chio guard sign and verify, install, and blocklist.

Step 1: scaffold the crate

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 scaffold’s Cargo.toml is complete as written. The cdylib crate type produces a .wasm module instead of 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: write the guard

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; that fallback blocks the three named tools and nothing else.

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

// Default for a host that supplies no denylist configuration.
// Extra deployment restrictions are not preserved by this fallback.
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.
    // The guard reads 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 runs 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 progress goes to stderr; the capture 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

The two commands underneath work on their own and serve a toolchain without chio guard; only the binary size line is lost.

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

This project produces a core module. A Component Model project needs its own WIT bindings and component build configuration; start from one of the complete component SDK examples and integrate the matching backend.

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 appears in the export list and stays out of that check, because the host never calls it. The memory line reports the module’s declared minimum; the ceiling is max_memory_bytes, set on the host side.

Step 5: watch it refuse

Two fixtures 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: workspace
    agent_id: fixer
    arguments:
      path: "/workspace/README.md"
  expected_verdict: allow

- name: denies a tool on the denylist
  request:
    tool_name: delete_file
    server_id: workspace
    agent_id: fixer
    arguments:
      path: "/workspace/src/pricing.py"
  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 makes the deny reason a tested string. Assert expected_verdict: allow on wipe_database, a tool the default denylist covers, and the runner reports what the guard 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[CHIO-KERNEL-GUARD-DENIED]: 1 guard test case failed
  help: each [FAIL] line above names the case and the verdict it expected
  code: urn:chio:error:guard:denied
  docs: https://chio.computer/docs/reference/errors#error-guard-denied
exit 1in tool-denylist-guard

The bracketed identifier is a registry URN, and the string_code beside it is its stable spelling. These are the codes in the guard domain; a script that branches on a refusal branches on them, never on the message.

URNstring_codeSeverityStability
urn:chio:error:guard:denied
CHIO-KERNEL-GUARD-DENIED
error
stable
urn:chio:error:guard:input-redacted
CHIO-GUARD-INPUT-REDACTED
warning
unstable
urn:chio:error:guard:output-redacted
CHIO-GUARD-OUTPUT-REDACTED
warning
unstable
urn:chio:error:guard:wasm-trap
CHIO-GUARD-WASM-TRAP
fatal
unstable

chio guard bench repeatedly submits a fixed bench_tool request with empty configuration, which for this rule exercises the default denylist’s allow branch. Its fuel count describes that one request, with no claim to a lower bound or worst-case budget; measure representative configured requests when you choose a deployment limit.

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

The capture reports repeated runs of the same input, and latency varies with the host machine. A stable fuel count or a latency percentile describes that input only; measure other request sizes and rule branches separately.

Step 6: write the manifest

The manifest binds a name and a digest to the module. Take the digest from the binary you built; it changes with the toolchain and the SDK version.

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 resolves 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 printed>"
config:
  denylist: "delete_file,execute_command_as_root,wipe_database"
allow_unsigned: true   # development only

Add the wit_world line by hand. The manifest chio guard new writes carries name, version, abi_version, wasm_path, and an empty wasm_sha256 value to fill in. chio guard pack archives a manifest without wit_world, and load_manifest refuses it when a node loads the guard from chio.yaml.

chio guard pack then writes one gzipped tar holding the manifest and the module. The archive name is {name}-{version}.arcguard, read from the manifest, so renaming the guard in the manifest renames the archive. Inside, the module is stored under its filename alone, and chio guard install rewrites wasm_path to match when it unpacks.

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

Add this excerpt to an already configured node, beside your existing kernel and adapter settings. It registers the guard and leaves tool servers as they are. The complete document project uses the policy loader instead.

chio.yaml · add to your existing nodeyaml
wasm_guards:
  - name: tool-denylist
    path: /etc/chio/guards/tool-denylist/tool_denylist_guard.wasm
    fuel_limit: 5000000
    priority: 100
    advisory: false

The loader finds the manifest in the parent directory of path, exactly one level up, and reads guard-manifest.yaml there; the module and its manifest share a directory. chio.yaml configuration has the schema.

The chio.yaml loader sorts by ascending priority, with advisory guards after blocking guards at equal priority. A host using WasmGuardRuntime directly keeps insertion order. Check the loader comparison before relying on ordering in a custom embedding.

Step 8: reload behind a canary

Canary fixtures live beside the manifest. They are JSON documents matching the CanaryFixture shape and cover the verdicts the guard must preserve across a reload: one allow, one deny, and the edge cases. The corpus holds 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 runs over those bytes. A candidate that reaches the same allow or deny with a different reason string fails to match, so a reason edit aborts the swap the way a flipped verdict does.

When your host calls the canary reload entry point with a candidate, the reload component loads it, replays the canary fixtures, compares verdicts to the recorded baseline, and either swaps the live module or aborts. A later live failure trips the watchdog and rolls back; the next request is served by the prior module while the operator inspects the incident log.

Other language targets

The public guard SDKs cover Rust core-module authoring and Go, TypeScript, and Python Component Model targets. Building Custom Guards keeps complete source examples together. Pair each binding with its matching backend and size limits in the host.

A custom core toolchain exports memory and evaluate(i32, i32) -> i32, stays within the host’s memory bounds, and imports only the supported chio functions. An optional chio_alloc chooses request placement; without it the host writes at offset zero. The deny-reason conventions are under Core module ABI. The runtime refuses WASI imports.

Next: sign and install a guard

WASM Guard Runtime · Chio Docs