Chio/Docs

BuildCustom Guards

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:

Build and run · exits 2 when a document is refusedbash
python3 run.py
Captured allow and guard denialtext
Evidence: 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:

Captured guard diagnostic · stderrtext
 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_words

Change the limit without rebuilding

The guard reads max_words from this complete declaration. In guard-policy.json, change the value from "8" to "16":

guard-policy.json · completejson
{
  "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
    }
  ]
}
Run again after editing max_wordsbash
target/debug/guarded-documents sample.md long.md
Captured result with max_words set to 16text
Evidence: 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

guarded-documents/guard/src/lib.rsL1–26rust
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()
}

Open the complete source file

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.

A replaceable WASM rule narrows an authorized operation
A replaceable WASM rule narrows an authorized operationThe kernel checks the signed capability, passes document arguments into an isolated WASM guard and supplies max_words through a declared configuration host function. A guard allow continues to the document tool. A guard denial stops dispatch. The kernel signs the resulting decision.Agent requestcount_words + document textSigned capabilityScope · lifetime · allowanceKernel authority checksA grant admits this operationISOLATED WASM GUARDword-limit.wasmRead text · count up to the limitOwn linear memoryFuel budget per evaluationNo file or network accessHost configurationmax_words: 8chio.get_configDocument toolReturn the word countAllowDeny / errorStop before dispatchSigned decisionRequest + policy contextA replaceable WASM rule narrows an authorized operationThe kernel checks the signed capability, passes document arguments into an isolated WASM guard and supplies max_words through a declared configuration host function. A guard allow continues to the document tool. A guard denial stops dispatch. The kernel signs the resulting decision.Agent requestcount_words + document textKernel authority checksSigned capabilityScope · lifetime · allowanceHost configurationmax_words: 8chio.get_configISOLATED WASM GUARDword-limit.wasmRead text · count up to limitOwn memory · metered fuelNo file or network accessAllowDeny / errorDocumenttoolReturn countStopNo toolexecutionSigned decisionRequest + policy context
Authority admits the operation; the custom guard can narrow it before dispatch. The host supplies declared configuration access, and the kernel signs the resulting decision.
Source

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:

src/loader.rs · inside load()rust
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
guarded-documents/src/loader.rsL1–92rust
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,
    })
}

Open the complete source file

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
guarded-documents/src/host.rsL1–130rust
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 })
}

Open the complete source file

Read the complete application, tool and dependency manifests
guarded-documents/src/main.rsL1–104rust
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(())
}

Open the complete source file

guarded-documents/src/service.rsL1–74rust
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,
    }
}

Open the complete source file

guarded-documents/Cargo.tomlL1–22toml
[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"] }

Open the complete source file

guarded-documents/guard/Cargo.tomlL1–13toml
[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" }

Open the complete source file

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.

Rebuild the WASM rulebash
cargo build --manifest-path guard/Cargo.toml --target wasm32-unknown-unknown --target-dir guard/target --release --locked

Choose 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
Captured invalid max_words refusaltext
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)
Captured fuel exhaustion refusaltext
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)
Captured fuel diagnostic · stderrtext
 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_alloc

Inspect 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:

Signed guard evidence · actual refusaljson
[
  {
    "guard_name": "word-limit",
    "verdict": false,
    "details": "document exceeds max_words"
  }
]
Read Captured custom-guard denial · complete receipt 50 lines · complete file
Captured custom-guard denial · complete receiptjson
{
  "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:

Find denials and verify a signed recordbash
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
Captured verification of the guard denialjson
{
  "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.

tool-gate/src/lib.rs:6-38sdks/guard/chio-guard-ts/examples/tool-gate/guard.ts:8-29sdks/guard/chio-guard-py/examples/tool-gate/app.py:12-29sdks/guard/chio-guard-go/examples/tool-gate/main.go:10-35
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
}

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.

Terminalbash
chio new custom-guards --example custom-guards
cd custom-guards/tool-gate
chio guard build
chio guard test tests/*.yaml
Output
build 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:

Terminal · custom-guardsbash
cd ../enriched-inspector
chio guard build
chio guard test tests/*.yaml
Output
[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:

Terminal · custom-guards/tool-gatebash
chio guard inspect target/wasm32-unknown-unknown/release/chio_example_tool_gate.wasm
Output
=== 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.

examples/guards/enriched-inspector/src/lib.rs21-28rust
#[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
examples/guards/enriched-inspector/src/lib.rs12-19rust
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";
examples/guards/enriched-inspector/src/lib.rs37-84rust
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
}
examples/guards/enriched-inspector/src/lib.rs93-103rust
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
wasm-guard · newtranscript
$ chio guard new tool-denylist-guard
created 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
exit 0
wasm-guard · buildtranscript
$ chio guard build
build 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.22s
exit 0in tool-denylist-guard

The 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
examples/guards/tool-gate/src/lib.rs54-67rust
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,
    }
}
examples/guards/tool-gate/src/lib.rs120-133rust
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

ExtensionUse it forDelivery
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.