Chio/Docs
LOGIN · JOIN

Testing Guards & Policies

Test a guard or policy change at each layer it touches, from the rule's decision to the configured kernel's receipts.

Guide chio@5f63e6e

On this page

A guard or policy change is covered once every layer it reaches has a test. A unit test checks the rule's decision, chio check runs the compiled pipeline in a kernel session, and the configured application proves what reaches the tool and what the kernel signs.

Policy is executable authority, so a one-line edit can widen what an agent may do. The running example is a word limit on the documents / count_words operation, the same rule as Building Custom Guards; each step below covers one more part of the governed call.

Start
Test reachChoose a test.
native testevaluate()chio checkchio guard testapplicationrrequestπpolicyσscopegguardKkerneldtoolρreceiptunitevalcheckwasmapprrequestπpolicyσscopegguardKkerneldtoolρreceipt

The configured application reaches every part

The host loads the WASM rule with max_words, issues a capability, dispatches admitted calls, counts tool invocations, and saves signed receipts that verify against the configured kernel key. Step 6.

The parts of one governed call that each test in the guard-tests project and the guarded-documents application reaches. Hollow marks are inputs a test supplies or a tool it simulates. Workload identity, runtime attestation, budgets, and approvals are left out.

1. Run the guard test project

Download the complete guard-tests project: every source file, both YAML policies, the Cargo lockfiles, and the optional CLI verifier. Unpack it anywhere: Cargo fetches the Chio crates from their pinned public revision, and rust-toolchain.toml pins the Rust version.

Terminalbash
unzip guard-tests.zip
cd guard-tests
cargo test --locked --test native --test policy --quiet
Output

running 8 tests
........
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

You should see eight native tests and five policy tests pass. Change max_words from 8 to 16 in the eleven-word case and its denial assertion fails. Update an assertion only when the changed permission is intentional.

2. Test the rule's decision in a native guard

WordLimitGuard is the portable admission-time version of the document rule. It checks documents / count_words, accepts a limit from 1 to 10,000, and counts whitespace-separated words. Missing configuration or a non-string text argument denies, and calls to other tools pass this guard to the rest of the kernel.

guard-tests/src/lib.rsL1–30rust
use chio_kernel_core::{Guard, GuardContext, KernelCoreError, Verdict};

/// Admission-time version of the document application's word-limit rule.
pub struct WordLimitGuard {
    pub max_words: Option<usize>,
}

impl Guard for WordLimitGuard {
    fn name(&self) -> &str {
        "word-limit"
    }

    fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<Verdict, KernelCoreError> {
        let request = ctx.request;
        if request.server_id != "documents" || request.tool_name != "count_words" {
            return Ok(Verdict::Allow);
        }
        let Some(limit) = self.max_words.filter(|n| (1..=10_000).contains(n)) else {
            return Ok(Verdict::Deny);
        };
        let Some(text) = request.arguments.get("text").and_then(|v| v.as_str()) else {
            return Ok(Verdict::Deny);
        };
        Ok(if text.split_whitespace().take(limit + 1).count() > limit {
            Verdict::Deny
        } else {
            Verdict::Allow
        })
    }
}

Open the complete source file

The tests construct a request and GuardContext directly. A helper supplies every required field, and each test states one behavior, including the exact limit and the inputs that must fail closed.

guard-tests/tests/native.rsL1–151rust
use chio_core_types::capability::scope::ChioScope;
use chio_kernel_core::{Guard, GuardContext, PortableToolCallRequest, Verdict};
use guard_tests::WordLimitGuard;
use serde_json::{json, Value};

fn check(server: &str, tool: &str, arguments: Value, limit: Option<usize>) -> Verdict {
    let request = PortableToolCallRequest {
        request_id: "unit-document-1".into(),
        tool_name: tool.into(),
        server_id: server.into(),
        agent_id: "unit-agent".into(),
        arguments,
    };
    let scope = ChioScope::default();
    let context = GuardContext {
        request: &request,
        scope: &scope,
        agent_id: &request.agent_id,
        server_id: &request.server_id,
        session_filesystem_roots: None,
        matched_grant_index: None,
    };
    WordLimitGuard { max_words: limit }
        .evaluate(&context)
        .unwrap()
}

