Skip to main content

chio_kernel_core/
normalized.rs

1//! Proof-facing normalized types for the bounded verified core.
2//!
3//! These types deliberately carve out the pure authorization subset the
4//! executable spec and future Lean refinement work can talk about without
5//! depending on the full runtime object graph.
6
7use 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/// Errors raised while projecting runtime objects into the normalized
23/// proof-facing surface.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum NormalizationError {
26    /// The current bounded proof lane does not model this constraint yet.
27    UnsupportedConstraint { kind: String },
28}
29
30/// Proof-facing monetary cap.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct NormalizedMonetaryAmount {
33    pub units: u64,
34    pub currency: String,
35}
36
37/// Proof-facing runtime assurance tier.
38#[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/// Proof-facing operation enum.
48#[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/// Constraint subset currently admitted into the normalized proof-facing AST.
60///
61/// Unsupported runtime-only constraints remain outside this boundary and cause
62/// normalization to fail closed.
63#[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/// Proof-facing tool grant.
80#[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    /// Mirrors `ToolGrant::is_subset_of` over the normalized proof-facing AST.
94    #[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/// Proof-facing resource grant.
146#[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/// Proof-facing prompt grant.
164#[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/// Proof-facing scope.
182#[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    /// Mirrors `ChioScope::is_subset_of` over the normalized proof-facing AST.
191    #[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/// Proof-facing capability token projection.
213#[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/// Proof-facing verified-capability output.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct NormalizedVerifiedCapability {
226    pub capability: NormalizedCapability,
227    pub evaluated_at: u64,
228}
229
230/// Proof-facing request shape for the current evaluated tool call.
231#[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/// Proof-facing verdict enum.
241#[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/// Proof-facing evaluation output.
250#[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}