Chio/Docs
LOGIN · JOIN

External Guards

Put a policy service you already run in front of your tools, then watch it refuse when the service is slow, overloaded, or down.

Guide chio@5f63e6e

On this page

Your own policy service decides whether a covered tool call runs. The external guard asks it during admission, before the tool runs, and refuses the call when the service rejects it or cannot answer.

Steps 1 to 4 extend Larkfield's repricing service from Handle Tool Outcomes with a pricing policy service that refuses any repricing deeper than 40 percent. Steps 5 to 7 wire a built-in provider from chio.yaml and read its refusal in a receipt.

Start

1. Start the pricing policy service

Start from chio new pricing-desk --example pricing-desk, or download the project, and start its policy service from the project root.

Terminal 1 · pricing-deskbash
python3 policy_service.py
Output
Policy service: http://127.0.0.1:4314/evaluate
Credential: state/policy/token.txt; mode: state/policy/mode.txt

You should see the service address and its two state files. The service creates its bearer credential in state/policy/token.txt and reads its operating mode from state/policy/mode.txt.

2. Connect the repricing service through an external-guard adapter

Stop any repricing service that is running. In a second terminal, pass the credential and the service address to the host.

Terminal 2 · pricing-deskbash
export PRICING_POLICY_TOKEN="$(cat state/policy/token.txt)"
PRICING_POLICY_URL=http://127.0.0.1:4314/evaluate \
  cargo run --locked --bin repricing-service

The host wraps the service in Chio's AsyncGuardAdapter, which owns the cache, the rate limiter, the circuit breaker, and retries. These are the operator's settings for this example:

src/external_policy.rs · operator-selected behaviorrust
AsyncGuardAdapter::builder(Arc::new(guard))
    .cache_ttl(Duration::from_secs(5))
    .rate_limit(5.0, 2)
    .retry(RetryConfig { max_retries: 0, ..Default::default() })
    .build()

Verdicts stay cached for five seconds, and the bucket refills at five tokens a second with a burst of two. The HTTP client has a 300 ms deadline, and max_retries: 0 makes one provider attempt, so each failure shows up directly. Adapter overload, an open circuit, a failed request, or an invalid response refuses the job.

Complete files: policy service, external-guard adapter, and host dispatch.

3. Start a 10 percent repricing and a 60 percent one

Open the repricing page on port 4312 with the token from state/repricing/client-token.txt. Start a job at 10 percent, then one at 60 percent.

You should see the 10 percent job run, subject to the remaining allowance. The 60 percent job receives a signed refusal and writes no price sheet. The policy check runs before the job's budget reservation, so the refusal reserves nothing.

4. Slow, overload, and stop the service

Switch the service's mode from a third terminal. Start a job at a discount you have not used after each change, so the request misses the cache.

Terminal 3 · pricing-deskbash
echo slow > state/policy/mode.txt
# Start a job at a new discount: the 300 ms client deadline expires.
echo overloaded > state/policy/mode.txt
# Start another at a new discount: the provider returns HTTP 429.
echo healthy > state/policy/mode.txt

You should see a refusal in both modes: the slow response outlasts the 300 ms deadline, and the overloaded service answers HTTP 429. Stop the service and start a job at another new discount to see a connection failure refused the same way.

One external checkChoose an outcome.
gscoped guardcircuit breakercacherate limiterfprovidervverdictgscoped guardbreakercacheratefprovidervverdict

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.

One external check inside a synchronous guard pipeline, in the order 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

Within five seconds, a job at a discount already evaluated can still return its cached verdict while the circuit permits calls. The cache key binds the policy version, agent, server, tool, and arguments; the job ID is outside it. A change inside the policy service leaves cached verdicts in place until they expire.

After enough failures the circuit opens and refuses cached and uncached calls alike until it recovers. The service's request log separates a network evaluation from a cache hit. These controls belong to the host's policy integration, and a caller has no setting that skips them.

5. Wire a built-in provider from chio.yaml

Six providers ship with Chio. Azure AI Content Safety and Google Safe Browsing load from a policy file; Bedrock Guardrails, Vertex AI Safety, VirusTotal, and Snyk are constructed in Rust and added to the pipeline. External guards live under the guards: key of a native chio.yaml policy, beside kernel and capabilities.

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"

Each provider block takes the provider's settings, an adapter block for cache, rate limit, circuit breaker, and retry, and tool_patterns, the wildcard tool names the guard covers. Every block parses with deny_unknown_fields, so a misspelled key fails the load. Keep this file free of a hushspec: line. HushSpec is a separate format, routed by that key, and it rejects a top-level guards: block.

Write each endpoint in full. The validator rejects a placeholder host before the policy loads, and the message names the field it came from:

external-guards · bad-endpointtranscript
$ chio --session-db ./admission.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
  help: fix the policy entry the message names, then run the command again
  code: urn:chio:error:policy:compile-failed
  docs: https://chio.computer/docs/reference/errors#error-policy-compile-failed
exit 1

With the endpoint fixed, confirm which tools each provider covers before any call reaches it. chio policy explain prints the compiled pipeline with each external guard's tool_patterns:

