BuildCustom Guards
Building Custom Guards
Build tool-gate and enriched-inspector WASM guards with chio-guard-sdk and load them into the kernel.
Prerequisites
wasm32-unknown-unknown target installed (rustup target add wasm32-unknown-unknown). The chio workspace built locally. See Installation for setup, Custom Guards for the native-Rust Guard trait, and WASM guards for the runtime model these examples plug into.What It Shows
These examples introduce WASM guards:
| Example | Path | Topics |
|---|---|---|
tool-gate | examples/guards/tool-gate | Basic tool-name inspection; the smallest possible #[chio_guard] body |
enriched-inspector | examples/guards/enriched-inspector | Enriched request fields ( action_type, extracted_path) plus host functions ( chio.log, chio.get_config) |
Both crates declare crate-type = ["cdylib"] and pull chio-guard-sdk plus chio-guard-sdk-macros in as workspace dependencies. Both inherit the workspace lint policy through [lints] workspace = true, which denies unwrap_used and expect_used: guards run inside the kernel hot path, so panicking is not on the table.
Native Guard trait or WASM module?
Guard trait for guards that ship with the kernel binary and live alongside built-ins. Pick a WASM module when you want to ship a guard separately from the kernel: hot reload without restarting, distribute through a manifest, or run third-party policy that does not have access to the kernel internals. The two coexist in the same pipeline.Run It
Build either example to wasm32-unknown-unknown:
# Tool-gate
cargo build -p chio-example-tool-gate \
--target wasm32-unknown-unknown --release
# Enriched-inspector
cargo build -p chio-example-enriched-inspector \
--target wasm32-unknown-unknown --releaseThe output .wasm lands under target/wasm32-unknown-unknown/release/. The kernel loads that WASM module.
Tool-Gate: a minimal guard
tool-gate is a three-item blocklist with one guard in front of it. Before it checks the blocklist it rejects a tool name that is empty or carries surrounding whitespace, so a padded " drop_database " cannot slip past an exact-match deny list. The #[chio_guard] macro wires up the WASM exports the host runtime expects (evaluate, chio_alloc, chio_free, chio_deny_reason) and delegates to a plain Rust function over GuardRequest returning GuardVerdict.
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;
const BLOCKED_TOOLS: &[&str] = &["dangerous_tool", "rm_rf", "drop_database"];
const BLOCKED_REASON: &str = "tool is blocked by policy";
const INVALID_TOOL_NAME_REASON: &str = "tool name is empty or not canonical";
#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
verdict_for_request(&req)
}
fn verdict_for_request(req: &GuardRequest) -> GuardVerdict {
let name = req.tool_name.as_str();
// Reject empty or whitespace-padded names before the blocklist check.
let trimmed = name.trim();
if trimmed.is_empty() || trimmed != name {
return GuardVerdict::deny(INVALID_TOOL_NAME_REASON);
}
if BLOCKED_TOOLS.contains(&name) {
return GuardVerdict::deny(BLOCKED_REASON);
}
GuardVerdict::allow()
}The macro wraps evaluate, so the decision logic lives in a plain verdict_for_request function. That split is what the unit tests below call directly.
Cargo manifest:
[package]
name = "chio-example-tool-gate"
version.workspace = true
edition.workspace = true
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
chio-guard-sdk = { workspace = true }
chio-guard-sdk-macros = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
[lints]
workspace = trueEnriched-Inspector: Reading Extracted Fields and Calling Host Functions
enriched-inspector demonstrates two guard SDK features that tool-gate skips:
- Enriched request fields.
GuardRequest.action_typeandGuardRequest.extracted_pathare populated by the kernel before evaluation. The guard reads them to write rules from the requested action and path, not only the raw tool name. - Host functions.
log(level, msg)emits a structured log entry throughchio.log;get_config(key)reads per-deployment guard configuration throughchio.get_config.
The example gates file_write actions against a protected root with segment-aware path containment. A write is denied when its extracted_path is a normalized absolute path that sits under the configured blocked_path (or /etc as the built-in fallback) at a segment boundary. A path that is relative or carries ., .., or // segments fails closed instead of being compared, so /etc/../secret never reaches the containment check, and blocking /etc does not also block a sibling like /etcetera/passwd.
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;
const DEFAULT_BLOCKED_PATH: &str = "/etc";
#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
log(log_level::INFO, "enriched inspector evaluating request");
let blocked_path = get_config("blocked_path");
// Only file_write actions are gated; a missing action_type allows.
let Some(action) = req.action_type.as_deref() else {
return GuardVerdict::allow();
};
// action_type must be canonical: non-empty, no surrounding whitespace.
if action.is_empty() || action.trim() != action {
return GuardVerdict::deny("action_type is empty or not canonical");
}
if action != "file_write" {
return GuardVerdict::allow();
}
// file_write requires normalized absolute path evidence; anything
// relative or carrying '.'/'..'/'//' segments fails closed.
let Some(path) = req.extracted_path.as_deref() else {
return GuardVerdict::deny("file_write missing normalized path evidence");
};
if !is_normalized_absolute_path(path) {
return GuardVerdict::deny("file_write path evidence is not normalized");
}
// Segment-safe containment: /etc matches /etc and /etc/*, never /etcetera.
if let Some(root) = blocked_path.as_deref() {
if path_is_under(path, root) {
return GuardVerdict::deny("write to protected path blocked by policy");
}
}
if path_is_under(path, DEFAULT_BLOCKED_PATH) {
return GuardVerdict::deny("write to /etc blocked");
}
GuardVerdict::allow()
}
/// Absolute, with no empty, `.`, or `..` path segments.
fn is_normalized_absolute_path(path: &str) -> bool {
if path == "/" {
return true;
}
if !path.starts_with('/') {
return false;
}
path.split('/')
.skip(1)
.all(|seg| !seg.is_empty() && seg != "." && seg != "..")
}
/// True only when `path` equals `root` or sits under it at a segment boundary.
fn path_is_under(path: &str, root: &str) -> bool {
if root == "/" {
return path.starts_with('/');
}
let root = root.trim_end_matches('/');
if root.is_empty() {
return false;
}
path == root || path.strip_prefix(root).is_some_and(|rest| rest.starts_with('/'))
}The distinct deny reasons are what an auditor keys on: action_type is empty or not canonical, file_write missing normalized path evidence, and file_write path evidence is not normalized each mark a different failure of the enriched-field contract, ahead of the two containment denials.
This example uses these SDK calls:
log(level, message)and thelog_levelconstants (INFO,WARN) wrap thechio.loghost import.get_config(key) -> Option<String>wrapschio.get_configand returnsNonewhen the deployment has no value for that key.
The full prelude exposes more: a get_time wrapper for chio.get_time_unix_secs, a fetch_blob wrapper for chio:guard/host.fetch-blob, and a PolicyContext resource wrapper for the policy-context bundle handle. See the WASM guard reference for the full host ABI.
Building and Loading a WASM Guard
Build
WASM guards are cdylib crates targeting wasm32-unknown-unknown. On native targets the SDK keeps no-op fallbacks for host imports so cargo test runs without a WASM runtime; the production build is the WASM target.
rustup target add wasm32-unknown-unknown
cargo build -p chio-example-enriched-inspector \
--target wasm32-unknown-unknown --release
ls target/wasm32-unknown-unknown/release/chio_example_enriched_inspector.wasmManifest and Hot Reload
WASM guards are loaded by the kernel through a guard manifest that points at the .wasm module and includes the per-deployment configuration the guard expects (such as the blocked_path key the enriched-inspector reads). The manifest path, hot-reload model, and signing rules are normative in the WASM guard reference.
Once the manifest is registered, the kernel sends each GuardRequest to the guest, deserializes the returned GuardVerdict, and folds the result into the same conjunctive pipeline as built-in guards. Errors from the guest run fail-closed: a guard that crashes or returns an undecodable verdict denies the request, matching the native Guard trait does.
Inspect After Build
The build writes the WASM module to Cargo's output path. Check it with:
ls -la target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm
file target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm
# Expected output (from file):
# ... WebAssembly (wasm) binary module version 0x1 (MVP)That .wasm is the module the kernel loads through a guard manifest. The enriched-inspector build produces chio_example_enriched_inspector.wasm at the same path.
Guard Lifecycle with chio guard
The raw cargo build above is just the compiler step. The chio guard CLI wraps lifecycle: scaffold, build, inspect, fixture-test, package, and publish. It produces a signed distributable module from the project.
# Scaffold Cargo.toml, src/lib.rs, and guard-manifest.yaml
chio guard new my-guard
cd my-guard
# Compile the current directory to wasm32-unknown-unknown
chio guard build
# Print exports, ABI compatibility, and memory config for a .wasm
chio guard inspect target/wasm32-unknown-unknown/release/my_guard.wasm
# Run YAML test fixtures against the compiled module, fuel-metered
chio guard test \
--wasm target/wasm32-unknown-unknown/release/my_guard.wasm \
fixtures/*.yaml --fuel-limit 1000000
# Benchmark fuel consumption and latency
chio guard bench target/wasm32-unknown-unknown/release/my_guard.wasm
# Package a distributable .arcguard archive
chio guard packDistribution goes through an OCI registry. chio guard publish uploads a three-part package: the WIT world, the WASM module, and a config blob carrying the fuel and memory limits plus the epoch seed — to a tag-addressed reference; chio guard pull fetches a digest-pinned package into the local content-addressed cache.
chio guard publish my-guard \
--ref oci://ghcr.io/chio/my-guard:v1 \
--epoch-id-seed <seed>
chio guard pull \
--ref oci://ghcr.io/chio/my-guard@sha256:<digest>chio guard test is the CLI-level analog to the unit tests below: it runs fixture cases against the compiled module under a fuel limit, exercising the WASM module the kernel loads, not the native fallback path. chio guard sign, install, and blocklist round out local trust management for pulled guards.
Decision rule
Guard trait when the guard lives in-tree alongside built-ins and needs full access to kernel types; see Custom Guards. Pick a HushSpec rule when the policy is just allow/deny lists or regex matching; see HushSpec.Testing Locally
Because the SDK provides no-op fallbacks for host imports on native targets, you can call the guard's decision function directly with constructed GuardRequest values. The macro wraps evaluate, so tests target the plain verdict_for_request helper and match on the GuardVerdict enum:
#[cfg(test)]
mod tests {
use super::*;
use chio_guard_sdk::prelude::*;
fn request(tool_name: &str) -> GuardRequest {
GuardRequest {
tool_name: tool_name.to_string(),
server_id: "test-server".to_string(),
agent_id: "test-agent".to_string(),
arguments: serde_json::json!({}),
scopes: vec![],
action_type: None,
extracted_path: None,
extracted_target: None,
filesystem_roots: vec![],
matched_grant_index: None,
}
}
#[test]
fn allows_unknown_tool() {
assert!(matches!(
verdict_for_request(&request("safe_tool")),
GuardVerdict::Allow
));
}
#[test]
fn denies_listed_tool() {
assert!(matches!(
verdict_for_request(&request("drop_database")),
GuardVerdict::Deny { reason } if reason == BLOCKED_REASON
));
}
#[test]
fn rejects_padded_tool_name() {
assert!(matches!(
verdict_for_request(&request(" drop_database ")),
GuardVerdict::Deny { reason } if reason == INVALID_TOOL_NAME_REASON
));
}
}GuardRequest construction needs every field — tool_name, server_id, agent_id, arguments, scopes, action_type, extracted_path, extracted_target, filesystem_roots, matched_grant_index. The tests run on the host target with cargo test and exercise the same decision body the WASM build exports.
Picking the Right Path
Three guard integration points are available; choose where you want the code to live and how you want it distributed.
| Integration point | Where it lives | When to use |
|---|---|---|
| Built-in HushSpec rule | YAML policy file | Allow/deny lists, regex argument matching, egress allowlists; covered by the default pipeline |
Native Guard trait | Compiled into the kernel binary | In-tree guards alongside built-ins; access to kernel types and full Rust ecosystem; see the Guard trait reference |
WASM module (chio-guard-sdk) | External .wasm module | Distributed separately, hot-reloadable, third-party policy; what these examples build |
Next Steps
- Custom Guards guide · the native Rust
Guardtrait, with two worked examples - WASM guards · normative reference for the host ABI, manifest format, and hot reload
- Guard trait reference · the in-process trait the kernel pipeline runs against, native and WASM alike