Building Custom Guards
Extend the kernel’s policy with your own sandboxed code. Ship a rule independently, constrain execution, and retain the resulting evidence.
On this page
Put your rule in the execution path
Chio’s Rust kernel gives an agentic operating system an extensible policy boundary. A custom guard can inspect an authorized request before the tool runs. Compiling that rule to WebAssembly lets you update organization-specific logic independently of the kernel binary, inside a runtime with its own memory and a fuel budget.
This application counts words in a document you provide. A separate WASM guard refuses documents above a configured word limit. You will run it, change the limit, and inspect the signed decision from the same project. The rule counts whitespace-separated words; it does not estimate model tokens.
Run your first guarded document
Download the complete guarded-documents project and extract it anywhere. Use Builder Setup for the Rust toolchain. From the project directory, one command builds the WASM rule and host, then evaluates both supplied documents:
python3 run.pyEvidence: runs/97fdef5c-339e-48c1-8e23-dd055b0d9ca2
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: d550c867aee3b3fcee2df6a040b014dea5b6f16e90ad724f8e8bde1a1d35cd38 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: document exceeds max_words
Receipt: 6d641461a606b704a750e891bb4ac0c66b5dbbdb5348a9e9c198bca1f4ab6229 (receipt-2.json)sample.md has six words. long.md has eleven and exceeds the eight-word limit. Replace either path with your own UTF-8 file, up to 65,536 bytes. The application reads the file before submitting its text; Chio mediates the subsequent tool call. A denial returns exit code 2 and retains its receipt.
The specific guest reason is retained in the signed receipt’s evidence array and printed by the host. The runtime also writes a diagnostic to stderr. A verifier can now explain the refusal without access to that process log:
WARN loading unsigned WASM guard: allow_unsigned=true in manifest wasm_path=guard/target/wasm32-unknown-unknown/release/word_limit.wasm guard=word-limit version=0.1.0
WARN WASM guard denied request guard=word-limit epoch_id=0 reason=document exceeds max_wordsChange the limit without rebuilding
The guard reads max_words from this complete declaration. In guard-policy.json, change the value from "8" to "16":
{
"modules": [
{
"name": "word-limit",
"version": "0.1.0",
"module_path": "guard/target/wasm32-unknown-unknown/release/word_limit.wasm",
"capabilities": [
"chio.get_config"
],
"config": {
"max_words": "8"
},
"fuel_limit": 1000000,
"priority": 100,
"advisory": false,
"allow_unsigned": true
}
]
}target/debug/guarded-documents sample.md long.mdEvidence: runs/57200ec8-9870-408a-93b3-c85f87e4269c
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: 07443615d7e44836fbfa8439f7d5fb325d7cd0450606c983112333b4156a6345 (receipt-1.json)
long.md: ALLOW {"bytes":59,"words":11}
Receipt: bb3b2492893f4ca73b30a74496d884c600dc1eafa8592955e760f1e93e4fc7e7 (receipt-2.json)Both calls now succeed with the same guest and host binaries. The host loads configuration at startup, so restart the command after an edit. authority.json separately sets the signed capability’s two-call allowance and lifetime. A new process issues a new grant; repeated files within one process share its allowance.
Read the complete custom rule
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()
}The rule applies to documents / count_words, validates its configuration and text argument, then stops counting as soon as it knows the limit was exceeded. A missing or invalid max_words produces a denial. The guest returns a verdict; the kernel owns dispatch and the signed receipt.
Connect the module to the kernel
The application’s loader reads the declaration, restricts the guest’s host-function access to chio.get_config and loads the compiled module. This excerpt checks that the loaded bytes match the module hash before saving that hash with the policy context:
let handles = load_guards_from_policy(
&policy,
&HashMap::<String, String>::new(),
&["chio.get_config".into()],
create_shared_engine()?,
)?;
anyhow::ensure!(
handles[0].guard().manifest_sha256().as_deref() == Some(module_sha256.as_str()),
"Module changed while loading; retry with a stable file"
);
let context = json!({"policy": policy, "module_sha256": module_sha256});Read the complete declaration parser and module loader
use chio_wasm_guards::{
host::create_shared_engine,
runtime::guard::WasmGuard,
runtime::wasmtime_backend::{load_guards_from_policy, PolicyCustomGuards},
};
use serde_json::{json, Value};
use std::collections::HashMap;
pub struct Prepared {
pub guards: Vec<WasmGuard>,
pub context: Value,
pub policy_bytes: Vec<u8>,
pub module_bytes: Vec<u8>,
}
pub fn load() -> anyhow::Result<Prepared> {
let policy_bytes = chio_example_runtime::files::read_bounded(
std::path::Path::new("guard-policy.json"),
65_536,
)?;
let value: Value = serde_json::from_slice(&policy_bytes)?;
anyhow::ensure!(
value
.as_object()
.is_some_and(|v| v.len() == 1 && v.contains_key("modules")),
"Expected only a modules field"
);
let module_values = value["modules"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("modules must be an array"))?;
for entry in module_values {
let fields = entry
.as_object()
.ok_or_else(|| anyhow::anyhow!("Each module must be an object"))?;
anyhow::ensure!(
fields.keys().all(|key| [
"name",
"version",
"module_path",
"capabilities",
"config",
"fuel_limit",
"priority",
"advisory",
"signer_public_key",
"allow_unsigned"
]
.contains(&key.as_str())),
"Unknown guard module field"
);
}
let policy: PolicyCustomGuards = serde_json::from_value(value)?;
anyhow::ensure!(
policy.modules.len() == 1,
"This application loads one word-limit guard"
);
let spec = &policy.modules[0];
anyhow::ensure!(
spec.name == "word-limit" && !spec.advisory,
"The word-limit guard must be blocking"
);
anyhow::ensure!(
spec.config
.as_object()
.is_some_and(|v| v.len() == 1 && v.contains_key("max_words")),
"Expected only max_words in guard config"
);
let path = spec
.module
.path()
.ok_or_else(|| anyhow::anyhow!("Supply module_path"))?;
let module_bytes =
chio_example_runtime::files::read_bounded(std::path::Path::new(path), 4 * 1024 * 1024)?;
let module_sha256 = chio_core::sha256_hex(&module_bytes);
let handles = load_guards_from_policy(
&policy,
&HashMap::<String, String>::new(),
&["chio.get_config".into()],
create_shared_engine()?,
)?;
anyhow::ensure!(
handles[0].guard().manifest_sha256().as_deref() == Some(module_sha256.as_str()),
"Module changed while loading; retry with a stable file"
);
let context = json!({"policy": policy, "module_sha256": module_sha256});
Ok(Prepared {
guards: handles.into_iter().map(|h| h.into_guard()).collect(),
context,
policy_bytes,
module_bytes,
})
}The local declaration explicitly permits your unsigned development module. For distribution, select a trusted signer and configure signature verification before accepting a module. The loader’s policy format supports that choice; WASM guard signing explains the trust configuration.
The host adds the loaded blocking guard to its kernel before issuing authority and handling requests. Registration, durable admission, receipt storage, the manifest and the full call loop are included:
Read the complete kernel host and authority setup
use chio_core::{
capability::{
scope::{ChioScope, Operation, ToolGrant},
token::CapabilityToken,
},
crypto::Keypair,
};
use chio_kernel::{
ChioKernel, KernelConfig, DEFAULT_CHECKPOINT_BATCH_SIZE, DEFAULT_MAX_STREAM_DURATION_SECS,
DEFAULT_MAX_STREAM_TOTAL_BYTES,
};
use std::path::Path;
pub struct Host {
pub kernel: ChioKernel,
pub capability: CapabilityToken,
}
pub fn boot(directory: &Path, prepared: crate::loader::Prepared) -> anyhow::Result<Host> {
let authority_bytes =
chio_example_runtime::files::read_bounded(Path::new("authority.json"), 65_536)?;
let config: serde_json::Value = serde_json::from_slice(&authority_bytes)?;
let fields = config
.as_object()
.ok_or_else(|| anyhow::anyhow!("Authority must be an object"))?;
anyhow::ensure!(
fields.keys().all(
|key| ["schema", "tool", "max_invocations", "lifetime_seconds"].contains(&key.as_str())
),
"Unknown authority field"
);
anyhow::ensure!(
config["schema"] == "document-tools.authority.v1",
"Unknown authority schema"
);
anyhow::ensure!(
config["tool"] == crate::service::TOOL,
"This host grants only count_words"
);
let call_limit = config["max_invocations"]
.as_u64()
.filter(|v| (1..=50).contains(v))
.ok_or_else(|| anyhow::anyhow!("max_invocations must be an integer from 1 to 50"))?
as u32;
let lifetime = config["lifetime_seconds"]
.as_u64()
.filter(|v| (1..=3600).contains(v))
.ok_or_else(|| anyhow::anyhow!("lifetime_seconds must be an integer from 1 to 3600"))?;
// A new application run gets a fresh session. Refuse to reuse its directory.
std::fs::create_dir(directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
}
std::fs::write(directory.join("authority.json"), &authority_bytes)?;
let authority =
chio_control_plane::DurableAdmissionRuntime::open(&directory.join("admission.db"))?;
let keypair = authority.kernel_keypair();
// The host chooses this key before any receipt exists.
std::fs::write(
directory.join("kernel-public-key.txt"),
keypair.public_key().to_hex(),
)?;
let mut kernel = ChioKernel::new(KernelConfig {
keypair,
ca_public_keys: Vec::new(),
max_delegation_depth: 3,
policy_hash: chio_core::sha256_hex(&serde_json::to_vec(&serde_json::json!({
"authority": config, "guard": prepared.context
}))?),
allow_sampling: false,
allow_sampling_tool_use: false,
allow_elicitation: false,
max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
require_web3_evidence: false,
allow_ephemeral_receipt_log: true,
allow_ephemeral_revocation_store: true,
checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
retention_config: None,
memory_budget: chio_kernel::MemoryBudgetConfig::defaults(),
deadlines: chio_kernel::HotPathDeadlineConfig::default(),
});
kernel.set_receipt_store(Box::new(chio_store_sqlite::SqliteReceiptStore::open(
directory.join("receipts.db"),
)?))?;
authority.attach(&mut kernel)?;
std::fs::write(directory.join("guard-policy.json"), &prepared.policy_bytes)?;
std::fs::write(directory.join("word_limit.wasm"), &prepared.module_bytes)?;
std::fs::write(
directory.join("guard-context.json"),
serde_json::to_vec_pretty(&prepared.context)?,
)?;
for guard in prepared.guards {
kernel.add_guard(Box::new(guard));
}
let service_key = Keypair::generate();
let manifest = crate::service::manifest(service_key.public_key().to_hex());
let signed = chio_manifest::sign_manifest(&manifest, &service_key)?;
chio_manifest::verify_manifest(&signed, &service_key.public_key())?;
std::fs::write(
directory.join("manifest.json"),
serde_json::to_vec_pretty(&signed)?,
)?;
kernel.register_tool_server(Box::new(crate::service::DocumentTools));
let agent = Keypair::generate();
let capability = kernel.issue_capability(
&agent.public_key(),
ChioScope {
grants: vec![ToolGrant {
server_id: crate::service::SERVER.into(),
tool_name: crate::service::TOOL.into(),
operations: vec![Operation::Invoke],
constraints: Vec::new(),
max_invocations: Some(call_limit),
max_cost_per_invocation: None,
max_total_cost: None,
dpop_required: None,
}],
..ChioScope::default()
},
lifetime,
)?;
std::fs::write(
directory.join("capability.json"),
serde_json::to_vec_pretty(&capability)?,
)?;
Ok(Host { kernel, capability })
}Read the complete application, tool and dependency manifests
mod host;
mod loader;
mod service;
use chio_kernel::{ToolCallOutput, ToolCallRequest};
use serde_json::json;
use std::{io::Read, path::Path};
use uuid::Uuid;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("chio_wasm_guards=warn")
.with_writer(std::io::stderr)
.without_time()
.with_ansi(false)
.with_target(false)
.init();
let paths: Vec<String> = std::env::args().skip(1).collect();
anyhow::ensure!(
!paths.is_empty(),
"usage: guarded-documents FILE [FILE ...]"
);
let mut documents = Vec::new();
for path in paths {
let mut bytes = Vec::new();
std::fs::File::open(&path)?
.take((service::TEXT_LIMIT + 1) as u64)
.read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() <= service::TEXT_LIMIT,
"Document exceeds 65536 UTF-8 bytes: {path}"
);
documents.push((path, String::from_utf8(bytes)?));
}
let prepared = loader::load()?;
let directory = Path::new("runs").join(Uuid::new_v4().to_string());
let mut parents = std::fs::DirBuilder::new();
parents.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
parents.mode(0o700);
}
parents.create("runs")?;
let host = host::boot(&directory, prepared)?;
println!("Evidence: {}", directory.display());
let mut refused = false;
for (index, (path, text)) in documents.into_iter().enumerate() {
let response = host
.kernel
.evaluate_tool_call(&ToolCallRequest {
request_id: Uuid::new_v4().to_string(),
capability: host.capability.clone(),
server_id: service::SERVER.into(),
tool_name: service::TOOL.into(),
agent_id: host.capability.subject.to_hex(),
arguments: json!({"text": text}),
dpop_proof: None,
execution_nonce: None,
governed_intent: None,
approval_token: None,
approval_tokens: Vec::new(),
threshold_approval_proposal: None,
supplemental_authorization: None,
model_metadata: None,
federated_origin_kernel_id: None,
})
.await?;
let receipt_file = format!("receipt-{}.json", index + 1);
std::fs::write(
directory.join(&receipt_file),
serde_json::to_vec_pretty(&response.receipt)?,
)?;
if response.receipt.is_allowed() {
let Some(ToolCallOutput::Value(value)) = response.output else {
anyhow::bail!("Expected a count result");
};
println!("{path}: ALLOW {value}");
} else {
refused = true;
println!(
"{path}: DENY {}",
response.reason.as_deref().unwrap_or("see signed receipt")
);
}
for evidence in &response.receipt.evidence {
if !evidence.verdict {
if let Some(details) = &evidence.details {
println!("Guard {}: {details}", evidence.guard_name);
}
}
}
println!("Receipt: {} ({receipt_file})", response.receipt.id);
}
std::fs::write(
directory.join("execution-count.json"),
serde_json::to_vec_pretty(&json!({"tool_invocations": service::invocation_count()}))?,
)?;
if refused {
std::process::exit(2);
}
Ok(())
}use chio_kernel::{KernelError, NestedFlowBridge, ToolServerConnection};
use chio_manifest::{ToolDefinition, ToolManifest};
use serde_json::{json, Value};
pub const SERVER: &str = "documents";
pub const TOOL: &str = "count_words";
pub const TEXT_LIMIT: usize = 65_536;
pub struct DocumentTools;
static INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub fn invocation_count() -> usize {
INVOCATIONS.load(std::sync::atomic::Ordering::Relaxed)
}
fn count(tool: &str, arguments: &Value) -> Result<Value, KernelError> {
if tool != TOOL {
return Err(KernelError::ToolNotRegistered(tool.to_owned()));
}
let text = arguments
.get("text")
.and_then(Value::as_str)
.ok_or_else(|| KernelError::RequestIncomplete("text must be a string".into()))?;
if text.len() > TEXT_LIMIT {
return Err(KernelError::RequestIncomplete(
"text exceeds 65536 UTF-8 bytes".into(),
));
}
Ok(json!({"words": text.split_whitespace().count(), "bytes": text.len()}))
}
#[async_trait::async_trait]
impl ToolServerConnection for DocumentTools {
fn server_id(&self) -> &str {
SERVER
}
fn tool_names(&self) -> Vec<String> {
vec![TOOL.into()]
}
async fn invoke(
&self,
tool: &str,
arguments: Value,
_nested: Option<&mut dyn NestedFlowBridge>,
) -> Result<Value, KernelError> {
INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
count(tool, &arguments)
}
}
pub fn manifest(public_key: String) -> ToolManifest {
ToolManifest {
schema: "chio.manifest.v1".into(),
server_id: SERVER.into(),
name: "Document tools".into(),
version: "1.0.0".into(),
description: Some("Count words in caller-provided text".into()),
tools: vec![ToolDefinition {
name: TOOL.into(),
description: "Count whitespace-separated words and UTF-8 bytes".into(),
input_schema: json!({
"type": "object", "properties": {"text": {"type": "string"}},
"required": ["text"]
}),
output_schema: None,
pricing: None,
has_side_effects: false,
latency_hint: None,
}],
server_tools: Vec::new(),
required_permissions: None,
public_key,
}
}[package]
name = "guarded-documents"
version = "0.1.0"
edition = "2021"
default-run = "guarded-documents"
[workspace]
[dependencies]
chio-example-runtime = { path = "runtime" }
chio-core = { package = "chio-core-types", git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-kernel = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-manifest = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-control-plane = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-store-sqlite = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
anyhow = "1"
async-trait = "0.1"
serde_json = "1"
chio-wasm-guards = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }[package]
name = "word-limit"
version = "0.1.0"
edition = "2021"
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
chio-guard-sdk = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-guard-sdk-macros = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }Recompile when the rule’s code changes
Editing guard/src/lib.rs changes the compiled rule. Run the guest build again, then restart the host. Editing only max_words changes configuration and needs no compilation.
cargo build --manifest-path guard/Cargo.toml --target wasm32-unknown-unknown --target-dir guard/target --release --lockedChoose loading and failure behavior explicitly
This example loads once at startup. A long-running host can integrate the separate reload machinery described in WASM guard reload. Merely compiling a new module does not replace the running guard.
advisory is false here: a guard denial or runtime error stops dispatch. An advisory guard is deliberately non-blocking. Reducing the fuel budget too far also stops a blocking guard; increasing the budget changes runtime resources, while increasing max_words changes this rule’s decision.
Inspect invalid configuration and fuel-exhaustion captures
Evidence: runs/a1742c96-6a0c-4f92-b644-731d315cf4ab
sample.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: max_words must be an integer from 1 to 10000
Receipt: b3195d987808a12fab85b01eba7db0f163fc73a935f88e6518fcf372b3e60907 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: max_words must be an integer from 1 to 10000
Receipt: 654c655ddbd05e5984fa73778a66630bb6d25ff4360f622c6b8703182a465cf4 (receipt-2.json)Evidence: runs/5c8209d9-8417-4ae5-9cdf-82d9ccc5f864
sample.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: WASM execution error: WASM trap: chio_alloc call failed: error while executing at wasm backtrace:
0: 0xf20c - word_limit.wasm!chio_alloc
Receipt: 826249a4433c06cbe7d1550d0172fb483b0a6c2b5de0f24b39ea3954b09b53fe (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: WASM execution error: WASM trap: chio_alloc call failed: error while executing at wasm backtrace:
0: 0xf20c - word_limit.wasm!chio_alloc
Receipt: ddf4049de00d1d676c1fd6b123acb339679d6cdc277ed660a2795d46b730d923 (receipt-2.json) WARN loading unsigned WASM guard: allow_unsigned=true in manifest wasm_path=guard/target/wasm32-unknown-unknown/release/word_limit.wasm guard=word-limit version=0.1.0
WARN WASM guard execution error guard=word-limit epoch_id=0 error=WASM trap: chio_alloc call failed: error while executing at wasm backtrace:
0: 0xf20c - word_limit.wasm!chio_alloc
WARN WASM guard execution error guard=word-limit epoch_id=0 error=WASM trap: chio_alloc call failed: error while executing at wasm backtrace:
0: 0xf20c - word_limit.wasm!chio_allocInspect what the kernel recorded
Each run prints its evidence directory. It contains the exact configuration, loaded WASM bytes, authority, manifest, stores and complete receipts. This is the guard denial from the first run:
[
{
"guard_name": "word-limit",
"verdict": false,
"details": "document exceeds max_words"
}
]Read Captured custom-guard denial · complete receipt 50 lines · complete file
{
"id": "6d641461a606b704a750e891bb4ac0c66b5dbbdb5348a9e9c198bca1f4ab6229",
"timestamp": 1789100295,
"capability_id": "cap-01a08eb0-037a-76ec-8961-64b6f9ccadea",
"tool_server": "documents",
"tool_name": "count_words",
"action": {
"parameters": {
"text": "This document has too many words for the eight word limit.\n"
},
"parameter_hash": "1016a55103c3629c4ac66296401a765e450c2e9c40a0d66fb9ba9e1fa3139695"
},
"decision": {
"verdict": "deny",
"reason": "guard denied the request: guard \"word-limit\" denied the request",
"guard": "kernel"
},
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"tool_origin": "caller_executed",
"redaction_mode": "none",
"content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"policy_hash": "8689f677403260c6382aa132ad03aabf052e6f9f06c57d0ad3d7b0f1f31f3e19",
"evidence": [
{
"guard_name": "word-limit",
"verdict": false,
"details": "document exceeds max_words"
}
],
"metadata": {
"attribution": {
"delegation_depth": 0,
"issuer_key": "1146df2f218f26c20dae0e1b3071420da3ab141540b3501e2c088f99e7b7a85a",
"subject_key": "8b02895d39d3ae8ccfc863dbaf1c612c2f2e7517e091b9ebda63fb5a27a0c5bc"
},
"budget_authority": {
"authority_profile": "authoritative_hold_event",
"guarantee_level": "single_node_atomic",
"metering_profile": "max_cost_preauthorize_then_reconcile_actual"
},
"chio_receipt_signing_nonce": "rcpt-01a08eb0-04d7-70b6-93c9-fa41178b9066",
"receipt_context": {
"request_id": "12dadf28-9620-4523-8c9b-7803212ea925"
}
},
"trust_level": "mediated",
"kernel_key": "1146df2f218f26c20dae0e1b3071420da3ab141540b3501e2c088f99e7b7a85a",
"signature": "41ba3d78eae607f32e1d35f7f20d6ac499923402525275a649fb5587fc6ef99dcb7573370805d1586c255988818af001e75c82eb74748d7bdb06b38214883f0f"
}Download the allowed receipt and guard-denial receipt. Replace your-run-id with the directory printed by your command:
chio --receipt-db runs/your-run-id/receipts.db receipt list --admin-all --outcome deny
target/debug/verify-guard-receipt runs/your-run-id/receipt-2.json runs/your-run-id/kernel-public-key.txt{
"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": "6d641461a606b704a750e891bb4ac0c66b5dbbdb5348a9e9c198bca1f4ab6229",
"receipt_id_valid": true,
"signature_valid": true,
"signer_trusted": true
}A valid signature establishes the record’s integrity under the expected host key. The decision still says deny. For a remote host, select its expected key through trusted operator configuration; offline receipt verification continues that task.
Compare the four guard SDKs
The tool-gate guards implement the same three-name deny list in Rust, TypeScript, Python and Go. These source excerpts show each SDK’s entry point. The Rust version also rejects empty or padded names; that additional rule is absent from the other three examples.
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()
}Rust’s macro emits a core WASM module. The TypeScript, Python and Go build scripts produce Component Model components for chio:guard@0.2.0. The runtime supports both formats through different backends. This application uses the core-module loader; a component needs the component backend and its loading integration. Test the compiled artifact for the backend you deploy.
Build and test both guards
The custom-guards project holds the Rust tool-gate and the enriched inspector in the chio guard new layout, each with fixtures written against Larkfield's servers. You need the wasm32-unknown-unknown Rust target. You can also download the project.
chio new custom-guards --example custom-guards
cd custom-guards/tool-gate
chio guard build
chio guard test tests/*.yamlbuild complete: target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm binary size: 113.9 KiB [PASS] allows a quote on the pricing server [PASS] denies rm_rf on the shell server [PASS] denies a tool name with padding 3 passed, 0 failed out of 3 total
You should see a quote on pricing allowed, rm_rf on shell refused by the deny list, and a padded tool name refused before the list is read. chio guard test runs the module named by wasm_path in guard-manifest.yaml. The enriched inspector runs the same way:
cd ../enriched-inspector
chio guard build
chio guard test tests/*.yaml[PASS] allows a write to the pricing module [PASS] denies a write under /etc [PASS] allows /etcetera, a different directory [PASS] denies a write without normalized path evidence 4 passed, 0 failed out of 4 total
Inspect the compiled tool-gate to confirm it exports the guard ABI the runtime loads:
chio guard inspect target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm=== WASM Guard Inspection === File: target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm Size: 113.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
For TypeScript, Python and Go, install that SDK's componentizer: jco, componentize-py, or TinyGo with wasm-tools.
Inspect action and path context
The enriched-inspector guard works with extracted action and path fields. It refuses file_write requests under a protected root, rejects malformed path evidence, and reads blocked_path through a host function. Segment-aware containment separates /etc from /etcetera; the default /etc protection also remains in effect when another root is configured.
#[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())
}Read the complete decision helper, denial reasons and root validation
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";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
}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
}
}The kernel must supply appropriate extracted evidence for the action being governed. A string comparison alone cannot establish how a filesystem resolves symlinks or a tool interprets a path. The WASM reference defines the request fields and available host functions.
Package and verify a reusable guard
The CLI supports scaffolding, compilation, ABI inspection, fixture testing, benchmarking, signing and packaging. The application above already includes its rule and loader. Use this separate lifecycle when creating a guard package for reuse:
Follow the captured scaffold and source-verification workflow
$ 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 scaffold is a different project. Its fixture tests evaluate a compiled rule in the guard runtime; they do not invoke your application’s document tool or create that kernel’s receipt.
The custom-guard lifecycle guide continues through inspection, fuel measurement, signing, verification, packaging and local installation with captured outputs. Publishing additionally requires a registry you control.
Test the rule and its integration
A native test can exercise a pure decision helper quickly. A compiled-WASM test also exercises serialization, imports and fuel. Finally, invoke the tool through your host and inspect both dispatch behavior and the signed result, as this application does.
Inspect the public tool-gate unit-test examples
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,
}
}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
));
}Choose the smallest useful extension
| Extension | Use it for | Delivery |
|---|---|---|
HushSpec rule | A built-in policy check already expresses your restriction. | Edit and load policy configuration. |
WASM guard | Custom decision logic that should run in a bounded guest. | Build and load a module independently of the host. |
Native Guard trait | Logic embedded in your Rust host with access to kernel types. | Recompile the host and register the guard. |
Continue with your rule
Keep guarded-documents when changing the rule or observing its evidence. Continue to Custom Guards for the broader authoring and lifecycle APIs, WASM guards for the ABI and trust configuration, or the Guard trait when embedding native logic.