Chio/Docs
LOGIN · JOIN

BuildPolicy

External Guards

Configure external guards, understand their failure behavior, and review the receipt evidence they record.

The compiler test is the schema

The external-guard crates (chio-external-guards, chio-guards::external) are workspace version 0.1.0. Treat the policy-compiler test build_pipeline_from_external_guard_policy as the executable spec for the YAML shape. Every provider config struct carries deny_unknown_fields, so a key this page does not name is a policy-load error rather than a no-op.

Prerequisites

  • A chio.yaml policy file. External guards are configured under its guards: key, not under HushSpec.
  • Credentials for the provider you are wiring, and an endpoint that passes the checks in Endpoint Security.
  • For a dry run, a session database and a receipt database: chio check refuses to evaluate without both, and each check needs its own fresh session database because it reuses one request id.

Provider Catalog

Six providers ship today. Each implements the async ExternalGuard trait and composes with the same AsyncGuardAdapter infrastructure. Two of the six are reachable from a policy file; the rest are constructed in Rust. The Policy key column says which is which.

ProviderChio guard typeRegistered nameClassPolicy key
Azure AI Content SafetyAzureContentSafetyGuardazure-content-safetyContent safetycloud_guardrails.azure_content_safety
Google Safe BrowsingSafeBrowsingGuardsafe-browsingURL threat intelthreat_intel.safe_browsing
AWS Bedrock GuardrailsBedrockGuardrailGuardbedrock-guardrailContent safetynone; construct in Rust
Google Vertex AI SafetyVertexSafetyGuardvertex-safetyContent safetynone; construct in Rust
VirusTotalVirusTotalGuardvirustotalURL threat intelnone; construct in Rust
SnykSnykGuardsnykVulnerabilitynone; construct in Rust

The registered name is the value a guard reports through Guard::name(), and it is the string that lands in a receipt's evidence[].guard_name when the guard denies.

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.

crates/guards/chio-guards/src/external/mod.rs338-399rust
pub async fn evaluate(&self, ctx: &GuardCallContext) -> Verdict {
    // 1. Circuit breaker check.
    if !self.circuit.allow_call() {
        return self.config.circuit_open_verdict.to_verdict();
    }

    // 2. Cache check. Done before rate limiting so that cached hits
    //    don't count against the external QPS budget.
    let cache_key = self.inner.cache_key(ctx);
    if let Some(key) = cache_key.as_ref() {
        if let Some(cached) = self.cache.get(key) {
            return cached;
        }
    }

    // 3. Rate limit.
    if !self.bucket.try_acquire() {
        return self.config.rate_limited_verdict.to_verdict();
    }

    // 4. Retry loop against the inner guard. A permanent error short-
    //    circuits by being returned as an Ok(Err(_)) so the retry
    //    loop doesn't keep calling a known-bad request.
    let inner = Arc::clone(&self.inner);
    let ctx_ref = ctx;
    let loop_outcome: Result<Result<Verdict, ExternalGuardError>, ExternalGuardError> =
        retry_with_jitter(&self.config.retry, move |_attempt| {
            let inner = Arc::clone(&inner);
            async move {
                match inner.eval(ctx_ref).await {
                    Ok(v) => Ok(Ok(v)),
                    Err(err) if err.is_retryable() => Err(err),
                    Err(err) => Ok(Err(err)),
                }
            }
        })
        .await;

    let call_result: Result<Verdict, ExternalGuardError> = match loop_outcome {
        Ok(inner) => inner,
        Err(err) => Err(err),
    };

    match call_result {
        Ok(verdict) => {
            self.circuit.record_success();
            if let Some(key) = cache_key {
                self.cache.insert(key, verdict, self.config.cache_ttl);
            }
            verdict
        }
        Err(err) => {
            self.circuit.record_failure();
            tracing::warn!(
                guard = self.inner.name(),
                error = %err,
                "external guard failed"
            );
            Verdict::Deny
        }
    }
}