Terminalbash
chio policy explain chio.yaml
Output
policy     chio.yaml
grants     none
guards     1 azure-content-safety   tools slack_*
           2 safe-browsing          tools fetch_url
output     none
approvals  none

A tool that matches neither pattern passes both guards without a provider call, which step 6 shows from the other side.

6. Dry-run a covered tool and an uncovered one

chio check evaluates one call against the policy and needs no reachable provider. It takes a session database (--session-db) and a receipt database (--receipt-db); keep the same two through step 7. Point the Azure endpoint at a closed local port, so the guard fails on every call it covers.

external-guards · denytranscript
$ chio --session-db ./admission.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: 1875b7a5cc8b080257d89d3ecceb128d87b3c5766337577b17c56878e902aa9a
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-3ff7b4fa-5c96-488f-8ad4-cbddd64eb017 reason=guard denied the request: guard \"guard-pipeline\" denied the request
exit 2deny

You should see DENY with guard-pipeline in the reason, because the pipeline is the one guard the CLI registers. The guard= field on the warning names the external guard that fired, and error= carries the provider failure. Then call a tool outside tool_patterns:

external-guards · allow-out-of-scopetranscript
$ chio --session-db ./admission.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: 732c23c1699ad96d38510e361801776f249f4a1aef169bdcdd3e4d725b54f3c3
policy:     5977a837dcba0042563e2d89ac43691543dcae0e78226f80ef0f7f788f750738
source:     8a12d5aaa2ffd88ee23d7dd164619108125fc4d8ebbe67873dce748bedfe8798
mode:       preflight
fixture:    false
exit 0allow

You should see ALLOW and no provider warning. An uncovered tool returns allow before any provider call, spends no rate token, and leaves no cache entry. A provider warning on a tool you meant to leave uncovered means the pattern is wider than intended.

7. Read the refusing guard from the receipt

List both decisions from the receipt store with their guard evidence.

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

You should see slack_post refused with one GuardEvidence entry naming azure-content-safety, and read_file allowed with no evidence. The pipeline builds that entry from the failing guard's name(), with a fixed details string. The adapter's warning log records provider request errors. When an audit needs the provider's decision detail, record it in your host's authenticated provider log, linked to the invocation.

Live, cached, and circuit-open verdicts share one receipt shape

A receipt records the adapter's verdict and, on a refusal, the guard's name. Four different events produce those same fields:

  • A live allow reports the provider's policy verdict on this request; the correctness of that verdict rests with the provider.
  • A cached verdict reports a provider decision made within the cache TTL.
  • A circuit-open refusal reports that the breaker opened after counted failures, which can include permanent provider errors. No provider evaluation ran for this call.
  • An allow for a tool outside tool_patterns says nothing about the request: the guard never examined it.

GuardEvidence has no field for an upstream request id or a cache hit. Keep the adapter's log records beside the receipts when an audit must separate these classes, and name each class for what it is in any release-facing claim. For a fresh provider call on every request, set cache_ttl_seconds to 0 or override cache_key to return None.

A bad endpoint fails at load; a failing provider fails each call

Endpoint errors stop the policy from loading and name the field. Provider errors refuse each covered call and appear on the adapter's warning.

  • must be a valid URL at load. The endpoint has a placeholder or unparseable host. Write the full endpoint.
  • must use https or localhost-only http. A plain http endpoint that is not loopback. Use https; loopback http serves test doubles only.
  • must not target localhost, link-local, or private-network hosts. The endpoint literal is loopback, link-local, or RFC1918. Point it at the provider; the check keeps a policy from turning a guard into an internal-network probe.
  • could not be resolved at load. DNS is unavailable or the host does not exist, and the transient variant is returned. Fix DNS, or build the guard through the no-DNS validator where config-time resolution is unavailable.
  • Every covered call is refused with external guard failed. The provider is unreachable or rejecting, and fail-closed is working. Read error= on the warning. A permanent error skips retries, so a bad key refuses immediately.
  • Refusals continue after the provider recovers. The breaker is open; permanent errors count 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 refusal the receipt cannot explain. The receipt carries evidence[].guard_name only. Read the tracing::warn! record for the provider error.
  • A configured guard never fires. The tool name falls outside tool_patterns, or the provider has no policy key. Check the pattern against the tool name; only Azure AI Content Safety and Google Safe Browsing load from YAML.

Providers, fail modes, and adapter settings

Provider catalog

Each provider implements the async ExternalGuard trait and composes with the same AsyncGuardAdapter. The registered name is the value Guard::name() reports, and the string a receipt carries in evidence[].guard_name when the guard refuses.

ProviderChio guard typeRegistered nameClassPolicy key
Azure AI Content Safety
AzureContentSafetyGuard
azure-content-safety
Content safety
cloud_guardrails.azure_content_safety
Google Safe Browsing
SafeBrowsingGuard
safe-browsing
URL threat intel
threat_intel.safe_browsing
AWS Bedrock Guardrails
BedrockGuardrailGuard
bedrock-guardrail
Content safety
Constructed in Rust
Google Vertex AI Safety
VertexSafetyGuard
vertex-safety
Content safety
Constructed in Rust
VirusTotal
VirusTotalGuard
virustotal
URL threat intel
Constructed in Rust
Snyk
SnykGuard
snyk
Vulnerability
Constructed in Rust

