Chio/Docs

The Guard Trait

Every guard the kernel runs implements one trait with two methods: a name and an evaluate. The trait lives in the portable core so the same guard implementation works in the desktop sidecar, the browser kernel, and the mobile FFI without modification.


Signature

The trait is defined in chio-kernel-core and re-exported by chio-kernel. Source:

crates/chio-kernel-core/src/guard.rs
pub trait Guard: Send + Sync {
    /// Human-readable guard name (e.g. `forbidden-path`).
    fn name(&self) -> &str;

    /// Evaluate this guard against a tool-call context.
    ///
    /// Returns `Ok(Verdict::Allow)` to pass, `Ok(Verdict::Deny)` to block,
    /// or `Err(KernelCoreError)` to signal an internal guard failure (which
    /// the kernel core treats as a fail-closed deny).
    fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<Verdict, crate::KernelCoreError>;
}

The bound Send + Sync is not optional. Guards are stored as Box<dyn Guard> and evaluated on whichever thread the kernel happens to be running on.

name()

A short kebab-case identifier. The pipeline uses this to label evidence on receipts and to format denial messages. Use stable names; operators reference them in policy YAML and in promotion rules.

evaluate()

The hot-path method. Three things to keep in mind:

  • No I/O. The trait is synchronous. If you need to call an external service, use the ExternalGuard + AsyncGuardAdapter machinery (see External Adapters).
  • Pure on the context. The method takes &self and a borrowed GuardContext. Mutable state should be wired through interior mutability with care, since guards may run concurrently for different requests.
  • Errors mean deny. Returning an Err is equivalent to returning Verdict::Deny in the pipeline. See Fail-Closed Semantics.

GuardContext

The context the kernel passes to every guard. Borrowed for the lifetime of the evaluation; guards must not store references past return.

crates/chio-kernel-core/src/guard.rs
pub struct GuardContext<'a> {
    /// The tool call request being evaluated.
    pub request: &'a PortableToolCallRequest,
    /// The verified capability scope.
    pub scope: &'a ChioScope,
    /// The agent making the request.
    pub agent_id: &'a str,
    /// The target server.
    pub server_id: &'a str,
    /// 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
    /// `evaluate` before guards run.
    pub matched_grant_index: Option<usize>,
}
FieldPurpose
requestThe portable projection of the tool call. See PortableToolCallRequest below.
scopeThe ChioScope from the capability token. Already verified by the time the guard runs.
agent_idHex-encoded public key of the calling agent.
server_idIdentifier of the target tool server.
session_filesystem_rootsWhen the call runs through a session-backed runtime, this carries the enforceable filesystem roots for that session. None on edges that do not maintain a session.
matched_grant_indexIndex into the scope's grant list of the grant that matched this request. The core evaluate function populates this before guards run.

The full kernel adds more

chio-kernel keeps a distinct GuardContext with the same field names, plus the legacy adapter that builds a temporary PortableToolCallRequest when invoking the portable core. Guards that want DPoP, governed intent, or approval-token inputs read those off the full kernel's ToolCallRequest directly.

PortableToolCallRequest

The cross-platform projection of a tool call. Owns only the fields the sync core evaluate pipeline reads. Guards that need DPoP, governed intent, or approval tokens stay in the full kernel and use its ToolCallRequest directly.

crates/chio-kernel-core/src/guard.rs
#[derive(Debug, Clone)]
pub struct PortableToolCallRequest {
    /// Unique request identifier.
    pub request_id: String,
    /// The tool to invoke.
    pub tool_name: String,
    /// The server hosting the tool.
    pub server_id: String,
    /// The calling agent's identifier (hex-encoded public key).
    pub agent_id: String,
    /// Tool arguments as canonical JSON.
    pub arguments: serde_json::Value,
}

The full-kernel ToolCallRequest adds a signed capability token, optional DPoP proof, governed-intent and approval-token fields, model metadata, and federation routing. For portable evaluation those concerns are out of scope.


Verdict

The three-valued outcome. Defined identically in the portable core and the full kernel, with one nuance: only the full kernel produces PendingApproval.

crates/chio-kernel-core/src/lib.rs:99
/// Three-valued outcome of a kernel evaluation step.
///
/// The kernel core never emits `PendingApproval` itself; the full
/// `chio-kernel` orchestration shell wraps the core verdict with the
/// human-in-the-loop approval path where needed.
#[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. Only produced by
    /// the full `chio-kernel` shell, never by `chio-kernel-core` directly.
    PendingApproval,
}

And the full-kernel mirror with the matching Copy + Clone semantics:

crates/chio-kernel/src/runtime.rs:29
/// Verdict of a guard or capability evaluation.
///
/// Phase 3.4 introduced the `PendingApproval` variant. The 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.
#[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,
}

Properties to remember:

  • No payload. The verdict carries no reason, no guard name, no metadata. Denial reasons are formatted by the pipeline into KernelError::GuardDenied and recorded as evidence.
  • Copy + Clone. Existing call sites pattern-match on the enum without ceremony. Adding a payload would have broken those sites.
  • PendingApproval is shell-only. The portable core never produces it. Custom guards running through the portable surface should not return it either; they have no approval store to pair the marker with.

Returning Allow when not applicable

If your guard does not apply to the request (for example a network guard looking at a filesystem call), return Ok(Verdict::Allow). The pipeline uses conjunctive (AND) logic: every guard must allow for the call to proceed. There is no separate not applicable verdict.

Lifecycle

The full-kernel evaluation runs in three phases. A guard can participate in any of them, but each phase has its own trait.

PhaseTraitWhen
Pre-invocationGuardBefore the tool runs. Decides Allow / Deny / PendingApproval.
AdvisoryAdvisoryGuardAlongside pre-invocation. Emits AdvisorySignals without blocking, unless a promotion rule converts the signal into a denial.
Post-invocationPostInvocationHookAfter the tool returns. Can Allow, Redact(value), Block, or Escalate.

See Pipelines & Composition for the full picture.


Evidence

Guards record findings on the receipt through structured evidence records. The post-invocation sanitizer is the canonical example: when it redacts a tool response it stores a GuardEvidence summarizing the findings (counts and detector IDs, never raw secrets). The sync pre-invocation guards rely on the pipeline to annotate the receipt with their name and verdict; richer evidence is the post-invocation and external-adapter pattern.


A Minimal Guard

The smallest non-trivial guard: deny when the tool name appears on a deny list. No I/O, no allocation on the hot path beyond what the deny check needs.

rust
use std::collections::HashSet;

use chio_kernel::{Guard, GuardContext, KernelError, Verdict};

pub struct DenyByName {
    denied: HashSet<String>,
}

impl DenyByName {
    pub fn new(denied: impl IntoIterator<Item = String>) -> Self {
        Self { denied: denied.into_iter().collect() }
    }
}

impl Guard for DenyByName {
    fn name(&self) -> &str {
        "deny-by-name"
    }

    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
        if self.denied.contains(&ctx.request.tool_name) {
            Ok(Verdict::Deny)
        } else {
            Ok(Verdict::Allow)
        }
    }
}

Register it on the pipeline through pipeline.add(Box::new(DenyByName::new(...))). For more involved patterns see Custom Guards.


Where to Go Next