Chio/Docs

BuildConnectnew

Govern Any Provider

Send Anthropic and Bedrock tool calls through the Chio kernel and return provider-native results.

Prerequisites

Read Govern OpenAI Tool Calls first — both guides lift a call, evaluate it, lower the result, and sign a receipt. You also need the Chio CLI or the Rust crates installed; see Installation. The examples assume a running kernel whose boot (keypair, policy hash, tool servers) is configured elsewhere.

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.

ProviderIdPrincipal variantIdentity scope
open_aiOpenAiOrgorg_id
anthropicAnthropicWorkspaceworkspace_id
bedrockBedrockIamcaller_arn, account_id, optional assumed_role_session_arn
geminiGeminiProjectproject_id
groqGroqProjectproject_id
mistralMistralProjectproject_id
cohereCohereOrgorg_id
ollamaOllamaHosthost (local daemon, no upstream identity provider)
rendering…
Adapters convert provider-specific tool calls to ToolInvocation. The kernel returns a VerdictResult, which the adapter converts back to the provider response format.

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.

fabric-types.rs
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:

  • provider must equal provenance.provider.
  • The Principal variant must match the provider — a Bedrock invocation carrying an AnthropicWorkspace principal is rejected.
  • tool_name, request_id, and api_version must be non-empty, free of surrounding whitespace, and free of control characters.
  • arguments must re-canonicalize to itself byte-for-byte. Non-canonical bytes fail with NonCanonicalArguments.

The verdict side is the mirror image. The kernel returns a VerdictResult, which the adapter lowers back into the provider's tool-result shape.

fabric-verdict.rs
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.

Cargo.toml
[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"] }
src/anthropic.rs
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 nameManifest entry
computer_use, computer_use_YYYYMMDDcomputer_use
bash, bash_YYYYMMDDbash
text_editor, text_editor_YYYYMMDDtext_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

Construct with 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.

src/bedrock.rs
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/iam_principals.toml
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

The Bedrock IAM mapping is one instance of the broader Chio pattern for binding an execution environment to a Chio principal. See Workload Identity for how signed attestations anchor an agent's identity across clouds.

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.

src/stream_gate.rs
// 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.

ProviderErrorMeaning
RateLimitedUpstream throttling; preserves retry_after_ms when the provider exposes one
ContentPolicyAnthropic refusal or Bedrock guardrail intervention returned as a policy denial
BadToolArgsA tool_use.input / toolUse.input that cannot become canonical JSON object arguments
Upstream5xxProvider-side 5xx and overload envelopes, kept visible for retry and audit
TransportTimeoutLocal per-call timeout, classified separately from upstream timeout envelopes
VerdictBudgetExceededThe evaluator missed the streaming gate; fails closed with observed_ms and budget_ms
MalformedImpossible 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.

src/attest.rs
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