#[test]
fn six_words_are_allowed() {
    assert_eq!(
        check(
            "documents",
            "count_words",
            json!({
                "text": "One governed tool, two protocol surfaces."
            }),
            Some(8)
        ),
        Verdict::Allow
    );
}

#[test]
fn eleven_words_are_denied() {
    assert_eq!(
        check(
            "documents",
            "count_words",
            json!({
                "text": "This document has eleven words and should exceed the configured limit."
            }),
            Some(8)
        ),
        Verdict::Deny
    );
}

#[test]
fn changing_the_limit_allows_the_same_document() {
    assert_eq!(
        check(
            "documents",
            "count_words",
            json!({
                "text": "This document has eleven words and should exceed the configured limit."
            }),
            Some(16)
        ),
        Verdict::Allow
    );
}

#[test]
fn exactly_eight_words_are_allowed() {
    assert_eq!(
        check(
            "documents",
            "count_words",
            json!({
                "text": "one two three four five six seven eight"
            }),
            Some(8)
        ),
        Verdict::Allow
    );
}

#[test]
fn whitespace_is_not_a_model_token_count() {
    assert_eq!(
        check(
            "documents",
            "count_words",
            json!({
                "text": "one\ttwo\nthree   four"
            }),
            Some(4)
        ),
        Verdict::Allow
    );
}

#[test]
fn missing_or_invalid_configuration_denies() {
    for limit in [None, Some(0), Some(10_001), Some(usize::MAX)] {
        assert_eq!(
            check("documents", "count_words", json!({"text": "one"}), limit),
            Verdict::Deny
        );
    }
}

#[test]
fn missing_or_non_string_text_denies() {
    for arguments in [json!({}), json!({"text": null}), json!({"text": 12})] {
        assert_eq!(
            check("documents", "count_words", arguments, Some(8)),
            Verdict::Deny
        );
    }
}

#[test]
fn unrelated_calls_pass_this_guard_only() {
    assert_eq!(
        check("other", "count_words", json!({}), None),
        Verdict::Allow
    );
    assert_eq!(
        check("documents", "other_tool", json!({}), None),
        Verdict::Allow
    );
}

#[test]
fn whitespace_and_unicode_preserve_the_word_limit_invariant() {
    for limit in [1usize, 2, 7, 32, 127] {
        for count in [0, limit.saturating_sub(1), limit, limit + 1, limit + 9] {
            for separator in [" ", "\t", "\n", "\u{2003}"] {
                let text = vec!["café"; count].join(separator);
                let verdict = check(
                    "documents",
                    "count_words",
                    json!({"text":text}),
                    Some(limit),
                );
                assert_eq!(verdict == Verdict::Allow, count <= limit);
            }
        }
    }
}

Open the complete source file

You should see each test call the public chio_kernel_core::Guard interface with no capability, module, or tool in the loop. A guard that depends on mutable state or full-kernel inputs is tested through chio_kernel::Guard, which adds decision data and dispatch-revalidation hooks.

3. Tell a policy preference from a requirement

HushSpec has two evaluation paths. chio_policy::evaluate interprets a policy in memory and returns Allow, Warn, or Deny; compile_policy produces the grants and guard pipeline a kernel session runs. Both policies below permit count_words; only the second requires runtime assurance.

policies/preferred.yamlyaml
hushspec: "0.1.0"
name: document-assurance-preferred
rules:
  tool_access:
    enabled: true
    allow: [count_words]
    prefer_runtime_assurance_tier: attested
    default: block
policies/required.yamlyaml
hushspec: "0.1.0"
name: document-assurance-required
rules:
  tool_access:
    enabled: true
    allow: [count_words]
    require_runtime_assurance_tier: attested
    default: block
guard-tests/tests/policy.rsL1–74rust
use chio_core_types::capability::runtime_attestation::RuntimeAssuranceTier;
use chio_policy::evaluate::RuntimeAttestationContext;
use chio_policy::{compile_policy, evaluate, Decision, EvaluationAction, HushSpec};

const PREFERRED: &str = include_str!("../policies/preferred.yaml");
const REQUIRED: &str = include_str!("../policies/required.yaml");

fn document_action() -> EvaluationAction {
    EvaluationAction {
        action_type: "tool_call".into(),
        target: Some("count_words".into()),
        ..Default::default()
    }
}

