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
DebouncedReloadmachinery. 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:
| Format | Backend | Contract |
|---|---|---|
| Core module | WasmtimeBackend | Raw ABI: evaluate(ptr, len) -> i32 |
| Component Model | ComponentBackend | WIT 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:
evaluate(request_ptr: i32, request_len: i32) -> i32The 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 value | Meaning |
|---|---|
0 | Allow (constant VERDICT_ALLOW) |
1 | Deny (constant VERDICT_DENY) |
| any negative value | Error. Treated as deny (fail-closed). |
On a deny, the host calls a separate export to retrieve a structured deny reason:
chio_deny_reason(buf_ptr: i32, buf_len: i32) -> i32The 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
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:
chio_alloc(size: i32) -> i32 // returns a pointer, or 0 on failure
chio_free(ptr: i32, size: i32) // releases a previously allocated regionThe 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:
| Import | Signature | Effect |
|---|---|---|
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) -> i32 | Look up a string config value. Returns bytes written, or -1 if missing. |
chio.get_time_unix_secs | () -> i64 | Wall-clock time as Unix seconds. |
chio:guard/host@0.2.0 fetch-blob | (handle, offset, len, out_ptr, out_len) -> i32 | Read 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
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.
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, PolicyContextThe pieces:
| Symbol | Role |
|---|---|
GuardRequest | Read-only request: tool name, server, agent, JSON arguments, scopes, host-extracted action type and path/target. |
GuardVerdict | Allow or Deny { reason }. Construct via GuardVerdict::allow() / GuardVerdict::deny(reason). |
read_request | 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 / chio_free | Thread-local allocator exports the host probes via get_typed_func::<i32, i32>. |
chio_deny_reason | Writes 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.
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
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.
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| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable guard identifier. Surfaces in logs and receipts. |
version | Yes | Semantic version of the guard binary. |
abi_version | Yes | ABI the guard targets. Must be in SUPPORTED_ABI_VERSIONS (currently "1"). |
wit_world | Component Model: yes | Must equal "chio:guard/guard@0.2.0". Missing or older worlds are rejected fail-closed. |
wasm_path | Yes | Path to the binary, relative to the manifest or absolute. |
wasm_sha256 | Yes | Hex-encoded SHA-256 of the binary. Mismatch is a load-time error. |
config | No | String key-value pairs surfaced to the guest via 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. 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:
{
"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 isInMemoryBundleStore; additional backends implement the trait.DebouncedReload: coalesces rapid reload triggers into a single apply attempt.CanaryCorpus: a fixed set ofCANARY_FIXTURE_COUNT(32)CanaryFixtureentries replayed against the new module before it goes live. A divergent verdict on any fixture aborts the swap.IncidentWriter: recordsReloadIncidententries withEvalTracesamples 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:
| Outcome | Span name | Effect |
|---|---|---|
| Applied | RELOAD_APPLIED | New module is live; old module is dropped. |
| Canary failed | RELOAD_CANARY_FAILED | New module never serves traffic. Old module continues. |
| Rolled back | RELOAD_ROLLED_BACK | A 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:
| Metric | Kind |
|---|---|
chio_guard_verdict_total | Counter |
chio_guard_deny_total | Counter |
chio_guard_eval_duration_seconds | Histogram |
chio_guard_fuel_consumed_total | Counter |
chio_guard_host_call_duration_seconds | Histogram |
chio_guard_module_bytes | Gauge |
chio_guard_reload_total | Counter |
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
[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
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
$ 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.wasmFor 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
$ sha256sum target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
9f86d081... tool_denylist_guard.wasmname: 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 onlyStep 5: Register in 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: 100The 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
@externaldeclarations for the host imports and exporting your own allocator. - Go (TinyGo): use
//go:wasmexportfor the entry points and//go:wasmimportfor the host functions. TinyGo's default GC is acceptable inside the fuel limit. - C / C++: compile with the WASI SDK targeting
wasm32-unknown-unknownand use__attribute__((export_name("..."))).
Next Steps
- Custom Guards · the same evaluation contract, but as a native Rust crate
- chio.yaml Configuration · the
wasm_guardsblock, fuel and priority tuning - HushSpec Policy Format · how WASM guards interleave with the policy-driven default pipeline
- External Guards · cloud content-safety and threat-intel providers wrapped by the same async adapter pattern