All six fail closed by default: a downstream error produces Verdict::Deny. Advisory behavior, returning Allow on a degraded state, is an explicit opt-in per adapter. The adapters live in chio-external-guards and chio-guards::external, both at workspace version 0.1.0.

Policy compiler path

The control plane instantiates external guards through the policy compiler, and the build_pipeline_from_external_guard_policy test is the executable specification for the YAML shape. The native policy is the ChioPolicy struct with kernel, guards, and capabilities. Two groups exist under guards, each holding one provider: cloud_guardrails.azure_content_safety and threat_intel.safe_browsing. Every provider config struct and both groups carry deny_unknown_fields, so a key for another provider is a load error. The shared adapter block maps to ExternalAdapterPolicyConfig; absent fields take the defaults under Adapter defaults.

Adapter evaluation order

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:

  • The cache is read before the token bucket. Cache hits spend no rate-limit budget.
  • Rate-limited calls never count as breaker failures. They return before the retry loop, so record_failure is never reached.
  • Permanent errors skip retries and still count against the breaker. The retry loop returns a permanent error as Ok(Err(_)), 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.

ExternalGuardError::is_retryable matches Timeout and Transient only. A provider maps its HTTP status through classify_status_error, which calls 5xx and 429 Transient and every other non-success status Permanent. The shared egress path wraps any dispatch error as Permanent, so a refused or reset connection is not retried.

Fail modes

ConditionDefault verdictConfigurable
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 or 429)
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 response)
Deny, no retry, counts against breaker
Fixed
Transport failure (refused or reset)
Deny, no retry, counts against breaker
Fixed
Provider returns Allow
Allow, cached
Cache TTL
Provider returns Deny
Deny, cached
Cache TTL
Tool outside the scope patterns
Allow
ScopedAsyncGuard tool patterns

The Allow fallbacks suit guards whose output feeds a human-review queue. Keep both at Deny for a guard that is the last check on a capability.

Endpoint validation

Every adapter with a configurable endpoint passes it through chio_external_guards::validate_external_guard_url before any HTTP call, and providers call it themselves, so a host needs no duplicate URL check.

  • The scheme is https. http://localhost and http://127.0.0.1 are permitted for test doubles.
  • The host resolves outside loopback, link-local, and RFC1918 ranges. DNS resolution runs at config-load time by default. validate_external_guard_url_without_dns serves environments without DNS at config time and still enforces the scheme and literal-host checks.
  • Validation fails closed at policy load. The policy compiler maps any ExternalGuardError to PolicyError::Invalid. A malformed URL, a non-https scheme, and a loopback, link-local, or RFC1918 literal host return ExternalGuardError::Permanent; a host that fails to resolve, or resolves to zero addresses, returns ExternalGuardError::Transient. Both reject the config before any request.

Kernel bridge

The kernel's Guard trait is synchronous. chio_external_guards::ScopedAsyncGuard<E> wraps an AsyncGuardAdapter<E> in a sync Guard and scopes it to wildcard tool-name patterns. Empty patterns cover every tool. A tool outside the patterns returns Verdict::Allow without calling the service and without touching rate-limit or cache state.

  • MultiThread runtime. The bridge uses tokio::task::block_in_place.
  • CurrentThread runtime. The bridge spawns a fresh current-thread runtime on a scoped thread, keeping the caller's executor free of deadlock.
  • No runtime in scope. The bridge builds a transient current-thread runtime.

Once wrapped, an external guard runs anywhere the kernel evaluates the pipeline. Every other path out of the bridge fails closed with KernelError::GuardDenied, naming the guard and the runtime flavor or the runtime-build error; nothing panics, and the pipeline turns a guard Err into a deny. ScopedAsyncGuard::revalidate_before_dispatch returns Ok(()) without calling the adapter: a second evaluation would spend the rate-limit, cache, retry, and breaker budget twice for one decision, so the admission verdict governs that dispatch.

Guard evidence record

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>,
}

An allow carries no evidence: ScopedAsyncGuard::evaluate ends at GuardDecision::from_verdict, whose allow arm is GuardDecision::allow() with an empty evidence vector. A refusal's receipt entry comes from the pipeline: the failing guard's name() and a fixed details string. Each provider also implements evidence_from_decision(...), which builds a GuardEvidence from the provider's decision detail, such as AzureDecisionDetails serialized into details. Provider API response bodies stay out of the receipt.

Adapter defaults

Defaults come from AsyncGuardAdapterConfig::default().

SettingDefaultSource
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
  • Start with the defaults. Widen the rate limit or shorten the cache TTL when traffic shows budget pressure or stale decisions.
  • Keep the breaker threshold at 3 or above. A noisy network trips a one- or two-failure breaker, and the deny-on-open default turns that into a blanket outage.
  • The cache TTL bounds evidence freshness. Set it low, or disable caching, for per-call freshness.

Next: tune the adapter for your provider