Pipelines & Composition
Assemble input guards, advisory findings, output hooks, and provider checks into one enforcement path whose order, evidence, and failures you choose.
On this page
You can stack local policy, session context, and specialized providers around one mediated request, and choose the moment each check runs. Input guards decide before dispatch. Output hooks decide what returns after the tool has run.
Four components carry the composition: GuardPipeline stops an invocation before dispatch, AdvisoryPipeline collects findings and can promote them to a denial, PostInvocationPipeline inspects, transforms, or withholds returned data, and AsyncGuardAdapter consults an external provider within bounded failure rules.
Admission and output act at different moments
Order decides which checks run, what state they consume, and which evidence reaches the outcome. The stage decides whether a refusal can still prevent the effect.
after the response: report.md exists in 2 of 3 calls; one response delivered.
write_report call; spacing has no duration meaning. An output check acts on the returned value, so blocking it cannot undo a write that already happened.The mediated callA check before dispatch stops the call with no effect. A check on the output acts on a value the tool already produced, so it can withhold the content while the write stays done. Put every rule that must prevent an effect on the input side.
Input guards combine conjunctively, in registration order
GuardPipeline implements the hosted Guard trait, so a composed pipeline registers as one guard. Its children run in registration order, and it accumulates their returned evidence until a child denies or fails:
- Allow. Append the child's evidence and continue.
- PendingApproval. Keep pending as the running verdict and continue. A later pending result keeps it pending; a later Deny or error overrides it.
- Deny. Stop and return a deny decision with the accumulated evidence and a row naming the denying guard.
- Error. Stop and convert the error into a deny decision, with fail-closed attribution naming the failed guard.
A pending composition opens no approval. The kernel's synchronous guard admission loop accepts only Allow and refuses a registered guard's pending result, and the portable evaluator refuses it too. Human approval runs through the hosted runtime's separate approval orchestration.
GuardPipeline evaluation in Rust
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
let mut final_verdict = Verdict::Allow;
let mut evidence = Vec::new();
for guard in &self.guards {
match guard.evaluate(ctx) {
Ok(decision) => {
evidence.extend(decision.evidence);
match decision.verdict {
Verdict::Allow => continue,
Verdict::PendingApproval => {
// `PendingApproval` is 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;
}
Verdict::Deny => {
evidence.push(GuardEvidence {
guard_name: guard.name().to_string(),
verdict: false,
details: Some(
"action=deny; reason=guard denied request".to_string(),
),
});
return Ok(GuardDecision::deny(evidence));
}
}
}
Err(e) => {
// Fail closed: guard errors are treated as denials.
evidence.push(GuardEvidence {
guard_name: guard.name().to_string(),
verdict: false,
details: Some(format!("action=error; reason=fail-closed; error={e}")),
});
return Ok(GuardDecision::deny(evidence));
}
}
}
Ok(GuardDecision {
verdict: final_verdict,
evidence,
})
}Order changes evidence, state, and cost
A denied request never reaches the guards after the one that denied it. A stateful check can consume a rate token or read a session journal before a later check denies, so two orders that both deny can still leave different evidence and state behind.
chio policy explain shows the cut on one call. The fixer's write to a test stops at the second of the Quickstart project's three guards, and the third never runs:
chio policy explain ./policy.yaml --server workspace --tool write_file \
--params '{"path":"/workspace/tests/test_pricing.py"}'call workspace.write_file {"path":"/workspace/tests/test_pricing.py"}
1 forbidden-path pass
2 tests-are-evidence deny write /workspace/tests/test_pricing.py matches /workspace/tests/**
stopped at guard 2 of 3; decision denyPut inexpensive rejecting checks early where their semantics allow it, and confirm the sequence your policy requires. The default pipeline is a fixed registration sequence; nothing sorts guards by cost.
Evidence covers the findings returned by guards the request reached. A whole-pipeline timeout or a worker failure can lose those findings.
Advisory findings allow by default until a rule promotes them
An AdvisoryGuard returns signals describing what it observed. AdvisoryPipeline combines them and implements Guard, so it sits inside the input pipeline. This fragment, inside an existing session journal and input pipeline, blocks High and Critical anomaly findings and leaves data-transfer findings advisory:
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.clone(), 100, 5,
)));
advisory.add(Box::new(DataTransferAdvisoryGuard::new(
journal, 10_000_000,
)));
pipeline.add(Box::new(advisory));A promotion rule matches an exact guard name and a minimum severity, ordered Info → Low → Medium → High → Critical. A match marks the signal promoted and makes the completed advisory pipeline deny. The remaining advisory checks still run, so a successful evaluation can retain several findings.
An advisory error propagates at once, and the containing input pipeline treats it as a denial. Signals collected during that failed evaluation never reach the advisory export. last_signals() and last_outputs() read shared storage holding the most recent successful evaluation, whichever request it served; a failed evaluation leaves the previous values in place.
Advisory interfaces and promotion types
pub trait AdvisoryGuard: Send + Sync {
/// Human-readable guard name.
fn name(&self) -> &str;
/// Evaluate the request and return any advisory signals.
///
/// The returned signals are informational only and do not affect the
/// request verdict unless a promotion policy is in effect.
fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
}pub struct AdvisorySignal {
/// Name of the advisory guard that produced this signal.
pub guard_name: String,
/// Human-readable description of the observation.
pub description: String,
/// Severity level.
pub severity: AdvisorySeverity,
/// Structured metadata about the observation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// Whether this signal has been promoted to a deterministic denial.
/// This is set by the promotion policy, not by the advisory guard itself.
#[serde(default)]
pub promoted: bool,
}pub enum AdvisorySeverity {
/// Informational observation -- no action needed.
Info,
/// Low-severity warning -- worth monitoring.
Low,
/// Medium-severity warning -- may warrant investigation.
Medium,
/// High-severity warning -- likely needs attention.
High,
/// Critical observation -- strong signal of abuse or anomaly.
Critical,
}pub struct PromotionRule {
/// Guard name pattern to match (exact match).
pub guard_name: String,
/// Minimum severity to promote. Signals at or above this level
/// from the named guard become deterministic denials.
pub min_severity: AdvisorySeverity,
}pub struct PromotionPolicy {
/// Rules for promoting advisory signals to deterministic denials.
pub rules: Vec<PromotionRule>,
}Read more in Advisory Guards.
Output hooks govern what returns to the agent
PostInvocationPipeline is registered separately from input guards. Its hooks inspect the current response in order, after the tool has executed, and each hook's evidence is collected before its verdict applies, a blocking hook's included.
Allowleaves the current response unchanged and continues.Redact(value)replaces the current JSON value; later hooks inspect the replacement.Escalate(message)collects an operator-review signal and continues without waiting for approval.Block(reason)stops output processing and withholds the result, keeping prior evidence and escalation messages.
A later Block wins over prior redactions and escalations. When processing finishes, a changed response yields an aggregate Redact, collected escalations otherwise yield Escalate, and an unchanged response with none yields Allow. Escalation messages stay available under an aggregate Redact, and the hook trait has no Result or error arm.
The supplied SanitizerHook keeps the JSON structure while it redacts detected sensitive content, which generic Redact leaves to the hook. Its evidence reports detector IDs and counts, so raw secrets stay out of the receipt. Durable execution requires each hook to declare a stable, deterministic, non-blocking identity; a missing or changed identity, or an unsupported Block result, fails that path.
Read more in Durability.
A provider check is bounded by breaker, cache, rate, and retries
AsyncGuardAdapter brings a specialized provider into admission and wraps it with a circuit breaker, an optional cache, a rate limiter, and retries. Those controls bound provider traffic and retry attempts; the provider's judgment stays the provider's.
A miss spends one token on the whole retry loop
The cache misses, so the adapter takes one rate token and calls the provider. Transient errors and timeouts retry with backoff inside that token. A successful response, Allow or Deny, counts as service success, is cached, and is recorded once.
AsyncGuardAdapter runs its stages. Cache and rate limiter are optional per adapter; provider deadlines and the evidence an implementation records are left out.External adapters- An open circuit returns the configured fallback before any cache lookup. Circuit-open and rate-limited fallbacks default to Deny.
- A cache hit makes no provider call and consumes no rate token. A miss acquires one token before the retry loop.
- Transient errors and timeouts retry; a permanent error stops the loop. The adapter records one breaker failure when the operation finally fails, and records the final operation once.
- A provider response containing Deny counts as service success and can be cached: it is a policy decision, separate from availability.
- Allow as an availability fallback bypasses this guard under that failure condition, and the provider's denials stay denials.
The adapter puts no timeout of its own on the awaited provider call, so provider or host deadlines bound each attempt. ScopedAsyncGuard wraps the adapter as a synchronous Guard and applies its tool-name patterns: an unmatched tool passes without a provider call, and an empty pattern list applies the check to every tool. The bridge picks a Tokio strategy for the caller's runtime, and dispatch revalidation does not call the provider again.
The generic adapter returns a Verdict, and its synchronous wrapper builds a decision without provider evidence. Provider decision IDs reach the receipt only when an implementation supplies them as evidence. External Guard Adapters carries the full integration contract.
One registration holds the input stages; output and approval stay separate
A common arrangement registers one input GuardPipeline holding catalog guards, an optional AdvisoryPipeline, and scoped external checks. Output hooks are configured separately on the kernel, and the hosted approval workflow is a stage of its own that no pending pipeline entry can replace.
The Quickstart's fixer shows the split. Its read of /secrets/.env stops at input, before dispatch. A sanitizer hook that redacts a token from a file the fixer reads acts after the read has run. A promoted advisory finding denies admission, while an output escalation records a signal after execution.
chio-guards exports GuardPipeline, AdvisoryPipeline, the promotion types, and the output-hook types re-exported from the kernel. GuardOutput is the advisory export representation; receipt assembly combines the pre- and post-invocation GuardEvidence in the receipt body's evidence field.
GuardOutput and source
pub enum GuardOutput {
/// A deterministic verdict (allow or deny).
Deterministic {
guard_name: String,
verdict: bool,
details: Option<String>,
},
/// A non-blocking advisory signal.
Advisory(AdvisorySignal),
}