BuildPolicy
External Guards
Configure external guards, understand their failure behavior, and review the receipt evidence they record.
Stability
chio-external-guards, chio-guards::external) are workspace version 0.1.0 and still evolving. Treat the policy-compiler test build_pipeline_from_external_guard_policy as the executable spec for the YAML shape. This page does not freeze the schema in prose.Provider Catalog
Six providers ship today. Each implements the async ExternalGuard trait and composes with the same AsyncGuardAdapter infrastructure.
| Provider | Chio guard type | 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 |
| Google Safe Browsing | SafeBrowsingGuard | URL threat intel | SafeBrowsingEvidence |
| VirusTotal | VirusTotalGuard | URL threat intel | VirusTotalEvidence |
| Snyk | SnykGuard | Vulnerability | SnykEvidence |
All six are fail-closed by default: a downstream error produces Verdict::Deny. Advisory mode (return Allow on degraded states) is opt-in per adapter and must be enabled explicitly.
Adapter Architecture
The adapter wrapping an ExternalGuard composes four pieces in a fixed order. The order matters: it determines which failure mode returns which verdict.
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 adapter enforces these rules (see crates/guards/chio-guards/src/external/mod.rs):
- Cache is checked before the token bucket. Cache hits do not spend rate-limit budget.
- Rate-limited calls do not count as circuit-breaker failures. An external-service attempt does.
- Permanent errors short-circuit the retry loop. Only
TimeoutandTransienterrors retry and count against the breaker. 4xx and malformed-request errors do not. - The final fallback is deny. Any uncaught error path returns
Verdict::Denywith atracing::warn!record.
Fail-Mode Matrix
Each row maps a failure mode to the verdict returned to the kernel.
| Condition | Default verdict | Configurable? |
|---|---|---|
| Circuit breaker open | Deny | CircuitOpenVerdict::Allow opt-in |
| Rate limiter empty | Deny | RateLimitedVerdict::Allow opt-in |
| Cache hit | cached verdict | TTL and capacity |
| Transient error (5xx, reset) | retries | RetryConfig backoff and attempts |
| Timeout | retries | RetryConfig backoff and attempts |
| Retries exhausted (still retryable) | Deny | raise max_retries to trade latency |
| Permanent error (4xx, malformed) | Deny | not configurable |
Provider returns Allow | Allow (cached) | cache TTL |
Provider returns Deny | Deny (cached) | cache TTL |
| Tool name does not match scope patterns | Allow | ScopedAsyncGuard tool patterns |
Do not enable advisory Allow for last-line-of-defense guards
Allow modes (CircuitOpenVerdict::Allow, RateLimitedVerdict::Allow) exist for guards whose outputs inform 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.Policy Wiring
External guards are instantiated through the policy-compiler path in the control plane, exercised by the build_pipeline_from_external_guard_policy test. The provider blocks live under a top-level guards: key in the native chio.yaml policy format (the ChioPolicy struct: kernel, guards, capabilities) — not under HushSpec, whose schema has no guards key and rejects one. The shape is the same for every provider:
- Provider-specific config block (credentials, endpoint, thresholds).
adaptertuning (cache, rate limit, circuit breaker, retry).tool_patterns: wildcard tool-name patterns the guard applies to.
Providers land under two guards groups: cloud_guardrails for content-safety providers and threat_intel for threat-intel feeds. The minimal policy below compiles through build_pipeline_from_external_guard_policy:
guards:
cloud_guardrails:
azure_content_safety:
enabled: true
endpoint: "https://<region>.cognitiveservices.azure.com"
api_key: "azure-key"
severity_threshold: 4
tool_patterns:
- "slack_*"
adapter:
cache_ttl_seconds: 60
rate_per_second: 20
rate_burst: 20
circuit_failure_threshold: 5
retry_max_retries: 3
threat_intel:
safe_browsing:
enabled: true
api_key: "sb-key"
base_url: "https://safebrowsing.googleapis.com/v4"
tool_patterns:
- "fetch_url"This is the native chio.yaml format, not HushSpec
guards: block above is a native chio.yaml policy. HushSpec documents are routed by the presence of a top-level hushspec: key and validate with deny_unknown_fields, so a file that starts with hushspec: "0.1.0" and also carries a top-level guards: block is rejected outright. Do not add a hushspec: line to this file.The adapter block is shared by every provider and maps to ExternalAdapterPolicyConfig. Fields that are absent fall back to the adapter defaults listed under Operator Tuning.
Endpoint Security
Every external-guard adapter that accepts a configurable endpoint routes it through chio_external_guards::validate_external_guard_url before any HTTP call is issued. The rules:
- Scheme must be
https. The one exception ishttp://localhostorhttp://127.0.0.1, permitted to support test doubles. - Host must not resolve to loopback, link-local, or RFC1918 private ranges. DNS resolution runs at config-load time by default. A no-DNS variant (
validate_external_guard_url_without_dns) exists for environments where DNS is unavailable at config time; it still enforces the scheme and literal-host checks. - Validation errors fail closed at policy load. The policy compiler calls
validate_external_guard_urland maps anyExternalGuardErrortoPolicyError::Invalid, so a bad endpoint is rejected at config load. The literal checks — malformed URL, non-httpsscheme, a loopback/link-local/RFC1918 literal host — return theExternalGuardError::Permanentvariant; a host that fails to resolve, or resolves to zero addresses, returnsExternalGuardError::Transient. Both paths reject the config before any request is issued.
Use the built-in URL validation
validate_external_guard_url before issuing a request. Use that validation instead of maintaining a duplicate URL check.Kernel Bridge
The kernel's Guard trait is synchronous. The bridge is chio_external_guards::ScopedAsyncGuard<E>, which:
- Wraps an
AsyncGuardAdapter<E>in a syncGuardimpl. - Scopes the guard to a set of wildcard tool-name patterns. Empty patterns mean the guard applies to every tool. A non-matching tool returns
Verdict::Allowwithout calling the external service, and without consuming rate-limit or cache budget. - Bridges async to sync by detecting the current Tokio runtime flavor:
MultiThreadruntime: usestokio::task::block_in_place.CurrentThreadruntime: spawns a fresh current-thread runtime on a scoped thread to avoid deadlocking the caller's executor.- No runtime in scope: builds a transient current-thread runtime.
External guards work from Chio's stdio, Streamable HTTP, and Envoy ext-authz entry points. If a runtime flavor is unsupported (for instance, a custom flavor added in a future Tokio release), the guard returns KernelError::GuardDenied with a diagnostic name rather than panicking.
Guard Evidence on Receipts
Every provider guard exposes an evidence_from_decision(...) method that returns a structured guard-evidence record. The adapter records this onto the receipt's guard-evidence block on both allow and deny outcomes. Provider crates version the guard-evidence format. Store external-guard provenance in this record.
The evidence block preserves at minimum:
- Guard name and provider identity.
- Provider decision label (Azure severity, Vertex probability, VirusTotal detection count, and so on).
- Upstream request correlation identity where the provider returns one.
- The cache-or-live flag, so auditors can tell a cached decision from a fresh call.
The receipt does not embed provider API response bodies verbatim. Operators who need raw provider telemetry should route that through SIEM export, not the receipt log.
Cache TTL bounds evidence freshness
cache_ttl_seconds to 0, or override cache_key to return None.Operator Tuning
Defaults come from AsyncGuardAdapterConfig::default():
| Knob | Default | Source |
|---|---|---|
| Cache capacity | 1024 entries | AsyncGuardAdapterConfig::default |
| Cache TTL | 60s | cache_ttl_seconds |
| Rate limit | 20 calls/sec, burst 20 | rate_per_second, rate_burst |
| Circuit failure threshold | 5 failures per 60s window | CircuitBreakerConfig::default |
| Circuit reset timeout | 30s | reset_timeout |
| Circuit half-open close | 2 consecutive successes | success_threshold |
| Retry max attempts | 3 | retry_max_retries |
| Retry base delay | 100ms (exponential, jittered) | RetryConfig::default |
Guidance:
- Start with the defaults.Widen the rate limit or shorten the cache TTL only when production traffic shows budget pressure or stale decisions.
- Do not tune the circuit-breaker threshold below 3. A noisy network will trip a 1- or 2-failure breaker, and the deny-on-open default will translate into blanket outage behavior.
- Cache TTL bounds evidence freshness. Set it low, or disable caching, when you need per-call freshness.
Claim Boundary
External guards participate in Chio's fail-closed pipeline but do not upgrade Chio's outward claims about the external service. Per docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md:
- An external-guard
Allowverdict attests that the provider did not flag the request, not that the provider's judgment is correct. - A cached verdict attests to a prior provider decision within the cache TTL, not a live one.
- A circuit-open
Denyattests that the provider was unreachable under the configured breaker policy, not that the request was semantically unsafe. - Receipts record the distinction; reports and exports must preserve it.
Do not collapse claim classes
Allow, a live Allow, and a circuit-open Deny are distinct attestations. Any stronger claim needs separate qualification and evidence.Next Steps
- Custom Guards · implement your own synchronous guard alongside the external ones
- Write a Policy · HushSpec authoring reference for the
rulesblocks - Inherit & Merge Policies · layer provider config across environments without duplication
- Guards (Concept) · how the guard pipeline fits into the kernel