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). For the other three, the SDK's own componentizer: jco for TypeScript, componentize-py for Python, TinyGo plus wasm-tools for Go. 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. The host cares about the compiled evaluate export and not about the language that produced it, and it accepts two shapes of .wasm: a core module exporting the raw ABI, or a Component Model component built against the WIT world. It picks between them by reading the file's magic bytes at load time (crates/guards/chio-wasm-guards/src/lib.rs:7-19). Four SDKs write that export, and the source ships the same guard in all four:
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 {
match decide_tool_name(req.tool_name.as_str()) {
ToolGateDecision::Allow => GuardVerdict::allow(),
ToolGateDecision::Deny { reason } => GuardVerdict::deny(reason),
}
}
fn decide_tool_name(tool_name: &str) -> ToolGateDecision {
let trimmed = tool_name.trim();
if trimmed.is_empty() || trimmed != tool_name {
return ToolGateDecision::Deny {
reason: INVALID_TOOL_NAME_REASON,
};
}
if BLOCKED_TOOLS.contains(&tool_name) {
return ToolGateDecision::Deny {
reason: BLOCKED_REASON,
};
}
ToolGateDecision::Allow
}import type {
GuardRequest,
Verdict,
} from "../../src/index.js";
const BLOCKED_TOOLS: ReadonlySet<string> = new Set([
"dangerous_tool",
"rm_rf",
"drop_database",
]);
/**
* Evaluate a tool-call request and return a verdict.
*
* Exported as the guard world's `evaluate` function.
*/
export function evaluate(request: GuardRequest): Verdict {
if (BLOCKED_TOOLS.has(request.toolName)) {
return { tag: "deny", val: "tool is blocked by policy" };
}
return { tag: "allow" };
}from guard import Guard as BaseGuard
from guard.imports.types import GuardRequest, Verdict_Allow, Verdict_Deny
BLOCKED_TOOLS: frozenset[str] = frozenset({
"dangerous_tool",
"rm_rf",
"drop_database",
})
class Guard(BaseGuard):
"""Evaluate a tool-call request and return a verdict."""
def evaluate(self, request: GuardRequest) -> Verdict_Allow | Verdict_Deny:
if request.tool_name in BLOCKED_TOOLS:
return Verdict_Deny("tool is blocked by policy")
return Verdict_Allow()package main
import (
"github.com/backbay-labs/chio/sdks/guard/chio-guard-go/internal/chio/guard/guard"
"github.com/backbay-labs/chio/sdks/guard/chio-guard-go/internal/chio/guard/types"
)
// blockedTools is the set of tools that this guard denies.
var blockedTools = map[string]bool{
"dangerous_tool": true,
"rm_rf": true,
"drop_database": true,
}
func init() {
guard.Exports.Evaluate = evaluate
}
// evaluate inspects the tool name and returns deny for blocked tools,
// allow for everything else.
func evaluate(request guard.GuardRequest) guard.Verdict {
if blockedTools[request.ToolName] {
return types.VerdictDeny("tool is blocked by policy")
}
return types.VerdictAllow()
}The deny list and the deny reason are the same string in all four. The differences are the SDK's own idiom: Rust's #[chio_guard] macro generates the WASM exports the host expects (evaluate, chio_alloc, chio_free, chio_deny_reason); TypeScript exports a plain function that jco componentize turns into the component; Python subclasses the generated Guard base; Go assigns guard.Exports.Evaluate in init. All four target the same WIT world, chio:guard@0.2.0.
The Rust example carries one rule the other three skip: before 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 macro wraps evaluate, so that rule lives in a plain decide_tool_name function. That split is what the unit tests below call directly.
Each SDK compiles its own module. The three non-Rust SDKs ship a matching pair of scripts, so the build is two commands wherever you start:
# Rust
cargo build -p chio-example-tool-gate \
--target wasm32-unknown-unknown --release
# TypeScript, Python, or Go: run the pair from the SDK directory
cd sdks/guard/chio-guard-ts # or chio-guard-py, or chio-guard-go
./scripts/generate-types.sh # WIT bindings for chio:guard@0.2.0
./scripts/build-guard.sh # writes the component under dist/The Rust build lands in Cargo's target directory; the other three write theirs under dist/ in the SDK directory. The kernel loads either one through the same guard manifest. They are not the same shape of artifact, though: the Rust macro emits the raw ABI, so cargo build --target wasm32-unknown-unknown produces a core module, while jco, componentize-py and TinyGo plus wasm-tools produce components for chio:guard@0.2.0. The host routes each to its own backend and the guard behaves identically either way.
Cargo manifest for the Rust example:
[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.
Six named deny reasons, one per way the contract can fail:
const CONFIG_BLOCKED_PATH_KEY: &str = "blocked_path";
const DEFAULT_BLOCKED_PATH: &str = "/etc";
const PROTECTED_PATH_REASON: &str = "write to protected path blocked by policy";
const DEFAULT_PATH_REASON: &str = "write to /etc blocked";
const INVALID_ACTION_TYPE_REASON: &str = "action_type is empty or not canonical";
const MISSING_WRITE_PATH_REASON: &str = "file_write missing normalized path evidence";
const INVALID_WRITE_PATH_REASON: &str = "file_write path evidence is not normalized";
const INVALID_CONFIG_REASON: &str = "blocked_path config is empty or not normalized";The macro wraps evaluate, so the guard keeps the entry point thin and puts the decision in a plain function the unit tests can call:
#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
log(log_level::INFO, "enriched inspector evaluating request");
let blocked_path = get_config(CONFIG_BLOCKED_PATH_KEY);
verdict_for_request(&req, blocked_path.as_deref())
}fn decide_request(
req: &GuardRequest,
configured_blocked_path: Option<&str>,
) -> EnrichedInspectorDecision {
let Some(action_type) = req.action_type.as_deref() else {
return EnrichedInspectorDecision::Allow;
};
let Some(action_type) = canonical_action_type(action_type) else {
return EnrichedInspectorDecision::Deny {
reason: INVALID_ACTION_TYPE_REASON,
};
};
if action_type != "file_write" {
return EnrichedInspectorDecision::Allow;
}
let Some(path) = req.extracted_path.as_deref() else {
return EnrichedInspectorDecision::Deny {
reason: MISSING_WRITE_PATH_REASON,
};
};
if !is_normalized_absolute_path(path) {
return EnrichedInspectorDecision::Deny {
reason: INVALID_WRITE_PATH_REASON,
};
}
if let Some(root) = configured_blocked_path {
let Some(root) = normalized_blocked_root(root) else {
return EnrichedInspectorDecision::Deny {
reason: INVALID_CONFIG_REASON,
};
};
if path_is_under(path, root) {
return EnrichedInspectorDecision::Deny {
reason: PROTECTED_PATH_REASON,
};
}
}
if path_is_under(path, DEFAULT_BLOCKED_PATH) {
return EnrichedInspectorDecision::Deny {
reason: DEFAULT_PATH_REASON,
};
}
EnrichedInspectorDecision::Allow
}The distinct deny reasons are what an auditor keys on. Three of them mark a different failure of the enriched-field contract, ahead of the two containment denials: action_type is empty or not canonical, file_write missing normalized path evidence, and file_write path evidence is not normalized. The sixth is about the operator rather than the caller.
The configured blocked_path is not trusted either. It goes through normalized_blocked_root first, and a root that does not come back normalized denies with blocked_path config is empty or not normalized rather than falling through to a comparison against garbage. That is the guard failing closed on its own misconfiguration, and it is pinned by the guard's own test policy_fails_closed_on_malformed_configured_root (examples/guards/enriched-inspector/src/lib.rs:247-258).
fn normalized_blocked_root(root: &str) -> Option<&str> {
if root == "/" {
return Some(root);
}
let root = root.trim_end_matches('/');
if is_normalized_absolute_path(root) {
Some(root)
} else {
None
}
}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 fail closed for a blocking guard: a crash, an out-of-fuel stop or an undecodable verdict denies the request, the same way the native Guard trait does. An advisory guard is the opposite by design: the runtime logs the error and stays non-blocking (crates/guards/chio-wasm-guards/src/lib.rs:35-37, and the tests advisory_error_allows and advisory_guard_allows_on_deny in runtime.rs). Which one you get is a property of how the guard is registered, not of the module.
The pipeline your guard joins already has 7 members, registered in this order by GuardPipeline::default_pipeline(): forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, patch-integrity. The pipeline is conjunctive, so a WASM guard can only ever narrow what those 7 already allow. See the default pipeline for what each one checks.
Inspect After Build
The build writes the WASM module to Cargo's output path. file is the quickest way to see which of the two shapes came out:
$ file target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm \
target/wasm32-unknown-unknown/release/chio_example_enriched_inspector.wasmchio_example_tool_gate.wasm: WebAssembly (wasm) binary module version 0x1 (MVP) chio_example_enriched_inspector.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)
binary module, not component: the Rust path produces core modules, because #[chio_guard] emits the raw ABI directly (crates/sdk/chio-guard-sdk-macros/src/lib.rs:150-191). Both release builds land around 130 KiB, so a guard is a small artifact to ship and to hash. The exact byte count moves with the compiler, so read it off the build rather than off this page.
chio guard inspect goes further and reads the export table the macro wrote, which is what the host checks at load:
$ chio guard inspect target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm=== WASM Guard Inspection === File: chio_example_tool_gate.wasm Size: 129.9 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
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 packThe first two, run against a fresh scaffold:
$ chio guard new tool-denylist-guardcreated 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
$ chio guard buildbuild 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.22sThe WASM guard reference carries the rest of that sequence as captured runs: inspect, the fixture pass and the fixture failure, bench, and pack.
Distribution goes through an OCI registry. chio guard publish uploads a three-part package to a tag-addressed reference: the WIT world, the WASM module, and a config blob carrying the fuel and memory limits plus the epoch seed. 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>Those two are the only steps on this page with no captured output: both need a reachable registry, and the machine that built this page had none.
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:
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,
}
}Three further #[test] functions call decide_tool_name directly; one goes through the wrapper the macro leaves in place:
fn verdict_for_request_uses_policy_decision() {
assert!(matches!(
verdict_for_request(&request("read_file")),
GuardVerdict::Allow
));
assert!(matches!(
verdict_for_request(&request("dangerous_tool")),
GuardVerdict::Deny { reason } if reason == BLOCKED_REASON
));
assert!(matches!(
verdict_for_request(&request(" read_file")),
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