1use 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#[derive(Debug, thiserror::Error)]
25pub enum ProviderVerdictError {
26 #[error("fabric arguments payload is not valid JSON: {0}")]
30 InvalidArguments(#[source] serde_json::Error),
31}
32
33pub 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#[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
119fn 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
136pub fn canonical_invocation_bytes(
145 invocation: &ToolInvocation,
146) -> chio_core::error::Result<Vec<u8>> {
147 canonical_json_bytes(invocation)
148}
149
150pub const FABRIC_SHIM_PROVIDER_LANES: &[ProviderId] = &[
154 ProviderId::OpenAi,
155 ProviderId::Anthropic,
156 ProviderId::Bedrock,
157];
158
159impl crate::ChioKernel {
160 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 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 assert_eq!(FABRIC_SHIM_PROVIDER_LANES.len(), 3);
415 }
416}