Chio/Docs

PlatformAuthoringnew

Testing Guards & Policies

Test enforcement logic with policy dry runs, Rust simulations, WASM fixtures, and CI.


chio check: Dry-Run a Policy

chio check evaluates a single tool call against a policy with no server, no agent, and no MCP connection. It runs the same compiled guard pipeline the kernel runs at request time (see Compile Model) through a short-lived kernel session, so the guard names and reported guard names and receipt IDs come from that session.

FlagTypeDefaultNotes
--policypathrequiredHushSpec policy YAML to evaluate against.
--modepreflight | fullpreflightSee Preflight vs Full Mode below.
--toolstringrequiredTool name to evaluate.
--paramsJSON string"{}"Tool arguments, serialized as JSON.
--serverstring"*"Server ID to evaluate the call against.
--output-fixturepathnoneJSON file with the tool's simulated output. Only valid with --mode full.

Preflight vs Full Mode

Most guards only need the request to reach a verdict, so the default preflight mode never touches a tool server. Guards that inspect what the tool returned, such as Response Sanitization, have no response to inspect in preflight. chio check refuses to run in preflight mode when the policy has any post-invocation guards, and says what to do instead:

bash
$ chio check --policy ./policy.yaml --tool read_file --params '{"path": "./out.csv"}'
error: chio check preflight cannot evaluate post-output guards; use --mode
full --output-fixture <JSON> so output-sensitive policy is evaluated against
explicit fixture output

--mode full requires --output-fixture: a JSON file holding the value the tool would have returned. There is no default; supply the fixture or stay in preflight. Either mode prints the same shape of result: verdict, reason (deny only, always naming the guard that fired), receipt_id, and the compiled policy / source content hashes.

Exit Codes

Exit codeVerdictMeaning
0AllowThe call passed every guard.
2DenyA guard blocked the call.
3Pending approvalThe call needs a human decision first.

Branch on the exit code, not the presence of output

A clean deny is expected policy behavior, not a crash. A script that treats every non-zero exit as a failure breaks the moment a fixture is supposed to be denied.

Smoke-test cases

The examples in these docs, from the Quickstart to Wrap an MCP Server, test a policy with an allowed call and a denied call for each relevant guard.

bash
# Should ALLOW: a legitimate read inside the workspace
$ chio check --policy ./policy.yaml --tool read_file \
    --params '{"path": "./workspace/README.md"}'

# Should DENY: tool not in the allowlist
$ chio check --policy ./policy.yaml --tool write_file \
    --params '{"path": "./workspace/out.txt", "content": "hi"}'

# Should DENY: forbidden_paths pattern match
$ chio check --policy ./policy.yaml --tool read_file \
    --params '{"path": "./workspace/.env"}'

# Should DENY: outside the path_allowlist
$ chio check --policy ./policy.yaml --tool read_file \
    --params '{"path": "/etc/shadow"}'

The allow case proves the policy is not so strict it blocks legitimate use. Each deny case proves the corresponding guard is wired correctly. Write a Policy builds this checklist by hand; Testing in CI below turns it into a script.


HushSpec evaluation paths

HushSpec exposes two independent ways to answer “what would this policy do here,” and they are not interchangeable. Confusing them is the most common mistake in tooling built on Chio.

PathEntry pointReturnsWired to a CLI command?
compile_policy()chio_policy::compile_policy(spec)CompiledPolicy: a compiled guard pipeline plus capability grants.Yes: chio check, chio run, chio mcp serve all run it through a kernel session.
evaluate()chio_policy::evaluate(spec, action)EvaluationResult: a tri-state Decision, no side effects.No. A public Rust function with no chio-cli subcommand in front of it today.

The evaluate() Interpreter

chio_policy::evaluate is a pure-function reference interpreter. It walks a resolved HushSpec directly against one EvaluationAction (a tool call, egress, a file read or write, a patch, a shell command, computer-use input, or input injection) and returns an EvaluationResult. It never touches a kernel session or writes a receipt to a store, and its decision has three states, not two:

crates/guards/chio-policy/src/evaluate/context.rs
pub enum Decision {
    Allow,
    Warn,
    Deny,
}

pub struct EvaluationResult {
    pub decision: Decision,
    pub matched_rule: Option<String>,
    pub reason: Option<String>,
    pub origin_profile: Option<String>,
    pub posture: Option<PostureResult>,
}

Warn is not “deny, but softer.” It fires when a preference-level rule is not met but nothing required it: a tool_access.prefer_runtime_assurance_tier the caller falls short of produces Warn with a reason like “below preferred”; the equivalent require_runtime_assurance_tier produces a hard Deny. Call the interpreter directly from a test to see the difference:

rust
use chio_policy::{evaluate, Decision, EvaluationAction};

#[test]
fn preferred_tier_shortfall_warns_not_denies() {
    let spec = policy_with_preferred_tier(RuntimeAssuranceTier::Attested);
    let result = evaluate(
        &spec,
        &EvaluationAction {
            action_type: "tool_call".to_string(),
            target: Some("payments.charge".to_string()),
            ..Default::default()
        },
    );

    assert_eq!(result.decision, Decision::Warn);
}

chio check does not call evaluate()

The compiled pipeline that chio check exercises reports a different three-state verdict entirely: chio_kernel::Verdict::{Allow, Deny, PendingApproval}. That verdict enum has no Warn variant; the closest analog is the advisory guard mechanism (see allow_advisory_promotion), where a non-blocking finding is recorded without creating a third verdict. A policy that produces Decision::Warn under evaluate() will not necessarily show anything from chio check; verify the compiled behavior separately if it matters to you.

evaluate_with_context(spec, action, context, conditions) is the same interpreter with conditional activation applied first. evaluate_audited(spec, action, config) wraps either path with timing and a SHA-256 policy hash into a DecisionReceipt, still entirely in-memory, no store, no kernel. If you need fast, side-effect-free “what would this policy do” answers for your own tooling, call this API directly from Rust instead of invoking chio check in a loop. chio-policy itself leans on this property: its own property-test suite drives evaluate() through invariants like deny_overrides_warn_and_allow, scaled by a PROPTEST_CASES environment variable (256 cases per pull request, 4096 nightly), a reasonable model if you build logic on top of it.


Testing Custom Native Guards

A native guard implements the synchronous Guard trait directly in Rust instead of compiling to WASM. It is small enough to unit-test with no kernel, policy file, or receipt store at all:

crates/kernel/chio-kernel-core/src/guard.rs
pub trait Guard: Send + Sync {
    fn name(&self) -> &str;
    fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<Verdict, KernelCoreError>;
}

GuardContext carries a &PortableToolCallRequest (request_id, tool_name, server_id, agent_id, arguments, plain fields with no signature required), a &ChioScope (ChioScope::default() is enough for scope-blind guards), denormalized agent_id / server_id strings, and two optional fields the kernel populates before guards run: session_filesystem_roots and matched_grant_index. Construct one directly, no kernel required:

rust
use chio_kernel_core::{Guard, GuardContext, PortableToolCallRequest, Verdict};
use chio_core_types::capability::scope::ChioScope;

#[test]
fn denies_paths_outside_the_workspace_root() {
    let request = PortableToolCallRequest {
        request_id: "req-1".to_string(),
        tool_name: "read_file".to_string(),
        server_id: "srv-files".to_string(),
        agent_id: "agent-1".to_string(),
        arguments: serde_json::json!({ "path": "/etc/passwd" }),
    };
    let scope = ChioScope::default();
    let ctx = GuardContext {
        request: &request,
        scope: &scope,
        agent_id: "agent-1",
        server_id: "srv-files",
        session_filesystem_roots: Some(&["/workspace".to_string()]),
        matched_grant_index: None,
    };

    let guard = WorkspaceRootGuard::new(vec!["/workspace".into()]);
    assert_eq!(guard.evaluate(&ctx).unwrap(), Verdict::Deny);
}

In-tree guards register against the fuller chio_kernel::Guard, signature-compatible but backed by the complete ToolCallRequest (a signed CapabilityToken, DPoP proof, and other kernel-only fields the portable trait omits). crates/guards/chio-data-guards/tests/warehouse_cost_guard.rs provides an example: it mints a throwaway keypair, signs a capability, and evaluates the guard.


Testing WASM Guards

WASM guards run through the same chio guard lifecycle described in Custom WASM Guards (new, build, inspect). Testing sits between build and publish, with two dedicated subcommands: chio guard test --wasm <path> <fixtures...> --fuel-limit <n> replays YAML fixtures, and chio guard bench <path> --iterations <n> --fuel-limit <n> measures latency and fuel across warmup and timed runs.

Fixture Format

Each fixture file is a YAML list. Every case runs against a fresh Wasmtime backend, so fuel and memory never leak between fixtures:

FieldTypeNotes
namestringPrinted in the pass/fail output.
requestGuardRequesttool_name, server_id, agent_id, arguments required; the rest default to empty.
expected_verdictstring"allow" or "deny". Anything else fails the fixture.
deny_reason_containsstring, optionalOn a deny fixture, the actual reason must contain this substring.
fixtures/tool-denylist.yaml
- name: allows a tool that is not on the denylist
  request:
    tool_name: read_file
    server_id: srv-files
    agent_id: agent-1
    arguments:
      path: "./workspace/README.md"
  expected_verdict: allow

