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.
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.
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.
python3 policy_service.pyPolicy 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.
export PRICING_POLICY_TOKEN="$(cat state/policy/token.txt)"
PRICING_POLICY_URL=http://127.0.0.1:4314/evaluate \
cargo run --locked --bin repricing-serviceThe 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:
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.
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.txtYou 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.
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.External adaptersWithin 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.
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:
$ 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
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:
chio policy explain chio.yamlpolicy chio.yaml
grants none
guards 1 azure-content-safety tools slack_*
2 safe-browsing tools fetch_url
output none
approvals noneA 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.
$ 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
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:
$ 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
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.
$ 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}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_patternssays 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 URLat load. The endpoint has a placeholder or unparseable host. Write the full endpoint.must use https or localhost-only http. A plainhttpendpoint that is not loopback. Usehttps; loopbackhttpserves 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 resolvedat 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. Readerror=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 aftersuccess_thresholdconsecutive successes in half-open. - A refusal the receipt cannot explain. The receipt carries
evidence[].guard_nameonly. Read thetracing::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
Try safe-browsing, CircuitOpenVerdict, retry_max_retries, or ScopedAsyncGuard.
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.
| Provider | Chio guard type | Registered name | Class | Policy key |
|---|---|---|---|---|
Azure AI Content Safety | Azure | azure- | Content safety | cloud_ |
Google Safe Browsing | Safe | safe-browsing | URL threat intel | threat_ |
AWS Bedrock Guardrails | Bedrock | bedrock- | Content safety | Constructed in Rust |
Google Vertex AI Safety | Vertex | 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
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_failureis never reached. - Permanent errors skip retries and still count against the breaker. The retry loop returns a permanent error as
Ok(Err(_)), and the surroundingErrarm callsrecord_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::Denywith atracing::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
| Condition | Default verdict | Configurable |
|---|---|---|
Circuit breaker open | Deny | Circuit opt-in |
Rate limiter empty | Deny | Rate 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 | Scoped 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://localhostandhttp://127.0.0.1are 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_dnsserves 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
ExternalGuardErrortoPolicyError::Invalid. A malformed URL, a non-httpsscheme, and a loopback, link-local, or RFC1918 literal host returnExternalGuardError::Permanent; a host that fails to resolve, or resolves to zero addresses, returnsExternalGuardError::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.
MultiThreadruntime. The bridge usestokio::task::block_in_place.CurrentThreadruntime. 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
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().
| Setting | Default | Source |
|---|---|---|
Cache capacity | 1024 entries | Async |
Cache TTL | 60s | cache_ |
Rate limit | 20 calls/sec, burst 20 | rate_per_second, rate_burst |
Circuit failure threshold | 5 failures per 60s window | Circuit |
Circuit reset timeout | 30s | reset_timeout |
Circuit half-open close | 2 consecutive successes | success_ |
Retry max attempts | 3 | retry_ |
Retry base delay | 100ms, exponential, jittered | Retry |
- 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.