PlatformAuthoring
Custom WASM Guards
How to build and operate signed WASM guards with the Chio ABI, Wasmtime limits, manifests, and reloads.
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
DebouncedReloadcomponent. A failed canary restores 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 imports 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 probes for an optional export to retrieve a structured deny reason:
chio_deny_reason(buf_ptr: i32, buf_len: i32) -> i32When 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.
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 — 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
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 an optional allocator export:
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 thisIf 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 runs in a freshly created Wasmtime Store and instance that is dropped when the call returns, so there is nothing for the host to free. chio-guard-sdk still exports chio_free alongside chio_alloc — its thread-local Vec-based allocator satisfies both signatures for guest-side symmetry — but 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 can call the host through a fixed import API. 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 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
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 chio_alloc via get_typed_func::<i32, i32>; chio_free is a guest-side release the host never calls. |
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 crate doc shows the manual wiring only
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" — and 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.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. Recorded 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 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. 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. 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:
| Outcome | outcome value | Effect |
|---|---|---|
| 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; 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.
The rollback watchdog attaches to each applied reload. Its default policy trips after 5 error-class verdicts (traps, fuel exhaustion, serialization failures, and other fail-closed backend errors) within a 60-second sliding window; a clean evaluation resets the streak. On trip it restores the prior module and writes an incident directory under ${XDG_STATE_HOME}/chio/incidents/<utc-iso8601>-<guard_id>-<reload_seq>/ holding incident.json and last_5_eval_traces.ndjson (redacted trace summaries only; request arguments are never persisted).
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. The workflow scaffolds the crate, adds the SDK, implements the guard, builds for wasm32-unknown-unknown, write the manifest, and register it in chio.yaml.
The chio guard CLI wraps this workflow. The manual steps below show the work each command automates and support non-Rust toolchains:
# Scaffold Cargo.toml, src/lib.rs, and guard-manifest.yaml
$ chio guard new tool-denylist
# Compile the current directory to wasm32-unknown-unknown
$ chio guard build
# Print exports, ABI compatibility, and memory config
$ chio guard inspect ./tool_denylist_guard.wasm
# Replay YAML fixtures, then benchmark fuel and latency
$ chio guard test --wasm ./tool_denylist_guard.wasm ./fixtures/*.yaml --fuel-limit 1000000
$ chio guard bench ./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: 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 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) — ascending priority, with advisory guards 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 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 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.
- 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