Skip to main content

chio_kernel_core/
scope.rs

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