use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chio_core_types::capability::{CapabilityToken, ChioScope, Constraint, Operation, ToolGrant};
#[derive(Debug, Clone, Copy)]
pub struct MatchedGrant<'a> {
pub index: usize,
pub grant: &'a ToolGrant,
pub specificity: (u8, u8, usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeMatchError {
OutOfScope,
ConstraintError(String),
}
pub fn resolve_matching_grants<'a>(
scope: &'a ChioScope,
tool_name: &str,
server_id: &str,
arguments: &serde_json::Value,
) -> Result<Vec<MatchedGrant<'a>>, ScopeMatchError> {
let mut matches: Vec<MatchedGrant<'a>> = Vec::new();
for (index, grant) in scope.grants.iter().enumerate() {
let covered = match grant_covers(grant, tool_name, server_id, arguments) {
Ok(covered) => covered,
Err(error @ ScopeMatchError::ConstraintError(_)) => return Err(error),
Err(error) => return Err(error),
};
if !covered {
continue;
}
matches.push(MatchedGrant {
index,
grant,
specificity: (
u8::from(grant.server_id == server_id),
u8::from(grant.tool_name == tool_name),
grant.constraints.len(),
),
});
}
matches.sort_by(|left, right| {
right
.specificity
.cmp(&left.specificity)
.then_with(|| left.index.cmp(&right.index))
});
Ok(matches)
}
pub fn resolve_capability_grants<'a>(
capability: &'a CapabilityToken,
tool_name: &str,
server_id: &str,
arguments: &serde_json::Value,
) -> Result<Vec<MatchedGrant<'a>>, ScopeMatchError> {
let matches = resolve_matching_grants(&capability.scope, tool_name, server_id, arguments)?;
if matches.is_empty() {
return Err(ScopeMatchError::OutOfScope);
}
Ok(matches)
}
fn grant_covers(
grant: &ToolGrant,
tool_name: &str,
server_id: &str,
arguments: &serde_json::Value,
) -> Result<bool, ScopeMatchError> {
Ok(matches_pattern(&grant.server_id, server_id)
&& matches_pattern(&grant.tool_name, tool_name)
&& grant.operations.contains(&Operation::Invoke)
&& constraints_match(&grant.constraints, arguments)?)
}
fn constraints_match(
constraints: &[Constraint],
arguments: &serde_json::Value,
) -> Result<bool, ScopeMatchError> {
for constraint in constraints {
if !constraint_matches(constraint, arguments)? {
return Ok(false);
}
}
Ok(true)
}
fn constraint_matches(
constraint: &Constraint,
arguments: &serde_json::Value,
) -> Result<bool, ScopeMatchError> {
let string_leaves = collect_string_leaves(arguments);
match constraint {
Constraint::PathPrefix(prefix) => {
let candidates: Vec<&str> = string_leaves
.iter()
.filter(|leaf| {
leaf.key.as_deref().is_some_and(is_path_key) || looks_like_path(&leaf.value)
})
.map(|leaf| leaf.value.as_str())
.collect();
Ok(!candidates.is_empty()
&& candidates
.into_iter()
.all(|path| path_has_prefix(path, prefix)))
}
Constraint::DomainExact(expected) => {
let expected = normalize_domain(expected);
let domains = collect_domain_candidates(&string_leaves);
Ok(!domains.is_empty() && domains.into_iter().all(|domain| domain == expected))
}
Constraint::DomainGlob(pattern) => {
let pattern = pattern.to_ascii_lowercase();
let domains = collect_domain_candidates(&string_leaves);
Ok(!domains.is_empty()
&& domains
.into_iter()
.all(|domain| wildcard_matches(&pattern, &domain)))
}
Constraint::MaxLength(max) => Ok(string_leaves.iter().all(|leaf| leaf.value.len() <= *max)),
Constraint::MaxArgsSize(max) => Ok(arguments.to_string().len() <= *max),
Constraint::Custom(key, expected) => Ok(argument_contains_custom(arguments, key, expected)),
Constraint::AudienceAllowlist(allowed) => {
Ok(audience_allowlist_matches(arguments, allowed))
}
Constraint::MemoryStoreAllowlist(allowed) => {
Ok(memory_store_allowlist_matches(arguments, allowed))
}
Constraint::RegexMatch(_)
| Constraint::GovernedIntentRequired
| Constraint::RequireApprovalAbove { .. }
| Constraint::SellerExact(_)
| Constraint::MinimumRuntimeAssurance(_)
| Constraint::MinimumAutonomyTier(_)
| Constraint::TableAllowlist(_)
| Constraint::ColumnDenylist(_)
| Constraint::MaxRowsReturned(_)
| Constraint::OperationClass(_)
| Constraint::ContentReviewTier(_)
| Constraint::MaxTransactionAmountUsd(_)
| Constraint::RequireDualApproval(_)
| Constraint::ModelConstraint { .. }
| Constraint::MemoryWriteDenyPatterns(_) => Err(ScopeMatchError::ConstraintError(format!(
"portable kernel cannot safely evaluate {}",
constraint_name(constraint)
))),
}
}
fn matches_pattern(pattern: &str, candidate: &str) -> bool {
pattern == "*" || pattern == candidate
}
fn path_has_prefix(candidate: &str, prefix: &str) -> bool {
let Some(candidate) = normalize_path(candidate) else {
return false;
};
let Some(prefix) = normalize_path(prefix) else {
return false;
};
if candidate.is_absolute != prefix.is_absolute {
return false;
}
if prefix.segments.len() > candidate.segments.len() {
return false;
}
prefix
.segments
.iter()
.zip(candidate.segments.iter())
.all(|(expected, actual)| expected == actual)
}
#[derive(Debug, PartialEq, Eq)]
struct NormalizedPath {
is_absolute: bool,
segments: Vec<String>,
}
fn normalize_path(path: &str) -> Option<NormalizedPath> {
let is_absolute = path.starts_with('/') || path.starts_with('\\');
let mut segments = Vec::new();
for segment in path.split(['/', '\\']) {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
segments.pop()?;
continue;
}
segments.push(segment.to_string());
}
Some(NormalizedPath {
is_absolute,
segments,
})
}
fn 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",
}
}
#[derive(Clone)]
struct StringLeaf {
key: Option<String>,
value: String,
}
fn collect_string_leaves(arguments: &serde_json::Value) -> Vec<StringLeaf> {
let mut leaves = Vec::new();
collect_string_leaves_inner(arguments, None, &mut leaves);
leaves
}
fn collect_string_leaves_inner(
arguments: &serde_json::Value,
current_key: Option<&str>,
leaves: &mut Vec<StringLeaf>,
) {
match arguments {
serde_json::Value::String(value) => leaves.push(StringLeaf {
key: current_key.map(str::to_string),
value: value.clone(),
}),
serde_json::Value::Array(values) => {
for value in values {
collect_string_leaves_inner(value, current_key, leaves);
}
}
serde_json::Value::Object(map) => {
for (key, value) in map {
collect_string_leaves_inner(value, Some(key), leaves);
}
}
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
}
}
fn is_path_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("path")
|| matches!(
key.as_str(),
"file" | "filepath" | "dir" | "directory" | "root" | "cwd"
)
}
fn looks_like_path(value: &str) -> bool {
!value.contains("://")
&& (value.starts_with('/')
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with("~/")
|| value.contains('/')
|| value.contains('\\'))
}
fn collect_domain_candidates(string_leaves: &[StringLeaf]) -> Vec<String> {
string_leaves
.iter()
.filter_map(|leaf| parse_domain(&leaf.value))
.collect()
}
fn parse_domain(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let host_port = if let Some((_, rest)) = trimmed.split_once("://") {
rest
} else {
trimmed
};
let authority = host_port
.split(['/', '?', '#'])
.next()
.unwrap_or(host_port)
.rsplit('@')
.next()
.unwrap_or(host_port);
let host = authority
.split(':')
.next()
.unwrap_or(authority)
.trim_matches('.');
let normalized = normalize_domain(host);
if normalized == "localhost"
|| (!normalized.is_empty()
&& normalized.contains('.')
&& normalized.chars().all(|character| {
character.is_ascii_alphanumeric() || character == '-' || character == '.'
}))
{
Some(normalized)
} else {
None
}
}
fn normalize_domain(value: &str) -> String {
value.trim().trim_matches('.').to_ascii_lowercase()
}
fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
let pattern_chars: Vec<char> = pattern.chars().collect();
let candidate_chars: Vec<char> = candidate.chars().collect();
let (mut pattern_idx, mut candidate_idx) = (0usize, 0usize);
let (mut star_idx, mut match_idx) = (None, 0usize);
while candidate_idx < candidate_chars.len() {
if pattern_idx < pattern_chars.len()
&& (pattern_chars[pattern_idx] == candidate_chars[candidate_idx]
|| pattern_chars[pattern_idx] == '*')
{
if pattern_chars[pattern_idx] == '*' {
star_idx = Some(pattern_idx);
match_idx = candidate_idx;
pattern_idx += 1;
} else {
pattern_idx += 1;
candidate_idx += 1;
}
} else if let Some(star_position) = star_idx {
pattern_idx = star_position + 1;
match_idx += 1;
candidate_idx = match_idx;
} else {
return false;
}
}
while pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' {
pattern_idx += 1;
}
pattern_idx == pattern_chars.len()
}
fn argument_contains_custom(arguments: &serde_json::Value, key: &str, expected: &str) -> bool {
match arguments {
serde_json::Value::Object(map) => map.iter().any(|(entry_key, value)| {
(entry_key == key && value.as_str() == Some(expected))
|| argument_contains_custom(value, key, expected)
}),
serde_json::Value::Array(values) => values
.iter()
.any(|value| argument_contains_custom(value, key, expected)),
serde_json::Value::Null
| serde_json::Value::Bool(_)
| serde_json::Value::Number(_)
| serde_json::Value::String(_) => false,
}
}
fn audience_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
let mut observed: Vec<String> = Vec::new();
collect_audience_values(arguments, &mut observed);
if observed.is_empty() {
return true;
}
observed
.iter()
.all(|value| allowed.iter().any(|allowed_value| allowed_value == value))
}
fn collect_audience_values(arguments: &serde_json::Value, out: &mut Vec<String>) {
match arguments {
serde_json::Value::Object(map) => {
for (key, value) in map {
if is_audience_key(key) {
collect_string_values(value, out);
} else {
collect_audience_values(value, out);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
collect_audience_values(value, out);
}
}
_ => {}
}
}
fn is_audience_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"recipient" | "recipients" | "audience" | "to" | "channel" | "channels"
)
}
fn collect_string_values(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(s) => out.push(s.clone()),
serde_json::Value::Array(values) => {
for value in values {
collect_string_values(value, out);
}
}
_ => {}
}
}
fn memory_store_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
let mut observed: Vec<String> = Vec::new();
collect_memory_store_values(arguments, &mut observed);
if observed.is_empty() {
return true;
}
observed
.iter()
.all(|value| allowed.iter().any(|allowed_value| allowed_value == value))
}
fn collect_memory_store_values(arguments: &serde_json::Value, out: &mut Vec<String>) {
match arguments {
serde_json::Value::Object(map) => {
for (key, value) in map {
if is_memory_store_key(key) {
collect_string_values(value, out);
} else {
collect_memory_store_values(value, out);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
collect_memory_store_values(value, out);
}
}
_ => {}
}
}
fn is_memory_store_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"store" | "memory_store" | "collection" | "namespace"
)
}