#[test]
fn missing_preferred_assurance_warns() {
    let spec = HushSpec::parse(PREFERRED).unwrap();
    let result = evaluate(&spec, &document_action());
    assert_eq!(result.decision, Decision::Warn);
    assert_eq!(
        result.matched_rule.as_deref(),
        Some("rules.tool_access.prefer_runtime_assurance_tier")
    );
}

#[test]
fn missing_required_assurance_denies() {
    let spec = HushSpec::parse(REQUIRED).unwrap();
    let result = evaluate(&spec, &document_action());
    assert_eq!(result.decision, Decision::Deny);
}

#[test]
fn valid_attested_context_satisfies_both_policies() {
    let mut action = document_action();
    action.runtime_attestation = Some(RuntimeAttestationContext {
        tier: RuntimeAssuranceTier::Attested,
        valid: true,
        verifier: None,
        workload_identity: None,
    });
    for yaml in [PREFERRED, REQUIRED] {
        assert_eq!(
            evaluate(&HushSpec::parse(yaml).unwrap(), &action).decision,
            Decision::Allow
        );
    }
}

#[test]
fn an_invalid_attestation_does_not_satisfy_a_requirement() {
    let mut action = document_action();
    action.runtime_attestation = Some(RuntimeAttestationContext {
        tier: RuntimeAssuranceTier::Attested,
        valid: false,
        verifier: None,
        workload_identity: None,
    });
    assert_eq!(
        evaluate(&HushSpec::parse(REQUIRED).unwrap(), &action).decision,
        Decision::Deny
    );
}

#[test]
fn compiling_a_preference_does_not_create_a_required_constraint() {
    let preferred = compile_policy(&HushSpec::parse(PREFERRED).unwrap()).unwrap();
    let required = compile_policy(&HushSpec::parse(REQUIRED).unwrap()).unwrap();
    assert_eq!(preferred.default_scope.grants.len(), 1);
    assert!(preferred.default_scope.grants[0].constraints.is_empty());
    assert_eq!(required.default_scope.grants.len(), 1);
    assert_eq!(required.default_scope.grants[0].constraints.len(), 1);
}

Open the complete source file

You should see a tier below tool_access.prefer_runtime_assurance_tier return Warn with the reason runtime assurance tier '{actual}' is below preferred '{preferred}', while require_runtime_assurance_tier returns Deny. Compiling the preference adds no required capability constraint.

The test supplies the attestation context itself, so setting valid to true tests the policy's response to that input; your host verifies the workload's attestation evidence before it builds a trusted context. chio check reports the compiled verdict, Allow, Deny, or PendingApproval, which has no Warn. Test a preference's compiled behavior separately when it matters.

4. Dry-run allowed and refused calls with chio check

chio check evaluates one tool call in a short-lived kernel session against a simulated tool server. It runs the same compiled guard pipeline the kernel runs at request time, so the guard names and receipt ids it reports come from that session. The transcripts use the canonical HushSpec example policy, which the test project ships as verifier-policy.yaml.

Terminal · in guard-testsbash
cp verifier-policy.yaml policy.yaml

Run three cases against one receipt database, with the project's output-fixture.json standing in for the tool's output. Case 1 is a shell command none of the policy's three forbidden_patterns regexes match.

policy-check · allowtranscript
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
  check --policy ./policy.yaml --tool run_command \
  --params '{"command": "ls -la /workspace"}' \
  --mode full --output-fixture ./output-fixture.json
verdict:    ALLOW
tool:       run_command
server:     *
receipt_id: 5f65ab2d79acf48f7c6c8d7ddeb202ea775a090ae4ea751040d5eb6d9e78ad5c
policy:     89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da
source:     11e1aa04a3696c42034ae77afb4f9aee4e5c16c4e38a58f2a21fc2fa137779d8
mode:       full
fixture:    true
exit 0allow

Case 2 reads a path that forbidden_paths covers with **/.env.

policy-check · deny-guardtranscript
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
  check --policy ./policy.yaml --tool read_file \
  --params '{"path": "/workspace/.env"}' \
  --mode full --output-fixture ./output-fixture.json
verdict:    DENY
tool:       read_file
server:     *
reason:     guard denied the request: guard "guard-pipeline" denied the request
receipt_id: 0b8dd0573f189324215246c2b4509ee6f867724000d795bbc0f66b65cc016c33
policy:     89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da
source:     11e1aa04a3696c42034ae77afb4f9aee4e5c16c4e38a58f2a21fc2fa137779d8
mode:       full
fixture:    true
WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
exit 2deny

