Skip to main content

chio_kernel/
request_matching.rs

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