Skip to main content

chio_kernel/
request_matching.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use chio_core::capability::{ModelMetadata, ModelSafetyTier};
5use regex::Regex;
6
7use super::*;
8
9pub(super) fn session_from_map<'a>(
10    sessions: &'a HashMap<SessionId, Session>,
11    session_id: &SessionId,
12) -> Result<&'a Session, KernelError> {
13    sessions
14        .get(session_id)
15        .ok_or_else(|| KernelError::UnknownSession(session_id.clone()))
16}
17
18pub(super) fn session_mut_from_map<'a>(
19    sessions: &'a mut HashMap<SessionId, Session>,
20    session_id: &SessionId,
21) -> Result<&'a mut Session, KernelError> {
22    sessions
23        .get_mut(session_id)
24        .ok_or_else(|| KernelError::UnknownSession(session_id.clone()))
25}
26
27pub(super) fn begin_session_request_in_sessions(
28    sessions: &mut HashMap<SessionId, Session>,
29    context: &OperationContext,
30    operation_kind: OperationKind,
31    cancellable: bool,
32) -> Result<(), KernelError> {
33    let session = session_mut_from_map(sessions, &context.session_id)?;
34    session.validate_context(context)?;
35    session.ensure_operation_allowed(operation_kind)?;
36    session.track_request(context, operation_kind, cancellable)?;
37    Ok(())
38}
39
40pub(super) fn begin_child_request_in_sessions(
41    sessions: &mut HashMap<SessionId, Session>,
42    parent_context: &OperationContext,
43    request_id: RequestId,
44    operation_kind: OperationKind,
45    progress_token: Option<ProgressToken>,
46    cancellable: bool,
47) -> Result<OperationContext, KernelError> {
48    let parent_session = session_from_map(sessions, &parent_context.session_id)?;
49    parent_session.validate_context(parent_context)?;
50    parent_session.validate_parent_request_lineage(&request_id, &parent_context.request_id)?;
51
52    let child_context = OperationContext {
53        session_id: parent_context.session_id.clone(),
54        request_id,
55        agent_id: parent_context.agent_id.clone(),
56        parent_request_id: Some(parent_context.request_id.clone()),
57        progress_token,
58    };
59    begin_session_request_in_sessions(sessions, &child_context, operation_kind, cancellable)?;
60    Ok(child_context)
61}
62
63pub(super) fn complete_session_request_with_terminal_state_in_sessions(
64    sessions: &mut HashMap<SessionId, Session>,
65    session_id: &SessionId,
66    request_id: &RequestId,
67    terminal_state: OperationTerminalState,
68) -> Result<(), KernelError> {
69    session_mut_from_map(sessions, session_id)?
70        .complete_request_with_terminal_state(request_id, terminal_state)?;
71    Ok(())
72}
73
74pub(super) fn validate_sampling_request_in_sessions(
75    sessions: &HashMap<SessionId, Session>,
76    allow_sampling: bool,
77    allow_sampling_tool_use: bool,
78    context: &OperationContext,
79    operation: &CreateMessageOperation,
80) -> Result<(), KernelError> {
81    let session = session_from_map(sessions, &context.session_id)?;
82    session.validate_context(context)?;
83    session.ensure_operation_allowed(OperationKind::CreateMessage)?;
84
85    let parent_request_id = context
86        .parent_request_id
87        .as_ref()
88        .ok_or(KernelError::InvalidChildRequestParent)?;
89    session.validate_parent_request_lineage(&context.request_id, parent_request_id)?;
90
91    if !allow_sampling {
92        return Err(KernelError::SamplingNotAllowedByPolicy);
93    }
94
95    let peer_capabilities = session.peer_capabilities();
96    if !peer_capabilities.supports_sampling {
97        return Err(KernelError::SamplingNotNegotiated);
98    }
99
100    if matches!(
101        operation.include_context.as_deref(),
102        Some("thisServer") | Some("allServers")
103    ) && !peer_capabilities.sampling_context
104    {
105        return Err(KernelError::SamplingContextNotSupported);
106    }
107
108    let requests_tool_use = !operation.tools.is_empty()
109        || operation
110            .tool_choice
111            .as_ref()
112            .is_some_and(|choice| choice.mode != "none");
113    if requests_tool_use {
114        if !allow_sampling_tool_use {
115            return Err(KernelError::SamplingToolUseNotAllowedByPolicy);
116        }
117        if !peer_capabilities.sampling_tools {
118            return Err(KernelError::SamplingToolUseNotNegotiated);
119        }
120    }
121
122    Ok(())
123}
124
125pub(super) fn validate_elicitation_request_in_sessions(
126    sessions: &HashMap<SessionId, Session>,
127    allow_elicitation: bool,
128    context: &OperationContext,
129    operation: &CreateElicitationOperation,
130) -> Result<(), KernelError> {
131    let session = session_from_map(sessions, &context.session_id)?;
132    session.validate_context(context)?;
133    session.ensure_operation_allowed(OperationKind::CreateElicitation)?;
134
135    let parent_request_id = context
136        .parent_request_id
137        .as_ref()
138        .ok_or(KernelError::InvalidChildRequestParent)?;
139    session.validate_parent_request_lineage(&context.request_id, parent_request_id)?;
140
141    if !allow_elicitation {
142        return Err(KernelError::ElicitationNotAllowedByPolicy);
143    }
144
145    let peer_capabilities = session.peer_capabilities();
146    if !peer_capabilities.supports_elicitation {
147        return Err(KernelError::ElicitationNotNegotiated);
148    }
149
150    match operation {
151        CreateElicitationOperation::Form { .. } => {
152            if !peer_capabilities.elicitation_form {
153                return Err(KernelError::ElicitationFormNotSupported);
154            }
155        }
156        CreateElicitationOperation::Url { .. } => {
157            if !peer_capabilities.elicitation_url {
158                return Err(KernelError::ElicitationUrlNotSupported);
159            }
160        }
161    }
162
163    Ok(())
164}
165
166pub(super) fn nested_child_request_id(parent_request_id: &RequestId, suffix: &str) -> RequestId {
167    let nonce = SystemTime::now()
168        .duration_since(UNIX_EPOCH)
169        .unwrap_or_default()
170        .as_nanos();
171    RequestId::new(format!("{parent_request_id}-{suffix}-{nonce}"))
172}
173
174pub(super) fn check_time_bounds(cap: &CapabilityToken, now: u64) -> Result<(), KernelError> {
175    if now >= cap.expires_at {
176        return Err(KernelError::CapabilityExpired);
177    }
178    if now < cap.issued_at {
179        return Err(KernelError::CapabilityNotYetValid);
180    }
181    Ok(())
182}
183
184pub(super) fn check_subject_binding(
185    cap: &CapabilityToken,
186    agent_id: &str,
187) -> Result<(), KernelError> {
188    let expected = cap.subject.to_hex();
189    if expected == agent_id {
190        Ok(())
191    } else {
192        Err(KernelError::SubjectMismatch {
193            expected,
194            actual: agent_id.to_string(),
195        })
196    }
197}
198
199pub fn capability_matches_request(
200    cap: &CapabilityToken,
201    tool_name: &str,
202    server_id: &str,
203    arguments: &serde_json::Value,
204) -> Result<bool, KernelError> {
205    capability_matches_request_with_model_metadata(cap, tool_name, server_id, arguments, None)
206}
207
208pub fn capability_matches_request_with_model_metadata(
209    cap: &CapabilityToken,
210    tool_name: &str,
211    server_id: &str,
212    arguments: &serde_json::Value,
213    model_metadata: Option<&ModelMetadata>,
214) -> Result<bool, KernelError> {
215    Ok(!resolve_matching_grants(cap, tool_name, server_id, arguments, model_metadata)?.is_empty())
216}
217
218pub fn capability_matches_resource_request(
219    cap: &CapabilityToken,
220    uri: &str,
221) -> Result<bool, KernelError> {
222    Ok(cap
223        .scope
224        .resource_grants
225        .iter()
226        .any(|grant| resource_grant_matches_request(grant, uri)))
227}
228
229pub fn capability_matches_resource_subscription(
230    cap: &CapabilityToken,
231    uri: &str,
232) -> Result<bool, KernelError> {
233    Ok(cap
234        .scope
235        .resource_grants
236        .iter()
237        .any(|grant| resource_grant_matches_subscription(grant, uri)))
238}
239
240pub fn capability_matches_resource_pattern(
241    cap: &CapabilityToken,
242    pattern: &str,
243) -> Result<bool, KernelError> {
244    Ok(cap.scope.resource_grants.iter().any(|grant| {
245        resource_pattern_matches(&grant.uri_pattern, pattern)
246            && grant.operations.contains(&Operation::Read)
247    }))
248}
249
250pub fn capability_matches_prompt_request(
251    cap: &CapabilityToken,
252    prompt_name: &str,
253) -> Result<bool, KernelError> {
254    Ok(cap
255        .scope
256        .prompt_grants
257        .iter()
258        .any(|grant| prompt_grant_matches_request(grant, prompt_name)))
259}
260
261pub(super) fn resolve_matching_grants<'a>(
262    cap: &'a CapabilityToken,
263    tool_name: &str,
264    server_id: &str,
265    arguments: &serde_json::Value,
266    model_metadata: Option<&ModelMetadata>,
267) -> Result<Vec<MatchingGrant<'a>>, KernelError> {
268    let mut matches = Vec::new();
269
270    for (index, grant) in cap.scope.grants.iter().enumerate() {
271        if !grant_matches_request(grant, tool_name, server_id, arguments, model_metadata)? {
272            continue;
273        }
274
275        matches.push(MatchingGrant {
276            index,
277            grant,
278            specificity: (
279                u8::from(grant.server_id == server_id),
280                u8::from(grant.tool_name == tool_name),
281                grant.constraints.len(),
282            ),
283        });
284    }
285
286    matches.sort_by(|left, right| {
287        right
288            .specificity
289            .cmp(&left.specificity)
290            .then_with(|| left.index.cmp(&right.index))
291    });
292
293    Ok(matches)
294}
295
296fn grant_matches_request(
297    grant: &ToolGrant,
298    tool_name: &str,
299    server_id: &str,
300    arguments: &serde_json::Value,
301    model_metadata: Option<&ModelMetadata>,
302) -> Result<bool, KernelError> {
303    Ok(matches_server(&grant.server_id, server_id)
304        && matches_name(&grant.tool_name, tool_name)
305        && grant.operations.contains(&Operation::Invoke)
306        && constraints_match(&grant.constraints, arguments, model_metadata)?)
307}
308
309fn matches_server(pattern: &str, server_id: &str) -> bool {
310    pattern == "*" || pattern == server_id
311}
312
313fn matches_name(pattern: &str, name: &str) -> bool {
314    pattern == "*" || pattern == name
315}
316
317fn constraints_match(
318    constraints: &[Constraint],
319    arguments: &serde_json::Value,
320    model_metadata: Option<&ModelMetadata>,
321) -> Result<bool, KernelError> {
322    for constraint in constraints {
323        if !constraint_matches(constraint, arguments, model_metadata)? {
324            return Ok(false);
325        }
326    }
327    Ok(true)
328}
329
330fn constraint_matches(
331    constraint: &Constraint,
332    arguments: &serde_json::Value,
333    model_metadata: Option<&ModelMetadata>,
334) -> Result<bool, KernelError> {
335    let string_leaves = collect_string_leaves(arguments);
336
337    match constraint {
338        Constraint::PathPrefix(prefix) => {
339            let candidates: Vec<&str> = string_leaves
340                .iter()
341                .filter(|leaf| {
342                    leaf.key.as_deref().is_some_and(is_path_key) || looks_like_path(&leaf.value)
343                })
344                .map(|leaf| leaf.value.as_str())
345                .collect();
346            Ok(!candidates.is_empty()
347                && candidates
348                    .into_iter()
349                    .all(|path| path_has_prefix(path, prefix)))
350        }
351        Constraint::DomainExact(expected) => {
352            let expected = normalize_domain(expected);
353            let domains = collect_domain_candidates(&string_leaves);
354            Ok(!domains.is_empty() && domains.into_iter().all(|domain| domain == expected))
355        }
356        Constraint::DomainGlob(pattern) => {
357            let pattern = pattern.to_ascii_lowercase();
358            let domains = collect_domain_candidates(&string_leaves);
359            Ok(!domains.is_empty()
360                && domains
361                    .into_iter()
362                    .all(|domain| wildcard_matches(&pattern, &domain)))
363        }
364        Constraint::RegexMatch(pattern) => {
365            let regex = Regex::new(pattern).map_err(|error| {
366                KernelError::InvalidConstraint(format!(
367                    "regex \"{pattern}\" failed to compile: {error}"
368                ))
369            })?;
370            Ok(string_leaves.iter().any(|leaf| regex.is_match(&leaf.value)))
371        }
372        Constraint::MaxLength(max) => Ok(string_leaves.iter().all(|leaf| leaf.value.len() <= *max)),
373        Constraint::MaxArgsSize(max) => Ok(arguments.to_string().len() <= *max),
374        Constraint::GovernedIntentRequired
375        | Constraint::RequireApprovalAbove { .. }
376        | Constraint::SellerExact(_)
377        | Constraint::MinimumRuntimeAssurance(_)
378        | Constraint::MinimumAutonomyTier(_) => Ok(true),
379        Constraint::Custom(key, expected) => Ok(argument_contains_custom(arguments, key, expected)),
380
381        // Phase 2.2 additions. These constraints either require domain-
382        // specific evaluation (SQL parsing, post-invocation result
383        // inspection, or cross-request HITL state) that lives outside
384        // this argument-matching stage, or they match against
385        // well-known argument keys. Unless a specific check below
386        // rejects the request, the constraint is accepted at this
387        // stage and enforced by a downstream guard.
388        Constraint::TableAllowlist(_)
389        | Constraint::ColumnDenylist(_)
390        | Constraint::MaxRowsReturned(_)
391        | Constraint::OperationClass(_) => Ok(true),
392        Constraint::ContentReviewTier(_) => Ok(false),
393        Constraint::MaxTransactionAmountUsd(_) | Constraint::RequireDualApproval(_) => Ok(false),
394
395        // Phase 2.3 / RTC-08: evaluate the model-routing constraint
396        // against request-carried `model_metadata`. The separate
397        // provenance class rides on the metadata for receipt and audit
398        // surfaces; routing checks compare the concrete model identity
399        // and safety tier only.
400        Constraint::ModelConstraint {
401            allowed_model_ids,
402            min_safety_tier,
403        } => Ok(model_constraint_matches(
404            allowed_model_ids,
405            *min_safety_tier,
406            model_metadata,
407        )),
408
409        Constraint::AudienceAllowlist(allowed) => {
410            Ok(audience_allowlist_matches(arguments, allowed))
411        }
412        Constraint::MemoryStoreAllowlist(allowed) => {
413            Ok(memory_store_allowlist_matches(arguments, allowed))
414        }
415        Constraint::MemoryWriteDenyPatterns(patterns) => {
416            memory_write_deny_patterns_match(arguments, patterns)
417        }
418    }
419}
420
421#[cfg(test)]
422#[allow(clippy::expect_used, clippy::unwrap_used)]
423mod tests {
424    use super::*;
425    use chio_core::capability::{
426        CapabilityTokenBody, ChioScope, Constraint, ContentReviewTier, Operation, ToolGrant,
427    };
428    use chio_core::crypto::Keypair;
429
430    fn capability_with_constraints(constraints: Vec<Constraint>) -> CapabilityToken {
431        let issuer = Keypair::generate();
432        CapabilityToken::sign(
433            CapabilityTokenBody {
434                id: "cap-request-matching".to_string(),
435                issuer: issuer.public_key(),
436                subject: issuer.public_key(),
437                scope: ChioScope {
438                    grants: vec![ToolGrant {
439                        server_id: "srv".to_string(),
440                        tool_name: "tool".to_string(),
441                        operations: vec![Operation::Invoke],
442                        constraints,
443                        max_invocations: None,
444                        max_cost_per_invocation: None,
445                        max_total_cost: None,
446                        dpop_required: None,
447                    }],
448                    ..ChioScope::default()
449                },
450                issued_at: 1,
451                expires_at: u64::MAX,
452                delegation_chain: Vec::new(),
453            },
454            &issuer,
455        )
456        .expect("sign capability")
457    }
458
459    #[test]
460    fn content_review_tier_fails_closed_without_review_guard_context() {
461        let capability = capability_with_constraints(vec![Constraint::ContentReviewTier(
462            ContentReviewTier::Strict,
463        )]);
464        assert!(
465            !capability_matches_request(
466                &capability,
467                "tool",
468                "srv",
469                &serde_json::json!({"text": "review this outbound message"}),
470            )
471            .expect("evaluate request match"),
472            "content review tier should deny until a review guard supplies runtime context"
473        );
474    }
475
476    #[test]
477    fn governed_transaction_constraints_fail_closed_without_specialized_enforcement() {
478        let constraints = [
479            Constraint::MaxTransactionAmountUsd("100.00".to_string()),
480            Constraint::RequireDualApproval(true),
481        ];
482
483        for constraint in constraints {
484            let capability = capability_with_constraints(vec![constraint]);
485            assert!(
486                !capability_matches_request(
487                    &capability,
488                    "tool",
489                    "srv",
490                    &serde_json::json!({"amount_usd": "25.00"}),
491                )
492                .expect("evaluate request match"),
493                "governed transaction constraint should deny without its dedicated enforcement path"
494            );
495        }
496    }
497
498    #[test]
499    fn path_prefix_constraint_rejects_traversal_and_sibling_prefixes() {
500        let capability = capability_with_constraints(vec![Constraint::PathPrefix(
501            "/workspace/safe".to_string(),
502        )]);
503
504        assert!(capability_matches_request(
505            &capability,
506            "tool",
507            "srv",
508            &serde_json::json!({"path": "/workspace/safe/report.txt"}),
509        )
510        .expect("allow matching path"),);
511        assert!(!capability_matches_request(
512            &capability,
513            "tool",
514            "srv",
515            &serde_json::json!({"path": "/workspace/safe/../secret.txt"}),
516        )
517        .expect("deny traversal path"),);
518        assert!(!capability_matches_request(
519            &capability,
520            "tool",
521            "srv",
522            &serde_json::json!({"path": "/workspace/safeX/report.txt"}),
523        )
524        .expect("deny sibling prefix"),);
525    }
526
527    #[test]
528    fn audience_allowlist_rejects_non_string_values() {
529        assert!(audience_allowlist_matches(
530            &serde_json::json!({"recipient": "#ops"}),
531            &["#ops".to_string()]
532        ));
533        assert!(!audience_allowlist_matches(
534            &serde_json::json!({"recipient": {"channel": "#ops"}}),
535            &["#ops".to_string()]
536        ));
537        assert!(!audience_allowlist_matches(
538            &serde_json::json!({"recipients": []}),
539            &["#ops".to_string()]
540        ));
541    }
542
543    #[test]
544    fn memory_store_allowlist_rejects_non_string_values() {
545        assert!(memory_store_allowlist_matches(
546            &serde_json::json!({"store": "session-cache"}),
547            &["session-cache".to_string()]
548        ));
549        assert!(!memory_store_allowlist_matches(
550            &serde_json::json!({"store": {"name": "session-cache"}}),
551            &["session-cache".to_string()]
552        ));
553        assert!(!memory_store_allowlist_matches(
554            &serde_json::json!({"store": null}),
555            &["session-cache".to_string()]
556        ));
557    }
558}
559
560/// Evaluate `Constraint::ModelConstraint` against request-carried
561/// `model_metadata`.
562///
563/// Denies (returns `false`) when:
564/// - the constraint carries any requirement (non-empty `allowed_model_ids`
565///   or `Some(min_safety_tier)`) and `model_metadata` is absent;
566/// - `allowed_model_ids` is non-empty and the request's `model_id` is
567///   not in the list;
568/// - `min_safety_tier` is `Some` and the request's `safety_tier` is
569///   `None` or strictly below the required tier (the ordering comes
570///   from the `Ord` derive on `ModelSafetyTier`).
571///
572/// A constraint that specifies neither requirement is vacuously
573/// satisfied and returns `true` regardless of whether metadata is
574/// present.
575fn model_constraint_matches(
576    allowed_model_ids: &[String],
577    min_safety_tier: Option<ModelSafetyTier>,
578    model_metadata: Option<&ModelMetadata>,
579) -> bool {
580    let has_allowlist = !allowed_model_ids.is_empty();
581    let has_tier_floor = min_safety_tier.is_some();
582    if !has_allowlist && !has_tier_floor {
583        return true;
584    }
585
586    let Some(metadata) = model_metadata else {
587        return false;
588    };
589
590    if has_allowlist
591        && !allowed_model_ids
592            .iter()
593            .any(|allowed| allowed == &metadata.model_id)
594    {
595        return false;
596    }
597
598    if let Some(required_tier) = min_safety_tier {
599        match metadata.safety_tier {
600            Some(actual) if actual >= required_tier => {}
601            _ => return false,
602        }
603    }
604
605    true
606}
607
608/// Returns true when no recipient-style argument is present, or when
609/// every recipient value the call carries is in the allowlist.
610///
611/// Recognised argument keys: `recipient`, `recipients`, `audience`,
612/// `to`, `channel`, `channels`. Nested objects and arrays are walked.
613fn audience_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
614    let mut observed = ObservedStringValues::default();
615    collect_audience_values(arguments, &mut observed);
616    if observed.invalid {
617        return false;
618    }
619    if !observed.saw_relevant_key {
620        return true;
621    }
622    observed
623        .values
624        .iter()
625        .all(|value| allowed.iter().any(|a| a == value))
626}
627
628fn collect_audience_values(arguments: &serde_json::Value, out: &mut ObservedStringValues) {
629    match arguments {
630        serde_json::Value::Object(map) => {
631            for (key, value) in map {
632                if is_audience_key(key) {
633                    let before = out.values.len();
634                    out.saw_relevant_key = true;
635                    if !collect_string_values_strict(value, &mut out.values)
636                        || out.values.len() == before
637                    {
638                        out.invalid = true;
639                    }
640                } else {
641                    collect_audience_values(value, out);
642                }
643            }
644        }
645        serde_json::Value::Array(values) => {
646            for value in values {
647                collect_audience_values(value, out);
648            }
649        }
650        _ => {}
651    }
652}
653
654fn is_audience_key(key: &str) -> bool {
655    matches!(
656        key.to_ascii_lowercase().as_str(),
657        "recipient" | "recipients" | "audience" | "to" | "channel" | "channels"
658    )
659}
660
661fn collect_string_values_strict(value: &serde_json::Value, out: &mut Vec<String>) -> bool {
662    match value {
663        serde_json::Value::String(s) => {
664            out.push(s.clone());
665            true
666        }
667        serde_json::Value::Array(values) => {
668            for v in values {
669                if !collect_string_values_strict(v, out) {
670                    return false;
671                }
672            }
673            true
674        }
675        _ => false,
676    }
677}
678
679/// Returns true when no `store` argument is present, or when every
680/// `store` value the call carries is in the allowlist.
681fn memory_store_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
682    let mut observed = ObservedStringValues::default();
683    collect_memory_store_values(arguments, &mut observed);
684    if observed.invalid {
685        return false;
686    }
687    if !observed.saw_relevant_key {
688        return true;
689    }
690    observed
691        .values
692        .iter()
693        .all(|value| allowed.iter().any(|a| a == value))
694}
695
696fn collect_memory_store_values(arguments: &serde_json::Value, out: &mut ObservedStringValues) {
697    match arguments {
698        serde_json::Value::Object(map) => {
699            for (key, value) in map {
700                if is_memory_store_key(key) {
701                    let before = out.values.len();
702                    out.saw_relevant_key = true;
703                    if !collect_string_values_strict(value, &mut out.values)
704                        || out.values.len() == before
705                    {
706                        out.invalid = true;
707                    }
708                } else {
709                    collect_memory_store_values(value, out);
710                }
711            }
712        }
713        serde_json::Value::Array(values) => {
714            for value in values {
715                collect_memory_store_values(value, out);
716            }
717        }
718        _ => {}
719    }
720}
721
722fn is_memory_store_key(key: &str) -> bool {
723    matches!(
724        key.to_ascii_lowercase().as_str(),
725        "store" | "memory_store" | "collection" | "namespace"
726    )
727}
728
729/// Returns Ok(false) when any string leaf in the arguments matches any
730/// deny pattern. An invalid regex surfaces as `InvalidConstraint`.
731fn memory_write_deny_patterns_match(
732    arguments: &serde_json::Value,
733    patterns: &[String],
734) -> Result<bool, KernelError> {
735    let leaves = collect_string_leaves(arguments);
736    for pattern in patterns {
737        let regex = Regex::new(pattern).map_err(|error| {
738            KernelError::InvalidConstraint(format!(
739                "memory write deny pattern \"{pattern}\" failed to compile: {error}"
740            ))
741        })?;
742        for leaf in &leaves {
743            if regex.is_match(&leaf.value) {
744                return Ok(false);
745            }
746        }
747    }
748    Ok(true)
749}
750
751fn resource_grant_matches_request(grant: &ResourceGrant, uri: &str) -> bool {
752    resource_pattern_matches(&grant.uri_pattern, uri) && grant.operations.contains(&Operation::Read)
753}
754
755fn resource_grant_matches_subscription(grant: &ResourceGrant, uri: &str) -> bool {
756    resource_pattern_matches(&grant.uri_pattern, uri)
757        && grant.operations.contains(&Operation::Subscribe)
758}
759
760fn prompt_grant_matches_request(grant: &PromptGrant, prompt_name: &str) -> bool {
761    matches_pattern(&grant.prompt_name, prompt_name) && grant.operations.contains(&Operation::Get)
762}
763
764fn resource_pattern_matches(pattern: &str, uri: &str) -> bool {
765    matches_pattern(pattern, uri)
766}
767
768fn matches_pattern(pattern: &str, value: &str) -> bool {
769    if pattern == "*" {
770        return true;
771    }
772
773    if let Some(prefix) = pattern.strip_suffix('*') {
774        return value.starts_with(prefix);
775    }
776
777    pattern == value
778}
779
780fn path_has_prefix(candidate: &str, prefix: &str) -> bool {
781    let Some(candidate) = normalize_path(candidate) else {
782        return false;
783    };
784    let Some(prefix) = normalize_path(prefix) else {
785        return false;
786    };
787    if candidate.is_absolute != prefix.is_absolute {
788        return false;
789    }
790    if prefix.segments.len() > candidate.segments.len() {
791        return false;
792    }
793    prefix
794        .segments
795        .iter()
796        .zip(candidate.segments.iter())
797        .all(|(expected, actual)| expected == actual)
798}
799
800#[derive(Debug, PartialEq, Eq)]
801struct NormalizedPath {
802    is_absolute: bool,
803    segments: Vec<String>,
804}
805
806fn normalize_path(path: &str) -> Option<NormalizedPath> {
807    let is_absolute = path.starts_with('/') || path.starts_with('\\');
808    let mut segments = Vec::new();
809    for segment in path.split(['/', '\\']) {
810        if segment.is_empty() || segment == "." {
811            continue;
812        }
813        if segment == ".." {
814            segments.pop()?;
815            continue;
816        }
817        segments.push(segment.to_string());
818    }
819    Some(NormalizedPath {
820        is_absolute,
821        segments,
822    })
823}
824
825#[derive(Clone)]
826struct StringLeaf {
827    key: Option<String>,
828    value: String,
829}
830
831#[derive(Default)]
832struct ObservedStringValues {
833    values: Vec<String>,
834    saw_relevant_key: bool,
835    invalid: bool,
836}
837
838fn collect_string_leaves(arguments: &serde_json::Value) -> Vec<StringLeaf> {
839    let mut leaves = Vec::new();
840    collect_string_leaves_inner(arguments, None, &mut leaves);
841    leaves
842}
843
844fn collect_string_leaves_inner(
845    arguments: &serde_json::Value,
846    current_key: Option<&str>,
847    leaves: &mut Vec<StringLeaf>,
848) {
849    match arguments {
850        serde_json::Value::String(value) => leaves.push(StringLeaf {
851            key: current_key.map(str::to_string),
852            value: value.clone(),
853        }),
854        serde_json::Value::Array(values) => {
855            for value in values {
856                collect_string_leaves_inner(value, current_key, leaves);
857            }
858        }
859        serde_json::Value::Object(map) => {
860            for (key, value) in map {
861                collect_string_leaves_inner(value, Some(key), leaves);
862            }
863        }
864        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
865    }
866}
867
868fn is_path_key(key: &str) -> bool {
869    let key = key.to_ascii_lowercase();
870    key.contains("path")
871        || matches!(
872            key.as_str(),
873            "file" | "filepath" | "dir" | "directory" | "root" | "cwd"
874        )
875}
876
877fn looks_like_path(value: &str) -> bool {
878    !value.contains("://")
879        && (value.starts_with('/')
880            || value.starts_with("./")
881            || value.starts_with("../")
882            || value.starts_with("~/")
883            || value.contains('/')
884            || value.contains('\\'))
885}
886
887fn collect_domain_candidates(string_leaves: &[StringLeaf]) -> Vec<String> {
888    string_leaves
889        .iter()
890        .filter_map(|leaf| parse_domain(&leaf.value))
891        .collect()
892}
893
894fn parse_domain(value: &str) -> Option<String> {
895    let trimmed = value.trim();
896    if trimmed.is_empty() {
897        return None;
898    }
899
900    let host_port = if let Some((_, rest)) = trimmed.split_once("://") {
901        rest
902    } else {
903        trimmed
904    };
905
906    let authority = host_port
907        .split(['/', '?', '#'])
908        .next()
909        .unwrap_or(host_port)
910        .rsplit('@')
911        .next()
912        .unwrap_or(host_port);
913    let host = authority
914        .split(':')
915        .next()
916        .unwrap_or(authority)
917        .trim_matches('.');
918    let normalized = normalize_domain(host);
919
920    if normalized == "localhost"
921        || (!normalized.is_empty()
922            && normalized.contains('.')
923            && normalized.chars().all(|character| {
924                character.is_ascii_alphanumeric() || character == '-' || character == '.'
925            }))
926    {
927        Some(normalized)
928    } else {
929        None
930    }
931}
932
933fn normalize_domain(value: &str) -> String {
934    value.trim().trim_matches('.').to_ascii_lowercase()
935}
936
937fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
938    let pattern_chars: Vec<char> = pattern.chars().collect();
939    let candidate_chars: Vec<char> = candidate.chars().collect();
940    let (mut pattern_idx, mut candidate_idx) = (0usize, 0usize);
941    let (mut star_idx, mut match_idx) = (None, 0usize);
942
943    while candidate_idx < candidate_chars.len() {
944        if pattern_idx < pattern_chars.len()
945            && (pattern_chars[pattern_idx] == candidate_chars[candidate_idx]
946                || pattern_chars[pattern_idx] == '*')
947        {
948            if pattern_chars[pattern_idx] == '*' {
949                star_idx = Some(pattern_idx);
950                match_idx = candidate_idx;
951                pattern_idx += 1;
952            } else {
953                pattern_idx += 1;
954                candidate_idx += 1;
955            }
956        } else if let Some(star_position) = star_idx {
957            pattern_idx = star_position + 1;
958            match_idx += 1;
959            candidate_idx = match_idx;
960        } else {
961            return false;
962        }
963    }
964
965    while pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' {
966        pattern_idx += 1;
967    }
968
969    pattern_idx == pattern_chars.len()
970}
971
972fn argument_contains_custom(arguments: &serde_json::Value, key: &str, expected: &str) -> bool {
973    match arguments {
974        serde_json::Value::Object(map) => map.iter().any(|(entry_key, value)| {
975            (entry_key == key && value.as_str() == Some(expected))
976                || argument_contains_custom(value, key, expected)
977        }),
978        serde_json::Value::Array(values) => values
979            .iter()
980            .any(|value| argument_contains_custom(value, key, expected)),
981        serde_json::Value::Null
982        | serde_json::Value::Bool(_)
983        | serde_json::Value::Number(_)
984        | serde_json::Value::String(_) => false,
985    }
986}