Case 3 asks for delete_file, a tool outside tool_access.allow.

policy-check · deny-scopetranscript
$ chio --session-db ./admission-3.db --receipt-db ./receipts.db \
  check --policy ./policy.yaml --tool delete_file \
  --params '{"path": "/workspace/out.txt"}' \
  --mode full --output-fixture ./output-fixture.json
verdict:    DENY
tool:       delete_file
server:     *
reason:     requested tool delete_file on server * is not in capability scope
receipt_id: 0e570662187463441276e407bcb5e46c5b049d7cf59c614756c2acb5431149ee
policy:     89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da
source:     11e1aa04a3696c42034ae77afb4f9aee4e5c16c4e38a58f2a21fc2fa137779d8
mode:       full
fixture:    true
WARN chio_kernel::kernel::evaluation::async_evaluation_core message=capability rejected request_id=check-001 reason=requested tool delete_file on server * is not in capability scope
exit 2deny

You should see exit 0 for the allowed call and exit 2 for both refusals, each with a signed receipt. The stderr line names the check that stopped the call: guard denied when a guard refused it, capability rejected when the capability scope compiled from tool_access.allow never covered the tool. The allowed case shows the policy still permits legitimate use.

Read the case 2 refusal back from the receipt database. chio receipt list finds it without scrolling, and chio receipt explain prints the decision.

policy-check · explaintranscript
$ DENY=$(chio --receipt-db ./receipts.db receipt list --admin-all \
    | jq -r 'select(.action.parameters.path == "/workspace/.env") | .id')
$ chio --receipt-db ./receipts.db receipt explain "$DENY" --admin-all
receipt: 0b8dd0573f189324215246c2b4509ee6f867724000d795bbc0f66b65cc016c33
schema: chio.receipt.v1
identity: 0b8dd0573f189324215246c2b4509ee6f867724000d795bbc0f66b65cc016c33
decision: deny
reason: guard denied the request: guard "guard-pipeline" denied the request
guard: kernel
policy_hash: 89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da
scope_diff: requested scope vs granted scope is not embedded in this receipt
parents: 0
repair_hint: inspect the guard and policy_hash, then mint or narrow a matching capability
exit 0

The explanation names the kernel scope as guard. The guard that fired, forbidden-path, is named in the receipt's evidence array, shown in full under Receipt from a dry run.

Read more in Policy patterns.

5. Replay fixtures against the compiled WASM module

WASM guards follow the chio guard lifecycle in Custom WASM Guards: new, build, inspect, then test before publish. chio guard test --wasm <path> <fixtures...> --fuel-limit <n> replays YAML fixtures, each against a fresh Wasmtime backend.

./fixtures/tool-denylist.yamlyaml
- name: allows a tool that is not on the denylist
  request:
    tool_name: read_file
    server_id: workspace
    agent_id: fixer
    arguments:
      path: "/workspace/README.md"
  expected_verdict: allow

- name: denies a tool on the denylist
  request:
    tool_name: delete_file
    server_id: workspace
    agent_id: fixer
    arguments:
      path: "/workspace/src/pricing.py"
  expected_verdict: deny
  deny_reason_contains: "denylist"

Run both fixtures against the guard built in Worked Example: Tool Denylist Guard.

wasm-guard · testtranscript
$ chio guard test \
    --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
    ./fixtures/tool-denylist.yaml --fuel-limit 1000000
