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.
| Flag | Type | Default | Notes |
|---|---|---|---|
--policy | path | required | HushSpec policy YAML to evaluate against. |
--mode | preflight | full | preflight | See Preflight vs Full Mode below. |
--tool | string | required | Tool name to evaluate. |
--params | JSON string | "{}" | Tool arguments, serialized as JSON. |
--server | string | "*" | Server ID to evaluate the call against. |
--output-fixture | path | none | JSON 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:
$ 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 code | Verdict | Meaning |
|---|---|---|
0 | Allow | The call passed every guard. |
2 | Deny | A guard blocked the call. |
3 | Pending approval | The call needs a human decision first. |
Branch on the exit code, not the presence of output
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.
# 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.
| Path | Entry point | Returns | Wired 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:
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:
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()
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:
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:
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:
| Field | Type | Notes |
|---|---|---|
name | string | Printed in the pass/fail output. |
request | GuardRequest | tool_name, server_id, agent_id, arguments required; the rest default to empty. |
expected_verdict | string | "allow" or "deny". Anything else fails the fixture. |
deny_reason_contains | string, optional | On a deny fixture, the actual reason must contain this substring. |
- 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"$ 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 totalAny 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:
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:
#!/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:
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/*.yamlTreat policy and guard changes like code changes
Next Steps
- HushSpec Policy Format · the schema and compile model behind
chio check - chio.yaml Configuration ·
guards.requiredand advisory promotion - Custom WASM Guards · manifest format, signing, hot reload, and canaries
- Custom Guards · the guide-form walkthrough, including
sign/verify,install,blocklist - Observability · what to watch once a tested policy is running