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 it is used for built-ins that need kernel types or lower overhead. Adding one requires changes to those crates and recompiling the kernel.Prerequisites
- A Rust toolchain with the
wasm32-unknown-unknowntarget installed.chio guard buildcompiles for that target and nothing else. - The
chiobinary, for thechio guardlifecycle. No kernel, receipt store, or policy file is needed to author, compile, and test a guard. - Network access to crates.io on the first build. The scaffolded project depends on
chio-guard-sdk, which is fetched rather than vendored, so the compiled module size and its fuel floor move with the published SDK. - For the OCI steps only: a registry you can push to, and a Sigstore identity if you intend to verify on pull.
WASM Guest Guards
The chio-wasm-guards crate lets you write guards in any language that compiles to WebAssembly and load them into the kernel at runtime. The protocol specification names Rust, AssemblyScript, Go, and C. 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.
crates/guards/chio-wasm-guards/src/runtime/guard.rs:390-465at fe56570Two 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, with no JSON and no manual pointer/length handling. It routes toComponentBackend. The four cross-language SDKs target this world:chio-guard-cpp,chio-guard-go,chio-guard-py, andchio-guard-ts.
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. The host and the Rust SDK declare the same struct, field for field, so a guest that deserializes it with the SDK cannot drift from what the host sends:
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>,
}tool_name, server_id, agent_id and arguments are the request as it arrived. The rest is work the host already did: action_type is the category the kernel classified the call into, extracted_path and extracted_target are the normalized filesystem path and egress domain, and scopes, filesystem_roots and matched_grant_index describe the capability the call came in under. A guard that only needs to know whether a write is in bounds never has to re-parse the arguments.
Optional fields are skipped when empty, so a request for a tool with no path carries no extracted_path key at all. There is no session-context field: a guard sees one call, and cross-call state comes from the session journal described further down.
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 results in denial: memory access violation, stack overflow, unreachable instruction.
- Missing exports. Loading a module validates its size and rejects any import outside the
chionamespace; it does not probe the export table. A module with nomemoryor noevaluatetherefore loads cleanly and raisesWasmGuardError::MissingExporton its first evaluation, which is a runtime deny. Runchio guard inspectbefore you ship: it is the export check the loader does not do.
Declaring Guards in chio.yaml
A compiled guard is named to a deployment by listing it under wasm_guards. Each entry is one WasmGuardEntry, and the five keys below are the whole schema:
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 entry names a .wasm file, and the loader wants more than the file. load_wasm_guards sorts the entries by priority with advisory as the tie-breaker, then for each one reads a guard-manifest.yaml sitting beside the module and refuses the guard unless four things hold:
- The manifest's
abi_versionis"1", the only supported value. - Its
wit_worldischio:guard/guard@0.2.0. A missing or older world is rejected with the migration guide named in the error, so a 0.1.x component cannot be admitted quietly. - Its
wasm_sha256is the actual SHA-256 of the module bytes.chio guard newwrites a placeholder there andchio guard buildleaves it alone, so this is the field to fill in by hand after the first build. - The signature story is settled. Pin a
signer_public_keyand ship the.wasm.sigsidecar, or setallow_unsigned: trueand accept a warning in the log. Neither, and the guard does not load. Pinning a key wins overallow_unsigned, and a sidecar whose module name or version disagrees with the manifest is refused.
name: custom-pii-guard
version: "0.1.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: "target/wasm32-unknown-unknown/release/custom_pii_guard.wasm"
wasm_sha256: "<the digest chio guard sign printed>"
signer_public_key: "<the signer_pk chio guard sign printed>"Both hex values are printed by chio guard sign, so the signing step and the manifest are meant to be filled in together. When the pipeline is assembled, HushSpec-compiled guards run first and WASM guards second, in the order the loader sorted them.
The Guard Lifecycle
The chio guard subcommand covers authoring through distribution. Every capture in this section is one run of this sequence against the guard shown above, in one directory, in this order.
# 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 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
$ chio guard pack # -> .arcguard archive
$ chio guard install ./custom-pii-guard-0.1.0.arcguard --target-dir ./guards
# 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.comScaffolding runs against an empty directory and prints the path it wrote and the next two commands to run:
$ chio guard new custom-pii-guardcreated guard project at ./custom-pii-guard Next steps: cd custom-pii-guard chio guard build chio guard inspect target/wasm32-unknown-unknown/release/custom_pii_guard.wasm
Three files land: Cargo.toml, src/lib.rs, and guard-manifest.yaml. The scaffolded src/lib.rs denies every call with "unimplemented guard - deny by default", so a guard that is wired up before it is written closes the door rather than opening it. chio guard build compiles the crate for wasm32-unknown-unknown and reports the module size, which is the number to watch as a guard grows:
$ chio guard buildbuild complete: target/wasm32-unknown-unknown/release/custom_pii_guard.wasm binary size: 185.2 KiB
chio guard inspect is the ABI check. It reads the module's export table and confirms the three symbols the host calls are present before you ship it anywhere:
$ chio guard inspect target/wasm32-unknown-unknown/release/custom_pii_guard.wasm=== WASM Guard Inspection === File: target/wasm32-unknown-unknown/release/custom_pii_guard.wasm Size: 185.2 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
chio guard bench runs the module against the fuel limit and reports latency and fuel percentiles:
$ chio guard bench \
target/wasm32-unknown-unknown/release/custom_pii_guard.wasm \
--iterations 100=== Guard Benchmark === File: target/wasm32-unknown-unknown/release/custom_pii_guard.wasm Iterations: 100 Fuel limit: 1,000,000 Latency: p50: 539.24 us p99: 3510.78 us min: 483.28 us max: 3510.78 us mean: 658.48 us Fuel consumed: p50: 14,344 p99: 14,344 min: 14,344 max: 14,344 mean: 14,344
All four fuel percentiles are identical because every iteration walks the same instruction path, while the latency percentiles spread by an order of magnitude over the same run. Size the fuel limit against the fuel figure, not against wall clock. The absolute numbers move with the published SDK, so treat the shape as the contract and re-measure your own module.
Signing writes a detached .sig sidecar beside the module, and verify reads it back. Both print the signer key, the module coordinates, and the digest, so the two outputs are directly comparable, and both hex values go straight into guard-manifest.yaml:
$ chio guard sign \
target/wasm32-unknown-unknown/release/custom_pii_guard.wasm \
--key ./signer.seed --name custom-pii-guard --version 1.0.0signed target/wasm32-unknown-unknown/release/custom_pii_guard.wasm sidecar: target/wasm32-unknown-unknown/release/custom_pii_guard.wasm.sig signer_pk: d256022597743b7fbaa48cd9bd7d00567e2afe462aaba4d797e76af29a133787 module: custom-pii-guard@1.0.0 digest: b55efa80f8ce77734e1f5f6c20fbe0306287a74f334780014ff21cfeae02575f
$ chio guard verify target/wasm32-unknown-unknown/release/custom_pii_guard.wasmverified target/wasm32-unknown-unknown/release/custom_pii_guard.wasm signer_pk: d256022597743b7fbaa48cd9bd7d00567e2afe462aaba4d797e76af29a133787 module: custom-pii-guard@1.0.0 digest: b55efa80f8ce77734e1f5f6c20fbe0306287a74f334780014ff21cfeae02575f
Packing produces the distributable archive and reports its compressed size. The archive name carries the version from guard-manifest.yaml, not the version passed to chio guard sign, and installing is what unpacks it into the guard directory:
$ chio guard packpacked: custom-pii-guard-0.1.0.arcguard (74.9 KiB)
$ chio guard install ./custom-pii-guard-0.1.0.arcguard --target-dir ./guardsinstalled: custom-pii-guard to ./guards/custom-pii-guard/
Two 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, and the first uses the 64-hex-zero seed. verify_integrity() walks the chain to detect tampering. This is the shared state layer for session-aware and advisory guards.Verify the result
chio guard test runs the compiled module against YAML fixtures without a kernel, a policy file, or a receipt store. Each fixture is a GuardRequest under request, an expected_verdict of allow or deny, and, on a deny, an optional deny_reason_contains substring. That last field is the only place a guest's deny reason is checked, so write it.
- name: "allows the approved host"
request:
tool_name: browser_navigate
server_id: browser
agent_id: agent-1
arguments:
url: "https://app.example.com/orders"
scopes:
- "browser:browser_navigate"
expected_verdict: allow
- name: "denies every other host"
request:
tool_name: browser_navigate
server_id: browser
agent_id: agent-1
arguments:
url: "https://evil.example.net/steal"
scopes:
- "browser:browser_navigate"
expected_verdict: deny
deny_reason_contains: "allowlist"Both fixtures run against the guard from the authoring section, one allow and one deny:
$ chio guard test \
--wasm target/wasm32-unknown-unknown/release/custom_pii_guard.wasm \
fixtures/navigate.yaml[PASS] allows the approved host [PASS] denies every other host 2 passed, 0 failed out of 2 total
A guard is only as good as the case you forgot. The allowlist above matches on the whole https://app.example.com/ prefix, so a plaintext URL on the same host is refused, and a fixture that expects otherwise says exactly what it got:
$ chio guard test \
--wasm target/wasm32-unknown-unknown/release/custom_pii_guard.wasm \
fixtures/regression.yaml[FAIL] an http URL on the approved host: expected allow, got deny: navigation target is not on the allowlist 0 passed, 1 failed out of 1 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.The exit code is non-zero and the error carries urn:chio:error:guard:denied, so the whole fixture file works as a CI gate without any wrapper.
Failures and recovery
| Symptom | Cause and recovery |
|---|---|
Every call the guard sees is denied, and the log says unimplemented guard - deny by default. | The scaffolded stub is still in src/lib.rs. That is the intended starting point: replace the body, rebuild, and re-run chio guard test. |
| The guard refuses to load with a hash mismatch. | wasm_sha256 in guard-manifest.yaml is the scaffold placeholder or a digest of an older build. Recompute it from the module bytes after every build that ships. |
| The guard refuses to load and the error names a WIT world. | The manifest has no wit_world, or an older one. The loader requires chio:guard/guard@0.2.0 and names its migration guide in the error rather than admitting the module. |
| The guard refuses to load with a not-signed error. | No .wasm.sig sidecar and allow_unsigned is false. Sign the module, or set the flag and accept the warning the loader logs on every unsigned load. |
| Installing the archive fails on a missing file. | chio guard pack names the archive from the manifest, so it is <name>-<version>.arcguard. Pass the name pack printed; the unversioned name is the common slip. |
| A guard passes its fixtures and denies in production. | Fixtures set action_type, extracted_path and filesystem_roots by hand; the kernel fills them from the real call. A guard that branches on those fields needs fixtures that carry them. |
| A deny lands but the receipt does not explain it. | Expected. A WASM guard's deny reason goes to the log line, not to the receipt: the guard returns a decision with empty evidence, and the pipeline attaches one GuardEvidence carrying the guard's configured name and the fixed detail string every guard deny uses. |
$ chio guard install ./custom-pii-guard.arcguard --target-dir ./guardserror [urn:chio:error:cli:io]: failed to inspect Chio guard archive ./custom-pii-guard.arcguard: No such file or directory (os error 2)
context: {"domain":"cli","severity":"error","stability":"stable","string_code":"CHIO-CLI-IO"}
suggested fix: Check the path, permissions, and filesystem state before retrying.The reason line names the pipeline, not your guard
reason: on a denied call is guard "guard-pipeline" denied the request whichever guard denied, because the kernel formats it from the name of the guard registered on it and the CLI registers the pipeline. Your guard's name appears in the receipt, under evidence[].guard_name. Read the receipt, not the reason line.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
The trait is defined in chio-kernel. Two methods are required and six have default implementations, so a guard that only inspects the request writes name and evaluate and inherits the rest:
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 six defaults are the seams for guards that hold mutable state. requires_dispatch_revalidation and its two companions let a guard re-check state immediately before dispatch without spending a second quota or approval; validate_output_before_release and requires_exact_released_output bind an admission decision to the value the tool actually returned; and required_finding_status_feed_id names a status feed that must authorize a governed memory write. A stateless guard leaves all six alone. 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 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>,
}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. The two optional fields are the same pair a WASM guest sees as filesystem_roots and matched_grant_index.
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>,
}/// 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>,
}Construct decisions through the helpers: GuardDecision::allow(), GuardDecision::allow_with_evidence(evidence), 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, which is 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 7 guards in this order, each with its default configuration. The second column is what name() returns, which is the string that reaches a receipt's evidence[].guard_name:
| # | Type | name() |
|---|---|---|
| 1 | ForbiddenPathGuard | forbidden-path |
| 2 | ShellCommandGuard | shell-command |
| 3 | EgressAllowlistGuard | egress-allowlist |
| 4 | PathAllowlistGuard | path-allowlist |
| 5 | McpToolGuard | mcp-tool |
| 6 | SecretLeakGuard | secret-leak |
| 7 | PatchIntegrityGuard | patch-integrity |
Velocity and the session-aware guards are wired by the policy compiler from a HushSpec document, not by default_pipeline(). The Default Pipeline reference covers what each of them checks and why it sits where it does.
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 is itself a Guard, and that is how it is registered:
use chio_guards::GuardPipeline;
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.
Registering the pipeline is also why a deny's reason: line says guard-pipeline. The kernel formats that line from the name of the guard it holds, and it holds one: the pipeline. The guard that actually denied is appended to the evidence the pipeline returns, by name, with the same fixed detail string on every deny.
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