[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
exit 0in tool-denylist-guard

You should see two passing fixtures. Any failed fixture makes chio guard test exit nonzero, so a CI step fails on an unexpected result. Each backend starts with empty configuration: the configurable word-limit guard denies when max_words is missing, so test that rule in the configured application. The denylist guard carries a built-in default list.

6. Check what the configured application dispatches and signs

Run the complete document application. Its host loads the WASM rule, supplies max_words, issues a capability, and counts tool invocations. Keep the allowed case in every suite: a suite that checks only denials passes for a rule that blocks every request.

guard-policy.json · full application configurationjson
{
  "modules": [
    {
      "name": "word-limit",
      "version": "0.1.0",
      "module_path": "guard/target/wasm32-unknown-unknown/release/word_limit.wasm",
      "capabilities": [
        "chio.get_config"
      ],
      "config": {
        "max_words": "8"
      },
      "fuel_limit": 1000000,
      "priority": 100,
      "advisory": false,
      "allow_unsigned": true
    }
  ]
}
Terminal · in the built guarded-documents projectbash
target/release/guarded-documents sample.md long.md
Captured result · limit 8
Evidence: runs/97fdef5c-339e-48c1-8e23-dd055b0d9ca2
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: d550c867aee3b3fcee2df6a040b014dea5b6f16e90ad724f8e8bde1a1d35cd38 (receipt-1.json)
long.md: DENY guard denied the request: guard "word-limit" denied the request
Guard word-limit: document exceeds max_words
Receipt: 6d641461a606b704a750e891bb4ac0c66b5dbbdb5348a9e9c198bca1f4ab6229 (receipt-2.json)

You should see the six-word document allowed and the eleven-word document denied by word-limit, with one tool invocation and a receipt for each decision. Change config.max_words to "16" in guard-policy.json and rerun the same binary.

Captured application result · limit 16text
Evidence: runs/57200ec8-9870-408a-93b3-c85f87e4269c
sample.md: ALLOW {"bytes":43,"words":6}
Receipt: 07443615d7e44836fbfa8439f7d5fb325d7cd0450606c983112333b4156a6345 (receipt-1.json)
long.md: ALLOW {"bytes":59,"words":11}
Receipt: bb3b2492893f4ca73b30a74496d884c600dc1eafa8592955e760f1e93e4fc7e7 (receipt-2.json)

Both documents now pass. The application evidence walkthrough checks the saved receipt signatures against the configured kernel key and compares the dispatch count, and the signed deployment adds module-supplier verification.

7. Keep effects, receipts, and expected denials in CI

The Build applications carry an HTTP integration suite. It submits requests through the application, then checks filesystem effects and exact receipts: an approved document exists and a rejected one does not, each customer sees only their own evidence, and concurrent workers cannot publish three reports against an allowance that admits two.

.github/workflows/application.yml · after checkout and Rust setupyaml
- name: Build the application
  working-directory: build-apps
  run: cargo build --locked --bins
- name: Check protected effects and evidence
  working-directory: build-apps
  run: python3 application_ci.py

The runner creates private temporary state, stops only the subprocesses it started, and needs ports 4310 to 4312 free. It also exercises approval mutation and replay, cancellation before and during execution, partial work, scoped export, and balance persistence after restart. Read the complete application assertions.

Terminal · in guard-testsbash
cargo test --locked --tests
cargo test --manifest-path sdk-guard/Cargo.toml --locked
Output

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 8 tests
test eleven_words_are_denied ... ok
test six_words_are_allowed ... ok
test changing_the_limit_allows_the_same_document ... ok
test exactly_eight_words_are_allowed ... ok
test unrelated_calls_pass_this_guard_only ... ok
test missing_or_invalid_configuration_denies ... ok
test missing_or_non_string_text_denies ... ok
test whitespace_is_not_a_model_token_count ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 5 tests
test missing_preferred_assurance_warns ... ok
test an_invalid_attestation_does_not_satisfy_a_requirement ... ok
test missing_required_assurance_denies ... ok
test compiling_a_preference_does_not_create_a_required_constraint ... ok
test valid_attested_context_satisfies_both_policies ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 2 tests
test tests::allows_an_unblocked_tool ... ok
test tests::denies_the_dangerous_tool ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

For a policy, a denial is often the expected result, and the suite has to say so. In the pricing project from the Quickstart, keep a case table beside policy.yaml: one legitimate read, the secret the fixer reached for, and the test edit that tests-are-evidence refuses.

policy.test.yaml · in the pricing projectyaml
policy: ./policy.yaml
server: workspace
cases:
  - name: fixer reads the pricing source
    tool: read_file
    params: {path: /workspace/src/pricing.py}
    expect: allow
  - name: fixer reads /secrets/.env
    tool: read_file
    params: {path: /secrets/.env}
    expect: deny
  - name: fixer edits a test
    tool: write_file
    params: {path: /workspace/tests/test_pricing.py}
    expect: deny
    code: tests-are-evidence
Terminal · in the pricing projectbash
chio policy test
Output
policy   ./policy.yaml
PASS  fixer reads the pricing source   allow
PASS  fixer reads /secrets/.env        deny   outside the grant
PASS  fixer edits a test               deny   rule tests-are-evidence
3 cases: 3 passed

Each case runs in a fresh admission state with no network and no tool process, so the table checks the policy and nothing else. The third case names the rule that must decide it, so a deny from some other rule would fail it. As a negative control, change that case to expect: allow and run the table again:

Terminal · third case expects allowbash
chio policy test
Output
policy   ./policy.yaml
PASS  fixer reads the pricing source   allow
PASS  fixer reads /secrets/.env        deny   outside the grant
FAIL  fixer edits a test               expected allow, got deny (rule tests-are-evidence)
3 cases: 2 passed, 1 failed

The command exits 1 and names the rule that decided, so the CI step that runs it fails on a widened or narrowed policy. Restore the expected denial afterward. --junit <FILE> writes the same results for a CI test report, and chio policy test --mutate checks that each rule in the policy is one a case would notice losing.

Testing commands, flags, and fixtures

Test project manifest

The test project is its own Cargo workspace. It takes the Chio crates as git dependencies pinned to one public revision.

guard-tests/Cargo.tomlL1–14toml
[package]
name = "guard-tests"
version = "0.1.0"
edition = "2021"

[workspace]

[dependencies]
chio-core-types = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
chio-kernel-core = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
serde_json = "1"

[dev-dependencies]
chio-policy = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }

Open the complete source file

HushSpec evaluation paths

PathEntry pointReturnsCLI command
compile_policy()
chio_policy::compile_policy(spec)
CompiledPolicy: a compiled guard pipeline plus capability grants
chio check, chio run, chio mcp serve, each through a kernel session
evaluate()
chio_policy::evaluate(spec, action)
EvaluationResult: a tri-state Decision, no side effects
None; a public Rust function

chio_policy::evaluate is an in-memory reference interpreter. It walks a resolved HushSpec 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 without a kernel session or a receipt store. Its decision has three states.

crates/guards/chio-policy/src/evaluate/context.rsrust
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 fires when a preference-level rule is unmet and nothing required it. evaluate_with_context(spec, action, context, conditions) applies conditional activation first. evaluate_audited(spec, action, config) wraps evaluate with optional timing and policy hashing into a DecisionReceipt, still in memory, with no store and no kernel.

Call this API from Rust for fast, side-effect-free answers about what a policy would do, in place of chio check in a loop. Keep those assertions apart from the configured kernel tests: an interpreter result is an in-memory value, and a kernel receipt is persisted and signed. The compiled verdict chio_kernel::Verdict::{Allow, Deny, PendingApproval} has no Warn variant; the closest analog is advisory promotion, which records a non-blocking finding without a third verdict.

Preflight and full mode

Most guards reach a verdict from the request alone, so the default preflight mode invokes no tool. Guards that inspect what the tool returned, such as Response Sanitization, need output. When the compiled policy has a post-invocation pipeline, chio check refuses to run preflight and names the fix. The canonical policy declares rules.secret_patterns, which inspects tool output:

policy-check · preflighttranscript
$ chio --session-db ./admission-0.db --receipt-db ./receipts.db \
  check --policy ./policy.yaml --tool read_file \
  --params '{"path": "/workspace/README.md"}'
error[CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT]: preflight cannot evaluate post-output guards, because it never runs the tool
  help: run the check with --mode full --output-fixture <JSON> so output-sensitive guards see explicit fixture output
  code: urn:chio:error:cli:preflight-needs-output
  docs: https://chio.computer/docs/reference/errors#error-cli-preflight-needs-output
exit 1

The refusal is symmetric, and both halves exit 1. --mode full without a fixture says chio check --mode full requires --output-fixture <JSON>; use --mode preflight for admission-only checks, and --output-fixture in preflight says --output-fixture requires --mode full. The fixture is a JSON file holding the value the tool would have returned, with no default.

chio check flags and output

FlagTypeDefaultNotes
--policy
path
required
HushSpec policy YAML to evaluate against
--mode
preflight | full
preflight
See preflight and full mode
--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 with --mode full

