Skip to main content

chio_kernel_core/
scope.rs

1//! Portable scope matching for tool grants.
2//!
3//! This module implements portable scope matching for tool grants.
4//!
5//! The hosted kernel still carries the richest matcher in
6//! `chio-kernel::request_matching`, but the portable core must never
7//! silently drop a grant constraint. Constraints that can be evaluated
8//! from request arguments are enforced here; constraints that require
9//! richer kernel state (governed intent, runtime attestation, SQL result
10//! inspection, regex compilation, etc.) fail closed with an explicit
11//! error instead of widening scope.
12//!
13//! Callers that want the full constraint pipeline continue to go through
14//! `chio_kernel::capability_matches_request` -- the public API in the
15//! orchestration shell is unchanged. This function is the pure-compute
16//! kernel the portable adapters will consume directly.
17//!
18//! Verified-core boundary note:
19//! `formal/proof-manifest.toml` includes the portable matcher because it is
20//! the fail-closed subset of scope evaluation that never reaches into stores,
21//! regex engines, runtime-attestation records, or governed-transaction state.
22
23use alloc::format;
24use alloc::string::{String, ToString};
25use alloc::vec::Vec;
26
27use chio_core_types::capability::{CapabilityToken, ChioScope, Constraint, Operation, ToolGrant};
28
29/// Borrowed match result, ordered by specificity.
30///
31/// Mirrors the layout of `chio_kernel::MatchingGrant` but is exposed
32/// publicly so portable adapters can rank and iterate matches without
33/// re-running the sort.
34#[derive(Debug, Clone, Copy)]
35pub struct MatchedGrant<'a> {
36    /// Index of this grant inside the scope's grant vector.
37    pub index: usize,
38    /// The matched grant itself.
39    pub grant: &'a ToolGrant,
40    /// Specificity tuple: `(server-exact, tool-exact, constraint-count)`.
41    pub specificity: (u8, u8, usize),
42}
43
44/// Errors that can be raised by the portable scope matcher.
45///
46/// The full matcher in `chio-kernel` surfaces richer error variants
47/// (invalid-constraint, attestation-trust, etc.); the portable core
48/// returns the two coarse-grained cases that do not require regex or
49/// other IO-adjacent machinery.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ScopeMatchError {
52    /// No grant in the scope covers the requested `(server, tool, Invoke)`.
53    OutOfScope,
54    /// The portable kernel cannot safely evaluate a constraint carried by a
55    /// target-matching grant.
56    ConstraintError(String),
57}
58
59/// Resolve the set of grants that authorise a tool invocation on the
60/// given server.
61///
62/// Returns the matched grants sorted by decreasing specificity
63/// (exact-exact first, then exact-wildcard, then wildcard-wildcard; ties
64/// broken by grant-list order).
65pub fn resolve_matching_grants<'a>(
66    scope: &'a ChioScope,
67    tool_name: &str,
68    server_id: &str,
69    arguments: &serde_json::Value,
70) -> Result<Vec<MatchedGrant<'a>>, ScopeMatchError> {
71    let mut matches: Vec<MatchedGrant<'a>> = Vec::new();
72
73    for (index, grant) in scope.grants.iter().enumerate() {
74        let covered = match grant_covers(grant, tool_name, server_id, arguments) {
75            Ok(covered) => covered,
76            Err(error @ ScopeMatchError::ConstraintError(_)) => return Err(error),
77            Err(error) => return Err(error),
78        };
79        if !covered {
80            continue;
81        }
82
83        matches.push(MatchedGrant {
84            index,
85            grant,
86            specificity: (
87                u8::from(grant.server_id == server_id),
88                u8::from(grant.tool_name == tool_name),
89                grant.constraints.len(),
90            ),
91        });
92    }
93
94    matches.sort_by(|left, right| {
95        right
96            .specificity
97            .cmp(&left.specificity)
98            .then_with(|| left.index.cmp(&right.index))
99    });
100
101    Ok(matches)
102}
103
104/// Convenience wrapper that runs [`resolve_matching_grants`] against a
105/// full capability token.
106pub fn resolve_capability_grants<'a>(
107    capability: &'a CapabilityToken,
108    tool_name: &str,
109    server_id: &str,
110    arguments: &serde_json::Value,
111) -> Result<Vec<MatchedGrant<'a>>, ScopeMatchError> {
112    let matches = resolve_matching_grants(&capability.scope, tool_name, server_id, arguments)?;
113    if matches.is_empty() {
114        return Err(ScopeMatchError::OutOfScope);
115    }
116    Ok(matches)
117}
118
119fn grant_covers(
120    grant: &ToolGrant,
121    tool_name: &str,
122    server_id: &str,
123    arguments: &serde_json::Value,
124) -> Result<bool, ScopeMatchError> {
125    Ok(matches_pattern(&grant.server_id, server_id)
126        && matches_pattern(&grant.tool_name, tool_name)
127        && grant.operations.contains(&Operation::Invoke)
128        && constraints_match(&grant.constraints, arguments)?)
129}
130
131fn constraints_match(
132    constraints: &[Constraint],
133    arguments: &serde_json::Value,
134) -> Result<bool, ScopeMatchError> {
135    for constraint in constraints {
136        if !constraint_matches(constraint, arguments)? {
137            return Ok(false);
138        }
139    }
140    Ok(true)
141}
142
143fn constraint_matches(
144    constraint: &Constraint,
145    arguments: &serde_json::Value,
146) -> Result<bool, ScopeMatchError> {
147    let string_leaves = collect_string_leaves(arguments);
148
149    match constraint {
150        Constraint::PathPrefix(prefix) => {
151            let candidates: Vec<&str> = string_leaves
152                .iter()
153                .filter(|leaf| {
154                    leaf.key.as_deref().is_some_and(is_path_key) || looks_like_path(&leaf.value)
155                })
156                .map(|leaf| leaf.value.as_str())
157                .collect();
158            Ok(!candidates.is_empty()
159                && candidates
160                    .into_iter()
161                    .all(|path| path_has_prefix(path, prefix)))
162        }
163        Constraint::DomainExact(expected) => {
164            let expected = normalize_domain(expected);
165            let domains = collect_domain_candidates(&string_leaves);
166            Ok(!domains.is_empty() && domains.into_iter().all(|domain| domain == expected))
167        }
168        Constraint::DomainGlob(pattern) => {
169            let pattern = pattern.to_ascii_lowercase();
170            let domains = collect_domain_candidates(&string_leaves);
171            Ok(!domains.is_empty()
172                && domains
173                    .into_iter()
174                    .all(|domain| wildcard_matches(&pattern, &domain)))
175        }
176        Constraint::MaxLength(max) => Ok(string_leaves.iter().all(|leaf| leaf.value.len() <= *max)),
177        Constraint::MaxArgsSize(max) => Ok(arguments.to_string().len() <= *max),
178        Constraint::Custom(key, expected) => Ok(argument_contains_custom(arguments, key, expected)),
179        Constraint::AudienceAllowlist(allowed) => {
180            Ok(audience_allowlist_matches(arguments, allowed))
181        }
182        Constraint::MemoryStoreAllowlist(allowed) => {
183            Ok(memory_store_allowlist_matches(arguments, allowed))
184        }
185        Constraint::RegexMatch(_)
186        | Constraint::GovernedIntentRequired
187        | Constraint::RequireApprovalAbove { .. }
188        | Constraint::SellerExact(_)
189        | Constraint::MinimumRuntimeAssurance(_)
190        | Constraint::MinimumAutonomyTier(_)
191        | Constraint::TableAllowlist(_)
192        | Constraint::ColumnDenylist(_)
193        | Constraint::MaxRowsReturned(_)
194        | Constraint::OperationClass(_)
195        | Constraint::ContentReviewTier(_)
196        | Constraint::MaxTransactionAmountUsd(_)
197        | Constraint::RequireDualApproval(_)
198        | Constraint::ModelConstraint { .. }
199        | Constraint::MemoryWriteDenyPatterns(_) => Err(ScopeMatchError::ConstraintError(format!(
200            "portable kernel cannot safely evaluate {}",
201            constraint_name(constraint)
202        ))),
203    }
204}
205
206fn matches_pattern(pattern: &str, candidate: &str) -> bool {
207    pattern == "*" || pattern == candidate
208}
209
210fn path_has_prefix(candidate: &str, prefix: &str) -> bool {
211    let Some(candidate) = normalize_path(candidate) else {
212        return false;
213    };
214    let Some(prefix) = normalize_path(prefix) else {
215        return false;
216    };
217    if candidate.is_absolute != prefix.is_absolute {
218        return false;
219    }
220    if prefix.segments.len() > candidate.segments.len() {
221        return false;
222    }
223    prefix
224        .segments
225        .iter()
226        .zip(candidate.segments.iter())
227        .all(|(expected, actual)| expected == actual)
228}
229
230#[derive(Debug, PartialEq, Eq)]
231struct NormalizedPath {
232    is_absolute: bool,
233    segments: Vec<String>,
234}
235
236fn normalize_path(path: &str) -> Option<NormalizedPath> {
237    let is_absolute = path.starts_with('/') || path.starts_with('\\');
238    let mut segments = Vec::new();
239    for segment in path.split(['/', '\\']) {
240        if segment.is_empty() || segment == "." {
241            continue;
242        }
243        if segment == ".." {
244            segments.pop()?;
245            continue;
246        }
247        segments.push(segment.to_string());
248    }
249    Some(NormalizedPath {
250        is_absolute,
251        segments,
252    })
253}
254
255fn constraint_name(constraint: &Constraint) -> &'static str {
256    match constraint {
257        Constraint::PathPrefix(_) => "path_prefix",
258        Constraint::DomainExact(_) => "domain_exact",
259        Constraint::DomainGlob(_) => "domain_glob",
260        Constraint::RegexMatch(_) => "regex_match",
261        Constraint::MaxLength(_) => "max_length",
262        Constraint::MaxArgsSize(_) => "max_args_size",
263        Constraint::GovernedIntentRequired => "governed_intent_required",
264        Constraint::RequireApprovalAbove { .. } => "require_approval_above",
265        Constraint::SellerExact(_) => "seller_exact",
266        Constraint::MinimumRuntimeAssurance(_) => "minimum_runtime_assurance",
267        Constraint::MinimumAutonomyTier(_) => "minimum_autonomy_tier",
268        Constraint::Custom(_, _) => "custom",
269        Constraint::TableAllowlist(_) => "table_allowlist",
270        Constraint::ColumnDenylist(_) => "column_denylist",
271        Constraint::MaxRowsReturned(_) => "max_rows_returned",
272        Constraint::OperationClass(_) => "operation_class",
273        Constraint::AudienceAllowlist(_) => "audience_allowlist",
274        Constraint::ContentReviewTier(_) => "content_review_tier",
275        Constraint::MaxTransactionAmountUsd(_) => "max_transaction_amount_usd",
276        Constraint::RequireDualApproval(_) => "require_dual_approval",
277        Constraint::ModelConstraint { .. } => "model_constraint",
278        Constraint::MemoryStoreAllowlist(_) => "memory_store_allowlist",
279        Constraint::MemoryWriteDenyPatterns(_) => "memory_write_deny_patterns",
280    }
281}
282
283#[derive(Clone)]
284struct StringLeaf {
285    key: Option<String>,
286    value: String,
287}
288
289fn collect_string_leaves(arguments: &serde_json::Value) -> Vec<StringLeaf> {
290    let mut leaves = Vec::new();
291    collect_string_leaves_inner(arguments, None, &mut leaves);
292    leaves
293}
294
295fn collect_string_leaves_inner(
296    arguments: &serde_json::Value,
297    current_key: Option<&str>,
298    leaves: &mut Vec<StringLeaf>,
299) {
300    match arguments {
301        serde_json::Value::String(value) => leaves.push(StringLeaf {
302            key: current_key.map(str::to_string),
303            value: value.clone(),
304        }),
305        serde_json::Value::Array(values) => {
306            for value in values {
307                collect_string_leaves_inner(value, current_key, leaves);
308            }
309        }
310        serde_json::Value::Object(map) => {
311            for (key, value) in map {
312                collect_string_leaves_inner(value, Some(key), leaves);
313            }
314        }
315        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
316    }
317}
318
319fn is_path_key(key: &str) -> bool {
320    let key = key.to_ascii_lowercase();
321    key.contains("path")
322        || matches!(
323            key.as_str(),
324            "file" | "filepath" | "dir" | "directory" | "root" | "cwd"
325        )
326}
327
328fn looks_like_path(value: &str) -> bool {
329    !value.contains("://")
330        && (value.starts_with('/')
331            || value.starts_with("./")
332            || value.starts_with("../")
333            || value.starts_with("~/")
334            || value.contains('/')
335            || value.contains('\\'))
336}
337
338fn collect_domain_candidates(string_leaves: &[StringLeaf]) -> Vec<String> {
339    string_leaves
340        .iter()
341        .filter_map(|leaf| parse_domain(&leaf.value))
342        .collect()
343}
344
345fn parse_domain(value: &str) -> Option<String> {
346    let trimmed = value.trim();
347    if trimmed.is_empty() {
348        return None;
349    }
350
351    let host_port = if let Some((_, rest)) = trimmed.split_once("://") {
352        rest
353    } else {
354        trimmed
355    };
356
357    let authority = host_port
358        .split(['/', '?', '#'])
359        .next()
360        .unwrap_or(host_port)
361        .rsplit('@')
362        .next()
363        .unwrap_or(host_port);
364    let host = authority
365        .split(':')
366        .next()
367        .unwrap_or(authority)
368        .trim_matches('.');
369    let normalized = normalize_domain(host);
370
371    if normalized == "localhost"
372        || (!normalized.is_empty()
373            && normalized.contains('.')
374            && normalized.chars().all(|character| {
375                character.is_ascii_alphanumeric() || character == '-' || character == '.'
376            }))
377    {
378        Some(normalized)
379    } else {
380        None
381    }
382}
383
384fn normalize_domain(value: &str) -> String {
385    value.trim().trim_matches('.').to_ascii_lowercase()
386}
387
388fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
389    let pattern_chars: Vec<char> = pattern.chars().collect();
390    let candidate_chars: Vec<char> = candidate.chars().collect();
391    let (mut pattern_idx, mut candidate_idx) = (0usize, 0usize);
392    let (mut star_idx, mut match_idx) = (None, 0usize);
393
394    while candidate_idx < candidate_chars.len() {
395        if pattern_idx < pattern_chars.len()
396            && (pattern_chars[pattern_idx] == candidate_chars[candidate_idx]
397                || pattern_chars[pattern_idx] == '*')
398        {
399            if pattern_chars[pattern_idx] == '*' {
400                star_idx = Some(pattern_idx);
401                match_idx = candidate_idx;
402                pattern_idx += 1;
403            } else {
404                pattern_idx += 1;
405                candidate_idx += 1;
406            }
407        } else if let Some(star_position) = star_idx {
408            pattern_idx = star_position + 1;
409            match_idx += 1;
410            candidate_idx = match_idx;
411        } else {
412            return false;
413        }
414    }
415
416    while pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' {
417        pattern_idx += 1;
418    }
419
420    pattern_idx == pattern_chars.len()
421}
422
423fn argument_contains_custom(arguments: &serde_json::Value, key: &str, expected: &str) -> bool {
424    match arguments {
425        serde_json::Value::Object(map) => map.iter().any(|(entry_key, value)| {
426            (entry_key == key && value.as_str() == Some(expected))
427                || argument_contains_custom(value, key, expected)
428        }),
429        serde_json::Value::Array(values) => values
430            .iter()
431            .any(|value| argument_contains_custom(value, key, expected)),
432        serde_json::Value::Null
433        | serde_json::Value::Bool(_)
434        | serde_json::Value::Number(_)
435        | serde_json::Value::String(_) => false,
436    }
437}
438
439fn audience_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
440    let mut observed: Vec<String> = Vec::new();
441    collect_audience_values(arguments, &mut observed);
442    if observed.is_empty() {
443        return true;
444    }
445    observed
446        .iter()
447        .all(|value| allowed.iter().any(|allowed_value| allowed_value == value))
448}
449
450fn collect_audience_values(arguments: &serde_json::Value, out: &mut Vec<String>) {
451    match arguments {
452        serde_json::Value::Object(map) => {
453            for (key, value) in map {
454                if is_audience_key(key) {
455                    collect_string_values(value, out);
456                } else {
457                    collect_audience_values(value, out);
458                }
459            }
460        }
461        serde_json::Value::Array(values) => {
462            for value in values {
463                collect_audience_values(value, out);
464            }
465        }
466        _ => {}
467    }
468}
469
470fn is_audience_key(key: &str) -> bool {
471    matches!(
472        key.to_ascii_lowercase().as_str(),
473        "recipient" | "recipients" | "audience" | "to" | "channel" | "channels"
474    )
475}
476
477fn collect_string_values(value: &serde_json::Value, out: &mut Vec<String>) {
478    match value {
479        serde_json::Value::String(s) => out.push(s.clone()),
480        serde_json::Value::Array(values) => {
481            for value in values {
482                collect_string_values(value, out);
483            }
484        }
485        _ => {}
486    }
487}
488
489fn memory_store_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
490    let mut observed: Vec<String> = Vec::new();
491    collect_memory_store_values(arguments, &mut observed);
492    if observed.is_empty() {
493        return true;
494    }
495    observed
496        .iter()
497        .all(|value| allowed.iter().any(|allowed_value| allowed_value == value))
498}
499
500fn collect_memory_store_values(arguments: &serde_json::Value, out: &mut Vec<String>) {
501    match arguments {
502        serde_json::Value::Object(map) => {
503            for (key, value) in map {
504                if is_memory_store_key(key) {
505                    collect_string_values(value, out);
506                } else {
507                    collect_memory_store_values(value, out);
508                }
509            }
510        }
511        serde_json::Value::Array(values) => {
512            for value in values {
513                collect_memory_store_values(value, out);
514            }
515        }
516        _ => {}
517    }
518}
519
520fn is_memory_store_key(key: &str) -> bool {
521    matches!(
522        key.to_ascii_lowercase().as_str(),
523        "store" | "memory_store" | "collection" | "namespace"
524    )
525}