Chio/Docs

Custom WASM Guards

WebAssembly guards run inside the same kernel pipeline as native Rust guards but ship as portable .wasm binaries with a YAML manifest. The host loads them through Wasmtime, meters CPU with fuel, caps linear memory, and verifies an Ed25519 signature before any guest code runs. This page covers the ABI, the host imports, the manifest contract, and a worked Rust example that compiles to wasm32-unknown-unknown and loads via chio.yaml.

Why WASM

Native Rust guards (covered in Custom Guards) compile into the kernel and have full access to the address space. WASM guards give up that intimacy 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 machinery. A failed canary rolls back to the previous module without restarting the kernel.

Module Formats

The host supports two WASM formats and detects which one a binary uses by inspecting magic bytes:

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

create_backend in chio-wasm-guards picks the right backend at load time. Callers do not specify the format explicitly. Both backends share the same fuel meter, memory limit, host-import surface, and manifest verification path.


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 calls a separate export to retrieve a structured deny reason:

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

The guest writes a JSON { "reason": "..." } document into the host-provided buffer and returns the number of bytes written, or -1 if no reason is stored or the buffer is too small. If the export is absent, the host uses a generic denial message.

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.

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 two well-known exports:

text
chio_alloc(size: i32) -> i32     // returns a pointer, or 0 on failure
chio_free(ptr: i32, size: i32)   // releases a previously allocated region

The host calls chio_alloc(request_len), copies the JSON bytes to the returned pointer, calls evaluate(ptr, len), and then calls chio_free(ptr, len). Modules built with chio-guard-sdk get a thread-local Vec-based allocator that satisfies these signatures with no extra wiring.

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 can call back into the host through a fixed surface. All four imports run inside a tracing span and an unchecked import is a load-time error. Names follow the 0.1 compatibility set under the chio module plus the bundle-aware additions under chio:guard/host@0.2.0:

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, offset, len, out_ptr, out_len) -> i32Read a byte range from a host-owned content bundle resolved by SHA-256.

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 4096-byte scratch buffer on the guest side; values longer than that are truncated to None by the SDK wrapper.

No filesystem, no network

These four imports are the entire host surface. There is no way for a WASM guard to open a file, make a network request, or read an environment variable directly. If a guard needs external data, either expose it through chio.get_config or stage it as a content bundle and read via fetch-blob.

Component Model

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 host imports become no-op fallbacks so unit tests run without a WASM runtime.

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
GuardRequestRead-only request: tool name, server, agent, JSON arguments, scopes, host-extracted action type and path/target.
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 via get_typed_func::<i32, i32>.
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 lib doc still mentions deferred wiring

The current chio-guard-sdk crate-level documentation refers to the macro as a future helper. The macro crate 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.

Manifest Format

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.

guard-manifest.yaml
name: pii-scanner
version: "1.0.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: pii.wasm
wasm_sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
config:
  threshold: "0.8"
  mode: strict
signer_public_key: "aabbccdd..."  # 64-char hex Ed25519 key (optional)
allow_unsigned: false             # opt-out, dev only
FieldRequiredDescription
nameYesHuman-readable guard identifier. Surfaces in logs and receipts.
versionYesSemantic version of the guard binary.
abi_versionYesABI the guard targets. Must be in SUPPORTED_ABI_VERSIONS (currently "1").
wit_worldComponent Model: yesMust equal "chio:guard/guard@0.2.0". Missing or older worlds are rejected fail-closed.
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 surfaced to the guest via chio.get_config.
signer_public_keyNoHex-encoded Ed25519 public key. When set, a .wasm.sig sidecar is required.
allow_unsignedNoDefaults to false. Setting it true permits loading without a sidecar; intended for development.

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.sig
{
  "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.


Digest Blocklist

The kernel keeps a GuardDigestBlocklist of SHA-256 hashes that must never load, even if every other check passes. A module whose digest matches an entry returns the well-known error code E_GUARD_DIGEST_BLOCKLISTED at load time. Use this for known-malicious or known-broken modules that would otherwise pass signature verification (e.g., a leaked signer key or a withdrawn release).


Hot Reload and Canary

The hot-reload pipeline is built around four pieces:

  • BundleStore (trait): supplies module bytes by content address. The shipped implementation is InMemoryBundleStore; additional backends implement the trait.
  • DebouncedReload: coalesces rapid reload triggers into a single apply attempt.
  • CanaryCorpus: a fixed set of CANARY_FIXTURE_COUNT (32) CanaryFixture entries replayed against the new module before it goes live. A divergent verdict on any fixture aborts the swap.
  • IncidentWriter: records ReloadIncident entries with EvalTrace samples for every canary failure or rollback. Operators read these from the incident log to triage a failed reload.

The reload state machine has three terminal outcomes, each emitted as a tracing span:

OutcomeSpan nameEffect
AppliedRELOAD_APPLIEDNew module is live; old module is dropped.
Canary failedRELOAD_CANARY_FAILEDNew module never serves traffic. Old module continues.
Rolled backRELOAD_ROLLED_BACKA live failure tripped the watchdog; reverted to the previous module.

Metrics

Every WASM guard reports a fixed set of Prometheus families with labels for the guard digest (16-char prefix), epoch, verdict, outcome, and host-function name where applicable:

MetricKind
chio_guard_verdict_totalCounter
chio_guard_deny_totalCounter
chio_guard_eval_duration_secondsHistogram
chio_guard_fuel_consumed_totalCounter
chio_guard_host_call_duration_secondsHistogram
chio_guard_module_bytesGauge
chio_guard_reload_totalCounter

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.


Worked Example: Tool Denylist Guard

A guard that denies any tool whose name appears in a config-supplied denylist. End to end: scaffold the crate, depend on the SDK, write the body, build for wasm32-unknown-unknown, write the manifest, and register it in chio.yaml.

Step 1: Cargo.toml

Cargo.toml
[package]
name = "tool-denylist-guard"
version = "1.0.0"
edition = "2021"

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

[dependencies]
chio-guard-sdk = { path = "../chio/crates/chio-guard-sdk" }
chio-guard-sdk-macros = { path = "../chio/crates/chio-guard-sdk-macros" }

Step 2: src/lib.rs

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

#[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_default();
    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()
}

Step 3: Build

bash
$ rustup target add wasm32-unknown-unknown
$ cargo build --release --target wasm32-unknown-unknown
$ ls target/wasm32-unknown-unknown/release/*.wasm
target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm

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: Manifest

bash
$ sha256sum target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
9f86d081...  tool_denylist_guard.wasm
guard-manifest.yaml
name: tool-denylist
version: "1.0.0"
abi_version: "1"
wasm_path: tool_denylist_guard.wasm
wasm_sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
config:
  denylist: "delete_file,execute_command_as_root,wipe_database"
allow_unsigned: true   # development only

Step 5: Register in chio.yaml

chio.yaml
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 kernel discovers the manifest by walking up from path to the parent directory and reading guard-manifest.yaml. See chio.yaml Configuration for the full schema.

Step 6: Reload with 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; mismatched counts fail to load with HotReloadError::CanaryFixtureCount.

When the bundle store reports a new module digest, the reload machinery loads the candidate, replays every canary fixture, 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, chio_alloc(i32) -> i32, chio_free(i32, i32), and optionally chio_deny_reason(i32, i32) -> i32, and that imports the host functions documented above, will load.

  • 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 and use __attribute__((export_name("..."))).

Next Steps