Skip to main content

chio_api_protect/
evaluator.rs

1//! Request evaluator: matches routes, checks capabilities, signs receipts.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use chio_core_types::crypto::{Keypair, PublicKey};
7use chio_core_types::receipt::GuardEvidence;
8use chio_http_core::{
9    AuthMethod, CallerIdentity, ChioHttpRequest, HttpAuthority, HttpAuthorityError,
10    HttpAuthorityEvaluation, HttpAuthorityInput, HttpAuthorityPolicy, HttpMethod, HttpReceipt,
11    Verdict,
12};
13use chio_kernel::ApprovalStore;
14use chio_openapi::PolicyDecision;
15use serde_json::Value;
16
17/// Evaluated result for a single HTTP request.
18pub struct EvaluationResult {
19    pub verdict: Verdict,
20    pub receipt: HttpReceipt,
21    pub evidence: Vec<GuardEvidence>,
22}
23
24/// Route information extracted from the OpenAPI spec.
25#[derive(Debug, Clone)]
26pub struct RouteEntry {
27    pub pattern: String,
28    pub method: HttpMethod,
29    pub operation_id: Option<String>,
30    pub policy: PolicyDecision,
31}
32
33/// The request evaluator holds the loaded route table and shared HTTP authority.
34pub struct RequestEvaluator {
35    routes: Vec<RouteEntry>,
36    authority: HttpAuthority,
37}
38
39impl RequestEvaluator {
40    pub fn new(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
41        Self::new_with_trusted_capability_issuers(routes, keypair, policy_hash, Vec::new())
42    }
43
44    pub fn new_with_trusted_capability_issuers(
45        routes: Vec<RouteEntry>,
46        keypair: Keypair,
47        policy_hash: String,
48        trusted_capability_issuers: Vec<PublicKey>,
49    ) -> Self {
50        Self {
51            routes,
52            authority: HttpAuthority::new_with_approval_store_and_trusted_issuers(
53                keypair,
54                policy_hash,
55                Arc::new(chio_kernel::InMemoryApprovalStore::new()),
56                trusted_capability_issuers,
57            ),
58        }
59    }
60
61    pub fn new_with_approval_store(
62        routes: Vec<RouteEntry>,
63        keypair: Keypair,
64        policy_hash: String,
65        approval_store: Arc<dyn ApprovalStore>,
66    ) -> Self {
67        Self::new_with_approval_store_and_trusted_capability_issuers(
68            routes,
69            keypair,
70            policy_hash,
71            approval_store,
72            Vec::new(),
73        )
74    }
75
76    pub fn new_with_approval_store_and_trusted_capability_issuers(
77        routes: Vec<RouteEntry>,
78        keypair: Keypair,
79        policy_hash: String,
80        approval_store: Arc<dyn ApprovalStore>,
81        trusted_capability_issuers: Vec<PublicKey>,
82    ) -> Self {
83        Self {
84            routes,
85            authority: HttpAuthority::new_with_approval_store_and_trusted_issuers(
86                keypair,
87                policy_hash,
88                approval_store,
89                trusted_capability_issuers,
90            ),
91        }
92    }
93
94    #[cfg(test)]
95    #[must_use]
96    pub fn approval_store(&self) -> Arc<dyn ApprovalStore> {
97        self.authority.approval_store()
98    }
99
100    /// Evaluate an incoming HTTP request against the route table.
101    pub fn evaluate(
102        &self,
103        method: HttpMethod,
104        path: &str,
105        query: &HashMap<String, String>,
106        headers: &HashMap<String, String>,
107        body_hash: Option<String>,
108        body_length: u64,
109    ) -> Result<EvaluationResult, crate::error::ProtectError> {
110        let request_id = uuid::Uuid::now_v7().to_string();
111        let caller = extract_caller(headers);
112        let (route_pattern, matched_policy) = self.match_route(method, path);
113        let result = self.authority.evaluate(HttpAuthorityInput {
114            request_id,
115            method,
116            route_pattern,
117            path,
118            query,
119            caller,
120            body_hash,
121            body_length,
122            session_id: None,
123            capability_id_hint: None,
124            presented_capability: extract_presented_capability(headers, query),
125            requested_tool_server: None,
126            requested_tool_name: None,
127            requested_arguments: None,
128            model_metadata: None,
129            policy: policy_mode(matched_policy),
130        })?;
131        Ok(result.into())
132    }
133
134    /// Evaluate a fully normalized sidecar request.
135    pub fn evaluate_chio_request(
136        &self,
137        request: ChioHttpRequest,
138        presented_capability: Option<&str>,
139    ) -> Result<EvaluationResult, crate::error::ProtectError> {
140        let ChioHttpRequest {
141            request_id,
142            method,
143            path,
144            query,
145            headers,
146            caller,
147            body_hash,
148            body_length,
149            session_id,
150            capability_id,
151            tool_server,
152            tool_name,
153            arguments,
154            model_metadata,
155            ..
156        } = request;
157        let (route_pattern, matched_policy) = self.match_route(method, &path);
158        let raw_capability =
159            presented_capability.or_else(|| extract_presented_capability(&headers, &query));
160        let arguments = arguments.unwrap_or(Value::Null);
161        let result = self.authority.evaluate(HttpAuthorityInput {
162            request_id,
163            method,
164            route_pattern,
165            path: &path,
166            query: &query,
167            caller,
168            body_hash,
169            body_length,
170            session_id,
171            capability_id_hint: capability_id.as_deref(),
172            presented_capability: raw_capability,
173            requested_tool_server: tool_server.as_deref(),
174            requested_tool_name: tool_name.as_deref(),
175            requested_arguments: Some(&arguments),
176            model_metadata: model_metadata.as_ref(),
177            policy: policy_mode(matched_policy),
178        })?;
179        Ok(result.into())
180    }
181
182    /// Match a request path against the route table.
183    /// Returns (matched_pattern, policy). Falls back to a catch-all.
184    fn match_route(&self, method: HttpMethod, path: &str) -> (String, PolicyDecision) {
185        // Try exact pattern match first, then prefix match.
186        for route in &self.routes {
187            if route.method == method && path_matches_pattern(path, &route.pattern) {
188                return (route.pattern.clone(), route.policy);
189            }
190        }
191
192        // Fallback: use method-based default policy.
193        let pattern = path.to_string();
194        let policy = if method.is_safe() {
195            PolicyDecision::SessionAllow
196        } else {
197            PolicyDecision::DenyByDefault
198        };
199        (pattern, policy)
200    }
201}
202
203fn extract_presented_capability<'a>(
204    headers: &'a HashMap<String, String>,
205    query: &'a HashMap<String, String>,
206) -> Option<&'a str> {
207    headers
208        .get("x-chio-capability")
209        .or_else(|| headers.get("X-Chio-Capability"))
210        .map(String::as_str)
211        .or_else(|| query.get("chio_capability").map(String::as_str))
212}
213
214fn policy_mode(policy: PolicyDecision) -> HttpAuthorityPolicy {
215    match policy {
216        PolicyDecision::SessionAllow => HttpAuthorityPolicy::SessionAllow,
217        PolicyDecision::DenyByDefault => HttpAuthorityPolicy::DenyByDefault,
218    }
219}
220
221impl RequestEvaluator {
222    pub fn finalize_receipt(
223        &self,
224        decision_receipt: &HttpReceipt,
225        response_status: u16,
226    ) -> Result<HttpReceipt, crate::error::ProtectError> {
227        self.authority
228            .finalize_decision_receipt(decision_receipt, response_status)
229            .map_err(Into::into)
230    }
231}
232
233impl From<HttpAuthorityEvaluation> for EvaluationResult {
234    fn from(value: HttpAuthorityEvaluation) -> Self {
235        Self {
236            verdict: value.verdict,
237            receipt: value.receipt,
238            evidence: value.evidence,
239        }
240    }
241}
242
243impl From<HttpAuthorityError> for crate::error::ProtectError {
244    fn from(value: HttpAuthorityError) -> Self {
245        match value {
246            HttpAuthorityError::CallerIdentity(message)
247            | HttpAuthorityError::ContentHash(message)
248            | HttpAuthorityError::Kernel(message) => Self::Evaluation(message),
249            HttpAuthorityError::PendingApproval {
250                approval_id,
251                kernel_receipt_id,
252            } => Self::PendingApproval {
253                approval_id,
254                kernel_receipt_id,
255            },
256            HttpAuthorityError::ReceiptSign(message) => Self::ReceiptSign(message),
257        }
258    }
259}
260
261/// Match OpenAPI-style path templates such as `/pets/{petId}`.
262fn path_matches_pattern(path: &str, pattern: &str) -> bool {
263    let mut path_segments = path.split('/');
264    let mut pattern_segments = pattern.split('/');
265
266    loop {
267        match (path_segments.next(), pattern_segments.next()) {
268            (Some(path_segment), Some(pattern_segment))
269                if path_segment_matches_pattern(path_segment, pattern_segment) => {}
270            (None, None) => return true,
271            _ => return false,
272        }
273    }
274}
275
276fn path_segment_matches_pattern(path_segment: &str, pattern_segment: &str) -> bool {
277    pattern_segment.starts_with('{') && pattern_segment.ends_with('}')
278        || path_segment == pattern_segment
279}
280
281/// Extract caller identity from HTTP headers.
282fn extract_caller(headers: &HashMap<String, String>) -> CallerIdentity {
283    // Authorization headers become stable hashed caller identities.
284    if let Some(auth) = headers
285        .get("authorization")
286        .or_else(|| headers.get("Authorization"))
287    {
288        if let Some(token) = auth.strip_prefix("Bearer ") {
289            let token_hash = chio_core_types::sha256_hex(token.as_bytes());
290            return CallerIdentity {
291                subject: format!("bearer:{}", &token_hash[..16]),
292                auth_method: AuthMethod::Bearer { token_hash },
293                verified: false,
294                tenant: None,
295                agent_id: None,
296            };
297        }
298    }
299
300    // API keys follow the same non-secret identity derivation.
301    for key_header in &["x-api-key", "X-Api-Key", "X-API-Key"] {
302        if let Some(key_value) = headers.get(*key_header) {
303            let key_hash = chio_core_types::sha256_hex(key_value.as_bytes());
304            return CallerIdentity {
305                subject: format!("apikey:{}", &key_hash[..16]),
306                auth_method: AuthMethod::ApiKey {
307                    key_name: key_header.to_string(),
308                    key_hash,
309                },
310                verified: false,
311                tenant: None,
312                agent_id: None,
313            };
314        }
315    }
316
317    CallerIdentity::anonymous()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use chio_core_types::capability::{
324        CapabilityToken, CapabilityTokenBody, ChioScope, Constraint, ModelMetadata,
325        ModelSafetyTier, Operation, ProvenanceEvidenceClass, ToolGrant,
326    };
327    use chio_http_core::{
328        http_status_scope, CHIO_DECISION_RECEIPT_ID_KEY, CHIO_HTTP_STATUS_SCOPE_DECISION,
329        CHIO_HTTP_STATUS_SCOPE_FINAL,
330    };
331
332    fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
333        signed_capability_token_json_with_scope(issuer, id, ChioScope::default())
334    }
335
336    fn signed_capability_token_json_with_scope(
337        issuer: &Keypair,
338        id: &str,
339        scope: ChioScope,
340    ) -> String {
341        let now = chrono::Utc::now().timestamp() as u64;
342        let token = CapabilityToken::sign(
343            CapabilityTokenBody {
344                id: id.to_string(),
345                issuer: issuer.public_key(),
346                subject: issuer.public_key(),
347                scope,
348                issued_at: now.saturating_sub(60),
349                expires_at: now + 3600,
350                delegation_chain: Vec::new(),
351            },
352            &issuer,
353        )
354        .expect("token should sign");
355        serde_json::to_string(&token).expect("token should serialize")
356    }
357
358    #[test]
359    fn path_matching() {
360        assert!(path_matches_pattern("/pets/42", "/pets/{petId}"));
361        assert!(path_matches_pattern("/pets", "/pets"));
362        assert!(!path_matches_pattern("/pets/42/toys", "/pets/{petId}"));
363        assert!(!path_matches_pattern("/dogs/42", "/pets/{petId}"));
364    }
365
366    #[test]
367    fn extract_bearer_caller() {
368        let mut headers = HashMap::new();
369        headers.insert("Authorization".to_string(), "Bearer mytoken123".to_string());
370        let caller = extract_caller(&headers);
371        assert!(caller.subject.starts_with("bearer:"));
372        assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
373    }
374
375    #[test]
376    fn extract_anonymous_caller() {
377        let headers = HashMap::new();
378        let caller = extract_caller(&headers);
379        assert_eq!(caller.subject, "anonymous");
380    }
381
382    #[test]
383    fn evaluate_get_allowed() {
384        let keypair = Keypair::generate();
385        let routes = vec![RouteEntry {
386            pattern: "/pets".to_string(),
387            method: HttpMethod::Get,
388            operation_id: Some("listPets".to_string()),
389            policy: PolicyDecision::SessionAllow,
390        }];
391        let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
392
393        let result = evaluator
394            .evaluate(
395                HttpMethod::Get,
396                "/pets",
397                &HashMap::new(),
398                &HashMap::new(),
399                None,
400                0,
401            )
402            .unwrap();
403        assert!(result.verdict.is_allowed());
404        assert!(result.receipt.verify_signature().unwrap());
405        assert_eq!(
406            http_status_scope(result.receipt.metadata.as_ref()),
407            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
408        );
409    }
410
411    #[test]
412    fn evaluate_chio_request_denies_capability_for_different_tool_identity() {
413        let keypair = Keypair::generate();
414        let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
415        let capability = signed_capability_token_json_with_scope(
416            &keypair,
417            "cap-tool-scope",
418            ChioScope {
419                grants: vec![ToolGrant {
420                    server_id: "math".to_string(),
421                    tool_name: "double".to_string(),
422                    operations: vec![Operation::Invoke],
423                    constraints: Vec::new(),
424                    max_invocations: None,
425                    max_cost_per_invocation: None,
426                    max_total_cost: None,
427                    dpop_required: None,
428                }],
429                ..ChioScope::default()
430            },
431        );
432
433        let mut request = ChioHttpRequest::new(
434            "req-sidecar-tool-mismatch".to_string(),
435            HttpMethod::Post,
436            "/chio/tools/math/increment".to_string(),
437            "/chio/tools/math/increment".to_string(),
438            CallerIdentity::anonymous(),
439        );
440        request.tool_server = Some("math".to_string());
441        request.tool_name = Some("increment".to_string());
442        request.arguments = Some(serde_json::json!({ "value": 1 }));
443        request.body_hash = Some("tool-body".to_string());
444        request.body_length = 1;
445
446        let result = evaluator
447            .evaluate_chio_request(request, Some(&capability))
448            .unwrap();
449
450        assert!(result.verdict.is_denied());
451        assert_eq!(
452            result.receipt.evidence[0].details.as_deref(),
453            Some("capability does not authorize tool increment on server math")
454        );
455    }
456
457    #[test]
458    fn evaluate_chio_request_allows_model_constrained_capability_when_metadata_matches() {
459        let keypair = Keypair::generate();
460        let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
461        let capability = signed_capability_token_json_with_scope(
462            &keypair,
463            "cap-model-scope",
464            ChioScope {
465                grants: vec![ToolGrant {
466                    server_id: "math".to_string(),
467                    tool_name: "double".to_string(),
468                    operations: vec![Operation::Invoke],
469                    constraints: vec![Constraint::ModelConstraint {
470                        allowed_model_ids: vec!["gpt-5".to_string()],
471                        min_safety_tier: Some(ModelSafetyTier::Standard),
472                    }],
473                    max_invocations: None,
474                    max_cost_per_invocation: None,
475                    max_total_cost: None,
476                    dpop_required: None,
477                }],
478                ..ChioScope::default()
479            },
480        );
481
482        let mut request = ChioHttpRequest::new(
483            "req-model-scope".to_string(),
484            HttpMethod::Post,
485            "/chio/tools/math/double".to_string(),
486            "/chio/tools/math/double".to_string(),
487            CallerIdentity::anonymous(),
488        );
489        request.tool_server = Some("math".to_string());
490        request.tool_name = Some("double".to_string());
491        request.arguments = Some(serde_json::json!({ "value": 2 }));
492        request.model_metadata = Some(ModelMetadata {
493            model_id: "gpt-5".to_string(),
494            safety_tier: Some(ModelSafetyTier::Standard),
495            provider: Some("openai".to_string()),
496            provenance_class: ProvenanceEvidenceClass::Asserted,
497        });
498        request.body_hash = Some("tool-body".to_string());
499        request.body_length = 1;
500
501        let result = evaluator
502            .evaluate_chio_request(request, Some(&capability))
503            .unwrap();
504
505        assert!(result.verdict.is_allowed());
506        assert_eq!(
507            result.receipt.capability_id.as_deref(),
508            Some("cap-model-scope")
509        );
510    }
511
512    #[test]
513    fn evaluate_chio_request_allows_capability_from_configured_external_issuer() {
514        let signer = Keypair::generate();
515        let external_issuer = Keypair::generate();
516        let evaluator = RequestEvaluator::new_with_trusted_capability_issuers(
517            vec![],
518            signer,
519            "test-policy".to_string(),
520            vec![external_issuer.public_key()],
521        );
522        let capability = signed_capability_token_json(&external_issuer, "cap-external");
523
524        let mut request = ChioHttpRequest::new(
525            "req-external-issuer".to_string(),
526            HttpMethod::Post,
527            "/pets".to_string(),
528            "/pets".to_string(),
529            CallerIdentity::anonymous(),
530        );
531        request.body_hash = Some("body".to_string());
532        request.body_length = 1;
533
534        let result = evaluator
535            .evaluate_chio_request(request, Some(&capability))
536            .unwrap();
537
538        assert!(result.verdict.is_allowed());
539        assert_eq!(
540            result.receipt.capability_id.as_deref(),
541            Some("cap-external")
542        );
543    }
544
545    #[test]
546    fn evaluate_post_denied_without_capability() {
547        let keypair = Keypair::generate();
548        let routes = vec![RouteEntry {
549            pattern: "/pets".to_string(),
550            method: HttpMethod::Post,
551            operation_id: Some("createPet".to_string()),
552            policy: PolicyDecision::DenyByDefault,
553        }];
554        let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
555
556        let result = evaluator
557            .evaluate(
558                HttpMethod::Post,
559                "/pets",
560                &HashMap::new(),
561                &HashMap::new(),
562                None,
563                0,
564            )
565            .unwrap();
566        assert!(result.verdict.is_denied());
567        assert_eq!(result.receipt.response_status, 403);
568        assert!(result.receipt.verify_signature().unwrap());
569        assert_eq!(
570            http_status_scope(result.receipt.metadata.as_ref()),
571            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
572        );
573    }
574
575    #[test]
576    fn evaluate_post_allowed_with_capability() {
577        let keypair = Keypair::generate();
578        let routes = vec![RouteEntry {
579            pattern: "/pets".to_string(),
580            method: HttpMethod::Post,
581            operation_id: Some("createPet".to_string()),
582            policy: PolicyDecision::DenyByDefault,
583        }];
584        let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
585
586        let mut headers = HashMap::new();
587        headers.insert(
588            "X-Chio-Capability".to_string(),
589            signed_capability_token_json(&keypair, "cap-123"),
590        );
591
592        let result = evaluator
593            .evaluate(
594                HttpMethod::Post,
595                "/pets",
596                &HashMap::new(),
597                &headers,
598                None,
599                0,
600            )
601            .unwrap();
602        assert!(result.verdict.is_allowed());
603        assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
604        assert_eq!(
605            http_status_scope(result.receipt.metadata.as_ref()),
606            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
607        );
608    }
609
610    #[test]
611    fn finalize_receipt_rebinds_status_and_links_decision_receipt() {
612        let keypair = Keypair::generate();
613        let routes = vec![RouteEntry {
614            pattern: "/pets".to_string(),
615            method: HttpMethod::Get,
616            operation_id: Some("listPets".to_string()),
617            policy: PolicyDecision::SessionAllow,
618        }];
619        let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
620
621        let decision = evaluator
622            .evaluate(
623                HttpMethod::Get,
624                "/pets",
625                &HashMap::new(),
626                &HashMap::new(),
627                None,
628                0,
629            )
630            .unwrap()
631            .receipt;
632        let final_receipt = evaluator.finalize_receipt(&decision, 204).unwrap();
633
634        assert_ne!(final_receipt.id, decision.id);
635        assert_eq!(final_receipt.response_status, 204);
636        assert_eq!(
637            http_status_scope(final_receipt.metadata.as_ref()),
638            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
639        );
640        assert_eq!(
641            final_receipt
642                .metadata
643                .as_ref()
644                .and_then(|meta| meta.get(CHIO_DECISION_RECEIPT_ID_KEY))
645                .and_then(|value| value.as_str()),
646            Some(decision.id.as_str())
647        );
648        assert!(final_receipt.verify_signature().unwrap());
649    }
650
651    #[test]
652    fn path_matching_trailing_slash_mismatch() {
653        // Trailing slash should NOT match if pattern has no trailing slash
654        assert!(!path_matches_pattern("/pets/", "/pets"));
655        assert!(!path_matches_pattern("/pets", "/pets/"));
656    }
657
658    #[test]
659    fn path_matching_double_slashes() {
660        // Double slashes produce empty segments, should not match normal paths
661        assert!(!path_matches_pattern("//pets", "/pets"));
662    }
663
664    #[test]
665    fn path_matching_case_sensitivity() {
666        // Path matching should be case-sensitive
667        assert!(!path_matches_pattern("/Pets", "/pets"));
668        assert!(path_matches_pattern("/Pets", "/Pets"));
669    }
670
671    #[test]
672    fn path_matching_multiple_params() {
673        assert!(path_matches_pattern(
674            "/orgs/123/members/456",
675            "/orgs/{orgId}/members/{memberId}"
676        ));
677        assert!(!path_matches_pattern(
678            "/orgs/123/members",
679            "/orgs/{orgId}/members/{memberId}"
680        ));
681    }
682
683    #[test]
684    fn path_matching_root() {
685        assert!(path_matches_pattern("/", "/"));
686        assert!(!path_matches_pattern("/pets", "/"));
687    }
688
689    #[test]
690    fn extract_api_key_caller() {
691        let mut headers = HashMap::new();
692        headers.insert("X-API-Key".to_string(), "my-api-key-value".to_string());
693        let caller = extract_caller(&headers);
694        assert!(caller.subject.starts_with("apikey:"));
695        assert!(matches!(caller.auth_method, AuthMethod::ApiKey { .. }));
696    }
697
698    #[test]
699    fn evaluate_with_body_hash() {
700        let keypair = Keypair::generate();
701        let routes = vec![RouteEntry {
702            pattern: "/data".to_string(),
703            method: HttpMethod::Get,
704            operation_id: Some("getData".to_string()),
705            policy: PolicyDecision::SessionAllow,
706        }];
707        let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
708
709        let result = evaluator
710            .evaluate(
711                HttpMethod::Get,
712                "/data",
713                &HashMap::new(),
714                &HashMap::new(),
715                Some("bodyhash123".to_string()),
716                1024,
717            )
718            .unwrap();
719        assert!(result.verdict.is_allowed());
720        assert!(result.receipt.verify_signature().unwrap());
721    }
722
723    #[test]
724    fn fallback_policy_for_unmatched_route() {
725        let keypair = Keypair::generate();
726        let evaluator = RequestEvaluator::new(vec![], keypair, "test-policy".to_string());
727
728        // GET to unknown route should still be allowed (safe method)
729        let result = evaluator
730            .evaluate(
731                HttpMethod::Get,
732                "/unknown",
733                &HashMap::new(),
734                &HashMap::new(),
735                None,
736                0,
737            )
738            .unwrap();
739        assert!(result.verdict.is_allowed());
740
741        // DELETE to unknown route should be denied (side-effect method)
742        let result = evaluator
743            .evaluate(
744                HttpMethod::Delete,
745                "/unknown",
746                &HashMap::new(),
747                &HashMap::new(),
748                None,
749                0,
750            )
751            .unwrap();
752        assert!(result.verdict.is_denied());
753    }
754
755    #[test]
756    fn evaluate_invalid_capability_denied_fail_closed() {
757        let keypair = Keypair::generate();
758        let routes = vec![RouteEntry {
759            pattern: "/pets".to_string(),
760            method: HttpMethod::Post,
761            operation_id: Some("createPet".to_string()),
762            policy: PolicyDecision::DenyByDefault,
763        }];
764        let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
765        let mut headers = HashMap::new();
766        headers.insert("X-Chio-Capability".to_string(), "not-json".to_string());
767
768        let result = evaluator
769            .evaluate(
770                HttpMethod::Post,
771                "/pets",
772                &HashMap::new(),
773                &headers,
774                None,
775                0,
776            )
777            .unwrap();
778
779        assert!(result.verdict.is_denied());
780        assert!(result.receipt.capability_id.as_deref().is_none());
781    }
782
783    #[test]
784    fn evaluate_query_parameters_affect_content_hash() {
785        let keypair = Keypair::generate();
786        let routes = vec![RouteEntry {
787            pattern: "/search".to_string(),
788            method: HttpMethod::Get,
789            operation_id: Some("search".to_string()),
790            policy: PolicyDecision::SessionAllow,
791        }];
792        let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
793        let mut query_a = HashMap::new();
794        query_a.insert("q".to_string(), "cats".to_string());
795        let mut query_b = HashMap::new();
796        query_b.insert("q".to_string(), "dogs".to_string());
797
798        let result_a = evaluator
799            .evaluate(
800                HttpMethod::Get,
801                "/search",
802                &query_a,
803                &HashMap::new(),
804                None,
805                0,
806            )
807            .unwrap();
808        let result_b = evaluator
809            .evaluate(
810                HttpMethod::Get,
811                "/search",
812                &query_b,
813                &HashMap::new(),
814                None,
815                0,
816            )
817            .unwrap();
818
819        assert_ne!(result_a.receipt.content_hash, result_b.receipt.content_hash);
820    }
821}