Chio/Docs
LOGIN · JOIN

Custom Guards

Release your own WASM rule as signed bytes and run it under the release key the receiving operator pinned.

Guide chio@5f63e6e

On this page

Ship an organization's own policy rule as a signed WASM module. The operator who loads it pins the release key, and the kernel runs the rule inside admission, before any tool sees the request.

The project is guarded-documents from Building Custom Guards: a document tool, a word-count rule, and an eight-word limit. The rule and the limit stay the same through all seven steps. What changes is who may supply the executable rule.

Start

1. Build the rule into a WASM module

Download the guarded-documents project and install the Chio CLI from Installation. OpenSSL generates the release key in step 3. From the project root, build the rule and copy it into a release directory.

Terminal · guarded-documentsbash
cd guard
chio guard build
cd ..
mkdir -p release
cp guard/target/wasm32-unknown-unknown/release/word_limit.wasm release/word_limit.wasm
Output
build complete: target/wasm32-unknown-unknown/release/word_limit.wasm
binary size: 190.7 KiB

You should see the module path and its size. The build reads guard/Cargo.toml and targets wasm32-unknown-unknown. Keep Cargo.lock in the project: the byte count and the module digest change with the toolchain, the dependencies, and the build paths.

2. Check that the module exports the guard ABI

Terminalbash
chio guard inspect release/word_limit.wasm
Output
=== WASM Guard Inspection ===

File: release/word_limit.wasm
Size: 190.7 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

You should see ABI compatibility: COMPATIBLE with evaluate, chio_alloc, and chio_deny_reason marked. The memory line reports what the module declares; the deployment runtime applies its own memory limit. Inspection reads exports only. The rule first runs against a document in step 6.

3. Sign the module with your release key

Create the key once, in a private directory. Keep the seed for later releases; you distribute the public key and the signature.

Terminalbash
mkdir -m 700 keys
(umask 077; set -C; openssl rand -hex 32 > keys/guard.seed)
chio guard sign release/word_limit.wasm --key keys/guard.seed \
  --name word-limit --version 0.1.0
Output
signed release/word_limit.wasm
  sidecar:   release/word_limit.wasm.sig
  signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516
  module:    word-limit@0.1.0
  digest:    fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c

You should see the sidecar path, signer_pk, the module coordinates word-limit@0.1.0, and its digest. The command writes release/word_limit.wasm.sig, an envelope that binds the module hash, name, and version to your key. Your key and digest will differ.

Captured word_limit.wasm.sig · completejson
{
  "module_hash": "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c",
  "module_name": "word-limit",
  "version": "0.1.0",
  "signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516",
  "signature": "cd28937edd1f1a93d1301e43233828260d2bdcfd43b24019f32e74bc2e23fe8f35c045daff178b16d085102b8ae0e816b8bf7be0597ba049d8f62882e517fb0f"
}

4. Pin the signer you accept

Trust in a custom guard is the operator's decision. The operator names the release key it accepts, and loading checks every module against that key.

Guard release signersChoose an outcome.
guard authorreceiving operatorsignsmodule + .sigpinssigns✓signer✓digest✓versionσgκKρrelease keyhash · name · versionword_limit.wasmsigned modulepinned signersigner_public_keykernelloads, then admitsreceiptkernel signatureloaded under the operator's pinguard authorreceiving operator✓signer✓digest✓versionσgκKρloaded under the operator's pin

σrelease keygword_limit.wasmκpinned signerKkernelρreceipt

The operator's pin decides which release loads

The host takes the expected signer_public_key from guard-policy.json, with allow_unsigned set to false. The signer, the module digest, and the version must match the signed envelope before the application accepts requests. WASM Guards.

One signed guard release: the author's key, the module, the operator's pinned signer, the three load checks, and the receipt the kernel signs afterwards. Names come from the guarded-documents capture; the order of the load checks, the package format, and registry distribution are left out.

A valid release signature establishes which key signed those bytes; it says nothing about whether the rule implements the policy you intended. Review the rule, then choose its signer through your own release process.

Create release/guard-manifest.yaml. Copy the digest and signer_pk from the signing output into wasm_sha256 and signer_public_key, and keep the name and version equal to the signed envelope.

release/guard-manifest.yaml · completeyaml
name: word-limit
version: "0.1.0"
abi_version: "1"
wit_world: "chio:guard/guard@0.2.0"
wasm_path: word_limit.wasm
wasm_sha256: "fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c"
signer_public_key: "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
allow_unsigned: false
config:
  max_words: "8"
