1use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use core::convert::TryFrom;
10
11use chio_core_types::capability::{
12 CapabilityToken, ChioScope, Constraint, MonetaryAmount, Operation, PromptGrant, ResourceGrant,
13 RuntimeAssuranceTier, ToolGrant,
14};
15use serde::{Deserialize, Serialize};
16
17use crate::capability_verify::VerifiedCapability;
18use crate::evaluate::EvaluationVerdict;
19use crate::guard::PortableToolCallRequest;
20use crate::Verdict;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum NormalizationError {
26 UnsupportedConstraint { kind: String },
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct NormalizedMonetaryAmount {
33 pub units: u64,
34 pub currency: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum NormalizedRuntimeAssuranceTier {
41 None,
42 Basic,
43 Attested,
44 Verified,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum NormalizedOperation {
51 Invoke,
52 ReadResult,
53 Read,
54 Subscribe,
55 Get,
56 Delegate,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(tag = "type", content = "value", rename_all = "snake_case")]
65pub enum NormalizedConstraint {
66 PathPrefix(String),
67 DomainExact(String),
68 DomainGlob(String),
69 RegexMatch(String),
70 MaxLength(usize),
71 MaxArgsSize(usize),
72 GovernedIntentRequired,
73 RequireApprovalAbove { threshold_units: u64 },
74 SellerExact(String),
75 MinimumRuntimeAssurance(NormalizedRuntimeAssuranceTier),
76 Custom(String, String),
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct NormalizedToolGrant {
82 pub server_id: String,
83 pub tool_name: String,
84 pub operations: Vec<NormalizedOperation>,
85 pub constraints: Vec<NormalizedConstraint>,
86 pub max_invocations: Option<u32>,
87 pub max_cost_per_invocation: Option<NormalizedMonetaryAmount>,
88 pub max_total_cost: Option<NormalizedMonetaryAmount>,
89 pub dpop_required: Option<bool>,
90}
91
92impl NormalizedToolGrant {
93 #[must_use]
95 pub fn is_subset_of(&self, parent: &Self) -> bool {
96 if parent.server_id != "*" && self.server_id != parent.server_id {
97 return false;
98 }
99 if parent.tool_name != "*" && self.tool_name != parent.tool_name {
100 return false;
101 }
102
103 if !self
104 .operations
105 .iter()
106 .all(|operation| parent.operations.contains(operation))
107 {
108 return false;
109 }
110
111 if let Some(parent_max) = parent.max_invocations {
112 match self.max_invocations {
113 Some(child_max) if child_max <= parent_max => {}
114 _ => return false,
115 }
116 }
117
118 if !parent
119 .constraints
120 .iter()
121 .all(|constraint| self.constraints.contains(constraint))
122 {
123 return false;
124 }
125
126 if !monetary_cap_is_subset(
127 self.max_cost_per_invocation.as_ref(),
128 parent.max_cost_per_invocation.as_ref(),
129 ) {
130 return false;
131 }
132
133 if !monetary_cap_is_subset(self.max_total_cost.as_ref(), parent.max_total_cost.as_ref()) {
134 return false;
135 }
136
137 if parent.dpop_required == Some(true) && self.dpop_required != Some(true) {
138 return false;
139 }
140
141 true
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct NormalizedResourceGrant {
148 pub uri_pattern: String,
149 pub operations: Vec<NormalizedOperation>,
150}
151
152impl NormalizedResourceGrant {
153 #[must_use]
154 pub fn is_subset_of(&self, parent: &Self) -> bool {
155 pattern_covers(&parent.uri_pattern, &self.uri_pattern)
156 && self
157 .operations
158 .iter()
159 .all(|operation| parent.operations.contains(operation))
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct NormalizedPromptGrant {
166 pub prompt_name: String,
167 pub operations: Vec<NormalizedOperation>,
168}
169
170impl NormalizedPromptGrant {
171 #[must_use]
172 pub fn is_subset_of(&self, parent: &Self) -> bool {
173 pattern_covers(&parent.prompt_name, &self.prompt_name)
174 && self
175 .operations
176 .iter()
177 .all(|operation| parent.operations.contains(operation))
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct NormalizedScope {
184 pub grants: Vec<NormalizedToolGrant>,
185 pub resource_grants: Vec<NormalizedResourceGrant>,
186 pub prompt_grants: Vec<NormalizedPromptGrant>,
187}
188
189impl NormalizedScope {
190 #[must_use]
192 pub fn is_subset_of(&self, parent: &Self) -> bool {
193 self.grants.iter().all(|grant| {
194 parent
195 .grants
196 .iter()
197 .any(|candidate| grant.is_subset_of(candidate))
198 }) && self.resource_grants.iter().all(|grant| {
199 parent
200 .resource_grants
201 .iter()
202 .any(|candidate| grant.is_subset_of(candidate))
203 }) && self.prompt_grants.iter().all(|grant| {
204 parent
205 .prompt_grants
206 .iter()
207 .any(|candidate| grant.is_subset_of(candidate))
208 })
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct NormalizedCapability {
215 pub id: String,
216 pub issuer_hex: String,
217 pub subject_hex: String,
218 pub scope: NormalizedScope,
219 pub issued_at: u64,
220 pub expires_at: u64,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct NormalizedVerifiedCapability {
226 pub capability: NormalizedCapability,
227 pub evaluated_at: u64,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct NormalizedRequest {
233 pub request_id: String,
234 pub tool_name: String,
235 pub server_id: String,
236 pub agent_id: String,
237 pub arguments: serde_json::Value,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub enum NormalizedVerdict {
244 Allow,
245 Deny,
246 PendingApproval,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct NormalizedEvaluationVerdict {
252 pub request: NormalizedRequest,
253 pub verdict: NormalizedVerdict,
254 pub reason: Option<String>,
255 pub matched_grant_index: Option<usize>,
256 pub verified: Option<NormalizedVerifiedCapability>,
257}
258
259impl TryFrom<&CapabilityToken> for NormalizedCapability {
260 type Error = NormalizationError;
261
262 fn try_from(capability: &CapabilityToken) -> Result<Self, Self::Error> {
263 Ok(Self {
264 id: capability.id.clone(),
265 issuer_hex: capability.issuer.to_hex(),
266 subject_hex: capability.subject.to_hex(),
267 scope: NormalizedScope::try_from(&capability.scope)?,
268 issued_at: capability.issued_at,
269 expires_at: capability.expires_at,
270 })
271 }
272}
273
274impl TryFrom<&VerifiedCapability> for NormalizedVerifiedCapability {
275 type Error = NormalizationError;
276
277 fn try_from(verified: &VerifiedCapability) -> Result<Self, Self::Error> {
278 Ok(Self {
279 capability: NormalizedCapability {
280 id: verified.id.clone(),
281 issuer_hex: verified.issuer_hex.clone(),
282 subject_hex: verified.subject_hex.clone(),
283 scope: NormalizedScope::try_from(&verified.scope)?,
284 issued_at: verified.issued_at,
285 expires_at: verified.expires_at,
286 },
287 evaluated_at: verified.evaluated_at,
288 })
289 }
290}
291
292impl From<&PortableToolCallRequest> for NormalizedRequest {
293 fn from(request: &PortableToolCallRequest) -> Self {
294 Self {
295 request_id: request.request_id.clone(),
296 tool_name: request.tool_name.clone(),
297 server_id: request.server_id.clone(),
298 agent_id: request.agent_id.clone(),
299 arguments: request.arguments.clone(),
300 }
301 }
302}
303
304impl NormalizedEvaluationVerdict {
305 pub fn try_from_evaluation(
306 request: &PortableToolCallRequest,
307 verdict: &EvaluationVerdict,
308 ) -> Result<Self, NormalizationError> {
309 Ok(Self {
310 request: NormalizedRequest::from(request),
311 verdict: NormalizedVerdict::from(verdict.verdict),
312 reason: verdict.reason.clone(),
313 matched_grant_index: verdict.matched_grant_index,
314 verified: verdict
315 .verified
316 .as_ref()
317 .map(NormalizedVerifiedCapability::try_from)
318 .transpose()?,
319 })
320 }
321}
322
323impl From<Verdict> for NormalizedVerdict {
324 fn from(verdict: Verdict) -> Self {
325 match verdict {
326 Verdict::Allow => Self::Allow,
327 Verdict::Deny => Self::Deny,
328 Verdict::PendingApproval => Self::PendingApproval,
329 }
330 }
331}
332
333impl TryFrom<&ChioScope> for NormalizedScope {
334 type Error = NormalizationError;
335
336 fn try_from(scope: &ChioScope) -> Result<Self, Self::Error> {
337 Ok(Self {
338 grants: scope
339 .grants
340 .iter()
341 .map(NormalizedToolGrant::try_from)
342 .collect::<Result<Vec<_>, _>>()?,
343 resource_grants: scope
344 .resource_grants
345 .iter()
346 .map(NormalizedResourceGrant::from)
347 .collect(),
348 prompt_grants: scope
349 .prompt_grants
350 .iter()
351 .map(NormalizedPromptGrant::from)
352 .collect(),
353 })
354 }
355}
356
357impl TryFrom<&ToolGrant> for NormalizedToolGrant {
358 type Error = NormalizationError;
359
360 fn try_from(grant: &ToolGrant) -> Result<Self, Self::Error> {
361 Ok(Self {
362 server_id: grant.server_id.clone(),
363 tool_name: grant.tool_name.clone(),
364 operations: grant
365 .operations
366 .iter()
367 .cloned()
368 .map(NormalizedOperation::from)
369 .collect(),
370 constraints: grant
371 .constraints
372 .iter()
373 .map(NormalizedConstraint::try_from)
374 .collect::<Result<Vec<_>, _>>()?,
375 max_invocations: grant.max_invocations,
376 max_cost_per_invocation: grant
377 .max_cost_per_invocation
378 .as_ref()
379 .map(NormalizedMonetaryAmount::from),
380 max_total_cost: grant
381 .max_total_cost
382 .as_ref()
383 .map(NormalizedMonetaryAmount::from),
384 dpop_required: grant.dpop_required,
385 })
386 }
387}
388
389impl From<&ResourceGrant> for NormalizedResourceGrant {
390 fn from(grant: &ResourceGrant) -> Self {
391 Self {
392 uri_pattern: grant.uri_pattern.clone(),
393 operations: grant
394 .operations
395 .iter()
396 .cloned()
397 .map(NormalizedOperation::from)
398 .collect(),
399 }
400 }
401}
402
403impl From<&PromptGrant> for NormalizedPromptGrant {
404 fn from(grant: &PromptGrant) -> Self {
405 Self {
406 prompt_name: grant.prompt_name.clone(),
407 operations: grant
408 .operations
409 .iter()
410 .cloned()
411 .map(NormalizedOperation::from)
412 .collect(),
413 }
414 }
415}
416
417impl From<&MonetaryAmount> for NormalizedMonetaryAmount {
418 fn from(amount: &MonetaryAmount) -> Self {
419 Self {
420 units: amount.units,
421 currency: amount.currency.clone(),
422 }
423 }
424}
425
426impl From<Operation> for NormalizedOperation {
427 fn from(operation: Operation) -> Self {
428 match operation {
429 Operation::Invoke => Self::Invoke,
430 Operation::ReadResult => Self::ReadResult,
431 Operation::Read => Self::Read,
432 Operation::Subscribe => Self::Subscribe,
433 Operation::Get => Self::Get,
434 Operation::Delegate => Self::Delegate,
435 }
436 }
437}
438
439impl From<RuntimeAssuranceTier> for NormalizedRuntimeAssuranceTier {
440 fn from(tier: RuntimeAssuranceTier) -> Self {
441 match tier {
442 RuntimeAssuranceTier::None => Self::None,
443 RuntimeAssuranceTier::Basic => Self::Basic,
444 RuntimeAssuranceTier::Attested => Self::Attested,
445 RuntimeAssuranceTier::Verified => Self::Verified,
446 }
447 }
448}
449
450impl TryFrom<&Constraint> for NormalizedConstraint {
451 type Error = NormalizationError;
452
453 fn try_from(constraint: &Constraint) -> Result<Self, Self::Error> {
454 match constraint {
455 Constraint::PathPrefix(value) => Ok(Self::PathPrefix(value.clone())),
456 Constraint::DomainExact(value) => Ok(Self::DomainExact(value.clone())),
457 Constraint::DomainGlob(value) => Ok(Self::DomainGlob(value.clone())),
458 Constraint::RegexMatch(value) => Ok(Self::RegexMatch(value.clone())),
459 Constraint::MaxLength(value) => Ok(Self::MaxLength(*value)),
460 Constraint::MaxArgsSize(value) => Ok(Self::MaxArgsSize(*value)),
461 Constraint::GovernedIntentRequired => Ok(Self::GovernedIntentRequired),
462 Constraint::RequireApprovalAbove { threshold_units } => {
463 Ok(Self::RequireApprovalAbove {
464 threshold_units: *threshold_units,
465 })
466 }
467 Constraint::SellerExact(value) => Ok(Self::SellerExact(value.clone())),
468 Constraint::MinimumRuntimeAssurance(tier) => {
469 Ok(Self::MinimumRuntimeAssurance((*tier).into()))
470 }
471 Constraint::Custom(key, value) => Ok(Self::Custom(key.clone(), value.clone())),
472 unsupported => Err(NormalizationError::UnsupportedConstraint {
473 kind: unsupported_constraint_name(unsupported).to_string(),
474 }),
475 }
476 }
477}
478
479fn monetary_cap_is_subset(
480 child: Option<&NormalizedMonetaryAmount>,
481 parent: Option<&NormalizedMonetaryAmount>,
482) -> bool {
483 match parent {
484 None => true,
485 Some(parent_cap) => matches!(
486 child,
487 Some(child_cap)
488 if child_cap.currency == parent_cap.currency
489 && child_cap.units <= parent_cap.units
490 ),
491 }
492}
493
494fn pattern_covers(parent: &str, child: &str) -> bool {
495 if parent == "*" {
496 return true;
497 }
498
499 if let Some(prefix) = parent.strip_suffix('*') {
500 return child.starts_with(prefix);
501 }
502
503 parent == child
504}
505
506fn unsupported_constraint_name(constraint: &Constraint) -> &'static str {
507 match constraint {
508 Constraint::PathPrefix(_) => "path_prefix",
509 Constraint::DomainExact(_) => "domain_exact",
510 Constraint::DomainGlob(_) => "domain_glob",
511 Constraint::RegexMatch(_) => "regex_match",
512 Constraint::MaxLength(_) => "max_length",
513 Constraint::MaxArgsSize(_) => "max_args_size",
514 Constraint::GovernedIntentRequired => "governed_intent_required",
515 Constraint::RequireApprovalAbove { .. } => "require_approval_above",
516 Constraint::SellerExact(_) => "seller_exact",
517 Constraint::MinimumRuntimeAssurance(_) => "minimum_runtime_assurance",
518 Constraint::MinimumAutonomyTier(_) => "minimum_autonomy_tier",
519 Constraint::Custom(_, _) => "custom",
520 Constraint::TableAllowlist(_) => "table_allowlist",
521 Constraint::ColumnDenylist(_) => "column_denylist",
522 Constraint::MaxRowsReturned(_) => "max_rows_returned",
523 Constraint::OperationClass(_) => "operation_class",
524 Constraint::AudienceAllowlist(_) => "audience_allowlist",
525 Constraint::ContentReviewTier(_) => "content_review_tier",
526 Constraint::MaxTransactionAmountUsd(_) => "max_transaction_amount_usd",
527 Constraint::RequireDualApproval(_) => "require_dual_approval",
528 Constraint::ModelConstraint { .. } => "model_constraint",
529 Constraint::MemoryStoreAllowlist(_) => "memory_store_allowlist",
530 Constraint::MemoryWriteDenyPatterns(_) => "memory_write_deny_patterns",
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use alloc::vec;
538
539 fn grant(constraints: Vec<Constraint>) -> ToolGrant {
540 ToolGrant {
541 server_id: "srv-a".to_string(),
542 tool_name: "tool-a".to_string(),
543 operations: vec![Operation::Invoke],
544 constraints,
545 max_invocations: Some(4),
546 max_cost_per_invocation: Some(MonetaryAmount {
547 units: 100,
548 currency: "USD".to_string(),
549 }),
550 max_total_cost: None,
551 dpop_required: Some(true),
552 }
553 }
554
555 #[test]
556 fn normalized_scope_preserves_subset_logic_for_supported_surface() {
557 let parent = ChioScope {
558 grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
559 resource_grants: vec![ResourceGrant {
560 uri_pattern: "chio://receipts/*".to_string(),
561 operations: vec![Operation::Read],
562 }],
563 prompt_grants: vec![PromptGrant {
564 prompt_name: "*".to_string(),
565 operations: vec![Operation::Get],
566 }],
567 };
568 let child = ChioScope {
569 grants: vec![grant(vec![
570 Constraint::PathPrefix("/tmp".to_string()),
571 Constraint::MaxLength(32),
572 ])],
573 resource_grants: vec![ResourceGrant {
574 uri_pattern: "chio://receipts/session/*".to_string(),
575 operations: vec![Operation::Read],
576 }],
577 prompt_grants: vec![PromptGrant {
578 prompt_name: "risk_*".to_string(),
579 operations: vec![Operation::Get],
580 }],
581 };
582
583 let normalized_parent = NormalizedScope::try_from(&parent).expect("parent normalizes");
584 let normalized_child = NormalizedScope::try_from(&child).expect("child normalizes");
585
586 assert!(normalized_child.is_subset_of(&normalized_parent));
587 }
588
589 #[test]
590 fn normalized_scope_rejects_unsupported_constraint() {
591 let scope = ChioScope {
592 grants: vec![grant(vec![Constraint::TableAllowlist(vec![
593 "users".to_string()
594 ])])],
595 resource_grants: vec![],
596 prompt_grants: vec![],
597 };
598
599 let error = NormalizedScope::try_from(&scope).expect_err("unsupported constraint fails");
600 assert_eq!(
601 error,
602 NormalizationError::UnsupportedConstraint {
603 kind: "table_allowlist".to_string(),
604 }
605 );
606 }
607
608 #[test]
609 fn normalized_evaluation_captures_verified_projection() {
610 let request = PortableToolCallRequest {
611 request_id: "req-1".to_string(),
612 tool_name: "tool-a".to_string(),
613 server_id: "srv-a".to_string(),
614 agent_id: "agent-1".to_string(),
615 arguments: serde_json::json!({"path":"/tmp/demo.txt"}),
616 };
617 let verified = VerifiedCapability {
618 id: "cap-1".to_string(),
619 subject_hex: "agent-1".to_string(),
620 issuer_hex: "issuer-1".to_string(),
621 scope: ChioScope {
622 grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
623 resource_grants: vec![],
624 prompt_grants: vec![],
625 },
626 issued_at: 10,
627 expires_at: 20,
628 evaluated_at: 15,
629 };
630 let verdict = EvaluationVerdict {
631 verdict: Verdict::Allow,
632 reason: None,
633 matched_grant_index: Some(0),
634 verified: Some(verified),
635 };
636
637 let normalized = NormalizedEvaluationVerdict::try_from_evaluation(&request, &verdict)
638 .expect("evaluation normalizes");
639
640 assert_eq!(normalized.request.request_id, "req-1");
641 assert_eq!(normalized.verdict, NormalizedVerdict::Allow);
642 assert_eq!(
643 normalized
644 .verified
645 .as_ref()
646 .expect("verified projection present")
647 .capability
648 .id,
649 "cap-1"
650 );
651 }
652}