Four rules follow from that body, and the third is the one operators most often get backwards:

  • 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. They return before the retry loop, so record_failure is never reached.
  • Permanent errors skip the retries but still count against the breaker. The retry loop returns a permanent error as Ok(Err(_)) so it is not attempted again, and the surrounding Err arm calls record_failure() unconditionally. A run of 4xx responses trips the breaker exactly as a run of 5xx responses does.
  • The final fallback is deny. Every error path returns Verdict::Deny with a tracing::warn! record naming the guard and the error.

Which errors are retryable is a two-line rule: ExternalGuardError::is_retryable matches Timeout and Transient, and nothing else. A provider maps its HTTP status through classify_status_error, which calls 5xx and 429 Transient and every other non-success status Permanent. A transport failure does not reach that classifier at all: the shared egress path wraps any dispatch error as Permanent, so a connection refusal or reset is not retried.


Fail-Mode Matrix

Each row maps a failure mode to the verdict returned to the kernel.

ConditionDefault verdictConfigurable?
Circuit breaker openDenyCircuitOpenVerdict::Allow opt-in
Rate limiter emptyDenyRateLimitedVerdict::Allow opt-in
Cache hitcached verdictTTL and capacity
Transient error (provider 5xx or 429)retriesRetryConfig backoff and attempts
TimeoutretriesRetryConfig backoff and attempts
Retries exhausted (still retryable)Denyraise max_retries to trade latency
Permanent error (provider 4xx, malformed response)Deny, no retry, counts against the breakernot configurable
Transport failure (connection refused or reset)Deny, no retry, counts against the breakernot configurable
Provider returns AllowAllow (cached)cache TTL
Provider returns DenyDeny (cached)cache TTL
Tool name does not match scope patternsAllowScopedAsyncGuard tool patterns

Do not enable advisory Allow for last-line-of-defense guards

The advisory 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.

Two groups exist under guards, and each holds exactly one provider: cloud_guardrails.azure_content_safety and threat_intel.safe_browsing. Both groups carry deny_unknown_fields, so a key for any other provider is a policy-load error rather than a silently ignored block. Wiring Bedrock, Vertex, VirusTotal or Snyk means constructing the guard in Rust and adding it to the pipeline yourself.

Both provider blocks take the same three parts:

  • Provider-specific config (credentials, endpoint, thresholds).
  • adapter tuning (cache, rate limit, circuit breaker, retry).
  • tool_patterns: wildcard tool-name patterns the guard applies to.

The policy below compiles through build_pipeline_from_external_guard_policy:

chio.yamlyaml
guards:
  cloud_guardrails:
    azure_content_safety:
      enabled: true
      endpoint: "https://eastus.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"

Write the endpoint out in full. A placeholder region reads as a host name, and the validator rejects it before the policy loads:

external-guards · bad-endpointtranscript
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
  check --policy ./placeholder.yaml --server chat --tool slack_post \
  --params '{"text": "ship it"}'
error [CHIO-CLI-POLICY]: invalid policy: permanent external error: cloud_guardrails.azure_content_safety.endpoint must be a valid URL: invalid international domain name
context: {"source":"invalid policy: permanent external error: cloud_guardrails.azure_content_safety.endpoint must be a valid URL: invalid international domain name"}
suggested fix: Fix the policy file contents or path so the requested command can load a valid policy document.
exit 1

The message names the field it came from, so the fix is always the line the error points at.

This is the native chio.yaml format, not HushSpec

The 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 is http://localhost or http://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_url and maps any ExternalGuardError to PolicyError::Invalid, so a bad endpoint is rejected at config load. Three literal checks return the ExternalGuardError::Permanent variant: a malformed URL, a non-https scheme, and a loopback/link-local/RFC1918 literal host. A host that fails to resolve, or resolves to zero addresses, returns ExternalGuardError::Transient. Both paths reject the config before any request is issued.

Use the built-in URL validation

Providers already call 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 sync Guard impl.
  • 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::Allow without calling the external service, and without consuming rate-limit or cache budget.
  • Bridges async to sync by detecting the current Tokio runtime flavor:
    • MultiThread runtime: uses tokio::task::block_in_place.
    • CurrentThread runtime: 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.

