External Guard Adapters
Put a content-safety, vulnerability, or URL-reputation service in front of tool calls, with bounded waits, retries, and failure verdicts.
On this page
A tool call can be screened by an outside provider, such as a content-safety model or a URL-reputation service, before the kernel dispatches it. The provider answers as one guard in the kernel's pipeline, and an adapter around it decides how long admission waits, when it retries, and what an unavailable provider means.
The adapter is AsyncGuardAdapter. ScopedAsyncGuard wraps it as a synchronous guard, applies it to the tools whose names match its patterns, and returns the adapter's verdict to admission. The kernel signs the receipt for whatever admission decides.
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.- Cache before rate limit. A cache hit consumes no token from the bucket, and a miss takes one token for its whole retry loop.
- Only provider operations count toward the breaker. A rate-limited call never trips it, and a failed operation records one failure however many attempts it made.
- A provider's Deny is an answer. It records success with the breaker and is cached like an Allow.
- Permanent errors stop the retries.
ExternalGuardError::Timeoutand::Transientretry;::Permanentreturns immediately. - The fallback is deny. Any uncaught error path returns
Verdict::Denyand writes atracing::warn!record.
1. Choose a provider
Six providers ship as guards in chio-external-guards. Each one implements the same provider trait, so every one runs behind the same adapter.
| Provider | Guard | Class | Evidence type |
|---|---|---|---|
| AWS Bedrock Guardrails | BedrockGuardrailGuard | Content safety | BedrockDecisionDetails |
| Azure AI Content Safety | AzureContentSafetyGuard | Content safety | AzureDecisionDetails |
| Google Vertex AI Safety | VertexSafetyGuard | Content safety | VertexDecisionDetails |
| Snyk | SnykGuard | Vulnerability | SnykEvidence |
| VirusTotal | VirusTotalGuard | URL threat intel | VirusTotalEvidence |
| Google Safe Browsing | SafeBrowsingGuard | URL threat intel | SafeBrowsingEvidence |
External Guards lists each provider's credentials, endpoints, and thresholds, and the SSRF-safe URL validator that gates every provider endpoint.
2. Configure the provider in policy
Add the provider under the policy's guards block. The cloud_guardrails and threat_intel blocks are fields of the control plane's guard configuration, separate from HushSpec's rules schema.
guards:
cloud_guardrails:
azure_content_safety:
endpoint: "https://content-safety.example.net"
api_key: "azure-provider-key" # provider credential
timeout_seconds: 5 # bounds each provider attempt
tool_patterns: ["post_*", "send_message"]
adapter:
cache_ttl_seconds: 60
rate_per_second: 20
rate_burst: 20
circuit_failure_threshold: 5
retry_max_retries: 3You should see the guard apply only to tools named by tool_patterns; an empty list applies it to every tool. timeout_seconds bounds each provider attempt, because the adapter puts no deadline of its own on the awaited call. The adapter block takes exactly the five keys shown, and policy validation rejects any other. Failure & Recovery tunes them for a provider that keeps failing.
3. Wrap the provider for the kernel in Rust
A host that embeds the kernel builds the same chain in code: provider, adapter, bridge, then the kernel's guard list.
use std::{sync::Arc, time::Duration};
use chio_external_guards::{
AsyncGuardAdapter, AzureContentSafetyConfig, AzureContentSafetyGuard, ScopedAsyncGuard,
};
// kernel: the mutable ChioKernel your host builds. api_key: from your secret store.
let config = AzureContentSafetyConfig::new(api_key, "https://content-safety.example.net");
let provider = AzureContentSafetyGuard::new(config)?;
let adapter = AsyncGuardAdapter::builder(Arc::new(provider))
.cache_ttl(Duration::from_secs(60))
.rate_limit(20.0, 20)
.build();
let guard = ScopedAsyncGuard::new(adapter, vec!["post_*".into(), "send_message".into()]);
kernel.add_guard(Box::new(guard));A provider of your own implements ExternalGuard. Its eval describes a single attempt, one HTTP call and one decision; the adapter supplies retry, caching, and rate limiting. Its cache_key names the inputs that decide the verdict, and returning None keeps that call out of the cache. You should see a matching call that the provider rejects refused before dispatch, with a signed deny receipt; a call to any other tool never reaches the provider.
Read more in the trait and context types below.
4. Read what the decision records
The adapter returns a Verdict, and the bridge converts it with GuardDecision::from_verdict, which carries an empty evidence vector. The receipt records the guard and its verdict. A composed GuardPipeline can add a row naming the denying child; that row identifies the guard, separate from the provider's response.
An integration that needs provider findings in the receipt (provider identity, correlation IDs, category scores, cache-hit status) attaches its own GuardEvidence rows, defines their detail format, and keeps raw sensitive content out of them. Guard Evidence distinguishes the producer channels. Read the breaker's live state from AsyncGuardAdapter::circuit_state.
A cached verdict records a decision made within the cache TTL. For per-call freshness, set cache_ttl to 0 or return None from cache_key. Dispatch revalidation does not call the provider again: the admission verdict stands for that dispatch.
Fallback verdicts decide what an unavailable provider means
An open circuit and an empty token bucket each return a configured verdict without calling the provider: CircuitOpenVerdict and RateLimitedVerdict. Each has two variants, Deny, the fail-closed default, and Allow, which is advisory only. Human escalation belongs to the approval guard.
Use Allow only for a guard that feeds a human-review queue, or for one of several independent layers whose others still enforce the call. On a guard that is the last line of defense for a capability, keep Deny: an open-circuit Allow there turns a provider outage into an unguarded path.
Adapter configuration and types
Try failure_threshold, jitter, cache_key, or block_in_place.
Stage order
One call through the adapter passes four stages in a fixed order, and the order decides which failure produces which verdict.
AsyncGuardAdapter::evaluate(ctx)
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)The generic adapter and middleware live in chio-guards::external (workspace 0.1.0). The HTTP-backed providers live in chio-external-guards, which re-exports the adapter so callers depend on one crate, and which also holds ScopedAsyncGuard. The adapter's behavior is defined in crates/guards/chio-guards/src/external/mod.rs.
AsyncGuardAdapterConfig
Defaults from AsyncGuardAdapterConfig::default():
| Field | Type | Default | Purpose |
|---|---|---|---|
circuit | Circuit | 5 failures / 60s, reset 30s, success 2 | Three-state breaker tuning. |
retry | RetryConfig | 3 retries, 100 ms base, 5 s max, 0.25 jitter, exponential | Retry and backoff inside the breaker. |
cache_capacity | NonZeroUsize | 1024 | TTL cache size. |
cache_ttl | Duration | 60s | Per-entry expiration. |
rate_per_second | f64 | 20.0 | Token bucket refill rate. |
rate_burst | u32 | 20 | Token bucket capacity. |
circuit_ | Circuit | Deny | Verdict when the breaker is open. |
rate_ | Rate | Deny | Verdict when the bucket is empty. |
Circuit breaker
The breaker in crates/guards/chio-guards/src/external/circuit_breaker.rs has three states:
- Closed. Calls flow. Failures accumulate inside a sliding
failure_window; when the count reachesfailure_threshold, the breaker opens and records the time. - Open. Calls short-circuit for at least
reset_timeout, and the adapter returns the configuredCircuitOpenVerdictwithout touching the inner guard. - HalfOpen. A bounded set of trial calls is admitted. After
success_thresholdconsecutive successes the breaker closes; any failure reopens it.
pub struct CircuitBreakerConfig {
/// Failures observed within `failure_window` before the breaker opens.
pub failure_threshold: u32,
/// Rolling window for failure counting in the Closed state. Failures
/// older than this are discarded from the sliding count.
pub failure_window: Duration,
/// Consecutive successes required in the HalfOpen state before the
/// breaker transitions back to Closed.
pub success_threshold: u32,
/// Time the breaker remains Open before a HalfOpen probe is allowed.
pub reset_timeout: Duration,
}impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
failure_window: Duration::from_secs(60),
success_threshold: 2,
reset_timeout: Duration::from_secs(30),
}
}
}Time comes from a Clock abstraction, so tests drive transitions with tokio::time::pause and advance instead of wall-clock sleeps. The breaker closes from inside the process, on its own timers; Failure & Recovery covers recovery, and Fail-Closed Semantics follows an open breaker to its denied receipt.
Retry strategies
Three backoff strategies ship in crates/guards/chio-guards/src/external/retry.rs, and the curve is one field of the retry configuration:
pub enum BackoffStrategy {
/// Each attempt sleeps `base_delay * 2^(attempt - 1)` before jitter.
Exponential,
/// Each attempt sleeps `base_delay` before jitter.
Constant,
/// Each attempt sleeps `base_delay * attempt` before jitter.
Linear,
}pub struct RetryConfig {
/// Maximum number of retries after the initial attempt. A value of `0`
/// means the operation is attempted exactly once.
pub max_retries: u32,
/// Base delay for the first retry.
pub base_delay: Duration,
/// Upper bound on the sleep between attempts (before jitter is added).
pub max_delay: Duration,
/// Fraction of the computed delay to use as bounded multiplicative
/// jitter. Must be in `[0.0, 1.0]`; values outside that range are
/// clamped.
pub jitter_fraction: f64,
/// Backoff curve.
pub strategy: BackoffStrategy,
}impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
jitter_fraction: 0.25,
strategy: BackoffStrategy::Exponential,
}
}
}Total attempts are max_retries + 1, so a single attempt is RetryConfig::max_retries = 0, which suits a provider that returns every error as permanent. Jitter is bounded and multiplicative: the computed delay is scaled by 1 + uniform(-jitter_fraction, +jitter_fraction), so the defaults spread delays by ±25% and keep the worst case bounded. The default RNG seeds from max_retries + 0x9E37_79B9_7F4A_7C15 for deterministic test runs, and retry_with_jitter_rng accepts a caller-supplied Rng. The sleep between attempts is tokio::time::sleep, so it honors tokio::time::pause.
Exponential fits most providers, because a degraded service recovers faster as retries thin out. Linear fits providers that throttle on a fixed window, where doubling the delay overshoots the window. Constant is for tests and the rare health check that needs a fixed cadence.
TTL cache
The cache is a bounded TtlCache<String, Verdict> with a default TTL of 60 seconds and a default capacity of 1024 entries; eviction on the underlying map is recency-biased. Keys come from ExternalGuard::cache_key, and a provider that returns None keeps the call out of the cache. The adapter consults it before the rate limiter, so steady hot traffic never drains the provider's QPS budget.
Cache keys
A key captures every input that influences the verdict and nothing else. Typical recipes:
- Content-safety providers hash the prompt or tool argument body, so a benign prompt hits cache across agents.
- URL-threat providers hash the canonicalized URL host and path, dropping the fragment, which leaves reputation unchanged.
- Vulnerability providers hash the package coordinate (
name@version) plus the ecosystem.
Return None for a call whose input is sensitive or whose verdict is too contextual to share.
AsyncGuardAdapter
pub struct AsyncGuardAdapter<E: ExternalGuard + ?Sized> {
inner: Arc<E>,
config: AsyncGuardAdapterConfig,
cache: TtlCache<String, Verdict>,
circuit: CircuitBreaker,
bucket: TokenBucket,
}
impl<E: ExternalGuard + ?Sized> AsyncGuardAdapter<E> {
pub fn builder(inner: Arc<E>) -> AsyncGuardAdapterBuilder<E>;
pub fn name(&self) -> &str;
pub fn config(&self) -> &AsyncGuardAdapterConfig;
pub fn circuit_state(&self) -> CircuitState;
pub async fn evaluate(&self, ctx: &GuardCallContext) -> Verdict;
}Construction goes through the fluent builder. Tests pass a custom Clock through .clock(...) to drive breaker and cache transitions deterministically.
ScopedAsyncGuard bridge
The kernel's guard pipeline is synchronous. The bridge in chio-external-guards wraps an AsyncGuardAdapter in a synchronous Guard and scopes it to a set of wildcard tool-name patterns; an empty pattern list matches every tool.
pub struct ScopedAsyncGuard<E: ExternalGuard> {
adapter: AsyncGuardAdapter<E>,
tool_patterns: Vec<String>,
}impl<E: ExternalGuard> Guard for ScopedAsyncGuard<E> {
fn name(&self) -> &str {
self.adapter.name()
}
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
// By design: a tool-scoped guard returns Allow for traffic outside
// its scope. The deny-by-default contract is enforced by the
// composing authority layer, not by this single scoped guard.
if !self.matches_tool(&ctx.request.tool_name) {
return Ok(GuardDecision::allow());
}
let call_ctx = self.call_context(ctx);
self.block_on(self.adapter.evaluate(&call_ctx))
.map(GuardDecision::from_verdict)
}
fn revalidate_before_dispatch(&self, _ctx: &GuardContext) -> Result<(), KernelError> {
// External evaluation can consume rate-limit, cache, retry, and
// circuit-breaker state. The admission verdict is authoritative for
// this dispatch, so readiness revalidation must not call the adapter a
// second time.
Ok(())
}
}A tool outside the patterns gets Verdict::Allow from this guard without a provider call and without spending rate-limit or cache budget; the rest of the pipeline still decides the call. For a matching tool the bridge picks a strategy from the current Tokio runtime:
MultiThread:tokio::task::block_in_placewith the current handle.CurrentThread: a fresh current-thread runtime on a scoped thread, so the caller's executor never deadlocks.- No runtime in scope: a transient current-thread runtime, run to completion.
- Any other runtime flavor:
KernelError::GuardDeniedwith a diagnostic naming the guard.
ExternalGuard, GuardCallContext, ExternalGuardError
The async trait each provider implements:
pub trait ExternalGuard: Send + Sync {
/// Human-readable guard name (e.g. `"bedrock-guardrail"`).
fn name(&self) -> &str;
/// Return a cache key for this request, or `None` to skip caching.
fn cache_key(&self, ctx: &GuardCallContext) -> Option<String>;
/// Evaluate the request against the external service.
async fn eval(&self, ctx: &GuardCallContext) -> Result<Verdict, ExternalGuardError>;
}The context it reads is a flat projection of the request, with the arguments kept as a JSON string so a cache key can hash them without committing to a schema.
pub struct GuardCallContext {
/// Tool name being invoked.
pub tool_name: String,
/// Calling agent identifier.
pub agent_id: String,
/// Target server identifier.
pub server_id: String,
/// Tool arguments serialized as JSON. Kept as a `String` so the cache
/// key can hash it cheaply without committing to a fixed schema.
pub arguments_json: String,
}The error type separates what may be retried from what may not: is_retryable is true for Timeout and Transient only.
pub enum ExternalGuardError {
/// The downstream service timed out.
#[error("external guard timeout")]
Timeout,
/// The downstream service returned a retryable failure (5xx, connection
/// reset, etc.). Retryable errors are counted towards the circuit
/// breaker and may trigger a retry.
#[error("transient external error: {0}")]
Transient(String),
/// A permanent error that should not be retried (e.g. malformed request,
/// 4xx auth failure).
#[error("permanent external error: {0}")]
Permanent(String),
}Performance class
A cache hit costs a breaker check and a cache read. A miss adds token acquisition and the configured provider attempts, and a successful result can populate the cache. A circuit-open call returns after the breaker check with no provider request.