PlatformFoundations
The Guard Trait
The synchronous Guard trait, its context and decision types, and the narrower portable-core variant.
Signature
The trait is defined in chio-kernel and implemented by every guard in the catalog. Source:
pub trait Guard: Send + Sync {
/// Human-readable guard name (e.g. "forbidden-path").
fn name(&self) -> &str;
/// Evaluate the guard against a tool-call context.
///
/// Returns `Ok(GuardDecision)` - a verdict plus any evidence the guard
/// recorded - or `Err(KernelError)` to signal an internal guard failure
/// (which the kernel treats as a fail-closed deny).
fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<GuardDecision, KernelError>;
}A guard hands back a GuardDecision: the verdict plus the evidence it chose to record. The constructors cover the common cases without building the struct by hand.
pub struct GuardDecision {
pub verdict: Verdict,
pub evidence: Vec<GuardEvidence>,
}
impl GuardDecision {
pub fn allow() -> Self {
Self { verdict: Verdict::Allow, evidence: Vec::new() }
}
pub fn allow_with_evidence(evidence: Vec<GuardEvidence>) -> Self {
Self { verdict: Verdict::Allow, evidence }
}
pub fn deny(evidence: Vec<GuardEvidence>) -> Self {
Self { verdict: Verdict::Deny, evidence }
}
pub fn pending_approval(evidence: Vec<GuardEvidence>) -> Self {
Self { verdict: Verdict::PendingApproval, evidence }
}
}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+AsyncGuardAdapteradapter (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 anOk(GuardDecision::deny(...))in the pipeline. See Fail-Closed Semantics.
GuardContext
The context the kernel passes to guards. It is 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 ToolCallRequest,
/// The verified capability scope.
pub scope: &'a ChioScope,
/// The agent making the request.
pub agent_id: &'a AgentId,
/// The target server.
pub server_id: &'a ServerId,
/// 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
/// check_and_increment_budget before guards run.
pub matched_grant_index: Option<usize>,
}| Field | Purpose |
|---|---|
request | The chio-kernel ToolCallRequest: signed capability, tool name, arguments, and, when present, DPoP proof, governed intent, approval token, and model metadata. |
scope | The ChioScope from the capability token. Already verified by the time the guard runs. |
agent_id | Typed AgentId of the calling agent (wraps its public key). |
server_id | Typed ServerId 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. Populated before guards run, during the budget check-and-increment step. |
Portable-core variant
chio-kernel-core defines a second, narrower Guard trait. It is not the trait the catalog implements; it exists so the one guard that has to run byte-for-byte on wasm32-unknown-unknown, wasm32-wasip1, and the mobile FFI, the session-filesystem-roots check, can compile without thechio-kernel package. It returns a bare Verdict and a KernelCoreError, and reads a PortableToolCallRequest rather than the chio-kernel ToolCallRequest.
pub trait Guard: Send + Sync {
fn name(&self) -> &str;
/// Returns `Ok(Verdict::Allow)` to pass, `Ok(Verdict::Deny)` to block,
/// or `Err(KernelCoreError)` - treated as a fail-closed deny.
fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<Verdict, KernelCoreError>;
}
pub struct GuardContext<'a> {
pub request: &'a PortableToolCallRequest,
pub scope: &'a ChioScope,
pub agent_id: &'a str,
pub server_id: &'a str,
pub session_filesystem_roots: Option<&'a [String]>,
pub matched_grant_index: Option<usize>,
}The request projection carries only what the sync core evaluate pipeline reads. There is no DPoP proof, governed intent, approval token, or model metadata: the chio-kernel package handles those values. When it runs the core pipeline, it builds a temporary PortableToolCallRequest from its own ToolCallRequest.
#[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,
}Selecting a guard trait
chio-kernel chio_kernel::Guard. Reach for the portable-core trait only when a guard must run inside the browser or mobile kernel, where GuardDecision, the receipt store, and the approval shell are not present.Verdict
The three-valued outcome. Defined identically in the portable core and chio-kernel, with one nuance: only that package 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,
}The chio-kernel mirror has 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-core API should not return it either; it has no approval store to pair the marker with.
Returning Allow when not applicable
Ok(GuardDecision::allow()). The pipeline uses conjunctive (AND) logic: every guard must allow for the call to proceed. There is no separate not applicable verdict.Classifying the Request: ToolAction
A guard rarely reads raw tool names and argument JSON. It classifies the request into a ToolAction first, then pattern-matches on that. The enum is the categorized view of a request, derived by inspecting the tool name and arguments.
pub enum ToolAction {
FileAccess(String),
FileWrite(String, Vec<u8>),
NetworkEgress(String, u16),
ShellCommand(String),
McpTool(String, Value),
Patch(String, String),
CodeExecution { language: String, code: String },
BrowserAction { verb: String, target: Option<String> },
DatabaseQuery { database: String, query: String },
ExternalApiCall { service: String, endpoint: String },
MemoryWrite { store: String, key: String },
MemoryRead { store: String, key: Option<String> },
Unknown,
}Two extraction helpers turn a (tool_name, arguments) pair into a ToolAction:
extract_actionis best-effort: recognized-but-malformed shapes fall back toToolAction::Unknown.extract_action_checkedreturns aMalformedActionerror on a type mismatch. Prefer it at guard boundaries and deny on the error: a malformed action is exactly the input a guard should not wave through.
A guard that does not recognize the action returns Ok(GuardDecision::allow()): ToolAction::Unknown, and any variant the guard does not handle, is not its concern.
Lifecycle
chio-kernel evaluates guards 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 details.
Evidence
Every guard records findings on the receipt through structured GuardEvidence records. A pre-invocation guard attaches them directly on the decision it returns, GuardDecision::deny(evidence) or GuardDecision::allow_with_evidence(evidence), so per-guard evidence is available at the pre-invocation layer, not only from post-invocation hooks or external adapters.
pub struct GuardEvidence {
/// Name of the guard.
pub guard_name: String,
/// Whether the guard passed (true) or denied (false).
pub verdict: bool,
/// Optional details about the guard's decision.
pub details: Option<String>,
}The pipeline also folds in evidence the guard did not attach itself: on a deny it appends a GuardEvidence row naming the guard and the reason. The post-invocation sanitizer is the richest example: when it redacts a response it records counts and detector IDs, never raw secrets.
A Minimal Guard
This guard denies when the tool name appears on a deny list. It has no I/O and allocates only what the deny check needs.
use std::collections::HashSet;
use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
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<GuardDecision, KernelError> {
if self.denied.contains(&ctx.request.tool_name) {
Ok(GuardDecision::deny(vec![]))
} else {
Ok(GuardDecision::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