Pipelines & Composition
Chio runs guards through three pipeline shapes plus one adapter wrapper for external guards. Each shape has its own combination rules, but they share one invariant: when something goes wrong, the request stops.
GuardPipeline (synchronous, fail-closed)
The primary integration point. Sequential, conjunctive, short-circuit on deny. Defined in crates/chio-guards/src/pipeline.rs.
impl Guard for GuardPipeline {
fn name(&self) -> &str {
"guard-pipeline"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
let mut final_verdict = Verdict::Allow;
for guard in &self.guards {
match guard.evaluate(ctx) {
Ok(Verdict::Allow) => continue,
Ok(Verdict::PendingApproval) => {
// Phase 3.4 introduced `PendingApproval` as a sticky
// escalation state. Keep iterating so another guard can
// still short-circuit to Deny, but propagate the pending
// verdict up the stack if no deny occurs.
final_verdict = Verdict::PendingApproval;
}
Ok(Verdict::Deny) => {
return Err(KernelError::GuardDenied(format!(
"guard \"{}\" denied the request",
guard.name()
)));
}
Err(e) => {
// Fail closed: guard errors are treated as denials.
return Err(KernelError::GuardDenied(format!(
"guard \"{}\" error (fail-closed): {e}",
guard.name()
)));
}
}
}
Ok(final_verdict)
}
}Combination Rules
| Per-guard outcome | Pipeline behavior |
|---|---|
Ok(Allow) | Continue to the next guard. |
Ok(PendingApproval) | Sticky: remember it as the running verdict, keep iterating so a later guard can still short-circuit to Deny. |
Ok(Deny) | Stop. Return KernelError::GuardDenied with the guard name. |
Err(_) | Stop. Return KernelError::GuardDenied with error (fail-closed) in the message. |
PendingApproval is sticky, not short-circuit
PendingApproval still run. That is deliberate: an approval-waiting verdict should not mask a downstream guard that would otherwise have denied the call. The final pipeline verdict is PendingApproval only when every later guard returned Allow.Ordering and Cost
The pipeline runs guards in registration order. Ordering does not change correctness (conjunctive), but it changes cost on the hot path. Place cheap stateless guards first; place stateful or regex-heavy guards last. The default pipeline orders guards by cost. See Default Pipeline.
AdvisoryPipeline
Non-blocking signals. Defined in crates/chio-guards/src/advisory.rs. Wraps multiple AdvisoryGuard implementations and a PromotionPolicy. Implements the standard Guard trait so it slots into a regular GuardPipeline like any other guard.
pub trait AdvisoryGuard: Send + Sync {
fn name(&self) -> &str;
fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
}
pub struct AdvisorySignal {
pub guard_name: String,
pub description: String,
pub severity: AdvisorySeverity,
pub metadata: Option<serde_json::Value>,
pub promoted: bool,
}
pub enum AdvisorySeverity { Info, Low, Medium, High, Critical }
pub struct PromotionRule {
pub guard_name: String,
pub min_severity: AdvisorySeverity,
}
pub struct PromotionPolicy {
pub rules: Vec<PromotionRule>,
}Behavior
- Each
AdvisoryGuardreturns zero or moreAdvisorySignalvalues per call. - The pipeline accumulates every signal regardless of severity. Signals are stored on the pipeline for evidence export via
last_signals()andlast_outputs(). - Without a promotion rule, the pipeline always returns
Verdict::Allow. - With a matching
PromotionRule, a signal at or abovemin_severityis markedpromoted = trueand the pipeline returnsVerdict::Deny.
Severity ordering is Info < Low < Medium < High < Critical.
Promotion Example
let mut policy = PromotionPolicy::new();
policy.add_rule(PromotionRule {
guard_name: "anomaly-advisory".to_string(),
min_severity: AdvisorySeverity::High,
});
let mut advisory = AdvisoryPipeline::new(policy);
advisory.add(Box::new(AnomalyAdvisoryGuard::new(journal, 100, 5)));
advisory.add(Box::new(DataTransferAdvisoryGuard::new(journal, 10_000_000)));
// Plug it into the regular guard pipeline.
pipeline.add(Box::new(advisory));Advisory pipelines are not exempt from fail-closed
AdvisoryGuard::evaluate returns Err, the advisory pipeline propagates the error. The wrapping GuardPipeline then treats it as a fail-closed deny. Advisory means non-blocking on success, not non-blocking on failure.PostInvocationPipeline
Inspects tool responses before they reach the agent. Defined in crates/chio-guards/src/post_invocation.rs with the trait re-exported from chio-kernel.
pub use chio_kernel::{
PipelineOutcome, PostInvocationContext, PostInvocationHook,
PostInvocationPipeline, PostInvocationVerdict,
};A hook returns one of four verdicts:
| Verdict | Effect |
|---|---|
Allow | Pass the response through unmodified. |
Redact(Value) | Replace the response with the modified JSON value (structure preserved). Subsequent hooks see the redacted value. |
Block(reason) | Stop the pipeline. The kernel returns an error to the agent and records the block on the receipt. |
Escalate(message) | Non-blocking signal collected for operator review. Other hooks keep running. A subsequent Block still wins. |
The ready-made SanitizerHook wraps the full output sanitizer: when sensitive data is found, it returns Redact(sanitized) and emits GuardEvidence for the receipt with detector IDs and counts (never raw secrets).
AsyncGuardAdapter (External Guards)
External guards call out to third-party services. The kernel's guard pipeline is synchronous, so each external provider is wrapped in an AsyncGuardAdapter that adds resilience layers around a single async eval() call. Defined in crates/chio-guards/src/external/mod.rs.
Layering, innermost to outermost
Source-of-truth call order, from AsyncGuardAdapter::evaluate:
1. CircuitBreaker.allow_call() -> CircuitOpenVerdict on deny
2. TtlCache.get(cache_key) -> cached verdict on hit
3. TokenBucket.try_acquire() -> RateLimitedVerdict on empty
4. retry_with_jitter(inner.eval) -> Verdict::Deny on permanent failure
-> Verdict on success (also cached)Invariants
- Cache hits do not consume rate-limit budget.
- Rate-limited calls do not count as circuit-breaker failures. Only actual attempts at the external service do.
- Permanent errors (4xx, malformed) short-circuit the retry loop and return
Verdict::Deny. - Transient errors and timeouts retry and count against the breaker.
- The default for both
CircuitOpenVerdictandRateLimitedVerdictisDeny. Operators can flip either toAllowfor advisory deployments where the guard's output informs review rather than gating action.
Bridging to the Sync Pipeline
An AsyncGuardAdapter is async; the kernel's Guard trait is sync. chio_external_guards::ScopedAsyncGuard<E> wraps the adapter as a Guard, scopes it to wildcard tool-name patterns, and bridges async to sync by detecting the current tokio runtime flavor. See External Guards for the full bridge contract.
Composing Multiple Pipelines
A typical kernel registers one GuardPipeline as its single sync guard. That pipeline contains the catalog guards plus, optionally, an AdvisoryPipeline and any number of ScopedAsyncGuard wrappers. The PostInvocationPipeline is registered separately on the kernel for the response-side phase.
use chio_guards::{
GuardPipeline, AdvisoryPipeline, PromotionPolicy, PromotionRule,
AdvisorySeverity,
};
let mut pipeline = GuardPipeline::default_pipeline();
// Layer in advisory signals from session journal.
let mut policy = PromotionPolicy::new();
policy.add_rule(PromotionRule {
guard_name: "anomaly-advisory".to_string(),
min_severity: AdvisorySeverity::Critical,
});
let mut advisory = AdvisoryPipeline::new(policy);
advisory.add(Box::new(AnomalyAdvisoryGuard::new(journal.clone(), 100, 5)));
pipeline.add(Box::new(advisory));
// Layer in an external guard.
pipeline.add(Box::new(ScopedAsyncGuard::new(
Arc::new(adapter),
vec!["fetch_url".into()],
)));
kernel.add_guard(Box::new(pipeline));Where to Go Next
- Fail-Closed Semantics · failure-mode matrix and the advisory exception
- Advisory Signals · authoring advisory guards and writing promotion rules
- External Adapters · the full external-guard bridge contract
- Default Pipeline · what runs when you call
default_pipeline()