BuildPolicy
Custom Guards
Add a sandboxed WASM guard when built-in guards do not cover your policy.
Choose a guard implementation
chio.yaml, and managed through the chio guard CLI. The native Rust Guard trait is for the guards shipped inside chio-guards and chio-data-guards — and is used for built-ins that need kernel types or lower overhead. Adding one requires changes to those crates and recompiling the kernel.WASM Guest Guards
The chio-wasm-guards crate lets you write guards in any language that compiles to WebAssembly — the protocol specification names Rust, AssemblyScript, Go, and C — and load them into the kernel at runtime. A guest guard runs inside an isolated linear-memory sandbox with no access to the host filesystem, network, or kernel state, and terminates within a bounded fuel budget. Guards are declared per deployment in chio.yaml; they are not compiled into the kernel binary.
Two Guard Binary Formats
chio-wasm-guards accepts two WebAssembly binary shapes and auto-detects which one a module is at load time (detect_wasm_format):
- Core module. A raw
wasm32module that exportsevaluate(request_ptr, request_len) -> i32over the JSON ABI below. It routes toWasmtimeBackend. The Rust SDK (chio-guard-sdkwith#[chio_guard]) compiles to this shape. - Component Model component. A component built against the
chio:guard@0.2.0WIT world (wit/chio-guard/world.wit), which exports a typedevaluate(request: guard-request) -> verdictdirectly — no JSON, no manual pointer/length handling. It routes toComponentBackend. The four cross-language SDKs —chio-guard-cpp,chio-guard-go,chio-guard-py, andchio-guard-ts— target this world.
Both formats use the same request, verdict, and fail-closed behavior. They differ in how requests cross the sandbox boundary. The next section documents the core-module ABI. Cross-language SDKs generate the boundary code from the WIT world.
The Core-Module ABI
A core-module guard exports evaluate. The host serializes a GuardRequest as JSON, places the bytes in guest linear memory, and calls evaluate with the pointer and length. The guest reads the request, decides, and returns a verdict code.
evaluate(request_ptr: i32, request_len: i32) -> i32
Return codes:
0 Allow
1 Deny
any negative Error (fail-closed -> denial)Where the request lands, and how the deny reason comes back, depends on which exports the module carries. The host probes for them at evaluation time:
- Allocator path (SDK default). If the module exports
chio_alloc(request_len) -> ptr, the host calls it, validates the returned pointer is in bounds, and writes the request there. The deny reason is retrieved actively: on aDenyreturn the host calls the module'schio_deny_reason(65536, 4096)export, and the guest writes a JSONGuestDenyResponseinto that buffer and returns the byte count. Every guard built with#[chio_guard]takes this path — the macro re-exportschio_alloc,chio_free, andchio_deny_reason. - Hand-rolled fallback. A module with no
chio_allocexport gets the request written at offset 0. For the deny reason, a module with nochio_deny_reasonexport MAY passively leave a NUL-terminated UTF-8 string at linear-memory offset 65536 (64 KiB); the host reads up to 4096 bytes. An absent, empty, or malformed region yields a generic denial message.
GuardRequest
The JSON payload the host writes into guest memory:
| Field | Type | Description |
|---|---|---|
tool_name | string | Tool being invoked. |
server_id | string | Server hosting the tool. |
agent_id | string | Agent making the request. |
arguments | object | Tool arguments (opaque JSON). |
scopes | string[] | Granted scope names, formatted "server_id:tool_name". |
session_metadata | object or null | Optional session context for stateful guards. |
Authoring with chio-guard-sdk
You do not write the raw ABI by hand. The #[chio_guard] attribute macro from chio-guard-sdk-macros validates a plain fn evaluate(req: GuardRequest) -> GuardVerdict at compile time and expands it into the ABI exports (the evaluate entry point plus the chio_alloc, chio_free, and chio_deny_reason exports the host expects).
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;
#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
// Restrict browser automation to an approved host.
if req.tool_name == "browser_navigate" {
let host_ok = req
.arguments
.get("url")
.and_then(|v| v.as_str())
.map(|url| url.starts_with("https://app.example.com/"))
.unwrap_or(false);
if !host_ok {
return GuardVerdict::deny("navigation target is not on the allowlist");
}
}
GuardVerdict::allow()
}The macro renames your function, generates the #[no_mangle] pub extern "C" fn evaluate(ptr: i32, len: i32) -> i32 entry point plus the allocator and deny-reason exports, and encodes the verdict for you. A signature mismatch is a compile error. Four cross-language guest SDKs also ship under sdks/guard/ — chio-guard-cpp, chio-guard-go, chio-guard-py, and chio-guard-ts. Each compiles to a Component Model component against the chio:guard@0.2.0 WIT world. They pass requests and verdicts as typed WIT values instead of hand-serialized JSON.
Fuel Metering and Fail-Closed
Guest guards execute under a fuel budget that bounds CPU consumption. The runtime tracks fuel per instruction and terminates the guest when the budget is exhausted. The following failures deny the call:
- Fuel exhaustion. Default
fuel_limitis10,000,000. Running out raisesWasmGuardError::FuelExhausted, which the kernel treats as a denial. - Traps. Any WASM trap — memory access violation, stack overflow, unreachable instruction — results in denial.
- Missing exports. If the module does not export
evaluateormemory, the load fails and the guard is never registered — a config-time reject, not a runtime deny.
Declaring Guards in chio.yaml
A compiled guard is attached to a deployment by listing it under wasm_guards:
wasm_guards:
- name: custom-pii-guard
path: /etc/chio/guards/pii_guard.wasm
fuel_limit: 5000000
priority: 100
advisory: false| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Human-readable name, recorded in receipts and logs. |
path | string | required | Filesystem path to the .wasm module. |
fuel_limit | u64 | 10,000,000 | Maximum fuel units per invocation. |
priority | u32 | 1000 | Evaluation order; lower runs earlier. |
advisory | bool | false | If true, denials are logged but not enforced. |
Ship new guards advisory-first
advisory: true to run a new guard in production without blocking traffic: it logs its denials and errors but returns Verdict::Allow. Watch the logs, confirm the guard fires where you expect and nowhere you do not, then flip advisory to false to enforce.The Guard Lifecycle
The chio guard subcommand covers authoring through distribution:
# Scaffold, compile, and validate locally
$ chio guard new custom-pii-guard # Cargo.toml, src/lib.rs, guard-manifest.yaml
$ chio guard build # -> wasm32-unknown-unknown
$ chio guard inspect ./pii_guard.wasm # exports, ABI compatibility, memory config
$ chio guard test --wasm ./pii_guard.wasm ./fixtures/*.yaml
$ chio guard bench ./pii_guard.wasm --iterations 100
# Package and sign
$ chio guard pack # -> .arcguard archive
$ chio guard install ./custom-pii-guard.arcguard --target-dir ./guards
$ chio guard sign ./pii_guard.wasm --key ./signer.seed \
--name custom-pii-guard --version 1.0.0 # writes pii_guard.wasm.sig
$ chio guard verify ./pii_guard.wasm
# Distribute over OCI with Sigstore verification
$ chio guard publish ./custom-pii-guard \
--ref oci://ghcr.io/acme/pii-guard:v1 --epoch-id-seed ./epoch.seed
$ chio guard pull \
--ref oci://ghcr.io/acme/pii-guard@sha256:<digest> \
--sigstore-identity-regex '^https://github\.com/acme/' \
--sigstore-oidc-issuer https://token.actions.githubusercontent.comTwo additional commands support the lifecycle: chio guard blocklist remove <digest> manages the local digest blocklist, and chio guard market (list, info, install) browses and installs priced guards from a catalog.
Session-aware guards read the session journal
chio-http-session). Each entry carries the hash of the previous one — the first uses the 64-hex-zero seed — and verify_integrity() walks the chain to detect tampering. This is the shared state layer for session-aware and advisory guards.Native Built-in Guards
Chio uses the native Guard trait for built-in guards compiled into chio-guards and chio-data-guards. Use it when a guard needs kernel types or low overhead. It requires building the guard into those crates and recompiling the kernel. Use a WASM guest guard for organization-specific logic. This section also helps when reading built-in guards or wiring a pipeline in an embedded host.
The Guard Trait
Every guard implements a two-method trait defined in chio-kernel. evaluate returns a GuardDecision, or Err for an internal failure (which the kernel treats as a deny):
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>;
}The trait requires Send + Sync because guards are shared across threads in the kernel runtime.
GuardContext
The kernel passes a GuardContext to each evaluation. It includes the request and the data the guard needs:
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 available.
pub session_filesystem_roots: Option<&'a [String]>,
/// Index of the matched grant in the capability's scope.
pub matched_grant_index: Option<usize>,
}Through ctx.request you reach the complete ToolCallRequest — tool name, server ID, agent ID, the serde_json::Value arguments, and the signed capability token. A guard can inspect the action, actor, and authority.
GuardDecision and Verdict
evaluate returns a GuardDecision: a Verdict plus a vector of evidence. The Verdict enum has three unit variants — there is no Pass, and deny detail travels on the evidence, not inline on the verdict:
pub struct GuardDecision {
pub verdict: Verdict,
pub evidence: Vec<GuardEvidence>,
}
pub enum Verdict {
/// The action is allowed.
Allow,
/// The action is denied.
Deny,
/// Suspended pending a human decision. Only produced by the full
/// chio-kernel shell, never by chio-kernel-core directly.
PendingApproval,
}
pub struct GuardEvidence {
pub guard_name: String,
pub verdict: bool, // true = passed, false = denied
pub details: Option<String>,
}Construct decisions through the helpers: GuardDecision::allow(), GuardDecision::deny(evidence), GuardDecision::pending_approval(evidence), or GuardDecision::from_verdict(v). A guard that does not apply to a request must not block it — return Ok(GuardDecision::allow()), since the pipeline is conjunctive and each guard must allow for a call to proceed.
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")),
}]))
}
}Err means deny
evaluate returns Err, the pipeline treats it as a denial — the fail-closed guarantee. Return Err only for genuine internal failures (configuration errors, I/O problems). If the guard can evaluate the request but the request violates policy, return Ok(GuardDecision::deny(...)).The Default Pipeline
GuardPipeline::default_pipeline() registers seven guards, in this order, with their default configurations: ForbiddenPathGuard, ShellCommandGuard, EgressAllowlistGuard, PathAllowlistGuard, McpToolGuard, SecretLeakGuard, and PatchIntegrityGuard. Velocity and the session-aware guards are wired by the policy compiler from a HushSpec document, not by default_pipeline().
use chio_guards::GuardPipeline;
// The seven stateless built-ins, cheapest-first.
let pipeline = GuardPipeline::default_pipeline();
// Register the pipeline as a single guard on the kernel.
kernel.add_guard(Box::new(pipeline));Guards evaluate synchronously on each tool call, and the pipeline short-circuits on the first deny, so ordering matters for latency (not correctness). Keep evaluate a fast, allocation-light check against data prepared in new(): pre-compile regex and glob patterns, load any external allowlist at construction time, and avoid blocking I/O.
Next Steps
- External Guards · wire third-party content-safety and threat-intel providers into the pipeline
- Agent Passport · portable agent credentials for cross-organizational trust
- Architecture · how guards fit into the kernel evaluation pipeline
- Native Tool Server · build a tool server that your guards will protect