Chio/Docs

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.

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

Guards downstream of a 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.

crates/chio-guards/src/advisory.rs
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 AdvisoryGuard returns zero or more AdvisorySignal values per call.
  • The pipeline accumulates every signal regardless of severity. Signals are stored on the pipeline for evidence export via last_signals() and last_outputs().
  • Without a promotion rule, the pipeline always returns Verdict::Allow.
  • With a matching PromotionRule, a signal at or above min_severity is marked promoted = true and the pipeline returns Verdict::Deny.

Severity ordering is Info < Low < Medium < High < Critical.

Promotion Example

rust
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

If an 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.

rust
pub use chio_kernel::{
    PipelineOutcome, PostInvocationContext, PostInvocationHook,
    PostInvocationPipeline, PostInvocationVerdict,
};

A hook returns one of four verdicts:

VerdictEffect
AllowPass 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

rendering…
Composition order around an external guard. The cache check sits between the breaker and the rate limiter so cached hits do not spend rate budget.

Source-of-truth call order, from AsyncGuardAdapter::evaluate:

text
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 CircuitOpenVerdict and RateLimitedVerdict is Deny. Operators can flip either to Allow for 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.

rust
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

Pipelines & Composition · Chio Docs