External Guard Adapters
Every external-guard provider (Bedrock, Azure Content Safety, Vertex Safety, Snyk, VirusTotal, Safe Browsing) reaches the kernel through the same generic adapter: AsyncGuardAdapter. This page documents the adapter mechanics: how the circuit breaker, cache, rate limiter, and retry loop compose; how the async evaluator bridges back to a sync Guard impl; and which provider lives behind which struct. For configuration of a specific provider, read External Guards first.
Two crates
chio-guards::external (workspace 0.1.0). HTTP-backed concrete providers live in chio-external-guards, which re-exports the adapter so callers depend on a single crate. The sync bridge ScopedAsyncGuard lives inchio-external-guards.Layering
A single call through the adapter passes four stages in a fixed order. The order matters because it determines which failure mode 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)Invariants enforced by crates/chio-guards/src/external/mod.rs:
- Cache before rate limit. Cache hits do not consume the bucket.
- Rate-limited calls do not fail the breaker. Only real attempts at the external service count toward the breaker's failure threshold.
- Permanent errors short-circuit retry. Only
ExternalGuardError::Timeoutand::Transientretry;::Permanentreturns immediately. - The fallback is deny. Any uncaught error path returns
Verdict::Denywith atracing::warn!record.
ExternalGuard trait
The async trait a concrete provider implements:
#[async_trait]
pub trait ExternalGuard: Send + Sync {
fn name(&self) -> &str;
fn cache_key(&self, ctx: &GuardCallContext) -> Option<String>;
async fn eval(&self, ctx: &GuardCallContext)
-> Result<Verdict, ExternalGuardError>;
}
pub enum ExternalGuardError {
Timeout,
Transient(String), // 5xx, connection reset, etc.
Permanent(String), // 4xx auth, malformed request, etc.
}
pub struct GuardCallContext {
pub tool_name: String,
pub agent_id: String,
pub server_id: String,
pub arguments_json: String,
}eval describes a single attempt: one HTTP call, one decision. Concrete providers do not implement retry, caching, or rate limiting. The adapter wraps that for them.
AsyncGuardAdapterConfig
Defaults from AsyncGuardAdapterConfig::default():
| Field | Type | Default | Purpose |
|---|---|---|---|
circuit | CircuitBreakerConfig | 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 / 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_open_verdict | CircuitOpenVerdict | Deny | Verdict when the breaker is open. |
rate_limited_verdict | RateLimitedVerdict | Deny | Verdict when the bucket is empty. |
Both CircuitOpenVerdict and RateLimitedVerdict are two-variant enums: Deny (default, fail-closed) and Allow (advisory only). There is no Escalate variant; a guard that needs human escalation pairs with the approval guard rather than overloading this knob.
Do not enable Allow on last-line guards
Allow modes exist for guards whose outputs feed a human-review queue rather than gate an action. If this guard is the last line of defense on a capability, leave the defaults (Deny) in place. Failing open hides outages.Circuit breaker
Three-state breaker from crates/chio-guards/src/external/circuit_breaker.rs:
- Closed. Calls flow. Failures accumulate inside a sliding
failure_window. Once the count reachesfailure_threshold, the breaker opens and records the timestamp. - Open. Calls short-circuit for at least
reset_timeout. 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.
Time uses a Clock abstraction; tests drive transitions through tokio::time::pause + advance without wall-clock sleeps. For the deeper rationale see Fail-Closed Semantics.
Retry strategies
From crates/chio-guards/src/external/retry.rs. Three strategies ship in tree:
pub enum BackoffStrategy {
Exponential, // base * 2^(attempt-1)
Constant, // base
Linear, // base * attempt
}
pub struct RetryConfig {
pub max_retries: u32, // additional retries after the first attempt
pub base_delay: Duration, // 100 ms default
pub max_delay: Duration, // 5 s ceiling before jitter
pub jitter_fraction: f64, // 0.0..=1.0; 0.25 default
pub strategy: BackoffStrategy, // Exponential default
}Jitter is bounded multiplicative. The default RNG seeds from max_retries + 0x9E37_79B9_7F4A_7C15 for deterministic test runs; retry_with_jitter_rng accepts a caller-supplied Rng when needed. The sleep between attempts uses tokio::time::sleep so it honours tokio::time::pause.
No-retry equals max_retries: 0
NoRetry variant. To run a single attempt, set RetryConfig::max_retries = 0. Permanent errors already short-circuit retry; the no-retry case is for permanent-only providers where every error should surface immediately.TTL cache
TtlCache<String, Verdict> with bounded capacity. Keys come from ExternalGuard::cache_key; a provider that returns None opts the call out of caching. Default TTL is 60 seconds; default capacity is 1024 entries. Eviction is recency-biased on the underlying map.
The cache is consulted before the rate limiter so steady-state hot traffic does not drain the QPS budget. A cached verdict attests to a decision made within the configured TTL, not a live one; operators that need per-call freshness either set cache_ttl to 0 or override cache_key to return None.
AsyncGuardAdapter shape
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 via .clock(...) to drive breaker and cache transitions deterministically.
Sync bridge: ScopedAsyncGuard
The kernel guard pipeline is sync. The bridge in chio-external-guards wraps an AsyncGuardAdapter in a sync Guard and scopes it to a wildcard tool-name pattern set:
pub struct ScopedAsyncGuard<E: ExternalGuard> {
adapter: AsyncGuardAdapter<E>,
tool_patterns: Vec<String>, // empty means "every tool"
}
impl<E: ExternalGuard> Guard for ScopedAsyncGuard<E> {
fn name(&self) -> &str { self.adapter.name() }
fn evaluate(&self, ctx: &GuardContext)
-> Result<Verdict, KernelError>
{
if !self.matches_tool(&ctx.request.tool_name) {
return Ok(Verdict::Allow);
}
let call_ctx = self.call_context(ctx);
self.block_on(self.adapter.evaluate(&call_ctx))
}
}A non-matching tool returns Verdict::Allow without calling the external service and without consuming rate-limit or cache budget. The bridge detects the current Tokio runtime flavor and dispatches:
MultiThread:tokio::task::block_in_place+ the current handle.CurrentThread: spawn a fresh current-thread runtime on a scoped thread to avoid deadlocking the caller's executor.- No runtime in scope: build a transient current-thread runtime and run to completion.
- Unknown future Tokio flavor: return
KernelError::GuardDeniedwith a diagnostic name rather than panic.
Provider catalog
| Provider | Guard struct | Class | Evidence struct |
|---|---|---|---|
| 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 |
For credentials, endpoints, and per-provider thresholds, read External Guards. That page covers the HushSpec cloud_guardrails and threat_intel blocks and the SSRF-safe URL validator that gates every adapter.
Cache-key design
Each provider implements cache_key(ctx). The key must capture 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 canonicalised URL host + path; the fragment is dropped because it does not change reputation.
- Vulnerability providers hash the package coordinate (
name@version) plus the ecosystem.
Returning None opts out of caching for that specific call, useful when the input is sensitive or the verdict is too contextual to share.
Receipt evidence
The adapter records every decision on the receipt's guard-evidence block. The evidence struct preserves the guard name and provider identity, the provider decision label (Azure severity, Vertex probability, VirusTotal detection count, and so on), the upstream request correlation id when one is returned, and a cache-or-live flag so auditors can tell a cached decision from a fresh call.
Receipts do not embed raw provider responses
Performance class
Hot path on a cache hit: one breaker check, one cache read. Sub-microsecond. Hot path on a miss: one breaker check, one cache read miss, one bucket acquire, one HTTP call (latency dominated by the provider), one cache insert. Hot path on a circuit-open call: one breaker check, one verdict return; never touches the network.
Next steps
- External Guards for provider configuration, the SSRF posture, and the fail-mode matrix.
- Fail-Closed Semantics for the kernel-wide invariants this adapter inherits.
- Rate Limit Guards for in-process limits that complement provider-side QPS budgets.