A completed run reports verdict, tool, server, reason (deny only), receipt_id, the compiled policy and source content hashes, then mode and fixture. On a guard deny the reason names the compiled pipeline; the guard that fired is on the receipt, in the evidence array. --json prints the same fields as one object, with the hashes spelled policy_hash and policy_source_hash.

--session-db holds durable admission state and --receipt-db holds receipts. Both are global options that work on either side of the check subcommand. The two paths must differ: pointing both at one file fails with durable admission database must not alias receipt database. Each check receives a new request identity, so one admission database and one receipt database serve every case in a suite.

Exit codes

Exit codeVerdictMeaning
0
Allow
The simulated call was allowed
1
Error
Policy, mode, or setup failed; read the diagnostic
2
Deny
Capability or guard checks denied the simulated call
3
Pending approval
The call needs a human decision first

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

Path allowlists need session roots

PathAllowlistGuard gates on the session's enforceable filesystem roots before it consults its own allowlist, and a root set that is present but empty matches nothing. A chio check session declares no roots, so every path-bearing call denies, including the read the canonical policy permits:

policy-check · allowlist-denytranscript
$ chio --session-db ./admission-4.db --receipt-db ./receipts.db \
  check --policy ./policy.yaml --tool read_file \
  --params '{"path": "/workspace/README.md"}' \
  --mode full --output-fixture ./output-fixture.json
verdict:    DENY
tool:       read_file
server:     *
reason:     guard denied the request: guard "guard-pipeline" denied the request
receipt_id: fbe57d052bcb6ad1b79b0f8ff2d5613b4203c9f8cc31fe4ca75b8443b82075dc
policy:     89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da
source:     11e1aa04a3696c42034ae77afb4f9aee4e5c16c4e38a58f2a21fc2fa137779d8
mode:       full
fixture:    true
WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
exit 2deny

Test rules.path_allowlist against a session with enforceable roots, as described in the session-root requirements. A direct guard test supplies the context that guard expects. Workload identity, runtime attestation, budgets, approvals, and tool output can also differ between a dry run and your deployed session.

Receipt from a dry run

chio receipt list prints the whole record, one JSON receipt per line. The deny receipt for case 2 carries the refused parameters, their hash, the policy hash, an evidence row naming the guard, and an Ed25519 signature over the canonical body. The del(.metadata) filter drops four metadata keys: attribution, budget authority, the receipt signing nonce, and receipt_context.request_id.

policy-check · receipttranscript
$ chio --receipt-db ./receipts.db receipt list --admin-all \
    | jq 'select(.action.parameters.path == "/workspace/.env") | del(.metadata)'
{
  "id": "0b8dd0573f189324215246c2b4509ee6f867724000d795bbc0f66b65cc016c33",
  "timestamp": 1788551990,
  "capability_id": "cap-01a06e01-8ae8-7fd0-a7b0-b3593f7a666f",
  "tool_server": "*",
  "tool_name": "read_file",
  "action": {
    "parameters": {
      "path": "/workspace/.env"
    },
    "parameter_hash": "1708328df9dddb4147395f5741ee7df3cf5dc4922554c08feb6a73bc0d82f245"
  },
  "decision": {
    "verdict": "deny",
    "reason": "guard denied the request: guard \"guard-pipeline\" denied the request",
    "guard": "kernel"
  },
  "receipt_kind": "mediated_decision",
  "boundary_class": "prevent",
  "tool_origin": "caller_executed",
  "redaction_mode": "none",
  "content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
  "policy_hash": "89e8cd101e3fb0c311cceb9bd7b988a6a65d371310b4fccceb08a015333885da",
  "evidence": [
    {
      "guard_name": "forbidden-path",
      "verdict": false,
      "details": "action=deny; reason=guard denied request"
    }
  ],
  "trust_level": "mediated",
  "kernel_key": "722344ad01d71706e495a5049569bf3a5dbc419f5a95920379829a206f762553",
  "signature": "4e1d438f45b249195039733149f1e9b027dc7e5fd5a98d275b424f585c147832bdf98d0d01c357a947ab600ecf1ec0d95ced5a4e1ccb5052e2ae365d5e150f00"
}
exit 0deny

evidence[0].guard_name is the only field that names forbidden-path, so a test that asserts on the reason string alone cannot tell which guard fired. decision.guard carries the fixed kernel-scope literal kernel on this path.