An external guard is an ordinary Guard impl once it is wrapped, so it runs anywhere the kernel evaluates the pipeline. Every path out of the bridge that is not one of the three flavors above fails closed: KernelError::GuardDenied naming the guard and the flavor, or naming the guard and the runtime-build error. Nothing panics, and the pipeline turns a guard Err into a deny.

Dispatch revalidation is deliberately a no-op

ScopedAsyncGuard::revalidate_before_dispatch returns Ok(()) without calling the adapter. External evaluation consumes rate-limit, cache, retry and breaker state, so a second call at dispatch time would double-spend the provider budget for one decision. The admission verdict is authoritative for that dispatch.

Guard Evidence on Receipts

A denial by an external guard puts one GuardEvidence record on the receipt, and the record has three fields:

crates/core/chio-core-types/src/receipt/metadata.rs186-194rust
pub struct GuardEvidence {
    /// Name of the guard (e.g. "ForbiddenPathGuard").
    pub guard_name: String,
    /// Whether the guard passed (true) or denied (false).
    pub verdict: bool,
    /// Optional details about the guard's decision.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub details: Option<String>,
}

The record is synthesized by the pipeline from the failing guard's name(), not by the provider adapter, and its details is a fixed string. So the receipt tells you which external guard denied and nothing about why the provider said no. An allow carries no evidence at all, because ScopedAsyncGuard::evaluate ends at GuardDecision::from_verdict, whose allow arm is GuardDecision::allow() with an empty evidence vector. Both halves in one read, from a policy whose Azure endpoint is a closed local port:

external-guards · evidencetranscript
$ chio --receipt-db ./receipts.db receipt list --admin-all \
    | jq -c '{tool_name, verdict: .decision.verdict, evidence}'
{"tool_name":"slack_post","verdict":"deny","evidence":[{"guard_name":"azure-content-safety","verdict":false,"details":"action=deny; reason=guard denied request"}]}
{"tool_name":"read_file","verdict":"allow","evidence":null}
exit 0deny

Each provider does implement an evidence_from_decision(...) method that builds a richer GuardEvidence carrying the provider's own decision detail, such as AzureDecisionDetails serialized into details. Nothing on the evaluation path calls it: its only callers in the workspace are two tests. Treat provider decision detail as log data, read from the tracing::warn! record the adapter emits, and route it to SIEM export rather than expecting it on the receipt.

The receipt does not embed provider API response bodies verbatim, and there is no field on GuardEvidence for an upstream request id or for whether a verdict came from the cache. Neither distinction is recoverable from a receipt.

Cache TTL bounds what a verdict attests to

A cached verdict attests to a decision made within the configured TTL, not a live call, and the receipt does not record which of the two it was. If you require a fresh call per request, set cache_ttl_seconds to 0, or override cache_key to return None.

Operator Tuning

Defaults come from AsyncGuardAdapterConfig::default():

