Skip to main content

chio_kernel/
provider_verdict.rs

1//! Provider-fabric verdict shim.
2//!
3//! Wires the provider-agnostic [`chio_tool_call_fabric`] crate into the kernel
4//! via a thin conversion layer. Lifts a [`ToolInvocation`] into a kernel
5//! [`ToolCallRequest`] (when paired with the surrounding capability context)
6//! and lowers a kernel [`ToolCallResponse`] into a fabric [`VerdictResult`].
7//! The shim is a single conversion point so the fabric vocabulary never leaks
8//! into the kernel's internals.
9
10use chio_core::canonical::canonical_json_bytes;
11use chio_core::session::OperationTerminalState;
12use chio_tool_call_fabric::{DenyReason, ProviderId, ReceiptId, ToolInvocation, VerdictResult};
13
14use crate::runtime::{ToolCallRequest, ToolCallResponse, Verdict};
15use crate::{AgentId, ServerId};
16use chio_core::capability::token::CapabilityToken;
17
18/// Errors surfaced when adapting fabric types into the kernel's MCP path.
19///
20/// The shim is conversion-only; the underlying MCP pipeline still owns its
21/// own [`crate::KernelError`] surface. This enum is scoped to the bytes-to-
22/// JSON translation step that bridges the fabric's canonical-JSON argument
23/// payload into [`serde_json::Value`].
24#[derive(Debug, thiserror::Error)]
25pub enum ProviderVerdictError {
26    /// The fabric arguments payload was not valid JSON. Fabric promises
27    /// canonical-JSON bytes (RFC 8785); a parse failure here is a contract
28    /// violation by the upstream adapter.
29    #[error("fabric arguments payload is not valid JSON: {0}")]
30    InvalidArguments(#[source] serde_json::Error),
31}
32
33/// Build a [`ToolCallRequest`] from a fabric [`ToolInvocation`] plus the
34/// surrounding kernel context (capability token, calling agent, target tool
35/// server). All policy decisions remain with `evaluate_tool_call*`; this
36/// helper only translates the wire shape.
37///
38/// `request_id` defaults to `invocation.provenance.request_id` so the
39/// upstream provider's request id flows into the kernel verdict and into the
40/// resulting receipt without a second round of bookkeeping.
41pub fn build_tool_call_request(
42    invocation: &ToolInvocation,
43    capability: CapabilityToken,
44    agent_id: AgentId,
45    server_id: ServerId,
46) -> Result<ToolCallRequest, ProviderVerdictError> {
47    let arguments = if invocation.arguments.is_empty() {
48        serde_json::Value::Null
49    } else {
50        serde_json::from_slice(&invocation.arguments)
51            .map_err(ProviderVerdictError::InvalidArguments)?
52    };
53
54    Ok(ToolCallRequest {
55        request_id: invocation.provenance.request_id.clone(),
56        capability,
57        tool_name: invocation.tool_name.clone(),
58        server_id,
59        agent_id,
60        arguments,
61        dpop_proof: None,
62        execution_nonce: None,
63        governed_intent: None,
64        approval_token: None,
65        approval_tokens: Vec::new(),
66        threshold_approval_proposal: None,
67        supplemental_authorization: None,
68        model_metadata: None,
69        federated_origin_kernel_id: None,
70    })
71}
72
73/// Lower a kernel [`ToolCallResponse`] into a fabric [`VerdictResult`].
74///
75/// The mapping is structural and lossless within the fabric's vocabulary:
76///
77/// - `Verdict::Allow` -> `VerdictResult::Allow { redactions: [], receipt_id }`
78/// - `Verdict::Deny` (and the non-allow terminal variants) ->
79///   `VerdictResult::Deny { reason, receipt_id }`
80/// - `Verdict::PendingApproval` -> `VerdictResult::Deny { reason, receipt_id }`
81///   so callers fail-closed if they ignore the approval channel. The fabric
82///   verdict vocabulary has no pending state, so an approval-gated outcome
83///   maps to a denial here.
84///
85/// Redactions are emitted as an empty list: the fabric verdict does not
86/// carry data-guard redaction detail.
87#[must_use]
88pub fn verdict_result_from_response(
89    invocation: &ToolInvocation,
90    response: &ToolCallResponse,
91) -> VerdictResult {
92    let receipt_id = ReceiptId(response.receipt.id.clone());
93    match response.verdict {
94        Verdict::Allow if matches!(response.terminal_state, OperationTerminalState::Completed) => {
95            VerdictResult::Allow {
96                redactions: Vec::new(),
97                receipt_id,
98            }
99        }
100        Verdict::Allow => VerdictResult::Deny {
101            reason: DenyReason::PolicyDeny {
102                rule_id: "kernel.execution_nonce_preflight".to_string(),
103            },
104            receipt_id,
105        },
106        Verdict::Deny => VerdictResult::Deny {
107            reason: classify_deny_reason(invocation, response),
108            receipt_id,
109        },
110        Verdict::PendingApproval => VerdictResult::Deny {
111            reason: DenyReason::PolicyDeny {
112                rule_id: "kernel.pending_approval".to_string(),
113            },
114            receipt_id,
115        },
116    }
117}
118
119/// Pick a [`DenyReason`] variant from the kernel response.
120///
121/// The kernel's deny pathway encodes its rationale as a free-form
122/// [`ToolCallResponse::reason`] string. We surface this as
123/// [`DenyReason::PolicyDeny`] with the kernel's reason as the `rule_id`,
124/// preserving information for auditors without inventing a richer mapping.
125/// Specialized variants (`CapabilityExpired`, `BudgetExceeded`, etc.) would
126/// require kernel-side classification; this shim maps every deny to the
127/// single [`DenyReason::PolicyDeny`] form.
128fn classify_deny_reason(_invocation: &ToolInvocation, response: &ToolCallResponse) -> DenyReason {
129    let detail = response
130        .reason
131        .clone()
132        .unwrap_or_else(|| "kernel.deny".to_string());
133    DenyReason::PolicyDeny { rule_id: detail }
134}
135
136/// Stable canonical-JSON byte form of a [`ToolInvocation`].
137///
138/// Adapters frequently need a stable hash of the invocation for telemetry
139/// and replay correlation. The kernel exposes its canonical-JSON helper
140/// already; this is a typed wrapper so callers do not import the helper
141/// directly. The wrapped helper returns the workspace's own
142/// [`chio_core::error::Error`]; callers that already work in that error
143/// space can map straight through `?`.
144pub fn canonical_invocation_bytes(
145    invocation: &ToolInvocation,
146) -> chio_core::error::Result<Vec<u8>> {
147    canonical_json_bytes(invocation)
148}
149
150/// Marker constant tying the shim to the fabric crate version it was built
151/// against. Used by the workspace's drift checks to flag when the fabric
152/// trait surface evolves without the kernel shim being revisited.
153pub const FABRIC_SHIM_PROVIDER_LANES: &[ProviderId] = &[
154    ProviderId::OpenAi,
155    ProviderId::Anthropic,
156    ProviderId::Bedrock,
157];
158
159impl crate::ChioKernel {
160    /// Compute a fabric [`VerdictResult`] for a provider-native tool
161    /// invocation by routing through the existing MCP verdict path.
162    ///
163    /// The shim builds a [`ToolCallRequest`] from the supplied invocation
164    /// plus the surrounding capability context, calls
165    /// [`crate::ChioKernel::evaluate_tool_call_blocking`], and lowers the
166    /// kernel response into a fabric verdict via
167    /// [`verdict_result_from_response`].
168    ///
169    /// The kernel-side fabric integration point. Adapters call this method
170    /// with an invocation already lifted from the upstream wire format and a
171    /// capability token resolved from their authentication path.
172    pub fn verdict_for_provider_invocation(
173        &self,
174        invocation: &ToolInvocation,
175        capability: CapabilityToken,
176        agent_id: AgentId,
177        server_id: ServerId,
178    ) -> Result<VerdictResult, crate::KernelError> {
179        let request = build_tool_call_request(invocation, capability, agent_id, server_id)
180            .map_err(|e| {
181                crate::KernelError::Internal(format!(
182                    "fabric arguments could not be lowered into kernel JSON: {e}"
183                ))
184            })?;
185        let response = self.evaluate_tool_call_blocking(&request)?;
186        Ok(verdict_result_from_response(invocation, &response))
187    }
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used, clippy::expect_used)]
192mod tests {
193    use super::*;
194    use chio_core::receipt::{body::ChioReceipt, decision::Decision, decision::ToolCallAction};
195    use chio_core::session::OperationTerminalState;
196    use chio_tool_call_fabric::{Principal, ProvenanceStamp};
197    use std::time::{Duration, SystemTime};
198
199    fn sample_invocation() -> ToolInvocation {
200        ToolInvocation {
201            provider: ProviderId::OpenAi,
202            tool_name: "search_web".to_string(),
203            arguments: br#"{"query":"chio"}"#.to_vec(),
204            provenance: ProvenanceStamp {
205                provider: ProviderId::OpenAi,
206                request_id: "call_abc123".to_string(),
207                api_version: "responses.2026-04-25".to_string(),
208                principal: Principal::OpenAiOrg {
209                    org_id: "org_123".to_string(),
210                },
211                received_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
212            },
213        }
214    }
215
216    fn synthetic_receipt(id: &str, decision: Decision) -> ChioReceipt {
217        // Build a minimal signed receipt for the conversion tests. These
218        // tests only exercise the structural mapping from kernel verdict
219        // to fabric verdict; signature verification is covered by the
220        // kernel's own receipt tests.
221        let kp = chio_core::crypto::Keypair::generate();
222        let body = chio_core::receipt::body::ChioReceiptBody {
223            id: id.to_string(),
224            timestamp: 1_700_000_000,
225            capability_id: "cap-test".to_string(),
226            tool_server: "srv-test".to_string(),
227            tool_name: "search_web".to_string(),
228            action: ToolCallAction {
229                parameters: serde_json::json!({"query": "chio"}),
230                parameter_hash: "0".repeat(64),
231            },
232            decision: Some(decision),
233            receipt_kind: chio_core::receipt::kinds::ReceiptKind::MediatedDecision,
234            boundary_class: chio_core::receipt::kinds::BoundaryClass::Prevent,
235            observation_outcome: None,
236            tool_origin: chio_core::receipt::kinds::ToolOrigin::CallerExecuted,
237            redaction_mode: chio_core::receipt::kinds::RedactionMode::None,
238            actor_chain: Vec::new(),
239            content_hash: "0".repeat(64),
240            policy_hash: "0".repeat(64),
241            evidence: Vec::new(),
242            metadata: None,
243            trust_level: Default::default(),
244            tenant_id: None,
245            kernel_key: kp.public_key(),
246            bbs_projection_version: None,
247        };
248        ChioReceipt::sign(body, &kp).unwrap()
249    }
250
251    fn allow_response() -> ToolCallResponse {
252        ToolCallResponse {
253            request_id: "call_abc123".to_string(),
254            verdict: Verdict::Allow,
255            output: None,
256            reason: None,
257            terminal_state: OperationTerminalState::Completed,
258            receipt: synthetic_receipt("rcpt_allow", Decision::Allow),
259            execution_nonce: None,
260        }
261    }
262
263    fn deny_response(reason: &str) -> ToolCallResponse {
264        let dec = Decision::Deny {
265            reason: reason.to_string(),
266            guard: "policy.deny".to_string(),
267        };
268        ToolCallResponse {
269            request_id: "call_abc123".to_string(),
270            verdict: Verdict::Deny,
271            output: None,
272            reason: Some(reason.to_string()),
273            terminal_state: OperationTerminalState::Completed,
274            receipt: synthetic_receipt("rcpt_deny", dec),
275            execution_nonce: None,
276        }
277    }
278
279    fn pending_response() -> ToolCallResponse {
280        ToolCallResponse {
281            request_id: "call_abc123".to_string(),
282            verdict: Verdict::PendingApproval,
283            output: None,
284            reason: Some("approval pending".to_string()),
285            terminal_state: OperationTerminalState::Incomplete {
286                reason: "approval pending".to_string(),
287            },
288            receipt: synthetic_receipt("rcpt_pending", Decision::Allow),
289            execution_nonce: None,
290        }
291    }
292
293    fn nonce_preflight_response() -> ToolCallResponse {
294        ToolCallResponse {
295            request_id: "call_abc123".to_string(),
296            verdict: Verdict::Allow,
297            output: None,
298            reason: None,
299            terminal_state: OperationTerminalState::Incomplete {
300                reason: "execution nonce preflight requires retry with presented nonce".to_string(),
301            },
302            receipt: synthetic_receipt("rcpt_preflight", Decision::Allow),
303            execution_nonce: None,
304        }
305    }
306
307    #[test]
308    fn provider_verdict_allow_maps_to_fabric_allow() {
309        let inv = sample_invocation();
310        let resp = allow_response();
311        let v = verdict_result_from_response(&inv, &resp);
312        match v {
313            VerdictResult::Allow {
314                redactions,
315                receipt_id,
316            } => {
317                assert!(redactions.is_empty());
318                assert_eq!(receipt_id, ReceiptId(resp.receipt.id.clone()));
319            }
320            other => panic!("expected allow, got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn provider_verdict_deny_carries_kernel_reason_as_rule_id() {
326        let inv = sample_invocation();
327        let resp = deny_response("budget exhausted");
328        let v = verdict_result_from_response(&inv, &resp);
329        match v {
330            VerdictResult::Deny { reason, receipt_id } => {
331                assert_eq!(receipt_id, ReceiptId(resp.receipt.id.clone()));
332                match reason {
333                    DenyReason::PolicyDeny { rule_id } => {
334                        assert_eq!(rule_id, "budget exhausted");
335                    }
336                    other => panic!("expected policy_deny, got {other:?}"),
337                }
338            }
339            other => panic!("expected deny, got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn provider_verdict_deny_falls_back_to_default_rule_id_when_reason_missing() {
345        let inv = sample_invocation();
346        let mut resp = deny_response("unused");
347        resp.reason = None;
348        let v = verdict_result_from_response(&inv, &resp);
349        let VerdictResult::Deny { reason, .. } = v else {
350            panic!("expected deny");
351        };
352        let DenyReason::PolicyDeny { rule_id } = reason else {
353            panic!("expected policy_deny");
354        };
355        assert_eq!(rule_id, "kernel.deny");
356    }
357
358    #[test]
359    fn provider_verdict_pending_approval_fails_closed() {
360        let inv = sample_invocation();
361        let resp = pending_response();
362        let v = verdict_result_from_response(&inv, &resp);
363        match v {
364            VerdictResult::Deny { reason, .. } => match reason {
365                DenyReason::PolicyDeny { rule_id } => {
366                    assert_eq!(rule_id, "kernel.pending_approval");
367                }
368                other => panic!("expected policy_deny for pending, got {other:?}"),
369            },
370            other => panic!("expected deny for pending approval, got {other:?}"),
371        }
372    }
373
374    #[test]
375    fn provider_verdict_nonce_preflight_fails_closed() {
376        let inv = sample_invocation();
377        let resp = nonce_preflight_response();
378        let v = verdict_result_from_response(&inv, &resp);
379        match v {
380            VerdictResult::Deny { reason, receipt_id } => {
381                assert_eq!(receipt_id, ReceiptId(resp.receipt.id.clone()));
382                match reason {
383                    DenyReason::PolicyDeny { rule_id } => {
384                        assert_eq!(rule_id, "kernel.execution_nonce_preflight");
385                    }
386                    other => panic!("expected policy_deny for nonce preflight, got {other:?}"),
387                }
388            }
389            other => panic!("expected deny for nonce preflight, got {other:?}"),
390        }
391    }
392
393    #[test]
394    fn provider_verdict_receipt_id_round_trips() {
395        let inv = sample_invocation();
396        let resp = allow_response();
397        let v = verdict_result_from_response(&inv, &resp);
398        let json = serde_json::to_string(&v).unwrap();
399        let back: VerdictResult = serde_json::from_str(&json).unwrap();
400        assert_eq!(v, back);
401    }
402
403    #[test]
404    fn provider_verdict_canonical_invocation_bytes_are_stable() {
405        let inv = sample_invocation();
406        let a = canonical_invocation_bytes(&inv).unwrap();
407        let b = canonical_invocation_bytes(&inv).unwrap();
408        assert_eq!(a, b);
409    }
410
411    #[test]
412    fn provider_verdict_known_provider_lanes_are_three() {
413        // Sanity check that the provider-id constant tracks the fabric.
414        assert_eq!(FABRIC_SHIM_PROVIDER_LANES.len(), 3);
415    }
416}