Terminalbash
chio guard verify release/word_limit.wasm
Output
verified release/word_limit.wasm
  signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516
  module:    word-limit@0.1.0
  digest:    fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c

You should see verified with the same key and digest. With the manifest beside the module, chio guard verify checks the envelope against the manifest's pinned key. Without an adjacent manifest it checks against the key the sidecar carries, which proves the bytes match their own signature and names no trusted supplier.

5. Install the signed package

chio guard pack archives the manifest, the module, and the detached signature together, so a recipient needs only the archive.

Terminalbash
cd release
chio guard pack
cd ..
chio guard install release/word-limit-0.1.0.arcguard --target-dir installed-guards
chio guard verify installed-guards/word-limit/word_limit.wasm
Output
packed: word-limit-0.1.0.arcguard (77.7 KiB)
installed: word-limit to installed-guards/word-limit/
verified installed-guards/word-limit/word_limit.wasm
  signer_pk: 6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516
  module:    word-limit@0.1.0
  digest:    fc8baeefc6150ea58f99d02cf37cdce7ae3a9437a239a47b2c45ea9f7dd2581c

You should see the archive name, the installed directory, and a second verified line for the installed module. Installation stages and checks the complete signed package, then replaces the installed directory in one filesystem operation, so a failed update leaves the previous installation usable. The exchange needs atomic directory exchange on Linux or macOS; any other filesystem refuses the update.

6. Load the rule under the pinned signer

Installing places files on disk; the host loads them. In the application's guard-policy.json, point module_path at the installed module, set allow_unsigned to false, and add the expected signer_public_key. This host reads its configuration from this file alone.

guard-policy.json · signed deploymentjson
{
  "modules": [
    {
      "name": "word-limit",
      "version": "0.1.0",
      "module_path": "installed-guards/word-limit/word_limit.wasm",
      "capabilities": [
        "chio.get_config"
      ],
      "config": {
        "max_words": "8"
      },
      "fuel_limit": 1000000,
      "priority": 100,
      "advisory": false,
      "allow_unsigned": false,
      "signer_public_key": "6f20f65d1ce0a8763fc57f430b027e9520ac54d33454581475e4a6bf32086516"
    }
  ]
}
Terminalbash
target/release/guarded-documents sample.md long.md
Captured signed-guard application resulttext
Evidence: runs/a348e5c2-5bec-4a0e-a0ef-a4a7137571d5
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: e380bbac0029d0dc581a7a98528d6ff7154f6ec24d07aa0a0d0d3b64369aa994 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Receipt: ad7e0e17668a6d8e50ff1f4a41a0db28489f98dfa6c5f68586449315d1c7eabd (receipt-2.json)

You should see sample.md allowed at six words and the eleven-word long.md denied by the guard word-limit, each with its own receipt. The process exits with code 2 because one input was refused. Loading rejects a wrong pinned key, changed module bytes, or a version that disagrees with the signature before the application accepts requests.

Captured rule diagnostic · stderrtext
 WARN WASM guard denied request guard=word-limit epoch_id=0 reason=document exceeds max_words

7. Verify the denial receipt under the kernel key

The host diagnostic explains the refusal; the signed receipt records it. Download the allow and denial receipts. The example's verifier checks each against a separately supplied kernel public key.

Captured denial receipt verificationjson
{
  "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": "ad7e0e17668a6d8e50ff1f4a41a0db28489f98dfa6c5f68586449315d1c7eabd",
  "receipt_id_valid": true,
  "signature_valid": true,
  "signer_trusted": true
}

You should see ok, signature_valid, and signer_trusted true, authorized false, and a reason that names word-limit. The receipt carries the guard's name and the policy context. The word-count detail stays in the host diagnostic, outside the signed record.

A new limit restarts the host; new code ships a new release

The module reads max_words through the declared chio.get_config host function, refuses invalid configuration, and stops counting once the limit is passed.

guard/src/lib.rs · completerust
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()
}

Changing max_words needs a host restart and no new build or signature. Changing the code needs a new build, signature, manifest digest, and installation, with the host's pinned signer and release coordinates kept consistent. Set max_words from 8 to 16 to allow both documents, or fuel_limit to 1 to see a refusal before dispatch; Building Custom Guards keeps the captures of those edits.

