Custom Guards
Release your own WASM rule as signed bytes and run it under the release key the receiving operator pinned.
On this page
Ship an organization's own policy rule as a signed WASM module. The operator who loads it pins the release key, and the kernel runs the rule inside admission, before any tool sees the request.
The project is guarded-documents from Building Custom Guards: a document tool, a word-count rule, and an eight-word limit. The rule and the limit stay the same through all seven steps. What changes is who may supply the executable rule.
Start1. Build the rule into a WASM module
Download the guarded-documents project and install the Chio CLI from Installation. OpenSSL generates the release key in step 3. From the project root, build the rule and copy it into a release directory.
cd guard
chio guard build
cd ..
mkdir -p release
cp guard/target/wasm32-unknown-unknown/release/word_limit.wasm release/word_limit.wasmbuild complete: target/wasm32-unknown-unknown/release/word_limit.wasm binary size: 190.7 KiB
You should see the module path and its size. The build reads guard/Cargo.toml and targets wasm32-unknown-unknown. Keep Cargo.lock in the project: the byte count and the module digest change with the toolchain, the dependencies, and the build paths.
2. Check that the module exports the guard ABI
chio guard inspect release/word_limit.wasm=== WASM Guard Inspection === File: release/word_limit.wasm Size: 190.7 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
You should see ABI compatibility: COMPATIBLE with evaluate, chio_alloc, and chio_deny_reason marked. The memory line reports what the module declares; the deployment runtime applies its own memory limit. Inspection reads exports only. The rule first runs against a document in step 6.
3. Sign the module with your release key
Create the key once, in a private directory. Keep the seed for later releases; you distribute the public key and the signature.
mkdir -m 700 keys
(umask 077; set -C; openssl rand -hex 32 > keys/guard.seed)
chio guard sign release/word_limit.wasm --key keys/guard.seed \
--name word-limit --version 0.1.0signed release/word_limit.wasm sidecar: release/word_limit.wasm.sig signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516 module: word-limit@0.1.0 digest: fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c
You should see the sidecar path, signer_pk, the module coordinates word-limit@0.1.0, and its digest. The command writes release/word_limit.wasm.sig, an envelope that binds the module hash, name, and version to your key. Your key and digest will differ.
{
"module_hash": "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c",
"module_name": "word-limit",
"version": "0.1.0",
"signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516",
"signature": "cd28937edd1f1a93d1301e43233828260d2bdcfd43b24019f32e74bc2e23fe8f35c045daff178b16d085102b8ae0e816b8bf7be0597ba049d8f62882e517fb0f"
}4. Pin the signer you accept
Trust in a custom guard is the operator's decision. The operator names the release key it accepts, and loading checks every module against that key.
σrelease keygword_limit.wasmκpinned signerKkernelρreceipt
The operator's pin decides which release loads
The host takes the expected signer_public_key from guard-policy.json, with allow_unsigned set to false. The signer, the module digest, and the version must match the signed envelope before the application accepts requests. WASM Guards.
A valid release signature establishes which key signed those bytes; it says nothing about whether the rule implements the policy you intended. Review the rule, then choose its signer through your own release process.
Create release/guard-manifest.yaml. Copy the digest and signer_pk from the signing output into wasm_sha256 and signer_public_key, and keep the name and version equal to the signed envelope.
name: word-limit
version: "0.1.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: word_limit.wasm
wasm_sha256: "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c"
signer_public_key: "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
allow_unsigned: false
config:
max_words: "8"chio guard verify release/word_limit.wasmverified release/word_limit.wasm signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516 module: word-limit@0.1.0 digest: fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c
You should see verified with the same key and digest. With the manifest beside the module, chio guard verify checks the envelope against the manifest's pinned key. Without an adjacent manifest it checks against the key the sidecar carries, which proves the bytes match their own signature and names no trusted supplier.
5. Install the signed package
chio guard pack archives the manifest, the module, and the detached signature together, so a recipient needs only the archive.
cd release
chio guard pack
cd ..
chio guard install release/word-limit-0.1.0.arcguard --target-dir installed-guards
chio guard verify installed-guards/word-limit/word_limit.wasmpacked: word-limit-0.1.0.arcguard (77.7 KiB) installed: word-limit to installed-guards/word-limit/ verified installed-guards/word-limit/word_limit.wasm signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516 module: word-limit@0.1.0 digest: fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c
You should see the archive name, the installed directory, and a second verified line for the installed module. Installation stages and checks the complete signed package, then replaces the installed directory in one filesystem operation, so a failed update leaves the previous installation usable. The exchange needs atomic directory exchange on Linux or macOS; any other filesystem refuses the update.
6. Load the rule under the pinned signer
Installing places files on disk; the host loads them. In the application's guard-policy.json, point module_path at the installed module, set allow_unsigned to false, and add the expected signer_public_key. This host reads its configuration from this file alone.
{
"modules": [
{
"name": "word-limit",
"version": "0.1.0",
"module_path": "installed-guards/word-limit/word_limit.wasm",
"capabilities": [
"chio.get_config"
],
"config": {
"max_words": "8"
},
"fuel_limit": 1000000,
"priority": 100,
"advisory": false,
"allow_unsigned": false,
"signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
}
]
}target/release/guarded-documents sample.md long.mdEvidence: runs/a348e5c2-5bec-4a0e-a0ef-a4a7137571d5
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: e380bbac0029d0dc581a7a98528d6ff7154f6ec24d07aa0a0d0d3b64369aa994 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Receipt: ad7e0e17668a6d8e50ff1f4a41a0db28489f98dfa6c5f68586449315d1c7eabd (receipt-2.json)You should see sample.md allowed at six words and the eleven-word long.md denied by the guard word-limit, each with its own receipt. The process exits with code 2 because one input was refused. Loading rejects a wrong pinned key, changed module bytes, or a version that disagrees with the signature before the application accepts requests.
WARN WASM guard denied request guard=word-limit epoch_id=0 reason=document exceeds max_words7. Verify the denial receipt under the kernel key
The host diagnostic explains the refusal; the signed receipt records it. Download the allow and denial receipts. The example's verifier checks each against a separately supplied kernel public key.
{
"authorized": false,
"crypto_error": null,
"crypto_floor": "allow_classical",
"decision": {
"guard": "kernel",
"reason": "guard denied the request: guard \"word-limit\" denied the request",
"verdict": "deny"
},
"ok": true,
"parameter_hash_valid": true,
"receipt_id": "ad7e0e17668a6d8e50ff1f4a41a0db28489f98dfa6c5f68586449315d1c7eabd",
"receipt_id_valid": true,
"signature_valid": true,
"signer_trusted": true
}You should see ok, signature_valid, and signer_trusted true, authorized false, and a reason that names word-limit. The receipt carries the guard's name and the policy context. The word-count detail stays in the host diagnostic, outside the signed record.
A new limit restarts the host; new code ships a new release
The module reads max_words through the declared chio.get_config host function, refuses invalid configuration, and stops counting once the limit is passed.
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;
#[chio_guard]
fn evaluate(request: GuardRequest) -> GuardVerdict {
if request.server_id != "documents" || request.tool_name != "count_words" {
return GuardVerdict::allow();
}
let limit = get_config("max_words")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=10_000).contains(value));
let Some(limit) = limit else {
return GuardVerdict::deny("max_words must be an integer from 1 to 10000");
};
let Some(text) = request
.arguments
.get("text")
.and_then(|value| value.as_str())
else {
return GuardVerdict::deny("document text must be a string");
};
if text.split_whitespace().take(limit + 1).count() > limit {
return GuardVerdict::deny("document exceeds max_words");
}
GuardVerdict::allow()
}Changing max_words needs a host restart and no new build or signature. Changing the code needs a new build, signature, manifest digest, and installation, with the host's pinned signer and release coordinates kept consistent. Set max_words from 8 to 16 to allow both documents, or fuel_limit to 1 to see a refusal before dispatch; Building Custom Guards keeps the captures of those edits.
The fixture runner tests the module without the host's configuration
chio guard test runs core-module requests directly, outside the kernel, with no deployment configuration. For this rule, that makes the configured case fail and the missing-configuration case pass.
- name: six words with the configured limit
request:
tool_name: count_words
server_id: documents
agent_id: document-author
arguments:
text: "Count the words in this document."
scopes: ["documents:count_words"]
expected_verdict: allow
- name: missing configuration fails closed
request:
tool_name: count_words
server_id: documents
agent_id: document-author
arguments:
text: "Count the words in this document."
scopes: ["documents:count_words"]
expected_verdict: deny
deny_reason_contains: max_wordschio guard test --wasm release/word_limit.wasm document-cases.yaml[FAIL] six words with the configured limit: expected allow, got deny: max_words must be an integer from 1 to 10000
[PASS] missing configuration fails closed
1 passed, 1 failed out of 2 total
error [urn:chio:error:guard:denied]: 1 test(s) failed
context: {"domain":"guard","severity":"error","stability":"stable","string_code":"CHIO-KERNEL-GUARD-DENIED"}
suggested fix: Inspect the guard verdict and adjust the prompt, tool input, policy, or output before retrying.You should see one failure, because max_words is missing, and one pass, because missing configuration denies. Test a configuration-dependent rule through the configured application run in step 6. For a rule with no configuration, the fixture runner checks request fields, expected_verdict, and deny_reason_contains directly.
Fuel measured by the benchmark command
chio guard bench release/word_limit.wasm --iterations 20=== Guard Benchmark === File: release/word_limit.wasm Iterations: 20 Fuel limit: 1,000,000 Latency: p50: 145.33 us p99: 194.79 us min: 131.33 us max: 194.79 us mean: 148.31 us Fuel consumed: p50: 63,508 p99: 63,508 min: 63,508 max: 63,508 mean: 63,508
The benchmark submits a fixed bench_tool request with no deployment configuration, so this rule takes its immediate allow branch. The numbers describe that branch and its ABI overhead. Measure representative document requests through the configured host before choosing a fuel_limit.
The runtime bounds each evaluation with fuel and linear-memory limits. For a blocking guard, a denial, a trap, or exhausted fuel prevents dispatch. Advisory mode records denials without enforcing them, so keep advisory false for a rule that must block execution.
A refused load names the check that failed
The loader, the CLI, and the runtime each name the input that failed. Repair that input and rerun the step.
- The installed module is not signed. The
.wasm.sigsidecar is missing beside the module. Install from the packed archive, or copy the sidecar beside the module, then verify against the pinned manifest key. - Hash or signature mismatch. The module on disk is not the one you signed. A rebuild needs a fresh signature and an updated
wasm_sha256. - Wrong signer or version. Compare the operator's expected key, the configured version, and the signed envelope. Change the trust configuration only for a release you intend to accept.
- Missing
max_wordsin the fixture runner. The runner supplies no deployment configuration. Run the configured application instead. - Fuel exhausted. Inspect the rule and representative input sizes, and measure the document path before raising
fuel_limit.
Captured message for an unsigned installed module
error [urn:chio:error:manifest:signature-invalid]: guard module installed-guards/word-limit/word_limit.wasm is not signed (missing installed-guards/word-limit/word_limit.wasm.sig sidecar)
context: {"domain":"manifest","severity":"fatal","stability":"unstable","string_code":"CHIO-MANIFEST-SIGNATURE-INVALID"}
suggested fix: Reject the manifest and republish it with the expected signing key and canonical payload.Loaders, the guest ABI, and native guards
Try fuel_limit, chio_alloc, GuardContext, or default_pipeline.
chio.yaml deployment entry
guarded-documents loads its module through PolicyCustomGuards and load_guards_from_policy, with configuration, host-function permissions, and the expected signing key in guard-policy.json. A deployment configured through chio.yaml uses this entry instead:
wasm_guards:
- name: word-limit
path: /etc/chio/guards/word-limit/word_limit.wasm
fuel_limit: 1000000
priority: 100
advisory: falseFor that loader, place guard-manifest.yaml and word_limit.wasm.sig beside the module. The manifest supplies configuration and declares the expected signer and module hash. The loader checks the supported ABI and WIT world, the module hash, and the signature before it assembles the pipeline. Each loader reads only its own entry format.
| Field | Meaning | Default |
|---|---|---|
name | The configured guard name | Required |
path | The module on the deployment filesystem | Required |
fuel_limit | Execution fuel per invocation | 10,000,000 |
priority | Lower values run earlier | 1000 |
advisory | Record refusals without enforcing them | false |
Core modules and components
The Rust attribute macro emits a core WebAssembly module that uses the JSON ABI below. The cross-language guard SDKs target Component Model components for the chio:guard@0.2.0 WIT world. Chio ships a separate backend for each format; the core-module loader in guarded-documents loads core modules, so pair each compiled output with its matching backend.
Core-module ABI
The host serializes a request into guest linear memory and calls evaluate. The attribute macro generates the entry point, the allocator, and the deny-reason exports.
evaluate(request_ptr: i32, request_len: i32) -> i32
0: allow
1: deny
negative: error (a blocking guard denies)SDK modules export chio_alloc and chio_deny_reason. The host validates allocated bounds before it writes a request and reads the guest's denial response. Hand-authored modules can use the fallback request offset 0 and a denial-reason region at offset 65536, bounded to 4096 bytes; the SDK writes that memory protocol for you.
Request visible to a guest
pub struct GuardRequest {
/// Tool being invoked.
pub tool_name: String,
/// Server hosting the tool.
pub server_id: String,
/// Agent making the request.
pub agent_id: String,
/// Tool arguments as an opaque JSON value.
pub arguments: serde_json::Value,
/// Capability scopes granted (serialized scope names).
#[serde(default)]
pub scopes: Vec<String>,
/// Host-extracted action type from extract_action().
/// One of: "file_access", "file_write", "network_egress", "shell_command",
/// "mcp_tool", "patch", "unknown".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_type: Option<String>,
/// Normalized file path for filesystem actions (FileAccess, FileWrite, Patch).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extracted_path: Option<String>,
/// Target domain string for network egress actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extracted_target: Option<String>,
/// Session-scoped filesystem roots from the kernel context.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filesystem_roots: Vec<String>,
/// Index of the matched grant in the capability scope.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_grant_index: Option<usize>,
}The host supplies request identity, arguments, scopes, and any extracted action, path, and grant context; a guest treats those optional fields as possibly absent. Cross-call state reaches a guest only through a host interface the embedding integrates, and the isolated guest has no direct access to the session journal. WASM Guards covers host imports, memory bounds, signatures, and reload.
Native guards in a Rust host
A Rust host can implement the Guard trait and register it with its kernel. The guard reads kernel types and host-owned state with no WASM boundary, and the host recompiles when the implementation changes. Implementing the public trait leaves Chio's built-in guard crates untouched.
Guard trait
pub trait Guard: Send + Sync {
/// Human-readable guard name (e.g., "forbidden-path").
fn name(&self) -> &str;
/// Evaluate the guard against a tool call request.
///
/// Returns an allow or deny decision with optional evidence, or `Err` on
/// internal failure (which the kernel treats as deny).
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError>;
/// Return true when mutable guard state must be checked immediately before
/// dispatch even if runtime readiness never suspended.
fn requires_dispatch_revalidation(&self) -> bool {
false
}
/// Run the opt-in immediate dispatch check. Composite guards should
/// override this method and apply it recursively to their children.
fn revalidate_required_before_dispatch(&self, ctx: &GuardContext) -> Result<(), KernelError> {
if self.requires_dispatch_revalidation() {
self.revalidate_before_dispatch(ctx)
} else {
Ok(())
}
}
/// Revalidate mutable guard state without consuming a second quota,
/// approval, or rate-limit token.
fn revalidate_before_dispatch(&self, _ctx: &GuardContext) -> Result<(), KernelError> {
Ok(())
}
/// Name the authenticated Finding status feed that must authorize a
/// governed memory write. The kernel checks this against the delivery
/// receipt before invoking the tool so a write cannot enter a quarantine
/// domain whose resolver will later reject it.
fn required_finding_status_feed_id(
&self,
_ctx: &GuardContext,
) -> Result<Option<String>, KernelError> {
Ok(None)
}
/// Validate the exact output after the tool returns and before it can be
/// released or committed as a durable tool return. Stateful guards use
/// this seam to bind an admission decision to the value actually read.
fn validate_output_before_release(
&self,
_ctx: &GuardContext,
_output: &ToolServerOutput,
) -> Result<(), KernelError> {
Ok(())
}
/// Return true when output validation binds the exact released value and
/// therefore must run after every configured output transform.
fn requires_exact_released_output(&self, _ctx: &GuardContext) -> bool {
false
}
}The default hooks support dispatch revalidation and output checks; use them when the decision depends on mutable state or on the value released. Guard requires Send + Sync because the runtime shares guards across threads.
GuardContext
pub struct GuardContext<'a> {
/// The tool call request being evaluated.
pub request: &'a ToolCallRequest,
/// The verified capability scope.
pub scope: &'a ChioScope,
/// The agent making the request.
pub agent_id: &'a AgentId,
/// The target server.
pub server_id: &'a ServerId,
/// Session-scoped enforceable filesystem roots, when the request is being
/// evaluated through the supported session-backed runtime path.
pub session_filesystem_roots: Option<&'a [String]>,
/// Index of the matched grant in the capability's scope, populated by
/// check_and_increment_budget before guards run.
pub matched_grant_index: Option<usize>,
}The context exposes the requested operation, its arguments, and the signed capability. Optional grant and filesystem context let a guard apply the authority the host already resolved.
GuardDecision, Verdict, and GuardEvidence
pub struct GuardDecision {
pub verdict: Verdict,
pub evidence: Vec<GuardEvidence>,
}/// Verdict of a guard or capability evaluation.
///
/// This is the kernel's own verdict type, distinct from `chio_core::receipt::decision::Decision`.
/// The kernel uses this internally; it maps to `chio_core::receipt::decision::Decision` when
/// building receipts.
///
/// The `PendingApproval` variant is a marker: the payload (`ApprovalRequest`)
/// is returned separately via [`crate::approval::HitlVerdict`] so existing
/// call sites that pattern-match on `Verdict` and rely on its `Copy` semantics
/// keep compiling without change. The public contract is: `Allow`, `Deny`, and
/// `PendingApproval` are the three possible outcomes of guard evaluation, and
/// callers receive the full approval request via the richer HITL API surface
/// when they need it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// The action is allowed.
Allow,
/// The action is denied.
Deny,
/// The action is suspended pending a human decision. Look up the
/// associated `ApprovalRequest` via the HITL API.
PendingApproval,
}pub struct GuardEvidence {
/// Name of the guard (e.g. "ForbiddenPathGuard").
pub guard_name: String,
/// Whether the guard passed (true) or denied (false).
pub verdict: bool,
/// Optional details about the guard's decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}Return GuardDecision::allow() for a check that does not apply and GuardDecision::deny(evidence) for a policy refusal. Evidence belongs to this internal decision; read your host's receipt mapping before treating a diagnostic as part of the signed record.
Native source-address check
This method assumes allowed_ips and name() belong to the implementing guard. The host supplies _source_ip from its trusted transport context; an argument the agent sets proves no network origin.
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
let Some(ip_str) = ctx
.request
.arguments
.get("_source_ip")
.and_then(|v| v.as_str())
else {
// Not applicable to this request: allow.
return Ok(GuardDecision::allow());
};
let ip: IpAddr = ip_str
.parse()
.map_err(|e| KernelError::Internal(format!("invalid source IP: {e}")))?;
if self.allowed_ips.contains(&ip) {
Ok(GuardDecision::allow())
} else {
Ok(GuardDecision::deny(vec![GuardEvidence {
guard_name: self.name().to_string(),
verdict: false,
details: Some(format!("source IP {ip} not on allowlist")),
}]))
}
}Default pipeline registration
GuardPipeline::default_pipeline() registers 7 built-in guards, and a host can register that pipeline as one Guard.
use chio_guards::GuardPipeline;
let pipeline = GuardPipeline::default_pipeline();
kernel.add_guard(Box::new(pipeline));pub fn default_pipeline() -> Self {
let mut pipeline = Self::new();
pipeline.add(Box::new(crate::ForbiddenPathGuard::new()));
pipeline.add(Box::new(crate::ShellCommandGuard::new()));
pipeline.add(Box::new(crate::EgressAllowlistGuard::new()));
pipeline.add(Box::new(crate::PathAllowlistGuard::new()));
pipeline.add(Box::new(crate::McpToolGuard::new()));
pipeline.add(Box::new(crate::SecretLeakGuard::new()));
pipeline.add(Box::new(crate::PatchIntegrityGuard::new()));
pipeline
}The pipeline short-circuits on a blocking refusal or error. Prepare expensive data at construction time and keep evaluation bounded. Default Pipeline covers ordering and policy-compiled guards.
Registry distribution
For a registry you control, chio guard publish creates an OCI artifact and chio guard pull retrieves a digest-pinned release. Local installation needs no registry account. Configure the target repository, the signing identity, and the receiver's trust policy before publishing; the guard publish reference lists the inputs. Publish the word-limit project under a tag, then pull it on the receiving host by the digest the publish printed:
chio guard publish guard --ref oci://registry.larkfield.example/guards/word-limit:0.1.0 \
--epoch-id-seed 2026-10 --username ci
chio guard pull --ref oci://registry.larkfield.example/guards/word-limit@sha256:6a1f…92c0 \
--username cipublished oci://registry.larkfield.example/guards/word-limit:0.1.0 digest sha256:6a1f…92c0 layers wit, wasm, manifest pulled oci://registry.larkfield.example/guards/word-limit@sha256:6a1f…92c0 cached ~/chio/state/chio/guards/sha256/6a1f…92c0
Both commands read the registry password from CHIO_GUARD_REGISTRY_PASSWORD, which keeps it out of the process list. The pull accepts only a digest-pinned reference, so a moved tag cannot change what a host loads.
The signing and build commands live in chio-cli (src/guards/sign.rs, src/guard/build.rs); the core-module runtime is chio-wasm-guards (src/runtime/wasmtime_backend.rs).
Next: wire the module into your deployment
Keep guarded-documents as a regression check whenever the rule changes.