1use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use core::convert::TryFrom;
10
11use chio_core_types::capability::{
12 runtime_attestation::RuntimeAssuranceTier,
13 scope::{
14 ChioScope, Constraint, MonetaryAmount, Operation, PromptGrant, ResourceGrant, ToolGrant,
15 },
16 token::CapabilityToken,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::capability_verify::VerifiedCapability;
21use crate::evaluate::EvaluationVerdict;
22#[cfg(not(kani))]
23use crate::formal_core::monetary_cap_is_subset_by_parts;
24use crate::formal_core::{
25 exact_or_wildcard_covers, optional_u32_cap_is_subset, prefix_wildcard_or_exact_covers,
26 required_true_is_preserved,
27};
28use crate::guard::PortableToolCallRequest;
29use crate::Verdict;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum NormalizationError {
35 UnsupportedConstraint { kind: String },
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct NormalizedMonetaryAmount {
42 pub units: u64,
43 pub currency: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum NormalizedRuntimeAssuranceTier {
50 None,
51 Basic,
52 Attested,
53 Verified,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum NormalizedOperation {
60 Invoke,
61 ReadResult,
62 Read,
63 Subscribe,
64 Get,
65 Delegate,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(tag = "type", content = "value", rename_all = "snake_case")]
74pub enum NormalizedConstraint {
75 PathPrefix(String),
76 DomainExact(String),
77 DomainGlob(String),
78 RegexMatch(String),
79 MaxLength(usize),
80 MaxArgsSize(usize),
81 GovernedIntentRequired,
82 RequireApprovalAbove { threshold_units: u64 },
83 SellerExact(String),
84 MinimumRuntimeAssurance(NormalizedRuntimeAssuranceTier),
85 Custom(String, String),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct NormalizedToolGrant {
91 pub server_id: String,
92 pub tool_name: String,
93 pub operations: Vec<NormalizedOperation>,
94 pub constraints: Vec<NormalizedConstraint>,
95 pub max_invocations: Option<u32>,
96 pub max_cost_per_invocation: Option<NormalizedMonetaryAmount>,
97 pub max_total_cost: Option<NormalizedMonetaryAmount>,
98 pub dpop_required: Option<bool>,
99}
100
101impl NormalizedToolGrant {
102 #[must_use]
104 pub fn is_subset_of(&self, parent: &Self) -> bool {
105 #[cfg(kani)]
106 {
107 return self.is_subset_of_bounded_kani(parent);
108 }
109
110 #[cfg(not(kani))]
111 {
112 if !exact_or_wildcard_covers(&parent.server_id, &self.server_id) {
113 return false;
114 }
115 if !exact_or_wildcard_covers(&parent.tool_name, &self.tool_name) {
116 return false;
117 }
118
119 if !self
120 .operations
121 .iter()
122 .all(|operation| parent.operations.contains(operation))
123 {
124 return false;
125 }
126
127 if !optional_u32_cap_is_subset(
128 self.max_invocations.is_some(),
129 self.max_invocations.unwrap_or(0),
130 parent.max_invocations.is_some(),
131 parent.max_invocations.unwrap_or(0),
132 ) {
133 return false;
134 }
135
136 if !parent
137 .constraints
138 .iter()
139 .all(|constraint| self.constraints.contains(constraint))
140 {
141 return false;
142 }
143
144 if !monetary_cap_is_subset(
145 self.max_cost_per_invocation.as_ref(),
146 parent.max_cost_per_invocation.as_ref(),
147 ) {
148 return false;
149 }
150
151 if !monetary_cap_is_subset(self.max_total_cost.as_ref(), parent.max_total_cost.as_ref())
152 {
153 return false;
154 }
155
156 if !required_true_is_preserved(
157 parent.dpop_required == Some(true),
158 self.dpop_required == Some(true),
159 ) {
160 return false;
161 }
162
163 true
164 }
165 }
166
167 #[cfg(kani)]
168 fn is_subset_of_bounded_kani(&self, parent: &Self) -> bool {
169 if !exact_or_wildcard_covers(&parent.server_id, &self.server_id) {
170 return false;
171 }
172 if !exact_or_wildcard_covers(&parent.tool_name, &self.tool_name) {
173 return false;
174 }
175 if !normalized_operations_subset_bounded_kani(&self.operations, &parent.operations) {
176 return false;
177 }
178 if !optional_u32_cap_is_subset(
179 self.max_invocations.is_some(),
180 self.max_invocations.unwrap_or(0),
181 parent.max_invocations.is_some(),
182 parent.max_invocations.unwrap_or(0),
183 ) {
184 return false;
185 }
186 if !normalized_constraints_subset_bounded_kani(&self.constraints, &parent.constraints) {
187 return false;
188 }
189 if !monetary_cap_is_subset_bounded_kani(
190 self.max_cost_per_invocation.as_ref(),
191 parent.max_cost_per_invocation.as_ref(),
192 ) {
193 return false;
194 }
195 if !monetary_cap_is_subset_bounded_kani(
196 self.max_total_cost.as_ref(),
197 parent.max_total_cost.as_ref(),
198 ) {
199 return false;
200 }
201 if !required_true_is_preserved(
202 parent.dpop_required == Some(true),
203 self.dpop_required == Some(true),
204 ) {
205 return false;
206 }
207
208 true
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct NormalizedResourceGrant {
215 pub uri_pattern: String,
216 pub operations: Vec<NormalizedOperation>,
217}
218
219impl NormalizedResourceGrant {
220 #[must_use]
221 pub fn is_subset_of(&self, parent: &Self) -> bool {
222 pattern_covers(&parent.uri_pattern, &self.uri_pattern)
223 && self
224 .operations
225 .iter()
226 .all(|operation| parent.operations.contains(operation))
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct NormalizedPromptGrant {
233 pub prompt_name: String,
234 pub operations: Vec<NormalizedOperation>,
235}
236
237impl NormalizedPromptGrant {
238 #[must_use]
239 pub fn is_subset_of(&self, parent: &Self) -> bool {
240 pattern_covers(&parent.prompt_name, &self.prompt_name)
241 && self
242 .operations
243 .iter()
244 .all(|operation| parent.operations.contains(operation))
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct NormalizedScope {
251 pub grants: Vec<NormalizedToolGrant>,
252 pub resource_grants: Vec<NormalizedResourceGrant>,
253 pub prompt_grants: Vec<NormalizedPromptGrant>,
254}
255
256impl NormalizedScope {
257 #[must_use]
259 pub fn is_subset_of(&self, parent: &Self) -> bool {
260 #[cfg(kani)]
261 {
262 if !self.resource_grants.is_empty()
263 || !parent.resource_grants.is_empty()
264 || !self.prompt_grants.is_empty()
265 || !parent.prompt_grants.is_empty()
266 {
267 return false;
268 }
269 if self.grants.is_empty() {
270 return true;
271 }
272 if self.grants.len() == 1 && parent.grants.len() == 1 {
273 return self.grants[0].is_subset_of(&parent.grants[0]);
274 }
275 return false;
276 }
277
278 #[cfg(not(kani))]
279 {
280 self.grants.iter().all(|grant| {
281 parent
282 .grants
283 .iter()
284 .any(|candidate| grant.is_subset_of(candidate))
285 }) && self.resource_grants.iter().all(|grant| {
286 parent
287 .resource_grants
288 .iter()
289 .any(|candidate| grant.is_subset_of(candidate))
290 }) && self.prompt_grants.iter().all(|grant| {
291 parent
292 .prompt_grants
293 .iter()
294 .any(|candidate| grant.is_subset_of(candidate))
295 })
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct NormalizedCapability {
303 pub id: String,
304 pub issuer_hex: String,
305 pub subject_hex: String,
306 pub scope: NormalizedScope,
307 pub issued_at: u64,
308 pub expires_at: u64,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct NormalizedVerifiedCapability {
314 pub capability: NormalizedCapability,
315 pub evaluated_at: u64,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct NormalizedRequest {
321 pub request_id: String,
322 pub tool_name: String,
323 pub server_id: String,
324 pub agent_id: String,
325 pub arguments: serde_json::Value,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331pub enum NormalizedVerdict {
332 Allow,
333 Deny,
334 PendingApproval,
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct NormalizedEvaluationVerdict {
340 pub request: NormalizedRequest,
341 pub verdict: NormalizedVerdict,
342 pub reason: Option<String>,
343 pub matched_grant_index: Option<usize>,
344 pub verified: Option<NormalizedVerifiedCapability>,
345}
346
347impl TryFrom<&CapabilityToken> for NormalizedCapability {
348 type Error = NormalizationError;
349
350 fn try_from(capability: &CapabilityToken) -> Result<Self, Self::Error> {
351 Ok(Self {
352 id: capability.id.clone(),
353 issuer_hex: capability.issuer.to_hex(),
354 subject_hex: capability.subject.to_hex(),
355 scope: NormalizedScope::try_from(&capability.scope)?,
356 issued_at: capability.issued_at,
357 expires_at: capability.expires_at,
358 })
359 }
360}
361
362impl TryFrom<&VerifiedCapability> for NormalizedVerifiedCapability {
363 type Error = NormalizationError;
364
365 fn try_from(verified: &VerifiedCapability) -> Result<Self, Self::Error> {
366 Ok(Self {
367 capability: NormalizedCapability {
368 id: verified.id.clone(),
369 issuer_hex: verified.issuer_hex.clone(),
370 subject_hex: verified.subject_hex.clone(),
371 scope: NormalizedScope::try_from(&verified.scope)?,
372 issued_at: verified.issued_at,
373 expires_at: verified.expires_at,
374 },
375 evaluated_at: verified.evaluated_at,
376 })
377 }
378}
379
380impl From<&PortableToolCallRequest> for NormalizedRequest {
381 fn from(request: &PortableToolCallRequest) -> Self {
382 Self {
383 request_id: request.request_id.clone(),
384 tool_name: request.tool_name.clone(),
385 server_id: request.server_id.clone(),
386 agent_id: request.agent_id.clone(),
387 arguments: request.arguments.clone(),
388 }
389 }
390}
391
392impl NormalizedEvaluationVerdict {
393 pub fn try_from_evaluation(
394 request: &PortableToolCallRequest,
395 verdict: &EvaluationVerdict,
396 ) -> Result<Self, NormalizationError> {
397 Ok(Self {
398 request: NormalizedRequest::from(request),
399 verdict: NormalizedVerdict::from(verdict.verdict),
400 reason: verdict.reason.clone(),
401 matched_grant_index: verdict.matched_grant_index,
402 verified: verdict
403 .verified
404 .as_ref()
405 .map(NormalizedVerifiedCapability::try_from)
406 .transpose()?,
407 })
408 }
409}
410
411impl From<Verdict> for NormalizedVerdict {
412 fn from(verdict: Verdict) -> Self {
413 match verdict {
414 Verdict::Allow => Self::Allow,
415 Verdict::Deny => Self::Deny,
416 Verdict::PendingApproval => Self::PendingApproval,
417 }
418 }
419}
420
421impl TryFrom<&ChioScope> for NormalizedScope {
422 type Error = NormalizationError;
423
424 fn try_from(scope: &ChioScope) -> Result<Self, Self::Error> {
425 Ok(Self {
426 grants: scope
427 .grants
428 .iter()
429 .map(NormalizedToolGrant::try_from)
430 .collect::<Result<Vec<_>, _>>()?,
431 resource_grants: scope
432 .resource_grants
433 .iter()
434 .map(NormalizedResourceGrant::from)
435 .collect(),
436 prompt_grants: scope
437 .prompt_grants
438 .iter()
439 .map(NormalizedPromptGrant::from)
440 .collect(),
441 })
442 }
443}
444
445impl TryFrom<&ToolGrant> for NormalizedToolGrant {
446 type Error = NormalizationError;
447
448 fn try_from(grant: &ToolGrant) -> Result<Self, Self::Error> {
449 Ok(Self {
450 server_id: grant.server_id.clone(),
451 tool_name: grant.tool_name.clone(),
452 operations: grant
453 .operations
454 .iter()
455 .cloned()
456 .map(NormalizedOperation::from)
457 .collect(),
458 constraints: grant
459 .constraints
460 .iter()
461 .map(NormalizedConstraint::try_from)
462 .collect::<Result<Vec<_>, _>>()?,
463 max_invocations: grant.max_invocations,
464 max_cost_per_invocation: grant
465 .max_cost_per_invocation
466 .as_ref()
467 .map(NormalizedMonetaryAmount::from),
468 max_total_cost: grant
469 .max_total_cost
470 .as_ref()
471 .map(NormalizedMonetaryAmount::from),
472 dpop_required: grant.dpop_required,
473 })
474 }
475}
476
477impl From<&ResourceGrant> for NormalizedResourceGrant {
478 fn from(grant: &ResourceGrant) -> Self {
479 Self {
480 uri_pattern: grant.uri_pattern.clone(),
481 operations: grant
482 .operations
483 .iter()
484 .cloned()
485 .map(NormalizedOperation::from)
486 .collect(),
487 }
488 }
489}
490
491impl From<&PromptGrant> for NormalizedPromptGrant {
492 fn from(grant: &PromptGrant) -> Self {
493 Self {
494 prompt_name: grant.prompt_name.clone(),
495 operations: grant
496 .operations
497 .iter()
498 .cloned()
499 .map(NormalizedOperation::from)
500 .collect(),
501 }
502 }
503}
504
505impl From<&MonetaryAmount> for NormalizedMonetaryAmount {
506 fn from(amount: &MonetaryAmount) -> Self {
507 Self {
508 units: amount.units,
509 currency: amount.currency.clone(),
510 }
511 }
512}
513
514impl From<Operation> for NormalizedOperation {
515 fn from(operation: Operation) -> Self {
516 match operation {
517 Operation::Invoke => Self::Invoke,
518 Operation::ReadResult => Self::ReadResult,
519 Operation::Read => Self::Read,
520 Operation::Subscribe => Self::Subscribe,
521 Operation::Get => Self::Get,
522 Operation::Delegate => Self::Delegate,
523 }
524 }
525}
526
527impl From<RuntimeAssuranceTier> for NormalizedRuntimeAssuranceTier {
528 fn from(tier: RuntimeAssuranceTier) -> Self {
529 match tier {
530 RuntimeAssuranceTier::None => Self::None,
531 RuntimeAssuranceTier::Basic => Self::Basic,
532 RuntimeAssuranceTier::Attested => Self::Attested,
533 RuntimeAssuranceTier::Verified => Self::Verified,
534 }
535 }
536}
537
538impl TryFrom<&Constraint> for NormalizedConstraint {
539 type Error = NormalizationError;
540
541 fn try_from(constraint: &Constraint) -> Result<Self, Self::Error> {
542 match constraint {
543 Constraint::PathPrefix(value) => Ok(Self::PathPrefix(value.clone())),
544 Constraint::DomainExact(value) => Ok(Self::DomainExact(value.clone())),
545 Constraint::DomainGlob(value) => Ok(Self::DomainGlob(value.clone())),
546 Constraint::RegexMatch(value) => Ok(Self::RegexMatch(value.clone())),
547 Constraint::MaxLength(value) => Ok(Self::MaxLength(*value)),
548 Constraint::MaxArgsSize(value) => Ok(Self::MaxArgsSize(*value)),
549 Constraint::GovernedIntentRequired => Ok(Self::GovernedIntentRequired),
550 Constraint::RequireApprovalAbove { threshold_units } => {
551 Ok(Self::RequireApprovalAbove {
552 threshold_units: *threshold_units,
553 })
554 }
555 Constraint::SellerExact(value) => Ok(Self::SellerExact(value.clone())),
556 Constraint::MinimumRuntimeAssurance(tier) => {
557 Ok(Self::MinimumRuntimeAssurance((*tier).into()))
558 }
559 Constraint::Custom(key, value) => Ok(Self::Custom(key.clone(), value.clone())),
560 unsupported => Err(NormalizationError::UnsupportedConstraint {
561 kind: unsupported_constraint_name(unsupported).to_string(),
562 }),
563 }
564 }
565}
566
567#[cfg(not(kani))]
568fn monetary_cap_is_subset(
569 child: Option<&NormalizedMonetaryAmount>,
570 parent: Option<&NormalizedMonetaryAmount>,
571) -> bool {
572 let child_units = child.map(|cap| cap.units).unwrap_or(0);
573 let parent_units = parent.map(|cap| cap.units).unwrap_or(0);
574 let currency_matches = match (child, parent) {
575 (Some(child_cap), Some(parent_cap)) => child_cap.currency == parent_cap.currency,
576 _ => false,
577 };
578 monetary_cap_is_subset_by_parts(
579 child.is_some(),
580 child_units,
581 parent.is_some(),
582 parent_units,
583 currency_matches,
584 )
585}
586
587#[cfg(kani)]
588fn normalized_operations_subset_bounded_kani(
589 child: &[NormalizedOperation],
590 parent: &[NormalizedOperation],
591) -> bool {
592 if child.is_empty() {
593 return true;
594 }
595 if child.len() == 1 && parent.len() == 1 {
596 return child[0] == parent[0];
597 }
598 false
599}
600
601#[cfg(kani)]
602fn normalized_constraints_subset_bounded_kani(
603 _child: &[NormalizedConstraint],
604 parent: &[NormalizedConstraint],
605) -> bool {
606 parent.is_empty()
607}
608
609#[cfg(kani)]
610fn monetary_cap_is_subset_bounded_kani(
611 child: Option<&NormalizedMonetaryAmount>,
612 parent: Option<&NormalizedMonetaryAmount>,
613) -> bool {
614 match (child, parent) {
615 (None, None) | (Some(_), None) => true,
616 (None, Some(_)) => false,
617 (Some(child_cap), Some(parent_cap)) => {
618 child_cap.units <= parent_cap.units && child_cap.currency == parent_cap.currency
619 }
620 }
621}
622
623fn pattern_covers(parent: &str, child: &str) -> bool {
624 prefix_wildcard_or_exact_covers(parent, child)
625}
626
627fn unsupported_constraint_name(constraint: &Constraint) -> &'static str {
628 match constraint {
629 Constraint::PathPrefix(_) => "path_prefix",
630 Constraint::DomainExact(_) => "domain_exact",
631 Constraint::DomainGlob(_) => "domain_glob",
632 Constraint::RegexMatch(_) => "regex_match",
633 Constraint::MaxLength(_) => "max_length",
634 Constraint::MaxArgsSize(_) => "max_args_size",
635 Constraint::GovernedIntentRequired => "governed_intent_required",
636 Constraint::RequireApprovalAbove { .. } => "require_approval_above",
637 Constraint::RequireCumulativeApprovalAbove { .. } => "require_cumulative_approval_above",
638 Constraint::SellerExact(_) => "seller_exact",
639 Constraint::MinimumRuntimeAssurance(_) => "minimum_runtime_assurance",
640 Constraint::MinimumAutonomyTier(_) => "minimum_autonomy_tier",
641 Constraint::Custom(_, _) => "custom",
642 Constraint::TableAllowlist(_) => "table_allowlist",
643 Constraint::ColumnDenylist(_) => "column_denylist",
644 Constraint::MaxRowsReturned(_) => "max_rows_returned",
645 Constraint::OperationClass(_) => "operation_class",
646 Constraint::AudienceAllowlist(_) => "audience_allowlist",
647 Constraint::ContentReviewTier(_) => "content_review_tier",
648 Constraint::MaxTransactionAmountUsd(_) => "max_transaction_amount_usd",
649 Constraint::RequireDualApproval(_) => "require_dual_approval",
650 Constraint::ModelConstraint { .. } => "model_constraint",
651 Constraint::MemoryStoreAllowlist(_) => "memory_store_allowlist",
652 Constraint::MemoryWriteDenyPatterns(_) => "memory_write_deny_patterns",
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use alloc::vec;
660
661 fn grant(constraints: Vec<Constraint>) -> ToolGrant {
662 ToolGrant {
663 server_id: "srv-a".to_string(),
664 tool_name: "tool-a".to_string(),
665 operations: vec![Operation::Invoke],
666 constraints,
667 max_invocations: Some(4),
668 max_cost_per_invocation: Some(MonetaryAmount {
669 units: 100,
670 currency: "USD".to_string(),
671 }),
672 max_total_cost: None,
673 dpop_required: Some(true),
674 }
675 }
676
677 #[test]
678 fn normalized_scope_preserves_subset_logic_for_supported_surface() {
679 let parent = ChioScope {
680 grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
681 resource_grants: vec![ResourceGrant {
682 uri_pattern: "chio://receipts/*".to_string(),
683 operations: vec![Operation::Read],
684 }],
685 prompt_grants: vec![PromptGrant {
686 prompt_name: "*".to_string(),
687 operations: vec![Operation::Get],
688 }],
689 };
690 let child = ChioScope {
691 grants: vec![grant(vec![
692 Constraint::PathPrefix("/tmp".to_string()),
693 Constraint::MaxLength(32),
694 ])],
695 resource_grants: vec![ResourceGrant {
696 uri_pattern: "chio://receipts/session/*".to_string(),
697 operations: vec![Operation::Read],
698 }],
699 prompt_grants: vec![PromptGrant {
700 prompt_name: "risk_*".to_string(),
701 operations: vec![Operation::Get],
702 }],
703 };
704
705 let normalized_parent = NormalizedScope::try_from(&parent).expect("parent normalizes");
706 let normalized_child = NormalizedScope::try_from(&child).expect("child normalizes");
707
708 assert!(normalized_child.is_subset_of(&normalized_parent));
709 }
710
711 #[test]
712 fn normalized_scope_rejects_unsupported_constraint() {
713 let scope = ChioScope {
714 grants: vec![grant(vec![Constraint::TableAllowlist(vec![
715 "users".to_string()
716 ])])],
717 resource_grants: vec![],
718 prompt_grants: vec![],
719 };
720
721 let error = NormalizedScope::try_from(&scope).expect_err("unsupported constraint fails");
722 assert_eq!(
723 error,
724 NormalizationError::UnsupportedConstraint {
725 kind: "table_allowlist".to_string(),
726 }
727 );
728 }
729
730 #[test]
731 fn normalized_scope_rejects_cumulative_approval_until_enforced() {
732 let scope = ChioScope {
733 grants: vec![grant(vec![Constraint::RequireCumulativeApprovalAbove {
734 threshold: MonetaryAmount {
735 units: 10,
736 currency: "USD".to_string(),
737 },
738 approval_budget_id: "budget-1".to_string(),
739 approval_budget_epoch: 1,
740 cumulative_approval_root_binding: None,
741 }])],
742 resource_grants: vec![],
743 prompt_grants: vec![],
744 };
745
746 let error = NormalizedScope::try_from(&scope)
747 .expect_err("cumulative approval requires atomic enforcement");
748 assert_eq!(
749 error,
750 NormalizationError::UnsupportedConstraint {
751 kind: "require_cumulative_approval_above".to_string(),
752 }
753 );
754 }
755
756 #[test]
757 fn normalized_evaluation_captures_verified_projection() {
758 let request = PortableToolCallRequest {
759 request_id: "req-1".to_string(),
760 tool_name: "tool-a".to_string(),
761 server_id: "srv-a".to_string(),
762 agent_id: "agent-1".to_string(),
763 arguments: serde_json::json!({"path":"/tmp/demo.txt"}),
764 };
765 let verified = VerifiedCapability {
766 id: "cap-1".to_string(),
767 subject_hex: "agent-1".to_string(),
768 issuer_hex: "issuer-1".to_string(),
769 scope: ChioScope {
770 grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
771 resource_grants: vec![],
772 prompt_grants: vec![],
773 },
774 issued_at: 10,
775 expires_at: 20,
776 evaluated_at: 15,
777 };
778 let verdict = EvaluationVerdict {
779 verdict: Verdict::Allow,
780 reason: None,
781 matched_grant_index: Some(0),
782 verified: Some(verified),
783 };
784
785 let normalized = NormalizedEvaluationVerdict::try_from_evaluation(&request, &verdict)
786 .expect("evaluation normalizes");
787
788 assert_eq!(normalized.request.request_id, "req-1");
789 assert_eq!(normalized.verdict, NormalizedVerdict::Allow);
790 assert_eq!(
791 normalized
792 .verified
793 .as_ref()
794 .expect("verified projection present")
795 .capability
796 .id,
797 "cap-1"
798 );
799 }
800}