The fixture runner tests the module without the host's configuration

chio guard test runs core-module requests directly, outside the kernel, with no deployment configuration. For this rule, that makes the configured case fail and the missing-configuration case pass.

document-cases.yaml · completeyaml
- name: six words with the configured limit
  request:
    tool_name: count_words
    server_id: documents
    agent_id: document-author
    arguments:
      text: "Count the words in this document."
    scopes: ["documents:count_words"]
  expected_verdict: allow
- name: missing configuration fails closed
  request:
    tool_name: count_words
    server_id: documents
    agent_id: document-author
    arguments:
      text: "Count the words in this document."
    scopes: ["documents:count_words"]
  expected_verdict: deny
  deny_reason_contains: max_words
Terminalbash
chio guard test --wasm release/word_limit.wasm document-cases.yaml
Captured standalone fixture resultstext
[FAIL] six words with the configured limit: expected allow, got deny: max_words must be an integer from 1 to 10000
[PASS] missing configuration fails closed

1 passed, 1 failed out of 2 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.

You should see one failure, because max_words is missing, and one pass, because missing configuration denies. Test a configuration-dependent rule through the configured application run in step 6. For a rule with no configuration, the fixture runner checks request fields, expected_verdict, and deny_reason_contains directly.

Fuel measured by the benchmark command
Terminalbash
chio guard bench release/word_limit.wasm --iterations 20
Output
=== Guard Benchmark ===

File: release/word_limit.wasm
Iterations: 20
Fuel limit: 1,000,000

Latency:
  p50:  145.33 us
  p99:  194.79 us
  min:  131.33 us
  max:  194.79 us
  mean: 148.31 us

Fuel consumed:
  p50:  63,508
  p99:  63,508
  min:  63,508
  max:  63,508
  mean: 63,508

The benchmark submits a fixed bench_tool request with no deployment configuration, so this rule takes its immediate allow branch. The numbers describe that branch and its ABI overhead. Measure representative document requests through the configured host before choosing a fuel_limit.

The runtime bounds each evaluation with fuel and linear-memory limits. For a blocking guard, a denial, a trap, or exhausted fuel prevents dispatch. Advisory mode records denials without enforcing them, so keep advisory false for a rule that must block execution.

A refused load names the check that failed

The loader, the CLI, and the runtime each name the input that failed. Repair that input and rerun the step.

  • The installed module is not signed. The .wasm.sig sidecar is missing beside the module. Install from the packed archive, or copy the sidecar beside the module, then verify against the pinned manifest key.
  • Hash or signature mismatch. The module on disk is not the one you signed. A rebuild needs a fresh signature and an updated wasm_sha256.
  • Wrong signer or version. Compare the operator's expected key, the configured version, and the signed envelope. Change the trust configuration only for a release you intend to accept.
  • Missing max_words in the fixture runner. The runner supplies no deployment configuration. Run the configured application instead.
  • Fuel exhausted. Inspect the rule and representative input sizes, and measure the document path before raising fuel_limit.
Captured message for an unsigned installed module
Captured verification before copying the sidecartext
error [urn:chio:error:manifest:signature-invalid]: guard module installed-guards/word-limit/word_limit.wasm is not signed (missing installed-guards/word-limit/word_limit.wasm.sig sidecar)
context: {"domain":"manifest","severity":"fatal","stability":"unstable","string_code":"CHIO-MANIFEST-SIGNATURE-INVALID"}
suggested fix: Reject the manifest and republish it with the expected signing key and canonical payload.

Loaders, the guest ABI, and native guards

chio.yaml deployment entry

guarded-documents loads its module through PolicyCustomGuards and load_guards_from_policy, with configuration, host-function permissions, and the expected signing key in guard-policy.json. A deployment configured through chio.yaml uses this entry instead:

chio.yaml · deployment entryyaml
wasm_guards:
  - name: word-limit
    path: /etc/chio/guards/word-limit/word_limit.wasm
    fuel_limit: 1000000
    priority: 100
    advisory: false

For that loader, place guard-manifest.yaml and word_limit.wasm.sig beside the module. The manifest supplies configuration and declares the expected signer and module hash. The loader checks the supported ABI and WIT world, the module hash, and the signature before it assembles the pipeline. Each loader reads only its own entry format.

