BuildConnectnew
Govern Any Provider
Send Anthropic and Bedrock tool calls through the Chio kernel and return provider-native results.
Prerequisites
Shared Provider Interface
chio-tool-call-fabric defines the wire shape a native adapter's lift produces (a ToolInvocation), the verdict shape its lower consumes (a VerdictResult), and the streaming state machine that buffers a tool-call block until the verdict resolves. It holds no provider-specific logic and performs no I/O.
Three crates implement the ProviderAdapter trait directly — the OpenAI adapter, chio-anthropic-tools-adapter, and chio-bedrock-converse-adapter. Gemini, Groq, Mistral, Cohere, and Ollama use the same ToolInvocation / ProviderError / VerdictResult vocabulary in their own lift/lower functions without implementing the trait. The ProviderId enum names all eight, and each carries a Principal variant scoped to that provider's native identity boundary.
| ProviderId | Principal variant | Identity scope |
|---|---|---|
open_ai | OpenAiOrg | org_id |
anthropic | AnthropicWorkspace | workspace_id |
bedrock | BedrockIam | caller_arn, account_id, optional assumed_role_session_arn |
gemini | GeminiProject | project_id |
groq | GroqProject | project_id |
mistral | MistralProject | project_id |
cohere | CohereOrg | org_id |
ollama | OllamaHost | host (local daemon, no upstream identity provider) |
The Lift / Lower Contract
A ToolInvocation is what each adapter's lift emits. It names the provider, the tool, the canonical-JSON argument bytes, and a ProvenanceStamp binding the call to an upstream request id and principal.
pub struct ToolInvocation {
pub provider: ProviderId,
pub tool_name: String,
/// Canonical-JSON bytes (RFC 8785). Stored raw so the kernel can hash
/// without re-serializing.
pub arguments: Vec<u8>,
pub provenance: ProvenanceStamp,
}
pub struct ProvenanceStamp {
pub provider: ProviderId,
pub request_id: String, // toolu_... / toolUseId / call_...
pub api_version: String,
pub principal: Principal, // scoped to provenance.provider
pub received_at: SystemTime,
}ToolInvocation::validate is the fail-closed check an adapter or a replay consumer runs before trusting a value that crossed a boundary. It enforces the invariants the struct shape alone cannot:
providermust equalprovenance.provider.- The
Principalvariant must match the provider — a Bedrock invocation carrying anAnthropicWorkspaceprincipal is rejected. tool_name,request_id, andapi_versionmust be non-empty, free of surrounding whitespace, and free of control characters.argumentsmust re-canonicalize to itself byte-for-byte. Non-canonical bytes fail withNonCanonicalArguments.
The verdict side is the mirror image. The kernel returns a VerdictResult, which the adapter lowers back into the provider's tool-result shape.
pub enum VerdictResult {
Allow { redactions: Vec<Redaction>, receipt_id: ReceiptId },
Deny { reason: DenyReason, receipt_id: ReceiptId },
}
pub enum DenyReason {
PolicyDeny { rule_id: String },
GuardDeny { guard_id: String, detail: String },
CapabilityExpired,
PrincipalUnknown,
BudgetExceeded,
}
// Each redaction is a JSON Pointer path plus a replacement string,
// applied to the tool result on the allow path.
pub struct Redaction { pub path: String, pub replacement: String }Three opaque byte wrappers keep wire-format handling in the adapter: ProviderRequest (upstream request bytes an adapter lifts), ProviderResponse (bytes lower hands back to the transport), and ToolResult (canonical-JSON tool output for consistent downstream verification).
Anthropic Messages
chio-anthropic-tools-adapter is a mediation gateway. It owns the live transport to api.anthropic.com: send_messages forwards a native messages.create body to /v1/messages with the x-api-key and pinned anthropic-version: 2023-06-01 headers, then lifts every tool_use content block in the response.
[dependencies]
chio-anthropic-tools-adapter = "0.1"
chio-tool-call-fabric = "0.1"
chio-kernel = "0.1"
chio-manifest = "0.1"
tokio = { version = "1", features = ["full"] }use std::sync::Arc;
use chio_anthropic_tools_adapter::{
anthropic_transport_from_env, AnthropicAdapter, AnthropicAdapterConfig,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};
// ::new pins api_version to ANTHROPIC_VERSION ("2023-06-01"). workspace_id
// populates Principal::AnthropicWorkspace on every emitted ProvenanceStamp.
let config = AnthropicAdapterConfig::new(
"anthropic-front", // server_id
"Anthropic Messages", // server_name
"1.0.0", // server_version
std::env::var("CHIO_SERVER_PUBLIC_KEY")?, // hex Ed25519 public key
"wks_prod", // Anthropic workspace id
);
// Reads x-api-key from ANTHROPIC_API_KEY.
let transport = anthropic_transport_from_env()?;
let adapter = AnthropicAdapter::new(config, Arc::new(transport));
// Forward the messages.create body; lift every tool_use block in the reply.
let invocations = adapter.send_messages(&request_body).await?;
for invocation in &invocations {
invocation.validate()?; // fail closed before the kernel
let verdict: VerdictResult = kernel_verdict(invocation);
// Lower the verdict + executed output into a tool_result content block
// for the next user turn. The request_id is the tool_use id (toolu_...).
let block = adapter.lower_tool_result_block(
&invocation.provenance.request_id,
verdict,
ToolResult(tool_output_json.clone()),
)?;
// block.is_error is true on deny; its content carries the DenyReason.
}On the deny path the adapter emits a tool_result block with is_error: true whose content describes the DenyReason. The model receives the denial; the tool backend is not reached. If you drive the batch path yourself, lift_batch takes a ProviderRequest holding a response payload and returns the same Vec<ToolInvocation>.
Server Tools Need a Dual Gate
Anthropic's hosted server tools — computer_use, bash, and text_editor, plus their date-suffixed wire names — have more authority than client-hosted tools. The adapter fails closed on any of them unless both gates are open: the computer-use cargo feature must be compiled in (it also adds the anthropic-beta: computer-use-2025-01-24 header), and the tool manifest's server_tools list must name the matching stable entry. The feature alone does not admit a call.
| Anthropic wire name | Manifest entry |
|---|---|
computer_use, computer_use_YYYYMMDD | computer_use |
bash, bash_YYYYMMDD | bash |
text_editor, text_editor_YYYYMMDD | text_editor |
The mapping folds any 8-digit date suffix onto the bare name, so a wire version bump — bash_20241022 to a later date — stays behind the same allowlist entry. Build the gate from a validated manifest with AnthropicAdapter::new_with_manifest; the default AnthropicAdapter::new starts with a deny-all gate. Custom tool names Anthropic does not recognize as server tools skip the gate entirely.
Deny-all is the default
new and every server-tool call fails closed, even with the computer-use feature compiled. The manifest is the authority: only tools named in server_tools pass ensure_tool_allowed.Amazon Bedrock Converse
chio-bedrock-converse-adapter drives the Bedrock Runtime Converse operation through the AWS SDK for Rust, SigV4-signed, and lifts every toolUse content block in the response. The v1 API is pinned: BEDROCK_REGION is us-east-1 and BEDROCK_CONVERSE_API_VERSION is bedrock.converse.v1. BedrockAdapterConfig::validate and BedrockAdapter::new reject any other region or API version at construction, and the transport re-checks the region on every call.
use std::sync::Arc;
use aws_config::BehaviorVersion;
use chio_bedrock_converse_adapter::{
AwsSdkTransport, BedrockAdapter, BedrockAdapterConfig, ConverseRequest,
DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};
// ::new pins region to us-east-1 and api_version to bedrock.converse.v1.
let config = BedrockAdapterConfig::new(
"bedrock-front",
"Bedrock Converse",
"1.0.0",
std::env::var("CHIO_SERVER_PUBLIC_KEY")?,
"arn:aws:iam::123456789012:role/ChioAgentRole", // overwritten on the signed path
"123456789012",
);
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
.region("us-east-1")
.load()
.await;
let transport = AwsSdkTransport::from_sdk_config(&sdk_config)?;
// Production init resolves the IAM caller from a signed map before any
// tool traffic can be lifted (see below).
let adapter = BedrockAdapter::new_with_signed_iam_principals_config_from_sts(
config,
Arc::new(transport),
&sts_provider,
DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
&verifier,
&expected_identity,
).await?;
// Drive one Converse turn and lift every toolUse block.
let request = ConverseRequest::new("anthropic.claude-3-5-sonnet-20241022-v2:0", messages);
let invocations = adapter.converse(request).await?;
for invocation in &invocations {
let verdict: VerdictResult = kernel_verdict(invocation);
// response.0 is Bedrock JSON: { "toolResult": { toolUseId, content, status } }
let response = adapter.lower_tool_result(
&invocation.provenance.request_id, // toolUseId
verdict,
ToolResult(tool_output_json.clone()),
)?;
}If the Converse response carries a toolConfig, lift_batch enforces it: any lifted tool name must be declared in that config, or the payload fails closed as Malformed. On the allow path lower_tool_result applies JSON Pointer redactions and wraps the output as Bedrock content; on the deny path it emits a structured toolResult with status: "error" carrying a chio denial payload (verdict, receipt id, reason).
Resolve the IAM Caller
Bedrock has no per-request principal header the way Anthropic has a workspace. The adapter resolves the calling IAM identity from a signed map before any tool-use traffic is lifted. new_with_signed_iam_principals_config_from_sts resolves STS GetCallerIdentity once per process, loads config/iam_principals.toml, verifies the adjacent config/iam_principals.toml.sigstore-bundle.json through the chio-attest-verify verifier, and maps the caller ARN to the shared Principal::BedrockIam shape.
config_version = 1
default_action = "deny" # v1 accepts only "deny"
# Ordered list; the first exact or "*" wildcard match against the
# caller ARN wins.
[[mapping]]
match = "arn:aws:iam::123456789012:role/ChioAgentRole"
owner = "platform-team"
notes = "primary agent execution role"
[[mapping]]
match = "arn:aws:iam::123456789012:role/*"
owner = "platform-team"Resolution fails closed on a missing config file, a missing Sigstore bundle, a rejected signature, invalid TOML, an unsupported config_version, or a caller ARN with no matching entry. For an STS assumed-role caller the adapter keeps the session ARN in assumed_role_session_arn and stores the canonical role ARN separately in caller_arn. The resolved owner and the matched pattern are available via principal_owner() and matched_iam_principal_pattern().
Related: workload identity
Streaming Verdicts
The shared adapter crate's streaming state machine gates tool-call blocks at the block boundary for each provider. Frames are buffered while the kernel resolves a verdict, then flushed on allow or dropped on deny. StreamPhase walks Idle → Buffering → Emitting → Closed; adapters expose upstream events (Anthropic content_block_start / content_block_stop, Bedrock contentBlockStart / contentBlockDelta) as StreamEvents and evaluate the completed block before any of its bytes are released.
// Anthropic SSE: buffer each tool_use block through content_block_stop,
// evaluate it, and release its bytes only if the verdict allows.
let gated = adapter.gate_sse_stream(&sse_bytes, |invocation| {
Ok(kernel_verdict(invocation)) // Result<VerdictResult, ProviderError>
})?;
// gated.bytes: SSE frames that are safe to forward downstream.
// gated.invocations: the tool_use blocks evaluated, in stream order.
// gated.verdicts: the verdict returned for each, in the same order.
// Bedrock is the mirror image: gate_converse_stream buffers
// contentBlockStart / contentBlockDelta frames per block and releases
// them on allow, returning a GatedConverseStream.Buffering is bounded so a runaway upstream cannot pin unbounded heap while a verdict is pending. A single block is capped at DEFAULT_MAX_BUFFERED_BLOCK_BYTES (1 MiB) and DEFAULT_MAX_BUFFERED_RAW_FRAMES (4096 frames); an overflow fails the stream rather than continuing to buffer. If the evaluator misses the 250 ms stream gate the adapter preserves ProviderError::VerdictBudgetExceeded and fails closed; a slow verdict cannot become an allow.
The Error Taxonomy Is Shared
Both adapters map upstream failures to the shared adapter crate's ProviderError taxonomy. A native Anthropic error envelope or a Bedrock event-stream exception maps to one of seven named classes, or fails closed as Malformed. The catch-all Other variant is intentionally never produced by these adapters.
| ProviderError | Meaning |
|---|---|
RateLimited | Upstream throttling; preserves retry_after_ms when the provider exposes one |
ContentPolicy | Anthropic refusal or Bedrock guardrail intervention returned as a policy denial |
BadToolArgs | A tool_use.input / toolUse.input that cannot become canonical JSON object arguments |
Upstream5xx | Provider-side 5xx and overload envelopes, kept visible for retry and audit |
TransportTimeout | Local per-call timeout, classified separately from upstream timeout envelopes |
VerdictBudgetExceeded | The evaluator missed the streaming gate; fails closed with observed_ms and budget_ms |
Malformed | Impossible or out-of-order native payloads; the fail-closed default |
Detached Provenance
The Chio receipt is signed, but the ProvenanceStamp alone is not separately attestable from the receipt. When an auditor needs to verify the upstream identity — provider, request id, principal — without pulling the surrounding receipt, use detached signing. sign_provenance serializes the stamp to RFC 8785 canonical JSON, signs those bytes, and returns a SignedProvenance envelope carrying the stamp, the signed bytes, the algorithm, the public key, and the detached signature.
use chio_tool_call_fabric::{sign_provenance, verify_signed_provenance};
// Sign the stamp with the adapter's signing backend.
let envelope = sign_provenance(&invocation.provenance, &backend)?;
// verify_signed_provenance runs three checks in order:
// 1. envelope.algorithm matches the signature algorithm,
// 2. envelope.signed_bytes re-canonicalize from stamp byte-for-byte,
// 3. the signature verifies against public_key over signed_bytes.
// On success it returns the public key that signed the stamp.
let signer = verify_signed_provenance(&envelope)?;The envelope is itself canonical-JSON serializable, so it can be embedded in an audit log line, attached to a receipt, or transmitted as a standalone attestation. All cryptography is delegated to the same primitives used for capability tokens and receipts; the adapter adds no new key handling.
Same Kernel, Same Receipts
At the kernel boundary a single conversion shim, provider_verdict, turns a validated ToolInvocation into the kernel's internal request and turns the kernel's decision back into a VerdictResult. This keeps provider-specific terms out of kernel internals. Anthropic and Bedrock calls use the same receipt schema, guards, capability checks, and signature as the OpenAI path. The provenance stamp identifies the provider.
The lift/lower wire contract is pinned with byte-stable canonical-JSON fixtures under fixtures/lift_lower/{openai,anthropic,bedrock}/, and chio-provider-conformance replays captured invocations through each adapter to check the contract. The chio replay subcommand parses ToolInvocation from trace captured records, so a call from any provider can be re-evaluated offline against the same policy.
Next Steps
- Govern OpenAI Tool Calls · the shared execution model for OpenAI
- Receipts and the Receipt Format reference · the signed receipt produced for provider calls
- Capabilities · the scope model that decides which tools a token can invoke
- Write a Policy · the guards that evaluate a lifted invocation, provider-agnostic
- Bridge Between Protocols · route a lifted tool call to an MCP, A2A, or ACP backend