1use std::collections::{BTreeMap, HashMap};
2use std::sync::Arc;
3
4use chio_core_types::capability::{
5 CapabilityToken, ChioScope, ModelMetadata, Operation, ToolGrant,
6};
7use chio_core_types::crypto::{Keypair, PublicKey};
8use chio_core_types::receipt::GuardEvidence;
9use chio_cross_protocol::{
10 plan_authoritative_route, route_selection_metadata, DiscoveryProtocol, TargetProtocolRegistry,
11};
12use chio_kernel::{
13 ApprovalStore, ChioKernel, Guard, GuardContext, InMemoryApprovalStore, KernelConfig,
14 KernelError, ToolCallRequest, ToolServerConnection, Verdict as KernelVerdict,
15 DEFAULT_CHECKPOINT_BATCH_SIZE, DEFAULT_MAX_STREAM_DURATION_SECS,
16 DEFAULT_MAX_STREAM_TOTAL_BYTES,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value};
20use thiserror::Error;
21
22use crate::{
23 http_status_metadata_decision, http_status_metadata_final, CallerIdentity, ChioHttpRequest,
24 HttpMethod, HttpReceipt, HttpReceiptBody, Verdict, CHIO_KERNEL_RECEIPT_ID_KEY,
25};
26
27const HTTP_AUTHORITY_SERVER_ID: &str = "chio_http_authority";
28const HTTP_AUTHORITY_TOOL_NAME: &str = "authorize_http_request";
29const HTTP_AUTHORITY_TTL_SECS: u64 = 60;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum HttpAuthorityPolicy {
34 SessionAllow,
35 DenyByDefault,
36}
37
38#[derive(Clone)]
39pub struct HttpAuthority {
40 keypair: Arc<Keypair>,
41 policy_hash: String,
42 kernel: Arc<ChioKernel>,
43 kernel_subject: PublicKey,
44 kernel_agent_id: String,
45 approval_store: Arc<dyn ApprovalStore>,
46 trusted_capability_issuers: Vec<PublicKey>,
47}
48
49impl std::fmt::Debug for HttpAuthority {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_struct("HttpAuthority")
52 .field("policy_hash", &self.policy_hash)
53 .field("kernel_agent_id", &self.kernel_agent_id)
54 .finish_non_exhaustive()
55 }
56}
57
58pub struct HttpAuthorityInput<'a> {
59 pub request_id: String,
60 pub method: HttpMethod,
61 pub route_pattern: String,
62 pub path: &'a str,
63 pub query: &'a HashMap<String, String>,
64 pub caller: CallerIdentity,
65 pub body_hash: Option<String>,
66 pub body_length: u64,
67 pub session_id: Option<String>,
68 pub capability_id_hint: Option<&'a str>,
69 pub presented_capability: Option<&'a str>,
70 pub requested_tool_server: Option<&'a str>,
71 pub requested_tool_name: Option<&'a str>,
72 pub requested_arguments: Option<&'a Value>,
73 pub model_metadata: Option<&'a ModelMetadata>,
74 pub policy: HttpAuthorityPolicy,
75}
76
77#[derive(Debug, Clone)]
78pub struct PreparedHttpEvaluation {
79 pub verdict: Verdict,
80 pub evidence: Vec<GuardEvidence>,
81 pub request_id: String,
82 pub route_pattern: String,
83 pub http_method: HttpMethod,
84 pub caller_identity_hash: String,
85 pub content_hash: String,
86 pub session_id: Option<String>,
87 pub capability_id: Option<String>,
88 pub kernel_receipt_id: String,
89 pub route_selection_metadata: Option<Value>,
90}
91
92#[derive(Debug, Clone)]
93pub struct HttpAuthorityEvaluation {
94 pub verdict: Verdict,
95 pub receipt: HttpReceipt,
96 pub evidence: Vec<GuardEvidence>,
97}
98
99#[derive(Debug, Error)]
100pub enum HttpAuthorityError {
101 #[error("failed to hash caller identity: {0}")]
102 CallerIdentity(String),
103
104 #[error("failed to compute content hash: {0}")]
105 ContentHash(String),
106
107 #[error("kernel-backed authorization failed: {0}")]
108 Kernel(String),
109
110 #[error("kernel-backed authorization requires approval")]
111 PendingApproval {
112 approval_id: Option<String>,
113 kernel_receipt_id: String,
114 },
115
116 #[error("failed to sign receipt: {0}")]
117 ReceiptSign(String),
118}
119
120#[derive(Debug, Clone)]
121struct PresentedCapabilityState {
122 capability_id: Option<String>,
123 invalid_reason: Option<String>,
124}
125
126#[derive(Clone, Copy)]
127struct RequestedToolInvocation<'a> {
128 server_id: &'a str,
129 tool_name: &'a str,
130 arguments: &'a Value,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134struct HttpKernelAuthorizationRequest {
135 request_id: String,
136 method: HttpMethod,
137 route_pattern: String,
138 path: String,
139 content_hash: String,
140 caller_identity_hash: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 session_id: Option<String>,
143 policy: HttpAuthorityPolicy,
144 capability: HttpKernelCapabilityState,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148struct HttpKernelCapabilityState {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 id: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 invalid_reason: Option<String>,
153}
154
155struct HttpAuthorizationServer;
156
157impl ToolServerConnection for HttpAuthorizationServer {
158 fn server_id(&self) -> &str {
159 HTTP_AUTHORITY_SERVER_ID
160 }
161
162 fn tool_names(&self) -> Vec<String> {
163 vec![HTTP_AUTHORITY_TOOL_NAME.to_string()]
164 }
165
166 fn invoke(
167 &self,
168 tool_name: &str,
169 _arguments: serde_json::Value,
170 _nested_flow_bridge: Option<&mut dyn chio_kernel::NestedFlowBridge>,
171 ) -> Result<serde_json::Value, KernelError> {
172 if tool_name != HTTP_AUTHORITY_TOOL_NAME {
173 return Err(KernelError::Internal(format!(
174 "unsupported HTTP authority tool: {tool_name}"
175 )));
176 }
177 Ok(serde_json::json!({ "authorized": true }))
178 }
179}
180
181struct HttpProjectionGuard;
182
183impl Guard for HttpProjectionGuard {
184 fn name(&self) -> &str {
185 "http_projection_policy"
186 }
187
188 fn evaluate(&self, ctx: &GuardContext<'_>) -> Result<KernelVerdict, KernelError> {
189 let projected: HttpKernelAuthorizationRequest =
190 serde_json::from_value(ctx.request.arguments.clone()).map_err(|error| {
191 KernelError::Internal(format!(
192 "failed to decode projected HTTP authorization request: {error}"
193 ))
194 })?;
195
196 if let Some(reason) = projected.capability.invalid_reason {
197 return Err(KernelError::GuardDenied(reason));
198 }
199
200 match projected.policy {
201 HttpAuthorityPolicy::SessionAllow => Ok(KernelVerdict::Allow),
202 HttpAuthorityPolicy::DenyByDefault => {
203 if projected.capability.id.is_some() {
204 Ok(KernelVerdict::Allow)
205 } else {
206 Err(KernelError::GuardDenied(
207 "side-effect route requires a capability token".to_string(),
208 ))
209 }
210 }
211 }
212 }
213}
214
215impl HttpAuthority {
216 #[must_use]
217 pub fn new(keypair: Keypair, policy_hash: String) -> Self {
218 Self::new_with_approval_store_and_trusted_issuers(
219 keypair,
220 policy_hash,
221 Arc::new(InMemoryApprovalStore::new()),
222 Vec::new(),
223 )
224 }
225
226 #[must_use]
227 pub fn new_with_approval_store(
228 keypair: Keypair,
229 policy_hash: String,
230 approval_store: Arc<dyn ApprovalStore>,
231 ) -> Self {
232 Self::new_with_approval_store_and_trusted_issuers(
233 keypair,
234 policy_hash,
235 approval_store,
236 Vec::new(),
237 )
238 }
239
240 #[must_use]
241 pub fn new_with_approval_store_and_trusted_issuers(
242 keypair: Keypair,
243 policy_hash: String,
244 approval_store: Arc<dyn ApprovalStore>,
245 mut trusted_capability_issuers: Vec<PublicKey>,
246 ) -> Self {
247 let keypair = Arc::new(keypair);
248 let signer_public_key = keypair.public_key();
249 if !trusted_capability_issuers.contains(&signer_public_key) {
250 trusted_capability_issuers.push(signer_public_key.clone());
251 }
252 let kernel_subject = Keypair::generate().public_key();
253 let kernel_agent_id = kernel_subject.to_hex();
254
255 let mut kernel = ChioKernel::new(KernelConfig {
256 keypair: keypair.as_ref().clone(),
257 ca_public_keys: trusted_capability_issuers.clone(),
258 max_delegation_depth: 8,
259 policy_hash: policy_hash.clone(),
260 allow_sampling: false,
261 allow_sampling_tool_use: false,
262 allow_elicitation: false,
263 max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
264 max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
265 require_web3_evidence: false,
266 checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
267 retention_config: None,
268 });
269 kernel.register_tool_server(Box::new(HttpAuthorizationServer));
270 kernel.add_guard(Box::new(HttpProjectionGuard));
271
272 Self {
273 keypair,
274 policy_hash,
275 kernel: Arc::new(kernel),
276 kernel_subject,
277 kernel_agent_id,
278 approval_store,
279 trusted_capability_issuers,
280 }
281 }
282
283 #[must_use]
284 pub fn approval_store(&self) -> Arc<dyn ApprovalStore> {
285 Arc::clone(&self.approval_store)
286 }
287
288 fn trusted_capability_issuers(&self) -> &[PublicKey] {
289 &self.trusted_capability_issuers
290 }
291
292 pub fn evaluate(
293 &self,
294 input: HttpAuthorityInput<'_>,
295 ) -> Result<HttpAuthorityEvaluation, HttpAuthorityError> {
296 let prepared = self.prepare(input)?;
297 let receipt = self.sign_decision_receipt(&prepared)?;
298 Ok(HttpAuthorityEvaluation {
299 verdict: prepared.verdict.clone(),
300 receipt,
301 evidence: prepared.evidence.clone(),
302 })
303 }
304
305 pub fn prepare(
306 &self,
307 input: HttpAuthorityInput<'_>,
308 ) -> Result<PreparedHttpEvaluation, HttpAuthorityError> {
309 let presented_capability = validate_presented_capability(
310 input.capability_id_hint,
311 input.presented_capability,
312 self.trusted_capability_issuers(),
313 input.requested_tool_server,
314 input.requested_tool_name,
315 input.requested_arguments,
316 input.model_metadata,
317 );
318 let caller_identity_hash = input
319 .caller
320 .identity_hash()
321 .map_err(|e| HttpAuthorityError::CallerIdentity(e.to_string()))?;
322
323 let chio_request = ChioHttpRequest {
324 request_id: input.request_id.clone(),
325 method: input.method,
326 route_pattern: input.route_pattern.clone(),
327 path: input.path.to_string(),
328 query: input.query.clone(),
329 headers: HashMap::new(),
330 caller: input.caller,
331 body_hash: input.body_hash,
332 body_length: input.body_length,
333 session_id: input.session_id.clone(),
334 capability_id: presented_capability.capability_id.clone(),
335 tool_server: input.requested_tool_server.map(str::to_owned),
336 tool_name: input.requested_tool_name.map(str::to_owned),
337 arguments: input.requested_arguments.cloned(),
338 model_metadata: input.model_metadata.cloned(),
339 timestamp: chrono::Utc::now().timestamp() as u64,
340 };
341
342 let content_hash = chio_request
343 .content_hash()
344 .map_err(|e| HttpAuthorityError::ContentHash(e.to_string()))?;
345
346 let kernel_response = self.authorize_via_kernel(
347 &input.request_id,
348 input.method,
349 &input.route_pattern,
350 input.path,
351 &content_hash,
352 &caller_identity_hash,
353 input.session_id.as_deref(),
354 input.policy,
355 &presented_capability,
356 )?;
357
358 let verdict = projected_verdict(input.policy, &presented_capability);
359 let expected_allowed = verdict.is_allowed();
360 match (kernel_response.verdict, expected_allowed) {
361 (KernelVerdict::Allow, true) | (KernelVerdict::Deny, false) => {}
362 (KernelVerdict::Allow, false) => {
363 return Err(HttpAuthorityError::Kernel(
364 "kernel allowed an HTTP projection that should have been denied".to_string(),
365 ));
366 }
367 (KernelVerdict::Deny, true) => {
368 let reason = kernel_response
369 .reason
370 .unwrap_or_else(|| "kernel denied an allowed HTTP projection".to_string());
371 return Err(HttpAuthorityError::Kernel(reason));
372 }
373 (KernelVerdict::PendingApproval, _) => {
374 return Err(HttpAuthorityError::PendingApproval {
375 approval_id: pending_approval_id(
376 kernel_response.receipt.metadata.as_ref(),
377 kernel_response.reason.as_deref(),
378 ),
379 kernel_receipt_id: kernel_response.receipt.id,
380 });
381 }
382 }
383
384 let evidence = projected_evidence(input.policy, &presented_capability);
385
386 Ok(PreparedHttpEvaluation {
387 verdict,
388 evidence,
389 request_id: input.request_id,
390 route_pattern: input.route_pattern,
391 http_method: input.method,
392 caller_identity_hash,
393 content_hash,
394 session_id: input.session_id,
395 capability_id: presented_capability.capability_id,
396 kernel_receipt_id: kernel_response.receipt.id,
397 route_selection_metadata: metadata_value(
398 kernel_response.receipt.metadata.as_ref(),
399 "route_selection",
400 )
401 .cloned(),
402 })
403 }
404
405 pub fn sign_decision_receipt(
406 &self,
407 prepared: &PreparedHttpEvaluation,
408 ) -> Result<HttpReceipt, HttpAuthorityError> {
409 self.sign_receipt(
410 prepared,
411 decision_status(&prepared.verdict),
412 decision_metadata(
413 Some(&prepared.kernel_receipt_id),
414 prepared.route_selection_metadata.as_ref(),
415 ),
416 )
417 }
418
419 pub fn finalize_receipt(
420 &self,
421 prepared: &PreparedHttpEvaluation,
422 response_status: u16,
423 decision_receipt_id: Option<&str>,
424 ) -> Result<HttpReceipt, HttpAuthorityError> {
425 self.sign_receipt(
426 prepared,
427 response_status,
428 final_metadata(
429 decision_receipt_id,
430 Some(&prepared.kernel_receipt_id),
431 prepared.route_selection_metadata.as_ref(),
432 ),
433 )
434 }
435
436 pub fn finalize_decision_receipt(
437 &self,
438 decision_receipt: &HttpReceipt,
439 response_status: u16,
440 ) -> Result<HttpReceipt, HttpAuthorityError> {
441 let mut body = decision_receipt.body();
442 let decision_receipt_id = body.id.clone();
443 let kernel_receipt_id = metadata_string(body.metadata.as_ref(), CHIO_KERNEL_RECEIPT_ID_KEY)
444 .map(ToOwned::to_owned);
445 let route_selection = metadata_value(body.metadata.as_ref(), "route_selection").cloned();
446 body.id = uuid::Uuid::now_v7().to_string();
447 body.response_status = response_status;
448 body.timestamp = chrono::Utc::now().timestamp() as u64;
449 body.metadata = final_metadata(
450 Some(&decision_receipt_id),
451 kernel_receipt_id.as_deref(),
452 route_selection.as_ref(),
453 );
454 HttpReceipt::sign(body, self.keypair.as_ref())
455 .map_err(|e| HttpAuthorityError::ReceiptSign(e.to_string()))
456 }
457
458 #[allow(clippy::too_many_arguments)]
459 fn authorize_via_kernel(
460 &self,
461 request_id: &str,
462 method: HttpMethod,
463 route_pattern: &str,
464 path: &str,
465 content_hash: &str,
466 caller_identity_hash: &str,
467 session_id: Option<&str>,
468 policy: HttpAuthorityPolicy,
469 presented_capability: &PresentedCapabilityState,
470 ) -> Result<chio_kernel::ToolCallResponse, HttpAuthorityError> {
471 let capability = self
472 .kernel
473 .issue_capability(
474 &self.kernel_subject,
475 kernel_scope(),
476 HTTP_AUTHORITY_TTL_SECS,
477 )
478 .map_err(|error| HttpAuthorityError::Kernel(error.to_string()))?;
479
480 let projected = HttpKernelAuthorizationRequest {
481 request_id: request_id.to_string(),
482 method,
483 route_pattern: route_pattern.to_string(),
484 path: path.to_string(),
485 content_hash: content_hash.to_string(),
486 caller_identity_hash: caller_identity_hash.to_string(),
487 session_id: session_id.map(ToOwned::to_owned),
488 policy,
489 capability: HttpKernelCapabilityState {
490 id: presented_capability.capability_id.clone(),
491 invalid_reason: presented_capability.invalid_reason.clone(),
492 },
493 };
494
495 let request = ToolCallRequest {
496 request_id: request_id.to_string(),
497 capability,
498 tool_name: HTTP_AUTHORITY_TOOL_NAME.to_string(),
499 server_id: HTTP_AUTHORITY_SERVER_ID.to_string(),
500 agent_id: self.kernel_agent_id.clone(),
501 arguments: serde_json::to_value(projected)
502 .map_err(|error| HttpAuthorityError::Kernel(error.to_string()))?,
503 dpop_proof: None,
504 governed_intent: None,
505 approval_token: None,
506 model_metadata: None,
507 federated_origin_kernel_id: None,
508 };
509 let route_plan = plan_authoritative_route(
510 request_id,
511 DiscoveryProtocol::Http,
512 DiscoveryProtocol::Native,
513 None,
514 &TargetProtocolRegistry::new(DiscoveryProtocol::Native),
515 &BTreeMap::new(),
516 )
517 .map_err(|error| HttpAuthorityError::Kernel(error.to_string()))?;
518 let route_metadata = route_selection_metadata(&route_plan.evidence)
519 .map_err(|error| HttpAuthorityError::Kernel(error.to_string()))?;
520
521 self.kernel
522 .evaluate_tool_call_blocking_with_metadata(&request, Some(route_metadata))
523 .map_err(|error| HttpAuthorityError::Kernel(error.to_string()))
524 }
525
526 fn sign_receipt(
527 &self,
528 prepared: &PreparedHttpEvaluation,
529 response_status: u16,
530 metadata: Option<Value>,
531 ) -> Result<HttpReceipt, HttpAuthorityError> {
532 let body = HttpReceiptBody {
533 id: uuid::Uuid::now_v7().to_string(),
534 request_id: prepared.request_id.clone(),
535 route_pattern: prepared.route_pattern.clone(),
536 method: prepared.http_method,
537 caller_identity_hash: prepared.caller_identity_hash.clone(),
538 session_id: prepared.session_id.clone(),
539 verdict: prepared.verdict.clone(),
540 evidence: prepared.evidence.clone(),
541 response_status,
542 timestamp: chrono::Utc::now().timestamp() as u64,
543 content_hash: prepared.content_hash.clone(),
544 policy_hash: self.policy_hash.clone(),
545 capability_id: prepared.capability_id.clone(),
546 metadata,
547 kernel_key: self.keypair.public_key(),
548 };
549
550 HttpReceipt::sign(body, self.keypair.as_ref())
551 .map_err(|e| HttpAuthorityError::ReceiptSign(e.to_string()))
552 }
553}
554
555fn kernel_scope() -> ChioScope {
556 ChioScope {
557 grants: vec![ToolGrant {
558 server_id: HTTP_AUTHORITY_SERVER_ID.to_string(),
559 tool_name: HTTP_AUTHORITY_TOOL_NAME.to_string(),
560 operations: vec![Operation::Invoke],
561 constraints: Vec::new(),
562 max_invocations: Some(1),
563 max_cost_per_invocation: None,
564 max_total_cost: None,
565 dpop_required: None,
566 }],
567 resource_grants: Vec::new(),
568 prompt_grants: Vec::new(),
569 }
570}
571
572fn decision_status(verdict: &Verdict) -> u16 {
573 match verdict {
574 Verdict::Allow => 200,
575 Verdict::Deny { http_status, .. } => *http_status,
576 Verdict::Cancel { .. } | Verdict::Incomplete { .. } => 500,
577 }
578}
579
580fn validate_presented_capability(
581 capability_id_hint: Option<&str>,
582 presented_capability: Option<&str>,
583 trusted_issuers: &[PublicKey],
584 requested_tool_server: Option<&str>,
585 requested_tool_name: Option<&str>,
586 requested_arguments: Option<&Value>,
587 model_metadata: Option<&ModelMetadata>,
588) -> PresentedCapabilityState {
589 let requested_tool = match (requested_tool_server, requested_tool_name) {
590 (Some(server_id), Some(tool_name)) => Some(RequestedToolInvocation {
591 server_id,
592 tool_name,
593 arguments: requested_arguments.unwrap_or(&Value::Null),
594 }),
595 (None, None) => None,
596 _ => {
597 return PresentedCapabilityState {
598 capability_id: None,
599 invalid_reason: Some(
600 "tool-call evaluation requires both tool_server and tool_name".to_string(),
601 ),
602 };
603 }
604 };
605 let Some(raw_capability) = presented_capability else {
606 return PresentedCapabilityState {
607 capability_id: None,
608 invalid_reason: None,
609 };
610 };
611
612 match validate_capability_token(
613 raw_capability,
614 trusted_issuers,
615 requested_tool,
616 model_metadata,
617 ) {
618 Ok(token) => {
619 if let Some(hint) = capability_id_hint {
620 if hint != token.id {
621 return PresentedCapabilityState {
622 capability_id: None,
623 invalid_reason: Some(
624 "capability_id does not match the presented capability token"
625 .to_string(),
626 ),
627 };
628 }
629 }
630 PresentedCapabilityState {
631 capability_id: Some(token.id),
632 invalid_reason: None,
633 }
634 }
635 Err(reason) => PresentedCapabilityState {
636 capability_id: None,
637 invalid_reason: Some(reason),
638 },
639 }
640}
641
642fn projected_verdict(
643 policy: HttpAuthorityPolicy,
644 presented_capability: &PresentedCapabilityState,
645) -> Verdict {
646 if let Some(reason) = &presented_capability.invalid_reason {
647 return Verdict::deny(reason, "CapabilityGuard");
648 }
649
650 match policy {
651 HttpAuthorityPolicy::SessionAllow => Verdict::Allow,
652 HttpAuthorityPolicy::DenyByDefault => match &presented_capability.capability_id {
653 Some(_) => Verdict::Allow,
654 None => Verdict::deny(
655 "side-effect route requires a capability token",
656 "CapabilityGuard",
657 ),
658 },
659 }
660}
661
662fn projected_evidence(
663 policy: HttpAuthorityPolicy,
664 presented_capability: &PresentedCapabilityState,
665) -> Vec<GuardEvidence> {
666 if let Some(reason) = &presented_capability.invalid_reason {
667 return vec![GuardEvidence {
668 guard_name: "CapabilityGuard".to_string(),
669 verdict: false,
670 details: Some(reason.clone()),
671 }];
672 }
673
674 match policy {
675 HttpAuthorityPolicy::SessionAllow => vec![GuardEvidence {
676 guard_name: "DefaultPolicyGuard".to_string(),
677 verdict: true,
678 details: Some("safe method, session-scoped allow".to_string()),
679 }],
680 HttpAuthorityPolicy::DenyByDefault => match &presented_capability.capability_id {
681 Some(_) => vec![GuardEvidence {
682 guard_name: "CapabilityGuard".to_string(),
683 verdict: true,
684 details: Some("valid capability token presented".to_string()),
685 }],
686 None => vec![GuardEvidence {
687 guard_name: "CapabilityGuard".to_string(),
688 verdict: false,
689 details: Some("side-effect route requires a valid capability token".to_string()),
690 }],
691 },
692 }
693}
694
695fn validate_capability_token(
696 raw: &str,
697 trusted_issuers: &[PublicKey],
698 requested_tool: Option<RequestedToolInvocation<'_>>,
699 model_metadata: Option<&ModelMetadata>,
700) -> Result<CapabilityToken, String> {
701 let token: CapabilityToken =
702 serde_json::from_str(raw).map_err(|e| format!("invalid capability token: {e}"))?;
703 if !trusted_issuers.contains(&token.issuer) {
704 return Err("capability issuer is not trusted".to_string());
705 }
706 let signature_valid = token
707 .verify_signature()
708 .map_err(|e| format!("capability signature verification failed: {e}"))?;
709 if !signature_valid {
710 return Err("capability signature verification failed".to_string());
711 }
712 token
713 .validate_time(chrono::Utc::now().timestamp() as u64)
714 .map_err(|e| format!("invalid capability token: {e}"))?;
715
716 if let Some(requested_tool) = requested_tool {
717 let matches = chio_kernel::capability_matches_request_with_model_metadata(
718 &token,
719 requested_tool.tool_name,
720 requested_tool.server_id,
721 requested_tool.arguments,
722 model_metadata,
723 )
724 .map_err(|e| format!("failed to evaluate capability scope: {e}"))?;
725 if !matches {
726 return Err(format!(
727 "capability does not authorize tool {} on server {}",
728 requested_tool.tool_name, requested_tool.server_id
729 ));
730 }
731 }
732 Ok(token)
733}
734
735fn decision_metadata(
736 kernel_receipt_id: Option<&str>,
737 route_selection: Option<&Value>,
738) -> Option<Value> {
739 let mut metadata = http_status_metadata_decision();
740 insert_metadata_string(&mut metadata, CHIO_KERNEL_RECEIPT_ID_KEY, kernel_receipt_id);
741 insert_metadata_value(&mut metadata, "route_selection", route_selection);
742 Some(metadata)
743}
744
745fn final_metadata(
746 decision_receipt_id: Option<&str>,
747 kernel_receipt_id: Option<&str>,
748 route_selection: Option<&Value>,
749) -> Option<Value> {
750 let mut metadata = http_status_metadata_final(decision_receipt_id);
751 insert_metadata_string(&mut metadata, CHIO_KERNEL_RECEIPT_ID_KEY, kernel_receipt_id);
752 insert_metadata_value(&mut metadata, "route_selection", route_selection);
753 Some(metadata)
754}
755
756fn insert_metadata_string(metadata: &mut Value, key: &str, value: Option<&str>) {
757 let Some(value) = value else {
758 return;
759 };
760 if let Value::Object(map) = metadata {
761 map.insert(key.to_string(), Value::String(value.to_string()));
762 } else {
763 let mut map = Map::new();
764 map.insert(key.to_string(), Value::String(value.to_string()));
765 *metadata = Value::Object(map);
766 }
767}
768
769fn insert_metadata_value(metadata: &mut Value, key: &str, value: Option<&Value>) {
770 let Some(value) = value else {
771 return;
772 };
773 if let Value::Object(map) = metadata {
774 map.insert(key.to_string(), value.clone());
775 } else {
776 let mut map = Map::new();
777 map.insert(key.to_string(), value.clone());
778 *metadata = Value::Object(map);
779 }
780}
781
782fn metadata_string<'a>(metadata: Option<&'a Value>, key: &str) -> Option<&'a str> {
783 metadata
784 .and_then(Value::as_object)
785 .and_then(|map| map.get(key))
786 .and_then(Value::as_str)
787}
788
789fn metadata_value<'a>(metadata: Option<&'a Value>, key: &str) -> Option<&'a Value> {
790 metadata
791 .and_then(Value::as_object)
792 .and_then(|map| map.get(key))
793}
794
795fn pending_approval_id(metadata: Option<&Value>, reason: Option<&str>) -> Option<String> {
796 metadata_string(metadata, "approval_id")
797 .or_else(|| {
798 metadata_value(metadata, "pending_approval")
799 .and_then(Value::as_object)
800 .and_then(|pending| pending.get("approval_id"))
801 .and_then(Value::as_str)
802 })
803 .map(ToOwned::to_owned)
804 .or_else(|| extract_approval_id(reason))
805}
806
807fn extract_approval_id(reason: Option<&str>) -> Option<String> {
808 let reason = reason?;
809 for marker in ["/approvals/", "approval_id=", "approval_id:"] {
810 if let Some(start) = reason.find(marker) {
811 let suffix = reason[start + marker.len()..].trim_start();
812 let approval_id = suffix
813 .split(|character: char| {
814 character == '/'
815 || character == ','
816 || character == ';'
817 || character.is_whitespace()
818 })
819 .next()?;
820 if !approval_id.is_empty() {
821 return Some(approval_id.to_string());
822 }
823 }
824 }
825 None
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use crate::{
832 http_status_scope, AuthMethod, CHIO_DECISION_RECEIPT_ID_KEY,
833 CHIO_HTTP_STATUS_SCOPE_DECISION, CHIO_HTTP_STATUS_SCOPE_FINAL,
834 };
835 use chio_core_types::capability::{CapabilityTokenBody, ChioScope, Operation, ToolGrant};
836
837 fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
838 signed_capability_token_json_with_scope(issuer, id, ChioScope::default())
839 }
840
841 fn signed_capability_token_json_with_scope(
842 issuer: &Keypair,
843 id: &str,
844 scope: ChioScope,
845 ) -> String {
846 let now = chrono::Utc::now().timestamp() as u64;
847 let token = CapabilityToken::sign(
848 CapabilityTokenBody {
849 id: id.to_string(),
850 issuer: issuer.public_key(),
851 subject: issuer.public_key(),
852 scope,
853 issued_at: now.saturating_sub(60),
854 expires_at: now + 3600,
855 delegation_chain: Vec::new(),
856 },
857 &issuer,
858 )
859 .unwrap();
860 serde_json::to_string(&token).unwrap()
861 }
862
863 fn caller() -> CallerIdentity {
864 CallerIdentity {
865 subject: "tester".to_string(),
866 auth_method: AuthMethod::Anonymous,
867 verified: false,
868 tenant: None,
869 agent_id: None,
870 }
871 }
872
873 fn authority() -> HttpAuthority {
874 HttpAuthority::new(Keypair::generate(), "policy-hash".to_string())
875 }
876
877 fn authority_with_issuer() -> (HttpAuthority, Keypair) {
878 let issuer = Keypair::generate();
879 (
880 HttpAuthority::new(issuer.clone(), "policy-hash".to_string()),
881 issuer,
882 )
883 }
884
885 fn authority_with_trusted_issuer(trusted_issuer: PublicKey) -> HttpAuthority {
886 HttpAuthority::new_with_approval_store_and_trusted_issuers(
887 Keypair::generate(),
888 "policy-hash".to_string(),
889 Arc::new(InMemoryApprovalStore::new()),
890 vec![trusted_issuer],
891 )
892 }
893
894 #[test]
895 fn safe_policy_allows_without_capability() {
896 let query = HashMap::new();
897 let result = authority()
898 .evaluate(HttpAuthorityInput {
899 request_id: "req-1".to_string(),
900 method: HttpMethod::Get,
901 route_pattern: "/pets".to_string(),
902 path: "/pets",
903 query: &query,
904 caller: caller(),
905 body_hash: None,
906 body_length: 0,
907 session_id: None,
908 capability_id_hint: None,
909 presented_capability: None,
910 requested_tool_server: None,
911 requested_tool_name: None,
912 requested_arguments: None,
913 model_metadata: None,
914 policy: HttpAuthorityPolicy::SessionAllow,
915 })
916 .unwrap();
917
918 assert!(result.verdict.is_allowed());
919 assert_eq!(
920 http_status_scope(result.receipt.metadata.as_ref()),
921 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
922 );
923 assert!(
924 metadata_string(result.receipt.metadata.as_ref(), CHIO_KERNEL_RECEIPT_ID_KEY).is_some()
925 );
926 assert_eq!(
927 metadata_value(result.receipt.metadata.as_ref(), "route_selection")
928 .and_then(|value| value.get("selectedTargetProtocol"))
929 .and_then(Value::as_str),
930 Some("native")
931 );
932 }
933
934 #[test]
935 fn deny_by_default_requires_capability() {
936 let query = HashMap::new();
937 let result = authority()
938 .evaluate(HttpAuthorityInput {
939 request_id: "req-2".to_string(),
940 method: HttpMethod::Post,
941 route_pattern: "/pets".to_string(),
942 path: "/pets",
943 query: &query,
944 caller: caller(),
945 body_hash: Some("abc".to_string()),
946 body_length: 3,
947 session_id: None,
948 capability_id_hint: None,
949 presented_capability: None,
950 requested_tool_server: None,
951 requested_tool_name: None,
952 requested_arguments: None,
953 model_metadata: None,
954 policy: HttpAuthorityPolicy::DenyByDefault,
955 })
956 .unwrap();
957
958 assert!(result.verdict.is_denied());
959 assert_eq!(result.receipt.response_status, 403);
960 }
961
962 #[test]
963 fn invalid_presented_capability_denies_even_safe_route() {
964 let query = HashMap::new();
965 let result = authority()
966 .evaluate(HttpAuthorityInput {
967 request_id: "req-invalid".to_string(),
968 method: HttpMethod::Get,
969 route_pattern: "/pets".to_string(),
970 path: "/pets",
971 query: &query,
972 caller: caller(),
973 body_hash: None,
974 body_length: 0,
975 session_id: None,
976 capability_id_hint: None,
977 presented_capability: Some("{not-json"),
978 requested_tool_server: None,
979 requested_tool_name: None,
980 requested_arguments: None,
981 model_metadata: None,
982 policy: HttpAuthorityPolicy::SessionAllow,
983 })
984 .unwrap();
985
986 assert!(result.verdict.is_denied());
987 assert_eq!(result.receipt.evidence.len(), 1);
988 assert_eq!(result.receipt.evidence[0].guard_name, "CapabilityGuard");
989 }
990
991 #[test]
992 fn valid_capability_allows_deny_by_default() {
993 let query = HashMap::new();
994 let (authority, issuer) = authority_with_issuer();
995 let capability = signed_capability_token_json(&issuer, "cap-123");
996 let result = authority
997 .evaluate(HttpAuthorityInput {
998 request_id: "req-3".to_string(),
999 method: HttpMethod::Patch,
1000 route_pattern: "/pets/{petId}".to_string(),
1001 path: "/pets/42",
1002 query: &query,
1003 caller: caller(),
1004 body_hash: Some("def".to_string()),
1005 body_length: 3,
1006 session_id: Some("session-1".to_string()),
1007 capability_id_hint: None,
1008 presented_capability: Some(&capability),
1009 requested_tool_server: None,
1010 requested_tool_name: None,
1011 requested_arguments: None,
1012 model_metadata: None,
1013 policy: HttpAuthorityPolicy::DenyByDefault,
1014 })
1015 .unwrap();
1016
1017 assert!(result.verdict.is_allowed());
1018 assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
1019 assert_eq!(result.receipt.session_id.as_deref(), Some("session-1"));
1020 assert!(
1021 metadata_string(result.receipt.metadata.as_ref(), CHIO_KERNEL_RECEIPT_ID_KEY).is_some()
1022 );
1023 }
1024
1025 #[test]
1026 fn capability_hint_mismatch_becomes_denial() {
1027 let query = HashMap::new();
1028 let (authority, issuer) = authority_with_issuer();
1029 let capability = signed_capability_token_json(&issuer, "cap-123");
1030 let result = authority
1031 .evaluate(HttpAuthorityInput {
1032 request_id: "req-4".to_string(),
1033 method: HttpMethod::Put,
1034 route_pattern: "/pets/42".to_string(),
1035 path: "/pets/42",
1036 query: &query,
1037 caller: caller(),
1038 body_hash: None,
1039 body_length: 0,
1040 session_id: None,
1041 capability_id_hint: Some("cap-other"),
1042 presented_capability: Some(&capability),
1043 requested_tool_server: None,
1044 requested_tool_name: None,
1045 requested_arguments: None,
1046 model_metadata: None,
1047 policy: HttpAuthorityPolicy::DenyByDefault,
1048 })
1049 .unwrap();
1050
1051 assert!(result.verdict.is_denied());
1052 assert!(result.receipt.capability_id.is_none());
1053 }
1054
1055 #[test]
1056 fn untrusted_capability_denies_deny_by_default() {
1057 let query = HashMap::new();
1058 let authority = authority();
1059 let capability = signed_capability_token_json(&Keypair::generate(), "cap-untrusted");
1060 let result = authority
1061 .evaluate(HttpAuthorityInput {
1062 request_id: "req-untrusted".to_string(),
1063 method: HttpMethod::Post,
1064 route_pattern: "/pets".to_string(),
1065 path: "/pets",
1066 query: &query,
1067 caller: caller(),
1068 body_hash: Some("ghi".to_string()),
1069 body_length: 3,
1070 session_id: None,
1071 capability_id_hint: None,
1072 presented_capability: Some(&capability),
1073 requested_tool_server: None,
1074 requested_tool_name: None,
1075 requested_arguments: None,
1076 model_metadata: None,
1077 policy: HttpAuthorityPolicy::DenyByDefault,
1078 })
1079 .unwrap();
1080
1081 assert!(result.verdict.is_denied());
1082 assert_eq!(result.receipt.capability_id, None);
1083 assert_eq!(
1084 result.receipt.evidence[0].details.as_deref(),
1085 Some("capability issuer is not trusted")
1086 );
1087 }
1088
1089 #[test]
1090 fn configured_external_issuer_allows_deny_by_default() {
1091 let query = HashMap::new();
1092 let external_issuer = Keypair::generate();
1093 let authority = authority_with_trusted_issuer(external_issuer.public_key());
1094 let capability = signed_capability_token_json(&external_issuer, "cap-external");
1095 let result = authority
1096 .evaluate(HttpAuthorityInput {
1097 request_id: "req-external".to_string(),
1098 method: HttpMethod::Post,
1099 route_pattern: "/pets".to_string(),
1100 path: "/pets",
1101 query: &query,
1102 caller: caller(),
1103 body_hash: Some("issuer".to_string()),
1104 body_length: 6,
1105 session_id: None,
1106 capability_id_hint: None,
1107 presented_capability: Some(&capability),
1108 requested_tool_server: None,
1109 requested_tool_name: None,
1110 requested_arguments: None,
1111 model_metadata: None,
1112 policy: HttpAuthorityPolicy::DenyByDefault,
1113 })
1114 .unwrap();
1115
1116 assert!(result.verdict.is_allowed());
1117 assert_eq!(
1118 result.receipt.capability_id.as_deref(),
1119 Some("cap-external")
1120 );
1121 }
1122
1123 #[test]
1124 fn finalized_receipt_links_decision_receipt_and_kernel_receipt() {
1125 let query = HashMap::new();
1126 let shared = authority();
1127 let decision = shared
1128 .evaluate(HttpAuthorityInput {
1129 request_id: "req-5".to_string(),
1130 method: HttpMethod::Get,
1131 route_pattern: "/pets".to_string(),
1132 path: "/pets",
1133 query: &query,
1134 caller: caller(),
1135 body_hash: None,
1136 body_length: 0,
1137 session_id: None,
1138 capability_id_hint: None,
1139 presented_capability: None,
1140 requested_tool_server: None,
1141 requested_tool_name: None,
1142 requested_arguments: None,
1143 model_metadata: None,
1144 policy: HttpAuthorityPolicy::SessionAllow,
1145 })
1146 .unwrap()
1147 .receipt;
1148 let kernel_receipt_id =
1149 metadata_string(decision.metadata.as_ref(), CHIO_KERNEL_RECEIPT_ID_KEY)
1150 .map(ToOwned::to_owned)
1151 .unwrap();
1152 let final_receipt = shared.finalize_decision_receipt(&decision, 204).unwrap();
1153
1154 assert_ne!(final_receipt.id, decision.id);
1155 assert_eq!(final_receipt.response_status, 204);
1156 assert_eq!(
1157 http_status_scope(final_receipt.metadata.as_ref()),
1158 Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
1159 );
1160 assert_eq!(
1161 final_receipt
1162 .metadata
1163 .as_ref()
1164 .and_then(|metadata| metadata.get(CHIO_DECISION_RECEIPT_ID_KEY))
1165 .and_then(serde_json::Value::as_str),
1166 Some(decision.id.as_str())
1167 );
1168 assert_eq!(
1169 metadata_string(final_receipt.metadata.as_ref(), CHIO_KERNEL_RECEIPT_ID_KEY),
1170 Some(kernel_receipt_id.as_str())
1171 );
1172 assert_eq!(
1173 metadata_value(final_receipt.metadata.as_ref(), "route_selection")
1174 .and_then(|value| value.get("selectedTargetProtocol"))
1175 .and_then(Value::as_str),
1176 Some("native")
1177 );
1178 }
1179
1180 #[test]
1181 fn extract_approval_id_parses_resume_path() {
1182 assert_eq!(
1183 extract_approval_id(Some(
1184 "kernel returned PendingApproval; resume via /approvals/ap-123/respond"
1185 ))
1186 .as_deref(),
1187 Some("ap-123")
1188 );
1189 assert_eq!(
1190 extract_approval_id(Some("kernel returned PendingApproval; approval_id=ap-456"))
1191 .as_deref(),
1192 Some("ap-456")
1193 );
1194 assert_eq!(
1195 extract_approval_id(Some("kernel returned PendingApproval; approval_id: ap-789"))
1196 .as_deref(),
1197 Some("ap-789")
1198 );
1199 assert!(extract_approval_id(Some("kernel returned PendingApproval")).is_none());
1200 }
1201
1202 #[test]
1203 fn pending_approval_id_reads_nested_metadata() {
1204 let metadata = serde_json::json!({
1205 "pending_approval": {
1206 "approval_id": "ap-structured"
1207 }
1208 });
1209 assert_eq!(
1210 pending_approval_id(Some(&metadata), Some("kernel returned PendingApproval"))
1211 .as_deref(),
1212 Some("ap-structured")
1213 );
1214 }
1215
1216 #[test]
1217 fn deny_by_default_requires_matching_tool_grant() {
1218 let query = HashMap::new();
1219 let (authority, issuer) = authority_with_issuer();
1220 let capability = signed_capability_token_json_with_scope(
1221 &issuer,
1222 "cap-tool-scope",
1223 ChioScope {
1224 grants: vec![ToolGrant {
1225 server_id: "math".to_string(),
1226 tool_name: "double".to_string(),
1227 operations: vec![Operation::Invoke],
1228 constraints: Vec::new(),
1229 max_invocations: None,
1230 max_cost_per_invocation: None,
1231 max_total_cost: None,
1232 dpop_required: None,
1233 }],
1234 ..ChioScope::default()
1235 },
1236 );
1237
1238 let result = authority
1239 .evaluate(HttpAuthorityInput {
1240 request_id: "req-tool-mismatch".to_string(),
1241 method: HttpMethod::Post,
1242 route_pattern: "/chio/tools/math/increment".to_string(),
1243 path: "/chio/tools/math/increment",
1244 query: &query,
1245 caller: caller(),
1246 body_hash: Some("toolhash".to_string()),
1247 body_length: 8,
1248 session_id: None,
1249 capability_id_hint: None,
1250 presented_capability: Some(&capability),
1251 requested_tool_server: Some("math"),
1252 requested_tool_name: Some("increment"),
1253 requested_arguments: Some(&Value::Null),
1254 model_metadata: None,
1255 policy: HttpAuthorityPolicy::DenyByDefault,
1256 })
1257 .unwrap();
1258
1259 assert!(result.verdict.is_denied());
1260 assert!(result.receipt.capability_id.is_none());
1261 assert_eq!(
1262 result.receipt.evidence[0].details.as_deref(),
1263 Some("capability does not authorize tool increment on server math")
1264 );
1265 }
1266}