Chio/Docs

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>:

rust
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:

rust
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:

rust
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

If your guard does not apply to a given request (e.g., a network guard evaluating a filesystem operation), return 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.

rust
// 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:

src/guards/ip_allowlist.rs
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:

src/guards/business_hours.rs
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

The business-hours guard depends on the system clock, which makes it non-deterministic. This is fine for production enforcement, but for unit testing, consider accepting a clock trait or a 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:

rendering…
Conjunctive pipeline: every guard must Pass. First Deny (or Err) short-circuits.
rust
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:

rust
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:

rust
#[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 evaluate call 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 in evaluate().
  • Keep allocations minimal. The guard context is borrowed. Avoid cloning request data unless absolutely necessary.

Built-in guard order

The default pipeline orders guards from cheapest to most expensive: 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

ConceptRule
Guard traitname() + evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError>
VerdictTwo variants: Pass or Deny { reason, guard }. No skip.
Non-applicable guardsReturn Ok(Verdict::Pass)
ErrorsTreated as deny (fail-closed)
PipelineConjunctive. All guards must pass.
Short-circuitFirst deny stops evaluation
Registrationpipeline.add(Box::new(my_guard))

Next Steps