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
210pub 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
223pub 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 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 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
624fn 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
672fn 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
743fn 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
793fn 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}