KnobDefaultSource
Cache capacity1024 entriesAsyncGuardAdapterConfig::default
Cache TTL60scache_ttl_seconds
Rate limit20 calls/sec, burst 20rate_per_second, rate_burst
Circuit failure threshold5 failures per 60s windowCircuitBreakerConfig::default
Circuit reset timeout30sreset_timeout
Circuit half-open close2 consecutive successessuccess_threshold
Retry max attempts3retry_max_retries
Retry base delay100ms (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.

Verify the Result

Two dry runs settle whether a guard is wired the way you meant, and neither needs the provider to be reachable. Point the endpoint at a closed local port first: the guard is now guaranteed to fail, so any call it covers must deny.

external-guards · denytranscript
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
  check --policy ./chio.yaml --server chat --tool slack_post \
  --params '{"text": "ship it"}'
verdict:    DENY
tool:       slack_post
server:     chat
reason:     guard denied the request: guard "guard-pipeline" denied the request
receipt_id: 76ac26ec78bec52593f4b808d6911fa0d8ee41b263dfac803ced9b206cd84931
policy:     5977a837dcba0042563e2d89ac43691543dcae0e78226f80ef0f7f788f750738
source:     8a12d5aaa2ffd88ee23d7dd164619108125fc4d8ebbe67873dce748bedfe8798
mode:       preflight
fixture:    false
WARN chio_guards::external message=external guard failed guard=azure-content-safety error=permanent external error: HttpEgressContract rejects external guard dispatch: invalid egress URL: dispatch failed (connect error): error sending request for url (http://127.0.0.1:9/contentsafety/text:analyze?api-version=2023-10-01)
WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
exit 2deny

Read the stderr line, not the verdict line. The verdict names the pipeline (guard-pipeline) for every guard denial; the guard= field on the warning is what tells you the external guard was the one that fired, and the error= field carries the provider failure.

Then call a tool that is outside tool_patterns. It should allow, and it should allow without the guard reaching the provider at all:

external-guards · allow-out-of-scopetranscript
$ chio --session-db ./admission-3.db --receipt-db ./receipts.db \
  check --policy ./chio.yaml --server chat --tool read_file \
  --params '{"path": "./README.md"}'
verdict:    ALLOW
tool:       read_file
server:     chat
receipt_id: b9c169ed21e77c7e77c5da890cb987e0cb31f8f99cd32390202d3d753809aae4
policy:     5977a837dcba0042563e2d89ac43691543dcae0e78226f80ef0f7f788f750738
source:     8a12d5aaa2ffd88ee23d7dd164619108125fc4d8ebbe67873dce748bedfe8798
mode:       preflight
fixture:    false
exit 0allow

No warning on stderr is the signal here. A tool that does not match the patterns returns Verdict::Allow before any call, so it spends no rate-limit token and leaves no cache entry. If you see the provider warning on a tool you expected to be out of scope, the pattern is wider than you think.


Failures and Recovery

SymptomCauseFix
must be a valid URL at policy loadThe endpoint has a placeholder or otherwise unparseable host.Write the real endpoint. The error names the config field it came from.
must use https or localhost-only httpA plain http endpoint that is not loopback.Use https. Loopback http is permitted only for test doubles.
must not target localhost, link-local, or private-network hostsThe endpoint literal is loopback, link-local or RFC1918.Point at the provider. This check exists to stop a policy turning a guard into an internal-network probe.
could not be resolved at policy loadDNS is unavailable or the host does not exist. Returns the transient variant.Fix DNS, or construct the guard through the no-DNS validator when config-time resolution is not available.
Every covered call denies, warning says external guard failedThe provider is unreachable or rejecting. Fail-closed is working.Read error= on the warning. A permanent error is not retried, so a bad key denies immediately rather than after the backoff.
Denials continue after the provider recoversThe breaker is open. Permanent errors counted toward it, so a burst of 4xx opens it as readily as an outage.Wait out reset_timeout. The breaker closes after success_threshold consecutive successes in half-open.
A denial you cannot explain from the receiptProvider decision detail never reaches the receipt.Read the tracing::warn! record. The receipt gives you evidence[].guard_name and nothing more.
A guard you configured never firesThe tool name does not match tool_patterns, or the provider has no policy key.Check the pattern against the tool name. Only the two providers in the catalog table with a policy key can be wired from YAML.

Claim Boundary

External guards participate in Chio's fail-closed pipeline but do not upgrade Chio's outward claims about the external service. The governing rule is the one docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md states for lineage, that no report or export surface may collapse an asserted claim into a verified one. Applied here it gives four distinct attestations:

  • An external-guard Allow verdict 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 Deny attests that the provider was unreachable under the configured breaker policy, not that the request was semantically unsafe.
  • An Allow for a tool outside tool_patterns attests to nothing about the request. The guard never looked at it.

The receipt does not separate these. It records the verdict and, on a deny, the name of the guard that produced it. Anything that needs to distinguish a cached allow from a live one, or a circuit-open deny from a provider deny, has to carry that distinction itself from the adapter's log records.

Do not collapse claim classes

Do not add release-facing language ("content-safety verified", "threat-intel screened") that folds these classes together. A cached Allow, a live Allow, and a circuit-open Deny are distinct attestations. Any stronger claim needs separate qualification and evidence.

Next Steps