use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::convert::TryFrom;
use chio_core_types::capability::{
CapabilityToken, ChioScope, Constraint, MonetaryAmount, Operation, PromptGrant, ResourceGrant,
RuntimeAssuranceTier, ToolGrant,
};
use serde::{Deserialize, Serialize};
use crate::capability_verify::VerifiedCapability;
use crate::evaluate::EvaluationVerdict;
use crate::guard::PortableToolCallRequest;
use crate::Verdict;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NormalizationError {
UnsupportedConstraint { kind: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedMonetaryAmount {
pub units: u64,
pub currency: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NormalizedRuntimeAssuranceTier {
None,
Basic,
Attested,
Verified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NormalizedOperation {
Invoke,
ReadResult,
Read,
Subscribe,
Get,
Delegate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum NormalizedConstraint {
PathPrefix(String),
DomainExact(String),
DomainGlob(String),
RegexMatch(String),
MaxLength(usize),
MaxArgsSize(usize),
GovernedIntentRequired,
RequireApprovalAbove { threshold_units: u64 },
SellerExact(String),
MinimumRuntimeAssurance(NormalizedRuntimeAssuranceTier),
Custom(String, String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedToolGrant {
pub server_id: String,
pub tool_name: String,
pub operations: Vec<NormalizedOperation>,
pub constraints: Vec<NormalizedConstraint>,
pub max_invocations: Option<u32>,
pub max_cost_per_invocation: Option<NormalizedMonetaryAmount>,
pub max_total_cost: Option<NormalizedMonetaryAmount>,
pub dpop_required: Option<bool>,
}
impl NormalizedToolGrant {
#[must_use]
pub fn is_subset_of(&self, parent: &Self) -> bool {
if parent.server_id != "*" && self.server_id != parent.server_id {
return false;
}
if parent.tool_name != "*" && self.tool_name != parent.tool_name {
return false;
}
if !self
.operations
.iter()
.all(|operation| parent.operations.contains(operation))
{
return false;
}
if let Some(parent_max) = parent.max_invocations {
match self.max_invocations {
Some(child_max) if child_max <= parent_max => {}
_ => return false,
}
}
if !parent
.constraints
.iter()
.all(|constraint| self.constraints.contains(constraint))
{
return false;
}
if !monetary_cap_is_subset(
self.max_cost_per_invocation.as_ref(),
parent.max_cost_per_invocation.as_ref(),
) {
return false;
}
if !monetary_cap_is_subset(self.max_total_cost.as_ref(), parent.max_total_cost.as_ref()) {
return false;
}
if parent.dpop_required == Some(true) && self.dpop_required != Some(true) {
return false;
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedResourceGrant {
pub uri_pattern: String,
pub operations: Vec<NormalizedOperation>,
}
impl NormalizedResourceGrant {
#[must_use]
pub fn is_subset_of(&self, parent: &Self) -> bool {
pattern_covers(&parent.uri_pattern, &self.uri_pattern)
&& self
.operations
.iter()
.all(|operation| parent.operations.contains(operation))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedPromptGrant {
pub prompt_name: String,
pub operations: Vec<NormalizedOperation>,
}
impl NormalizedPromptGrant {
#[must_use]
pub fn is_subset_of(&self, parent: &Self) -> bool {
pattern_covers(&parent.prompt_name, &self.prompt_name)
&& self
.operations
.iter()
.all(|operation| parent.operations.contains(operation))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedScope {
pub grants: Vec<NormalizedToolGrant>,
pub resource_grants: Vec<NormalizedResourceGrant>,
pub prompt_grants: Vec<NormalizedPromptGrant>,
}
impl NormalizedScope {
#[must_use]
pub fn is_subset_of(&self, parent: &Self) -> bool {
self.grants.iter().all(|grant| {
parent
.grants
.iter()
.any(|candidate| grant.is_subset_of(candidate))
}) && self.resource_grants.iter().all(|grant| {
parent
.resource_grants
.iter()
.any(|candidate| grant.is_subset_of(candidate))
}) && self.prompt_grants.iter().all(|grant| {
parent
.prompt_grants
.iter()
.any(|candidate| grant.is_subset_of(candidate))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedCapability {
pub id: String,
pub issuer_hex: String,
pub subject_hex: String,
pub scope: NormalizedScope,
pub issued_at: u64,
pub expires_at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedVerifiedCapability {
pub capability: NormalizedCapability,
pub evaluated_at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedRequest {
pub request_id: String,
pub tool_name: String,
pub server_id: String,
pub agent_id: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NormalizedVerdict {
Allow,
Deny,
PendingApproval,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedEvaluationVerdict {
pub request: NormalizedRequest,
pub verdict: NormalizedVerdict,
pub reason: Option<String>,
pub matched_grant_index: Option<usize>,
pub verified: Option<NormalizedVerifiedCapability>,
}
impl TryFrom<&CapabilityToken> for NormalizedCapability {
type Error = NormalizationError;
fn try_from(capability: &CapabilityToken) -> Result<Self, Self::Error> {
Ok(Self {
id: capability.id.clone(),
issuer_hex: capability.issuer.to_hex(),
subject_hex: capability.subject.to_hex(),
scope: NormalizedScope::try_from(&capability.scope)?,
issued_at: capability.issued_at,
expires_at: capability.expires_at,
})
}
}
impl TryFrom<&VerifiedCapability> for NormalizedVerifiedCapability {
type Error = NormalizationError;
fn try_from(verified: &VerifiedCapability) -> Result<Self, Self::Error> {
Ok(Self {
capability: NormalizedCapability {
id: verified.id.clone(),
issuer_hex: verified.issuer_hex.clone(),
subject_hex: verified.subject_hex.clone(),
scope: NormalizedScope::try_from(&verified.scope)?,
issued_at: verified.issued_at,
expires_at: verified.expires_at,
},
evaluated_at: verified.evaluated_at,
})
}
}
impl From<&PortableToolCallRequest> for NormalizedRequest {
fn from(request: &PortableToolCallRequest) -> Self {
Self {
request_id: request.request_id.clone(),
tool_name: request.tool_name.clone(),
server_id: request.server_id.clone(),
agent_id: request.agent_id.clone(),
arguments: request.arguments.clone(),
}
}
}
impl NormalizedEvaluationVerdict {
pub fn try_from_evaluation(
request: &PortableToolCallRequest,
verdict: &EvaluationVerdict,
) -> Result<Self, NormalizationError> {
Ok(Self {
request: NormalizedRequest::from(request),
verdict: NormalizedVerdict::from(verdict.verdict),
reason: verdict.reason.clone(),
matched_grant_index: verdict.matched_grant_index,
verified: verdict
.verified
.as_ref()
.map(NormalizedVerifiedCapability::try_from)
.transpose()?,
})
}
}
impl From<Verdict> for NormalizedVerdict {
fn from(verdict: Verdict) -> Self {
match verdict {
Verdict::Allow => Self::Allow,
Verdict::Deny => Self::Deny,
Verdict::PendingApproval => Self::PendingApproval,
}
}
}
impl TryFrom<&ChioScope> for NormalizedScope {
type Error = NormalizationError;
fn try_from(scope: &ChioScope) -> Result<Self, Self::Error> {
Ok(Self {
grants: scope
.grants
.iter()
.map(NormalizedToolGrant::try_from)
.collect::<Result<Vec<_>, _>>()?,
resource_grants: scope
.resource_grants
.iter()
.map(NormalizedResourceGrant::from)
.collect(),
prompt_grants: scope
.prompt_grants
.iter()
.map(NormalizedPromptGrant::from)
.collect(),
})
}
}
impl TryFrom<&ToolGrant> for NormalizedToolGrant {
type Error = NormalizationError;
fn try_from(grant: &ToolGrant) -> Result<Self, Self::Error> {
Ok(Self {
server_id: grant.server_id.clone(),
tool_name: grant.tool_name.clone(),
operations: grant
.operations
.iter()
.cloned()
.map(NormalizedOperation::from)
.collect(),
constraints: grant
.constraints
.iter()
.map(NormalizedConstraint::try_from)
.collect::<Result<Vec<_>, _>>()?,
max_invocations: grant.max_invocations,
max_cost_per_invocation: grant
.max_cost_per_invocation
.as_ref()
.map(NormalizedMonetaryAmount::from),
max_total_cost: grant
.max_total_cost
.as_ref()
.map(NormalizedMonetaryAmount::from),
dpop_required: grant.dpop_required,
})
}
}
impl From<&ResourceGrant> for NormalizedResourceGrant {
fn from(grant: &ResourceGrant) -> Self {
Self {
uri_pattern: grant.uri_pattern.clone(),
operations: grant
.operations
.iter()
.cloned()
.map(NormalizedOperation::from)
.collect(),
}
}
}
impl From<&PromptGrant> for NormalizedPromptGrant {
fn from(grant: &PromptGrant) -> Self {
Self {
prompt_name: grant.prompt_name.clone(),
operations: grant
.operations
.iter()
.cloned()
.map(NormalizedOperation::from)
.collect(),
}
}
}
impl From<&MonetaryAmount> for NormalizedMonetaryAmount {
fn from(amount: &MonetaryAmount) -> Self {
Self {
units: amount.units,
currency: amount.currency.clone(),
}
}
}
impl From<Operation> for NormalizedOperation {
fn from(operation: Operation) -> Self {
match operation {
Operation::Invoke => Self::Invoke,
Operation::ReadResult => Self::ReadResult,
Operation::Read => Self::Read,
Operation::Subscribe => Self::Subscribe,
Operation::Get => Self::Get,
Operation::Delegate => Self::Delegate,
}
}
}
impl From<RuntimeAssuranceTier> for NormalizedRuntimeAssuranceTier {
fn from(tier: RuntimeAssuranceTier) -> Self {
match tier {
RuntimeAssuranceTier::None => Self::None,
RuntimeAssuranceTier::Basic => Self::Basic,
RuntimeAssuranceTier::Attested => Self::Attested,
RuntimeAssuranceTier::Verified => Self::Verified,
}
}
}
impl TryFrom<&Constraint> for NormalizedConstraint {
type Error = NormalizationError;
fn try_from(constraint: &Constraint) -> Result<Self, Self::Error> {
match constraint {
Constraint::PathPrefix(value) => Ok(Self::PathPrefix(value.clone())),
Constraint::DomainExact(value) => Ok(Self::DomainExact(value.clone())),
Constraint::DomainGlob(value) => Ok(Self::DomainGlob(value.clone())),
Constraint::RegexMatch(value) => Ok(Self::RegexMatch(value.clone())),
Constraint::MaxLength(value) => Ok(Self::MaxLength(*value)),
Constraint::MaxArgsSize(value) => Ok(Self::MaxArgsSize(*value)),
Constraint::GovernedIntentRequired => Ok(Self::GovernedIntentRequired),
Constraint::RequireApprovalAbove { threshold_units } => {
Ok(Self::RequireApprovalAbove {
threshold_units: *threshold_units,
})
}
Constraint::SellerExact(value) => Ok(Self::SellerExact(value.clone())),
Constraint::MinimumRuntimeAssurance(tier) => {
Ok(Self::MinimumRuntimeAssurance((*tier).into()))
}
Constraint::Custom(key, value) => Ok(Self::Custom(key.clone(), value.clone())),
unsupported => Err(NormalizationError::UnsupportedConstraint {
kind: unsupported_constraint_name(unsupported).to_string(),
}),
}
}
}
fn monetary_cap_is_subset(
child: Option<&NormalizedMonetaryAmount>,
parent: Option<&NormalizedMonetaryAmount>,
) -> bool {
match parent {
None => true,
Some(parent_cap) => matches!(
child,
Some(child_cap)
if child_cap.currency == parent_cap.currency
&& child_cap.units <= parent_cap.units
),
}
}
fn pattern_covers(parent: &str, child: &str) -> bool {
if parent == "*" {
return true;
}
if let Some(prefix) = parent.strip_suffix('*') {
return child.starts_with(prefix);
}
parent == child
}
fn unsupported_constraint_name(constraint: &Constraint) -> &'static str {
match constraint {
Constraint::PathPrefix(_) => "path_prefix",
Constraint::DomainExact(_) => "domain_exact",
Constraint::DomainGlob(_) => "domain_glob",
Constraint::RegexMatch(_) => "regex_match",
Constraint::MaxLength(_) => "max_length",
Constraint::MaxArgsSize(_) => "max_args_size",
Constraint::GovernedIntentRequired => "governed_intent_required",
Constraint::RequireApprovalAbove { .. } => "require_approval_above",
Constraint::SellerExact(_) => "seller_exact",
Constraint::MinimumRuntimeAssurance(_) => "minimum_runtime_assurance",
Constraint::MinimumAutonomyTier(_) => "minimum_autonomy_tier",
Constraint::Custom(_, _) => "custom",
Constraint::TableAllowlist(_) => "table_allowlist",
Constraint::ColumnDenylist(_) => "column_denylist",
Constraint::MaxRowsReturned(_) => "max_rows_returned",
Constraint::OperationClass(_) => "operation_class",
Constraint::AudienceAllowlist(_) => "audience_allowlist",
Constraint::ContentReviewTier(_) => "content_review_tier",
Constraint::MaxTransactionAmountUsd(_) => "max_transaction_amount_usd",
Constraint::RequireDualApproval(_) => "require_dual_approval",
Constraint::ModelConstraint { .. } => "model_constraint",
Constraint::MemoryStoreAllowlist(_) => "memory_store_allowlist",
Constraint::MemoryWriteDenyPatterns(_) => "memory_write_deny_patterns",
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
fn grant(constraints: Vec<Constraint>) -> ToolGrant {
ToolGrant {
server_id: "srv-a".to_string(),
tool_name: "tool-a".to_string(),
operations: vec![Operation::Invoke],
constraints,
max_invocations: Some(4),
max_cost_per_invocation: Some(MonetaryAmount {
units: 100,
currency: "USD".to_string(),
}),
max_total_cost: None,
dpop_required: Some(true),
}
}
#[test]
fn normalized_scope_preserves_subset_logic_for_supported_surface() {
let parent = ChioScope {
grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
resource_grants: vec![ResourceGrant {
uri_pattern: "chio://receipts/*".to_string(),
operations: vec![Operation::Read],
}],
prompt_grants: vec![PromptGrant {
prompt_name: "*".to_string(),
operations: vec![Operation::Get],
}],
};
let child = ChioScope {
grants: vec![grant(vec![
Constraint::PathPrefix("/tmp".to_string()),
Constraint::MaxLength(32),
])],
resource_grants: vec![ResourceGrant {
uri_pattern: "chio://receipts/session/*".to_string(),
operations: vec![Operation::Read],
}],
prompt_grants: vec![PromptGrant {
prompt_name: "risk_*".to_string(),
operations: vec![Operation::Get],
}],
};
let normalized_parent = NormalizedScope::try_from(&parent).expect("parent normalizes");
let normalized_child = NormalizedScope::try_from(&child).expect("child normalizes");
assert!(normalized_child.is_subset_of(&normalized_parent));
}
#[test]
fn normalized_scope_rejects_unsupported_constraint() {
let scope = ChioScope {
grants: vec![grant(vec![Constraint::TableAllowlist(vec![
"users".to_string()
])])],
resource_grants: vec![],
prompt_grants: vec![],
};
let error = NormalizedScope::try_from(&scope).expect_err("unsupported constraint fails");
assert_eq!(
error,
NormalizationError::UnsupportedConstraint {
kind: "table_allowlist".to_string(),
}
);
}
#[test]
fn normalized_evaluation_captures_verified_projection() {
let request = PortableToolCallRequest {
request_id: "req-1".to_string(),
tool_name: "tool-a".to_string(),
server_id: "srv-a".to_string(),
agent_id: "agent-1".to_string(),
arguments: serde_json::json!({"path":"/tmp/demo.txt"}),
};
let verified = VerifiedCapability {
id: "cap-1".to_string(),
subject_hex: "agent-1".to_string(),
issuer_hex: "issuer-1".to_string(),
scope: ChioScope {
grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
resource_grants: vec![],
prompt_grants: vec![],
},
issued_at: 10,
expires_at: 20,
evaluated_at: 15,
};
let verdict = EvaluationVerdict {
verdict: Verdict::Allow,
reason: None,
matched_grant_index: Some(0),
verified: Some(verified),
};
let normalized = NormalizedEvaluationVerdict::try_from_evaluation(&request, &verdict)
.expect("evaluation normalizes");
assert_eq!(normalized.request.request_id, "req-1");
assert_eq!(normalized.verdict, NormalizedVerdict::Allow);
assert_eq!(
normalized
.verified
.as_ref()
.expect("verified projection present")
.capability
.id,
"cap-1"
);
}
}