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:
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+AsyncGuardAdaptermachinery (see External Adapters). - Pure on the context. The method takes
&selfand a borrowedGuardContext. Mutable state should be wired through interior mutability with care, since guards may run concurrently for different requests. - Errors mean deny. Returning an
Erris equivalent to returningVerdict::Denyin 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.
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>,
}| Field | Purpose |
|---|---|
request | The portable projection of the tool call. See PortableToolCallRequest below. |
scope | The ChioScope from the capability token. Already verified by the time the guard runs. |
agent_id | Hex-encoded public key of the calling agent. |
server_id | Identifier of the target tool server. |
session_filesystem_roots | When 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_index | Index 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.
#[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.
/// 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:
/// 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::GuardDeniedand 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
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.
| Phase | Trait | When |
|---|---|---|
| Pre-invocation | Guard | Before the tool runs. Decides Allow / Deny / PendingApproval. |
| Advisory | AdvisoryGuard | Alongside pre-invocation. Emits AdvisorySignals without blocking, unless a promotion rule converts the signal into a denial. |
| Post-invocation | PostInvocationHook | After 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.
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
- Pipelines & Composition · how guards combine into a fail-closed sequence
- Fail-Closed Semantics · what happens when
evaluatereturns an error - Default Pipeline · the guards the kernel ships with
- Custom Guards · author your own