Custom Guards
The built-in guards cover common security policies: forbidden-path, path-allowlist, shell-command, egress-allowlist, mcp-tool, secret-leak, patch-integrity, and velocity. When your domain requires enforcement logic that does not exist yet, implement the Guard trait and add it to the pipeline.
The Guard Trait
Every guard implements a two-method trait defined in chio_kernel. The evaluate method has signature fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError>:
pub trait Guard: Send + Sync {
/// Human-readable guard name (e.g., "ip-allowlist").
fn name(&self) -> &str;
/// Evaluate the guard against a tool call request.
///
/// Returns Ok(Verdict::Allow) to pass, Ok(Verdict::Deny) to block,
/// or Err on internal failure (which the kernel treats as deny).
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError>;
}The trait requires Send + Sync because guards are shared across threads in the kernel runtime.
GuardContext
The kernel passes a GuardContext to every guard evaluation. It contains everything the guard needs to make a decision:
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 available.
pub session_filesystem_roots: Option<&'a [String]>,
/// Index of the matched grant in the capability's scope.
pub matched_grant_index: Option<usize>,
}Through ctx.request you have access to the full ToolCallRequest, which includes the tool name, server ID, agent ID, the serde_json::Value arguments, and the signed capability token. This means guards can inspect not just the action being performed, but also who is performing it and under what authority.
Verdict: Pass or Deny
The Verdict enum has exactly two variants: a pass verdict and a deny verdict that carries the denial reason and the guard that produced it:
pub enum Verdict {
/// The action passes this guard.
Pass,
/// The action is denied, with reason and the guard name.
Deny { reason: String, guard: String },
}There is no Skip variant
Ok(Verdict::Pass). A guard that is not applicable must not block the request. The pipeline uses conjunctive (AND) logic: every guard must pass for the request to proceed.Error Handling: Errors Mean Deny
The evaluate method returns Result<Verdict, KernelError>. If a guard returns Err, the pipeline treats it as a denial. This is the fail-closed guarantee.
// In the GuardPipeline implementation:
match guard.evaluate(ctx) {
Ok(Verdict::Pass) => continue,
Ok(Verdict::Deny { reason, guard: name }) => {
return Err(KernelError::GuardDenied(format!(
"guard \"{name}\" denied: {reason}"
)));
}
Err(e) => {
// Fail closed: guard errors are treated as denials.
return Err(KernelError::GuardDenied(format!(
"guard \"{}\" error (fail-closed): {e}",
guard.name()
)));
}
}This means you should only return Err for genuine internal failures (configuration errors, I/O problems, etc.). If the guard can evaluate the request but the request violates the policy, return Ok(Verdict::Deny { reason, guard }).
Example: IP Allowlist Guard
A guard that restricts tool calls to requests from allowed IP addresses, assuming the agent ID encodes origin information:
use std::collections::HashSet;
use std::net::IpAddr;
use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
pub struct IpAllowlistGuard {
allowed_ips: HashSet<IpAddr>,
}
impl IpAllowlistGuard {
pub fn new(allowed_ips: Vec<IpAddr>) -> Self {
Self {
allowed_ips: allowed_ips.into_iter().collect(),
}
}
}
impl Guard for IpAllowlistGuard {
fn name(&self) -> &str {
"ip-allowlist"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
// Extract the source IP from request metadata.
// If not present, this guard is not applicable: pass.
let Some(ip_str) = ctx.request.arguments.get("_source_ip")
.and_then(|v| v.as_str())
else {
return Ok(Verdict::Pass);
};
let ip: IpAddr = ip_str.parse().map_err(|e| {
KernelError::Internal(format!("invalid source IP: {e}"))
})?;
if self.allowed_ips.contains(&ip) {
Ok(Verdict::Pass)
} else {
Ok(Verdict::Deny {
reason: format!("source IP {ip} not on allowlist"),
guard: self.name().to_string(),
})
}
}
}Example: Time-of-Day Restriction Guard
A guard that only allows tool invocations during business hours. This demonstrates how guards can enforce temporal policies:
use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
use chrono::{Timelike, Utc};
pub struct BusinessHoursGuard {
start_hour: u32, // inclusive, UTC
end_hour: u32, // exclusive, UTC
}
impl BusinessHoursGuard {
/// Create a guard that allows calls only between start_hour
/// and end_hour (UTC, 24-hour format).
pub fn new(start_hour: u32, end_hour: u32) -> Self {
Self { start_hour, end_hour }
}
}
impl Guard for BusinessHoursGuard {
fn name(&self) -> &str {
"business-hours"
}
fn evaluate(&self, _ctx: &GuardContext) -> Result<Verdict, KernelError> {
let current_hour = Utc::now().hour();
if current_hour >= self.start_hour && current_hour < self.end_hour {
Ok(Verdict::Pass)
} else {
Ok(Verdict::Deny {
reason: format!(
"current hour {current_hour} outside [{}, {})",
self.start_hour, self.end_hour
),
guard: self.name().to_string(),
})
}
}
}Keep guards deterministic when possible
now parameter so you can freeze time in tests.Registering Guards in the Pipeline
The GuardPipeline from chio_guards runs guards in registration order. Add your custom guard alongside the built-in ones:
use chio_guards::GuardPipeline;
let mut pipeline = GuardPipeline::new();
// Built-in guards first (cheapest to most expensive)
pipeline.add(Box::new(chio_guards::ForbiddenPathGuard::new()));
pipeline.add(Box::new(chio_guards::ShellCommandGuard::new()));
pipeline.add(Box::new(chio_guards::EgressAllowlistGuard::new()));
pipeline.add(Box::new(chio_guards::PathAllowlistGuard::new()));
pipeline.add(Box::new(chio_guards::McpToolGuard::new()));
pipeline.add(Box::new(chio_guards::SecretLeakGuard::new()));
pipeline.add(Box::new(chio_guards::PatchIntegrityGuard::new()));
// Custom guards
pipeline.add(Box::new(BusinessHoursGuard::new(9, 17)));
pipeline.add(Box::new(IpAllowlistGuard::new(vec![
"10.0.0.1".parse().unwrap(),
"10.0.0.2".parse().unwrap(),
])));
// Register the pipeline as a single guard on the kernel
kernel.add_guard(Box::new(pipeline));Alternatively, start from the default pipeline and append:
let mut pipeline = GuardPipeline::default_pipeline();
pipeline.add(Box::new(BusinessHoursGuard::new(9, 17)));
kernel.add_guard(Box::new(pipeline));default_pipeline() returns seven stateless guards plus velocity
GuardPipeline::default_pipeline() registers the seven stateless guards: ForbiddenPathGuard, PathAllowlistGuard, ShellCommandGuard, EgressAllowlistGuard, McpToolGuard, SecretLeakGuard, PatchIntegrityGuard, plus VelocityGuard, with their default configurations.Testing Guards in Isolation
Guards are pure functions of GuardContext: they have no kernel dependency in tests. Construct a context manually and assert on the verdict:
#[cfg(test)]
mod tests {
use super::*;
use chio_core::crypto::Keypair;
use chio_core::capability::{ChioScope, CapabilityTokenBody, CapabilityToken};
use chio_kernel::{GuardContext, ToolCallRequest};
fn make_context(arguments: serde_json::Value) -> (
ToolCallRequest,
ChioScope,
String,
String,
) {
let kp = Keypair::generate();
let scope = ChioScope::default();
let agent_id = kp.public_key().to_hex();
let server_id = "srv-test".to_string();
let cap_body = CapabilityTokenBody {
id: "cap-test".to_string(),
issuer: kp.public_key(),
subject: kp.public_key(),
scope: scope.clone(),
issued_at: 0,
expires_at: u64::MAX,
delegation_chain: vec![],
};
let cap = CapabilityToken::sign(cap_body, &kp)
.expect("sign cap");
let request = ToolCallRequest {
request_id: "req-test".to_string(),
capability: cap,
tool_name: "read_file".to_string(),
server_id: server_id.clone(),
agent_id: agent_id.clone(),
arguments,
dpop_proof: None,
governed_intent: None,
approval_token: None,
};
(request, scope, agent_id, server_id)
}
#[test]
fn allows_listed_ip() {
let guard = IpAllowlistGuard::new(vec![
"10.0.0.1".parse().unwrap(),
]);
let (request, scope, agent_id, server_id) = make_context(
serde_json::json!({
"path": "/app/src/main.rs",
"_source_ip": "10.0.0.1"
}),
);
let ctx = GuardContext {
request: &request,
scope: &scope,
agent_id: &agent_id,
server_id: &server_id,
session_filesystem_roots: None,
matched_grant_index: None,
};
assert!(matches!(guard.evaluate(&ctx), Ok(Verdict::Pass)));
}
#[test]
fn denies_unlisted_ip() {
let guard = IpAllowlistGuard::new(vec![
"10.0.0.1".parse().unwrap(),
]);
let (request, scope, agent_id, server_id) = make_context(
serde_json::json!({
"path": "/app/src/main.rs",
"_source_ip": "192.168.1.100"
}),
);
let ctx = GuardContext {
request: &request,
scope: &scope,
agent_id: &agent_id,
server_id: &server_id,
session_filesystem_roots: None,
matched_grant_index: None,
};
assert!(matches!(guard.evaluate(&ctx), Ok(Verdict::Deny { .. })));
}
}Performance Considerations
Guards are evaluated synchronously on the hot path of every tool call. The pipeline short-circuits on the first deny, so ordering matters for performance (though not for correctness).
- Put cheap guards first. Simple string comparisons and set lookups should run before guards that parse arguments, compile patterns, or perform I/O.
- Avoid blocking I/O in evaluate(). If your guard needs external data (e.g., a remote allowlist), load it at construction time and cache it. The
evaluatecall should be a pure, fast check against that cached data. - Pre-compile patterns. If your guard uses regex or glob matching, compile the patterns in
new(), not inevaluate(). - Keep allocations minimal. The guard context is borrowed. Avoid cloning request data unless absolutely necessary.
Built-in guard order
ForbiddenPathGuard (simple glob match) runs first, PatchIntegrityGuard (content analysis) runs last. Place your custom guards at an appropriate position based on their computational cost.Summary
| Concept | Rule |
|---|---|
| Guard trait | name() + evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> |
| Verdict | Two variants: Pass or Deny { reason, guard }. No skip. |
| Non-applicable guards | Return Ok(Verdict::Pass) |
| Errors | Treated as deny (fail-closed) |
| Pipeline | Conjunctive. All guards must pass. |
| Short-circuit | First deny stops evaluation |
| Registration | pipeline.add(Box::new(my_guard)) |
Next Steps
- Agent Passport · portable agent credentials for cross-organizational trust
- Architecture · how guards fit into the kernel evaluation pipeline
- Native Tool Server · build a tool server that your guards will protect