Rerunning the cases reproduces every line except receipt_id, capability_id, timestamp, the keys, and the signature. The receipt id hashes a body with a fresh timestamp and signing nonce. The policy, source, and parameter_hash hashes are functions of the policy file and the arguments, so they are the assertions a test should make.

WASM fixture fields

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

FieldTypeNotes
name
string
Printed in the pass and 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 guard's reason must contain this substring

Policy case fields

chio policy test reads policy.test.yaml in the current directory unless you name case files. The top level sets policy, relative to the case file, and a default server. Exit 0 when every case matches, 1 when any differs, 2 when the file is invalid.

FieldTypeNotes
name
string
Printed on the PASS or FAIL line
server
string, optional
Overrides the file's default server for this case
tool, params
string, map
The call the case submits
output
map, optional
The tool result output guards see, as --output-fixture supplies for chio check
expect
allow | deny | pending | redact
The decision the case requires
code
string, optional
The rule, guard, or error code that must decide the case

Failing fixtures and benchmarks

A failure line carries the guard's own refusal. A fixture that expects allow for a denylisted tool gets the deny reason back verbatim, which makes the suite a regression test on wording as well as verdict:

./fixtures/wrong.yamlyaml
- name: wipe_database is still allowed
  request:
    tool_name: wipe_database
    server_id: orders
    agent_id: fixer
    arguments: {}
  expected_verdict: allow
wasm-guard · test-refusestranscript
$ chio guard test \
    --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
    ./fixtures/wrong.yaml --fuel-limit 1000000
[FAIL] wipe_database is still allowed: expected allow, got deny: tool 'wipe_database' is on the denylist

0 passed, 1 failed out of 1 total
error[CHIO-KERNEL-GUARD-DENIED]: 1 guard test case failed
  help: each [FAIL] line above names the case and the verdict it expected
  code: urn:chio:error:guard:denied
  docs: https://chio.computer/docs/reference/errors#error-guard-denied
exit 1in tool-denylist-guard

A fixture that never reaches a verdict prints evaluation error in place of a comparison, because the runner reports the backend error. Fuel exhaustion arrives that way, and a low enough --fuel-limit stops the guard inside its own request deserialization, before any policy logic runs. The fuel a guard spends is shown on Custom WASM Guards.

chio guard bench <path> --iterations <n> --fuel-limit <n> tracks latency and fuel regressions: five warmup iterations against a fixed bench_tool request, then the requested count, reporting p50, p99, min, max, and mean for latency and fuel. Every iteration gets its own backend, and the latency timer starts after module loading, so the timed interval covers evaluate on that fixed request with empty configuration. Size the configured document path and a worst-case fuel limit separately.

SDK decision helper

#[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, evaluate in an in-crate #[test] refers to the raw-pointer FFI entry point. Put the decision logic in a plain helper that the annotated function calls, and unit-test the helper without Wasmtime, fuel metering, or a linear-memory round trip. The archive includes this example in sdk-guard:

guard-tests/sdk-guard/Cargo.tomlL1–14toml
[package]
name = "guard-decision-helper"
version = "0.1.0"
edition = "2021"

[workspace]

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
chio-guard-sdk = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
chio-guard-sdk-macros = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
serde_json = "1"

Open the complete source file

guard-tests/sdk-guard/src/lib.rsL1–45rust
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 = serde_json::from_value(serde_json::json!({
            "tool_name": "dangerous_tool",
            "server_id": "srv",
            "agent_id": "agent-1",
            "arguments": {}
        }))
        .unwrap();
        assert!(matches!(decide(&req), GuardVerdict::Deny { .. }));
    }

    #[test]
    fn allows_an_unblocked_tool() {
        let req: GuardRequest = serde_json::from_value(serde_json::json!({
            "tool_name": "count_words",
            "server_id": "documents",
            "agent_id": "agent-1",
            "arguments": {"text": "one two"}
        }))
        .unwrap();
        assert!(matches!(decide(&req), GuardVerdict::Allow));
    }
}

Open the complete source file

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

WASM fixtures in CI

In the built tool-denylist project, name the passing fixture explicitly. A glob would also pick up wrong.yaml, which exists to demonstrate a failing expectation.

Terminal · in tool-denylist-guard, module and fixture builtbash
chio guard test \
  --wasm target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
  ./fixtures/tool-denylist.yaml --fuel-limit 1000000

Next: write the policy you tested