Skip to main content

chio_http_core/
authority.rs

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}