- name: denies a tool on the denylist
  request:
    tool_name: delete_file
    server_id: srv-files
    agent_id: agent-1
    arguments:
      path: "./workspace/output.txt"
  expected_verdict: deny
  deny_reason_contains: "denylist"
bash
$ chio guard test --wasm ./tool_denylist_guard.wasm ./fixtures/tool-denylist.yaml
[PASS] allows a tool that is not on the denylist
[PASS] denies a tool on the denylist

2 passed, 0 failed out of 2 total

Any failed fixture makes chio guard test return a nonzero exit, so a CI step can fail on an unexpected fixture result. In chio check, a deny is often the expected outcome. chio guard bench is the companion for latency and fuel regressions: five warmup iterations, then the requested count, reporting p50/p99/mean for both.

Unit-Test Guard Logic

The #[chio_guard] macro renames your function

#[chio_guard] renames your fn evaluate(req: GuardRequest) -> GuardVerdict to an internal generated name and puts a #[no_mangle] extern "C" fn evaluate(ptr: i32, len: i32) -> i32 in its place. After expansion there is no evaluate(request) left to call from an in-crate #[test]; the name now refers to the raw-pointer FFI entry point, not your decision logic.

Put the decision logic in a plain helper called by the macro-annotated function, then unit-test that helper directly. This avoids Wasmtime, fuel metering, and a linear-memory round trip:

src/lib.rs
use chio_guard_sdk::prelude::*;
use chio_guard_sdk_macros::chio_guard;

fn decide(req: &GuardRequest) -> GuardVerdict {
    if req.tool_name == "dangerous_tool" {
        GuardVerdict::deny("tool is blocked by policy")
    } else {
        GuardVerdict::allow()
    }
}

#[chio_guard]
fn evaluate(req: GuardRequest) -> GuardVerdict {
    decide(&req)
}

#[cfg(test)]
mod tests {
    use super::decide;
    use chio_guard_sdk::prelude::*;

    #[test]
    fn denies_the_dangerous_tool() {
        let req = GuardRequest {
            tool_name: "dangerous_tool".to_string(),
            server_id: "srv".to_string(),
            agent_id: "agent-1".to_string(),
            arguments: serde_json::json!({}),
            ..Default::default()
        };
        assert!(decide(&req).is_deny());
    }
}

Keep both layers: fast native tests against decide() for iteration, and chio guard test fixtures for the slower integration test that the compiled .wasm module exports the right ABI and behaves the same under fuel metering.


Testing in CI

The building blocks above are individually scriptable; the remaining work is deciding what “pass” means for a pipeline.

Golden deny/allow fixtures. Because a deny is often the correct, expected result, a CI step cannot simply run chio check and fail on any non-zero exit. Keep a small table of expected outcomes and compare against it instead:

scripts/check-policy.sh
#!/usr/bin/env bash
set -euo pipefail

# name:tool:params:expected_exit
cases=(
  "read-workspace:read_file:{\"path\": \"./workspace/README.md\"}:0"
  "write-blocked:write_file:{\"path\": \"./workspace/out.txt\", \"content\": \"x\"}:2"
  "env-forbidden:read_file:{\"path\": \"./workspace/.env\"}:2"
)

failures=0
for case in "${cases[@]}"; do
  IFS=':' read -r name tool params expected <<< "$case"
  set +e
  chio check --policy ./policy.yaml --tool "$tool" --params "$params" > /dev/null 2>&1
  actual=$?
  set -e
  if [ "$actual" -ne "$expected" ]; then
    echo "FAIL $name: expected exit $expected, got $actual"
    failures=$((failures + 1))
  else
    echo "PASS $name"
  fi
done

[ "$failures" -eq 0 ]

WASM guard fixtures. chio guard test already exits non-zero on failure, so it works as a CI step once chio is on PATH:

.github/workflows/policy-check.yml
steps:
  - name: Golden allow/deny fixtures
    run: ./scripts/check-policy.sh
  - name: WASM guard fixtures
    run: |
      chio guard build
      chio guard test --wasm target/wasm32-unknown-unknown/release/*.wasm \
        ./fixtures/*.yaml

Treat policy and guard changes like code changes

A HushSpec edit or a guard-logic change is a behavior change even when the diff is small. Gate merges on the smoke pattern for every policy and the fixture suite for every guard; a silent regression here can fail open without a test failure.

Next Steps