FieldMeaningDefault
name
The configured guard name
Required
path
The module on the deployment filesystem
Required
fuel_limit
Execution fuel per invocation
10,000,000
priority
Lower values run earlier
1000
advisory
Record refusals without enforcing them
false

Core modules and components

The Rust attribute macro emits a core WebAssembly module that uses the JSON ABI below. The cross-language guard SDKs target Component Model components for the chio:guard@0.2.0 WIT world. Chio ships a separate backend for each format; the core-module loader in guarded-documents loads core modules, so pair each compiled output with its matching backend.

Core-module ABI

The host serializes a request into guest linear memory and calls evaluate. The attribute macro generates the entry point, the allocator, and the deny-reason exports.

Core ABItext
evaluate(request_ptr: i32, request_len: i32) -> i32
0: allow
1: deny
negative: error (a blocking guard denies)

SDK modules export chio_alloc and chio_deny_reason. The host validates allocated bounds before it writes a request and reads the guest's denial response. Hand-authored modules can use the fallback request offset 0 and a denial-reason region at offset 65536, bounded to 4096 bytes; the SDK writes that memory protocol for you.

Request visible to a guest

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>,
}

The host supplies request identity, arguments, scopes, and any extracted action, path, and grant context; a guest treats those optional fields as possibly absent. Cross-call state reaches a guest only through a host interface the embedding integrates, and the isolated guest has no direct access to the session journal. WASM Guards covers host imports, memory bounds, signatures, and reload.

Native guards in a Rust host

A Rust host can implement the Guard trait and register it with its kernel. The guard reads kernel types and host-owned state with no WASM boundary, and the host recompiles when the implementation changes. Implementing the public trait leaves Chio's built-in guard crates untouched.

Guard trait

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 default hooks support dispatch revalidation and output checks; use them when the decision depends on mutable state or on the value released. Guard requires Send + Sync because the runtime shares guards across threads.

GuardContext

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>,
}

The context exposes the requested operation, its arguments, and the signed capability. Optional grant and filesystem context let a guard apply the authority the host already resolved.

GuardDecision, Verdict, and GuardEvidence

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>,
}

Return GuardDecision::allow() for a check that does not apply and GuardDecision::deny(evidence) for a policy refusal. Evidence belongs to this internal decision; read your host's receipt mapping before treating a diagnostic as part of the signed record.

Native source-address check

This method assumes allowed_ips and name() belong to the implementing guard. The host supplies _source_ip from its trusted transport context; an argument the agent sets proves no network origin.

Native source-address check · method excerptrust
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")),
        }]))
    }
}

Default pipeline registration

GuardPipeline::default_pipeline() registers 7 built-in guards, and a host can register that pipeline as one Guard.

Native registration · host excerptrust
use chio_guards::GuardPipeline;
let pipeline = GuardPipeline::default_pipeline();
kernel.add_guard(Box::new(pipeline));
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 short-circuits on a blocking refusal or error. Prepare expensive data at construction time and keep evaluation bounded. Default Pipeline covers ordering and policy-compiled guards.

Registry distribution

For a registry you control, chio guard publish creates an OCI artifact and chio guard pull retrieves a digest-pinned release. Local installation needs no registry account. Configure the target repository, the signing identity, and the receiver's trust policy before publishing; the guard publish reference lists the inputs. Publish the word-limit project under a tag, then pull it on the receiving host by the digest the publish printed:

Terminalbash
chio guard publish guard --ref oci://registry.larkfield.example/guards/word-limit:0.1.0 \
  --epoch-id-seed 2026-10 --username ci
chio guard pull --ref oci://registry.larkfield.example/guards/word-limit@sha256:6a1f…92c0 \
  --username ci
Output
published  oci://registry.larkfield.example/guards/word-limit:0.1.0
  digest   sha256:6a1f…92c0
  layers   wit, wasm, manifest
pulled     oci://registry.larkfield.example/guards/word-limit@sha256:6a1f…92c0
  cached   ~/chio/state/chio/guards/sha256/6a1f…92c0

Both commands read the registry password from CHIO_GUARD_REGISTRY_PASSWORD, which keeps it out of the process list. The pull accepts only a digest-pinned reference, so a moved tag cannot change what a host loads.

The signing and build commands live in chio-cli (src/guards/sign.rs, src/guard/build.rs); the core-module runtime is chio-wasm-guards (src/runtime/wasmtime_backend.rs).

Next: wire the module into your deployment

Keep guarded-documents as a regression check whenever the rule changes.