Chio/Docs
LOGIN · JOIN

BuildPolicy

Custom Guards

Add a sandboxed WASM guard when built-in guards do not cover your policy.

Choose a guard implementation

Chio provides two ways to write guards. Use WASM guest guards for organization-specific enforcement. They run in a sandbox, have a fuel limit, are declared in 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-unknown target installed. chio guard build compiles for that target and nothing else.
  • The chio binary, for the chio guard lifecycle. 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.

rendering
A WASM guest guard sits in the pipeline alongside the built-in guards. The host writes a GuardRequest into guest memory, calls evaluate, and reads back a verdict; a trap, a serialization failure, or exhausted fuel denies instead.
sourcecrates/guards/chio-wasm-guards/src/runtime/guard.rs:390-465at fe56570

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 wasm32 module that exports evaluate(request_ptr, request_len) -> i32 over the JSON ABI below. It routes to WasmtimeBackend. The Rust SDK (chio-guard-sdk with #[chio_guard]) compiles to this shape.
  • Component Model component. A component built against the chio:guard@0.2.0 WIT world (wit/chio-guard/world.wit), which exports a typed evaluate(request: guard-request) -> verdict directly, with no JSON and no manual pointer/length handling. It routes to ComponentBackend. The four cross-language SDKs target this world: chio-guard-cpp, chio-guard-go, chio-guard-py, and chio-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.

text
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 a Deny return the host calls the module's chio_deny_reason(65536, 4096) export, and the guest writes a JSON GuestDenyResponse into that buffer and returns the byte count. Every guard built with #[chio_guard] takes this path: the macro re-exports chio_alloc, chio_free, and chio_deny_reason.
  • Hand-rolled fallback. A module with no chio_alloc export gets the request written at offset 0. For the deny reason, a module with no chio_deny_reason export 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:

crates/guards/chio-wasm-guards/src/abi.rs29-58rust
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).

src/lib.rsrust
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_limit is 10,000,000. Running out raises WasmGuardError::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 chio namespace; it does not probe the export table. A module with no memory or no evaluate therefore loads cleanly and raises WasmGuardError::MissingExport on its first evaluation, which is a runtime deny. Run chio guard inspect before 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:

chio.yamlyaml
wasm_guards:
  - name: custom-pii-guard
    path: /etc/chio/guards/pii_guard.wasm
    fuel_limit: 5000000
    priority: 100
    advisory: false
OptionTypeDefaultDescription
namestringrequiredHuman-readable name, recorded in receipts and logs.
pathstringrequiredFilesystem path to the .wasm module.
fuel_limitu6410,000,000Maximum fuel units per invocation.
priorityu321000Evaluation order; lower runs earlier.
advisoryboolfalseIf true, denials are logged but not enforced.

Ship new guards advisory-first

Set 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_version is "1", the only supported value.
  • Its wit_world is chio: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_sha256 is the actual SHA-256 of the module bytes. chio guard new writes a placeholder there and chio guard build leaves 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_key and ship the .wasm.sig sidecar, or set allow_unsigned: true and accept a warning in the log. Neither, and the guard does not load. Pinning a key wins over allow_unsigned, and a sidecar whose module name or version disagrees with the manifest is refused.
guard-manifest.yamlyaml
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.

bash
# 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.com

Scaffolding runs against an empty directory and prints the path it wrote and the next two commands to run:

custom-pii-guard · newtranscript
$ chio guard new custom-pii-guard
created 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
exit 0

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:

custom-pii-guard · buildtranscript
$ chio guard build
build complete: target/wasm32-unknown-unknown/release/custom_pii_guard.wasm
binary size: 185.2 KiB
exit 0in custom-pii-guard

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:

custom-pii-guard · inspecttranscript
$ 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
exit 0in custom-pii-guard

chio guard bench runs the module against the fuel limit and reports latency and fuel percentiles:

custom-pii-guard · benchtranscript
$ 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
exit 0in custom-pii-guard

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:

custom-pii-guard · signtranscript
$ chio guard sign \
    target/wasm32-unknown-unknown/release/custom_pii_guard.wasm \
    --key ./signer.seed --name custom-pii-guard --version 1.0.0
signed 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
exit 0in custom-pii-guard
custom-pii-guard · verifytranscript
$ chio guard verify target/wasm32-unknown-unknown/release/custom_pii_guard.wasm
verified target/wasm32-unknown-unknown/release/custom_pii_guard.wasm
  signer_pk: d256022597743b7fbaa48cd9bd7d00567e2afe462aaba4d797e76af29a133787
  module:    custom-pii-guard@1.0.0
  digest:    b55efa80f8ce77734e1f5f6c20fbe0306287a74f334780014ff21cfeae02575f
exit 0in custom-pii-guard

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:

custom-pii-guard · packtranscript
$ chio guard pack
packed: custom-pii-guard-0.1.0.arcguard (74.9 KiB)
exit 0in custom-pii-guard
custom-pii-guard · installtranscript
$ chio guard install ./custom-pii-guard-0.1.0.arcguard --target-dir ./guards
installed: custom-pii-guard to ./guards/custom-pii-guard/
exit 0in 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

Guards that need cross-call state read the append-only, SHA-256 hash-chained 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.

navigate.yamlyaml
- 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:

custom-pii-guard · testtranscript
$ 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
exit 0in custom-pii-guard

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:

custom-pii-guard · test-regressiontranscript
$ 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.
exit 1in custom-pii-guard

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

SymptomCause 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.
custom-pii-guard · install-unversionedtranscript
$ chio guard install ./custom-pii-guard.arcguard --target-dir ./guards
error [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.
exit 1in custom-pii-guard

The reason line names the pipeline, not your guard

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

crates/kernel/chio-kernel/src/kernel/mod.rs573-632rust
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:

crates/kernel/chio-kernel/src/kernel/mod.rs635-650rust
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:

crates/kernel/chio-kernel/src/kernel/mod.rs513-516rust
pub struct GuardDecision {
    pub verdict: Verdict,
    pub evidence: Vec<GuardEvidence>,
}
crates/kernel/chio-kernel/src/runtime.rs16-38rust
/// 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,
}
crates/core/chio-core-types/src/receipt/metadata.rs186-194rust
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.

rust
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

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

#Typename()
1ForbiddenPathGuardforbidden-path
2ShellCommandGuardshell-command
3EgressAllowlistGuardegress-allowlist
4PathAllowlistGuardpath-allowlist
5McpToolGuardmcp-tool
6SecretLeakGuardsecret-leak
7PatchIntegrityGuardpatch-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.

crates/guards/chio-guards/src/pipeline.rs39-49rust
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:

rust
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