1use std::collections::{HashMap, HashSet};
22use std::path::Path;
23
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26use crate::error::PluginError;
27use crate::plugin::PluginConfig;
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct CpexConfig {
41 #[serde(default)]
44 pub global: GlobalConfig,
45
46 #[serde(default)]
48 pub plugin_dirs: Vec<String>,
49
50 #[serde(default)]
52 pub plugins: Vec<PluginConfig>,
53
54 #[serde(default)]
57 pub routes: Vec<RouteEntry>,
58
59 #[serde(default)]
61 pub plugin_settings: PluginSettings,
62}
63
64impl CpexConfig {
65 pub fn routing_enabled(&self) -> bool {
67 self.plugin_settings.routing_enabled
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PluginSettings {
81 #[serde(default)]
86 pub routing_enabled: bool,
87
88 #[serde(default = "default_timeout")]
90 pub plugin_timeout: u64,
91
92 #[serde(default = "default_true")]
94 pub short_circuit_on_deny: bool,
95
96 #[serde(default)]
98 pub parallel_execution_within_band: bool,
99
100 #[serde(default)]
102 pub fail_on_plugin_error: bool,
103
104 #[serde(default = "default_route_cache_max_entries")]
114 pub route_cache_max_entries: usize,
115}
116
117impl Default for PluginSettings {
118 fn default() -> Self {
119 Self {
120 routing_enabled: false,
121 plugin_timeout: 30,
122 short_circuit_on_deny: true,
123 parallel_execution_within_band: false,
124 fail_on_plugin_error: false,
125 route_cache_max_entries: default_route_cache_max_entries(),
126 }
127 }
128}
129
130fn default_route_cache_max_entries() -> usize {
131 10_000
132}
133
134fn default_timeout() -> u64 {
135 30
136}
137
138fn default_true() -> bool {
139 true
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct GlobalConfig {
152 #[serde(default)]
156 pub policies: HashMap<String, PolicyGroup>,
157
158 #[serde(default)]
161 pub defaults: HashMap<String, PolicyGroup>,
162
163 #[serde(
172 default,
173 rename = "authentication",
174 deserialize_with = "deserialize_route_identity"
175 )]
176 pub identity: Option<crate::identity::RouteIdentityConfig>,
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct PolicyGroup {
188 #[serde(default)]
190 pub description: Option<String>,
191
192 #[serde(default)]
194 pub metadata: HashMap<String, String>,
195
196 #[serde(default, deserialize_with = "deserialize_plugin_refs")]
198 pub plugins: Vec<PluginRouteRef>,
199
200 #[serde(
206 default,
207 rename = "authentication",
208 deserialize_with = "deserialize_route_identity"
209 )]
210 pub identity: Option<crate::identity::RouteIdentityConfig>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(untagged)]
228pub enum PluginRouteRef {
229 Name(String),
231 WithOverrides(HashMap<String, serde_json::Value>),
233}
234
235impl PluginRouteRef {
236 pub fn name(&self) -> &str {
238 match self {
239 Self::Name(name) => name,
240 Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""),
241 }
242 }
243
244 pub fn overrides(&self) -> Option<&serde_json::Value> {
246 match self {
247 Self::Name(_) => None,
248 Self::WithOverrides(map) => map.values().next(),
249 }
250 }
251}
252
253fn deserialize_plugin_refs<'de, D>(deserializer: D) -> Result<Vec<PluginRouteRef>, D::Error>
272where
273 D: serde::Deserializer<'de>,
274{
275 use serde::de::Error;
276
277 match serde_yaml::Value::deserialize(deserializer)? {
278 serde_yaml::Value::Sequence(items) => items
280 .into_iter()
281 .map(|item| serde_yaml::from_value(item).map_err(D::Error::custom))
282 .collect(),
283 serde_yaml::Value::Mapping(_) => Ok(Vec::new()),
286 serde_yaml::Value::Null => Ok(Vec::new()),
288 other => Err(D::Error::custom(format!(
289 "`plugins:` must be a sequence (activation list) or a mapping \
290 (APL per-plugin overrides), got {:?}",
291 other
292 ))),
293 }
294}
295
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305pub struct RouteEntry {
306 #[serde(default)]
308 pub tool: Option<StringOrList>,
309
310 #[serde(default)]
312 pub resource: Option<StringOrList>,
313
314 #[serde(default)]
316 pub prompt: Option<StringOrList>,
317
318 #[serde(default)]
320 pub llm: Option<StringOrList>,
321
322 #[serde(default)]
324 pub meta: Option<RouteMeta>,
325
326 #[serde(default)]
330 pub when: Option<String>,
331
332 #[serde(default, deserialize_with = "deserialize_plugin_refs")]
334 pub plugins: Vec<PluginRouteRef>,
335
336 #[serde(
359 default,
360 rename = "authentication",
361 deserialize_with = "deserialize_route_identity"
362 )]
363 pub identity: Option<crate::identity::RouteIdentityConfig>,
364}
365
366fn deserialize_route_identity<'de, D>(
377 deserializer: D,
378) -> Result<Option<crate::identity::RouteIdentityConfig>, D::Error>
379where
380 D: serde::Deserializer<'de>,
381{
382 use crate::identity::RouteIdentityConfig;
383 use serde::de::Error;
384
385 let raw = match Option::<serde_yaml::Value>::deserialize(deserializer)? {
388 None => return Ok(None),
389 Some(serde_yaml::Value::Null) => return Ok(None),
390 Some(v) => v,
391 };
392
393 let (replace_inherited, raw_steps): (bool, Vec<serde_yaml::Value>) = match raw {
394 serde_yaml::Value::Sequence(items) => (false, items),
395 serde_yaml::Value::Mapping(map) => {
396 let replace_inherited =
397 match map.get(serde_yaml::Value::String("replace_inherited".to_string())) {
398 Some(v) => v.as_bool().ok_or_else(|| {
399 D::Error::custom("`identity.replace_inherited` must be a boolean")
400 })?,
401 None => false,
402 };
403 let steps_val = map
404 .get(serde_yaml::Value::String("steps".to_string()))
405 .ok_or_else(|| {
406 D::Error::custom(
407 "`authentication:` object form requires `steps:` (a list of \
408 authentication steps); did you mean to write the list form?",
409 )
410 })?;
411 let items = steps_val
412 .as_sequence()
413 .ok_or_else(|| D::Error::custom("`authentication.steps` must be a list"))?
414 .clone();
415 (replace_inherited, items)
416 },
417 _ => {
418 return Err(D::Error::custom(
419 "`authentication:` must be a list of steps or an object with \
420 `steps:` (and optional `replace_inherited:`)",
421 ));
422 },
423 };
424
425 let mut steps = Vec::with_capacity(raw_steps.len());
426 for (i, raw) in raw_steps.into_iter().enumerate() {
427 steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?);
428 }
429
430 Ok(Some(RouteIdentityConfig {
431 steps,
432 replace_inherited,
433 }))
434}
435
436fn parse_identity_step(
440 raw: serde_yaml::Value,
441 index: usize,
442) -> Result<crate::identity::RouteIdentityStep, String> {
443 use crate::identity::RouteIdentityStep;
444
445 match raw {
446 serde_yaml::Value::String(name) => {
447 if name.is_empty() {
448 return Err(format!(
449 "identity step [{index}] plugin name cannot be empty"
450 ));
451 }
452 Ok(RouteIdentityStep {
453 name,
454 ..Default::default()
455 })
456 },
457 serde_yaml::Value::Mapping(_) => {
458 #[derive(serde::Deserialize)]
465 struct StepYaml {
466 name: String,
467 #[serde(default)]
468 on_error: Option<String>,
469 #[serde(default)]
470 config: Option<serde_json::Value>,
471 #[serde(default, flatten)]
472 extra: std::collections::HashMap<String, serde_json::Value>,
473 }
474 let parsed: StepYaml =
475 serde_yaml::from_value(raw).map_err(|e| format!("identity step [{index}]: {e}"))?;
476 if parsed.name.is_empty() {
477 return Err(format!("identity step [{index}] `name:` cannot be empty"));
478 }
479 Ok(RouteIdentityStep {
480 name: parsed.name,
481 config_override: parsed.config,
482 on_error: parsed.on_error,
483 extra: parsed.extra,
484 })
485 },
486 _ => Err(format!(
487 "identity step [{index}] must be a plugin name (string) or a map \
488 with `name:` (and optional `on_error:` / `config:`)"
489 )),
490 }
491}
492
493#[derive(Debug, Clone, Default, Serialize, Deserialize)]
499pub struct RouteMeta {
500 #[serde(default)]
502 pub tags: Vec<String>,
503
504 #[serde(default)]
507 pub scope: Option<String>,
508
509 #[serde(default)]
511 pub properties: HashMap<String, String>,
512}
513
514#[derive(Debug, Clone)]
535pub struct Pattern {
536 pattern: String,
537 matcher: wildmatch::WildMatch,
538}
539
540impl Pattern {
541 pub fn new(pattern: impl Into<String>) -> Self {
544 let pattern = pattern.into();
545 let matcher = wildmatch::WildMatch::new(&pattern);
546 Self { pattern, matcher }
547 }
548
549 pub fn matches(&self, name: &str) -> bool {
551 self.matcher.matches(name)
552 }
553
554 pub fn as_str(&self) -> &str {
556 &self.pattern
557 }
558}
559
560impl Default for Pattern {
561 fn default() -> Self {
562 Self::new("")
563 }
564}
565
566impl Serialize for Pattern {
567 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
568 serializer.serialize_str(&self.pattern)
569 }
570}
571
572impl<'de> Deserialize<'de> for Pattern {
573 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
574 let s = String::deserialize(deserializer)?;
575 Ok(Pattern::new(s))
576 }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
581#[serde(untagged)]
582pub enum StringOrList {
583 Single(Pattern),
587 List(Vec<String>),
589}
590
591impl Default for StringOrList {
592 fn default() -> Self {
593 Self::Single(Pattern::default())
594 }
595}
596
597impl StringOrList {
598 pub fn matches(&self, name: &str) -> bool {
600 match self {
601 Self::Single(pattern) => pattern.matches(name),
602 Self::List(names) => names.iter().any(|n| n == name),
603 }
604 }
605}
606
607pub fn load_config(path: &Path) -> Result<CpexConfig, Box<PluginError>> {
613 let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config {
614 message: format!("failed to read config file '{}': {}", path.display(), e),
615 })?;
616 parse_config(&content)
617}
618
619pub fn parse_config(yaml: &str) -> Result<CpexConfig, Box<PluginError>> {
621 let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config {
626 message: format!("failed to parse config YAML: {}", e),
627 })?;
628 reject_renamed_identity_key(&raw)?;
629 let config: CpexConfig = serde_yaml::from_value(raw).map_err(|e| PluginError::Config {
630 message: format!("failed to parse config YAML: {}", e),
631 })?;
632 validate_config(&config)?;
633 Ok(config)
634}
635
636fn reject_renamed_identity_key(raw: &serde_yaml::Value) -> Result<(), Box<PluginError>> {
641 fn renamed(scope: &str) -> Box<PluginError> {
642 Box::new(PluginError::Config {
643 message: format!(
644 "in `{scope}`: config field `identity` was renamed to `authentication` — update your config"
645 ),
646 })
647 }
648 if let Some(global) = raw.get("global") {
649 if global.get("identity").is_some() {
650 return Err(renamed("global"));
651 }
652 for section in ["policies", "defaults"] {
653 if let Some(map) = global.get(section).and_then(|m| m.as_mapping()) {
654 for (name, group) in map {
655 if group.get("identity").is_some() {
656 let n = name.as_str().unwrap_or("?");
657 return Err(renamed(&format!("global.{section}.{n}")));
658 }
659 }
660 }
661 }
662 }
663 if let Some(routes) = raw.get("routes").and_then(|r| r.as_sequence()) {
664 for (i, route) in routes.iter().enumerate() {
665 if route.get("identity").is_some() {
666 return Err(renamed(&format!("routes[{i}]")));
667 }
668 }
669 }
670 Ok(())
671}
672
673fn validate_config(config: &CpexConfig) -> Result<(), Box<PluginError>> {
689 let mut seen_names = HashSet::new();
690 for plugin in &config.plugins {
691 if !seen_names.insert(&plugin.name) {
692 return Err(Box::new(PluginError::Config {
693 message: format!("duplicate plugin name: '{}'", plugin.name),
694 }));
695 }
696 }
697
698 if config.routing_enabled() {
699 let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect();
700
701 for (i, route) in config.routes.iter().enumerate() {
702 let count = [
703 route.tool.is_some(),
704 route.resource.is_some(),
705 route.prompt.is_some(),
706 route.llm.is_some(),
707 ]
708 .iter()
709 .filter(|&&m| m)
710 .count();
711
712 if count == 0 {
713 return Err(Box::new(PluginError::Config {
714 message: format!(
715 "route {} has no entity matcher (need tool, resource, prompt, or llm)",
716 i
717 ),
718 }));
719 }
720 if count > 1 {
721 return Err(Box::new(PluginError::Config {
722 message: format!(
723 "route {} has multiple entity matchers (need exactly one)",
724 i
725 ),
726 }));
727 }
728
729 for plugin_ref in &route.plugins {
730 if !plugin_names.contains(plugin_ref.name()) {
731 return Err(Box::new(PluginError::Config {
732 message: format!(
733 "route {} references unknown plugin '{}'",
734 i,
735 plugin_ref.name()
736 ),
737 }));
738 }
739 }
740 }
741
742 for (group_name, group) in &config.global.policies {
743 for plugin_ref in &group.plugins {
744 if !plugin_names.contains(plugin_ref.name()) {
745 return Err(Box::new(PluginError::Config {
746 message: format!(
747 "policy group '{}' references unknown plugin '{}'",
748 group_name,
749 plugin_ref.name()
750 ),
751 }));
752 }
753 }
754 }
755 }
756
757 Ok(())
758}
759
760const SPECIFICITY_EXACT_NAME: usize = 1000;
766const SPECIFICITY_NAME_LIST: usize = 500;
767const SPECIFICITY_GLOB: usize = 300;
768const SPECIFICITY_WHEN_ONLY: usize = 10;
769const SPECIFICITY_WILDCARD: usize = 0;
770
771fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option<usize> {
776 let matcher = matcher?;
777 if !matcher.matches(entity_name) {
778 return None;
779 }
780 let score = match matcher {
781 StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD,
782 StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB,
783 StringOrList::List(_) => SPECIFICITY_NAME_LIST,
784 StringOrList::Single(_) => SPECIFICITY_EXACT_NAME,
785 };
786 Some(score)
787}
788
789pub fn resolve_plugins_for_entity(
799 config: &CpexConfig,
800 entity_type: &str,
801 entity_name: &str,
802 request_scope: Option<&str>,
803 request_tags: &HashSet<String>,
804) -> Vec<ResolvedPlugin> {
805 if !config.routing_enabled() {
806 return config
807 .plugins
808 .iter()
809 .map(|p| ResolvedPlugin {
810 name: p.name.clone(),
811 config_overrides: None,
812 when: None,
813 })
814 .collect();
815 }
816
817 let mut resolved = Vec::new();
818
819 if let Some(all_group) = config.global.policies.get("all") {
821 collect_plugin_refs(&all_group.plugins, &mut resolved, None);
822 }
823
824 if let Some(default_group) = config.global.defaults.get(entity_type) {
826 collect_plugin_refs(&default_group.plugins, &mut resolved, None);
827 }
828
829 if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) {
831 let mut merged_tags: HashSet<String> = request_tags.clone();
833 if let Some(meta) = &route.meta {
834 for tag in &meta.tags {
835 merged_tags.insert(tag.clone());
836 }
837 }
838
839 for tag in &merged_tags {
841 if tag == "all" {
842 continue; }
844 if let Some(group) = config.global.policies.get(tag.as_str()) {
845 collect_plugin_refs(&group.plugins, &mut resolved, None);
846 }
847 }
848
849 collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref());
851 }
852
853 let mut seen = HashSet::new();
855 let mut deduped = Vec::new();
856 for rp in resolved.into_iter().rev() {
857 if seen.insert(rp.name.clone()) {
858 deduped.push(rp);
859 }
860 }
861 deduped.reverse();
862 deduped
863}
864
865pub fn resolve_identity_plugins_for_route(
899 config: &CpexConfig,
900 entity_type: &str,
901 entity_name: &str,
902 request_scope: Option<&str>,
903) -> Vec<ResolvedPlugin> {
904 let route = find_matching_route(config, entity_type, entity_name, request_scope);
910 let route_identity = route.and_then(|r| r.identity.as_ref());
911
912 let replace_inherited = route_identity
915 .map(|id| id.replace_inherited)
916 .unwrap_or(false);
917
918 let mut steps: Vec<crate::identity::RouteIdentityStep> = Vec::new();
919
920 if !replace_inherited {
921 if let Some(global_identity) = config.global.identity.as_ref() {
923 steps.extend(global_identity.steps.iter().cloned());
924 }
925
926 if let Some(route) = route {
932 if let Some(meta) = &route.meta {
933 for tag in &meta.tags {
934 if let Some(bundle) = config.global.policies.get(tag) {
935 if let Some(bundle_identity) = bundle.identity.as_ref() {
936 steps.extend(bundle_identity.steps.iter().cloned());
937 }
938 }
939 }
940 }
941 }
942 }
943
944 if let Some(id) = route_identity {
946 steps.extend(id.steps.iter().cloned());
947 }
948
949 steps
950 .into_iter()
951 .map(|step| ResolvedPlugin {
952 name: step.name.clone(),
953 config_overrides: step.config_override.as_ref().map(|cfg| {
958 let mut wrapper = serde_json::Map::new();
959 wrapper.insert("config".to_string(), cfg.clone());
960 serde_json::Value::Object(wrapper)
961 }),
962 when: None,
963 })
964 .collect()
965}
966
967#[derive(Debug, Clone)]
969pub struct ResolvedPlugin {
970 pub name: String,
972
973 pub config_overrides: Option<serde_json::Value>,
975
976 pub when: Option<String>,
978}
979
980fn collect_plugin_refs(
982 refs: &[PluginRouteRef],
983 resolved: &mut Vec<ResolvedPlugin>,
984 route_when: Option<&str>,
985) {
986 for plugin_ref in refs {
987 resolved.push(ResolvedPlugin {
988 name: plugin_ref.name().to_string(),
989 config_overrides: plugin_ref.overrides().cloned(),
990 when: route_when.map(String::from),
991 });
992 }
993}
994
995fn find_matching_route<'a>(
1000 config: &'a CpexConfig,
1001 entity_type: &str,
1002 entity_name: &str,
1003 request_scope: Option<&str>,
1004) -> Option<&'a RouteEntry> {
1005 let mut best: Option<(usize, &RouteEntry)> = None;
1006
1007 for route in &config.routes {
1008 let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref());
1010 let scope_bonus = match (route_scope, request_scope) {
1011 (None, _) => 0, (Some(rs), Some(rq)) if rs == rq => 100, (Some(_), _) => continue, };
1015
1016 let entity_matcher = match entity_type {
1017 "tool" => route.tool.as_ref(),
1018 "resource" => route.resource.as_ref(),
1019 "prompt" => route.prompt.as_ref(),
1020 "llm" => route.llm.as_ref(),
1021 _ => continue,
1022 };
1023 let base_specificity = match score_entity_match(entity_matcher, entity_name) {
1024 Some(score) => score,
1025 None => continue,
1026 };
1027
1028 let when_bonus = if route.when.is_some() {
1029 SPECIFICITY_WHEN_ONLY
1030 } else {
1031 0
1032 };
1033 let total = base_specificity + scope_bonus + when_bonus;
1034
1035 if best.is_none_or(|(s, _)| total > s) {
1036 best = Some((total, route));
1037 }
1038 }
1039
1040 best.map(|(_, route)| route)
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 fn no_tags() -> HashSet<String> {
1049 HashSet::new()
1050 }
1051
1052 #[test]
1053 fn test_parse_minimal_config() {
1054 let yaml = r#"
1055plugins:
1056 - name: rate_limiter
1057 kind: builtin
1058 hooks: [tool_pre_invoke]
1059 mode: sequential
1060 priority: 5
1061 config:
1062 max_requests: 100
1063"#;
1064 let config = parse_config(yaml).unwrap();
1065 assert!(!config.routing_enabled());
1066 assert_eq!(config.plugins.len(), 1);
1067 assert_eq!(config.plugins[0].name, "rate_limiter");
1068 }
1069
1070 #[test]
1071 fn test_no_plugin_settings_defaults_routing_disabled() {
1072 let yaml = r#"
1073plugins:
1074 - name: test
1075 kind: builtin
1076 hooks: [tool_pre_invoke]
1077"#;
1078 let config = parse_config(yaml).unwrap();
1079 assert!(!config.routing_enabled());
1080 assert_eq!(config.plugin_settings.plugin_timeout, 30);
1081 }
1082
1083 #[test]
1084 fn test_routing_enabled() {
1085 let yaml = r#"
1086plugin_settings:
1087 routing_enabled: true
1088global:
1089 policies:
1090 all:
1091 plugins: [identity]
1092plugins:
1093 - name: identity
1094 kind: builtin
1095 hooks: [identity_resolve]
1096routes:
1097 - tool: get_compensation
1098 meta:
1099 tags: [pii]
1100"#;
1101 let config = parse_config(yaml).unwrap();
1102 assert!(config.routing_enabled());
1103 }
1104
1105 #[test]
1106 fn test_duplicate_plugin_names_rejected() {
1107 let yaml = r#"
1108plugins:
1109 - name: dup
1110 kind: builtin
1111 hooks: [tool_pre_invoke]
1112 - name: dup
1113 kind: builtin
1114 hooks: [tool_post_invoke]
1115"#;
1116 assert!(parse_config(yaml)
1117 .unwrap_err()
1118 .to_string()
1119 .contains("duplicate plugin name"));
1120 }
1121
1122 #[test]
1123 fn test_route_requires_one_entity_matcher() {
1124 let yaml = r#"
1125plugin_settings:
1126 routing_enabled: true
1127plugins: []
1128routes:
1129 - meta:
1130 tags: [pii]
1131"#;
1132 assert!(parse_config(yaml)
1133 .unwrap_err()
1134 .to_string()
1135 .contains("no entity matcher"));
1136 }
1137
1138 #[test]
1139 fn test_route_rejects_multiple_entity_matchers() {
1140 let yaml = r#"
1141plugin_settings:
1142 routing_enabled: true
1143plugins: []
1144routes:
1145 - tool: get_compensation
1146 resource: "hr://employees/*"
1147"#;
1148 assert!(parse_config(yaml)
1149 .unwrap_err()
1150 .to_string()
1151 .contains("multiple entity matchers"));
1152 }
1153
1154 #[test]
1155 fn test_route_unknown_plugin_rejected() {
1156 let yaml = r#"
1157plugin_settings:
1158 routing_enabled: true
1159plugins:
1160 - name: known
1161 kind: builtin
1162 hooks: [tool_pre_invoke]
1163routes:
1164 - tool: get_compensation
1165 plugins:
1166 - unknown
1167"#;
1168 assert!(parse_config(yaml)
1169 .unwrap_err()
1170 .to_string()
1171 .contains("unknown plugin 'unknown'"));
1172 }
1173
1174 #[test]
1175 fn test_policy_group_unknown_plugin_rejected() {
1176 let yaml = r#"
1177plugin_settings:
1178 routing_enabled: true
1179global:
1180 policies:
1181 all:
1182 plugins: [nonexistent]
1183plugins: []
1184routes: []
1185"#;
1186 assert!(parse_config(yaml)
1187 .unwrap_err()
1188 .to_string()
1189 .contains("unknown plugin 'nonexistent'"));
1190 }
1191
1192 #[test]
1193 fn test_resolve_conditions_mode_returns_all() {
1194 let yaml = r#"
1195plugins:
1196 - name: a
1197 kind: builtin
1198 hooks: [tool_pre_invoke]
1199 - name: b
1200 kind: builtin
1201 hooks: [tool_post_invoke]
1202"#;
1203 let config = parse_config(yaml).unwrap();
1204 let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags());
1205 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1206 assert_eq!(names, vec!["a", "b"]);
1207 }
1208
1209 #[test]
1210 fn test_resolve_routes_inherits_policy_groups() {
1211 let yaml = r#"
1212plugin_settings:
1213 routing_enabled: true
1214global:
1215 policies:
1216 all:
1217 plugins:
1218 - identity
1219 pii:
1220 plugins:
1221 - apl_policy
1222plugins:
1223 - name: identity
1224 kind: builtin
1225 hooks: [identity_resolve]
1226 - name: apl_policy
1227 kind: builtin
1228 hooks: [cmf.tool_pre_invoke]
1229routes:
1230 - tool: get_compensation
1231 meta:
1232 tags: [pii]
1233"#;
1234 let config = parse_config(yaml).unwrap();
1235 let resolved =
1236 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1237 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1238 assert!(names.contains(&"identity"));
1239 assert!(names.contains(&"apl_policy"));
1240 }
1241
1242 #[test]
1243 fn test_resolve_no_matching_route_gets_all_only() {
1244 let yaml = r#"
1245plugin_settings:
1246 routing_enabled: true
1247global:
1248 policies:
1249 all:
1250 plugins:
1251 - identity
1252plugins:
1253 - name: identity
1254 kind: builtin
1255 hooks: [identity_resolve]
1256routes:
1257 - tool: get_compensation
1258"#;
1259 let config = parse_config(yaml).unwrap();
1260 let resolved =
1261 resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags());
1262 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1263 assert_eq!(names, vec!["identity"]);
1264 }
1265
1266 #[test]
1267 fn test_exact_match_beats_glob() {
1268 let yaml = r#"
1269plugin_settings:
1270 routing_enabled: true
1271plugins:
1272 - name: specific
1273 kind: builtin
1274 hooks: [tool_pre_invoke]
1275 - name: general
1276 kind: builtin
1277 hooks: [tool_pre_invoke]
1278routes:
1279 - tool: "hr-*"
1280 plugins:
1281 - general
1282 - tool: hr-compensation
1283 plugins:
1284 - specific
1285"#;
1286 let config = parse_config(yaml).unwrap();
1287 let resolved =
1288 resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags());
1289 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1290 assert!(names.contains(&"specific"));
1291 assert!(!names.contains(&"general"));
1292 }
1293
1294 #[test]
1295 fn test_plugin_ref_bare_name() {
1296 let yaml = r#"
1297plugin_settings:
1298 routing_enabled: true
1299plugins:
1300 - name: rate_limiter
1301 kind: builtin
1302 hooks: [tool_pre_invoke]
1303routes:
1304 - tool: get_compensation
1305 plugins:
1306 - rate_limiter
1307"#;
1308 let config = parse_config(yaml).unwrap();
1309 let resolved =
1310 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1311 assert_eq!(resolved[0].name, "rate_limiter");
1312 assert!(resolved[0].config_overrides.is_none());
1313 }
1314
1315 #[test]
1316 fn test_plugin_ref_with_overrides() {
1317 let yaml = r#"
1318plugin_settings:
1319 routing_enabled: true
1320plugins:
1321 - name: rate_limiter
1322 kind: builtin
1323 hooks: [tool_pre_invoke]
1324 config:
1325 max_requests: 100
1326routes:
1327 - tool: get_compensation
1328 plugins:
1329 - rate_limiter:
1330 config:
1331 max_requests: 10
1332"#;
1333 let config = parse_config(yaml).unwrap();
1334 let resolved =
1335 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1336 assert_eq!(resolved[0].name, "rate_limiter");
1337 assert!(resolved[0].config_overrides.is_some());
1338 let overrides = resolved[0].config_overrides.as_ref().unwrap();
1339 assert_eq!(overrides["config"]["max_requests"], 10);
1340 }
1341
1342 #[test]
1343 fn test_plugin_ref_mixed_bare_and_overrides() {
1344 let yaml = r#"
1345plugin_settings:
1346 routing_enabled: true
1347plugins:
1348 - name: rate_limiter
1349 kind: builtin
1350 hooks: [tool_pre_invoke]
1351 - name: pii_scanner
1352 kind: builtin
1353 hooks: [tool_pre_invoke]
1354routes:
1355 - tool: get_compensation
1356 plugins:
1357 - rate_limiter
1358 - pii_scanner:
1359 config:
1360 sensitivity: high
1361"#;
1362 let config = parse_config(yaml).unwrap();
1363 let resolved =
1364 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1365 assert_eq!(resolved.len(), 2);
1366 assert_eq!(resolved[0].name, "rate_limiter");
1367 assert!(resolved[0].config_overrides.is_none());
1368 assert_eq!(resolved[1].name, "pii_scanner");
1369 assert!(resolved[1].config_overrides.is_some());
1370 }
1371
1372 #[test]
1373 fn test_deduplication_preserves_order() {
1374 let yaml = r#"
1375plugin_settings:
1376 routing_enabled: true
1377global:
1378 policies:
1379 all:
1380 plugins: [a, b]
1381 pii:
1382 plugins: [b, c]
1383plugins:
1384 - name: a
1385 kind: builtin
1386 hooks: [tool_pre_invoke]
1387 - name: b
1388 kind: builtin
1389 hooks: [tool_pre_invoke]
1390 - name: c
1391 kind: builtin
1392 hooks: [tool_pre_invoke]
1393routes:
1394 - tool: get_compensation
1395 meta:
1396 tags: [pii]
1397"#;
1398 let config = parse_config(yaml).unwrap();
1399 let resolved =
1400 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1401 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1402 assert_eq!(names, vec!["a", "b", "c"]);
1403 }
1404
1405 #[test]
1406 fn test_glob_trailing_wildcard() {
1407 let matcher = StringOrList::Single(Pattern::new("hr-*"));
1408 assert!(matcher.matches("hr-compensation"));
1409 assert!(matcher.matches("hr-benefits"));
1410 assert!(matcher.matches("hr-")); assert!(!matcher.matches("finance-report"));
1412 assert!(!matcher.matches("hr"));
1413 }
1414
1415 #[test]
1416 fn test_wildcard_matches_everything() {
1417 let matcher = StringOrList::Single(Pattern::new("*"));
1418 assert!(matcher.matches("anything"));
1419 assert!(matcher.matches(""));
1420 }
1421
1422 #[test]
1426 fn test_glob_leading_wildcard() {
1427 let matcher = StringOrList::Single(Pattern::new("*-prod"));
1428 assert!(matcher.matches("foo-prod"));
1429 assert!(matcher.matches("-prod")); assert!(!matcher.matches("foo-staging"));
1431 assert!(!matcher.matches("prod"));
1432 }
1433
1434 #[test]
1436 fn test_glob_mid_wildcard() {
1437 let matcher = StringOrList::Single(Pattern::new("hr-*-v1"));
1438 assert!(matcher.matches("hr-comp-v1"));
1439 assert!(matcher.matches("hr--v1")); assert!(!matcher.matches("hr-comp-v2"));
1441 assert!(!matcher.matches("finance-comp-v1"));
1442 }
1443
1444 #[test]
1446 fn test_glob_multiple_wildcards() {
1447 let matcher = StringOrList::Single(Pattern::new("*hr*comp*"));
1448 assert!(matcher.matches("hr-comp"));
1449 assert!(matcher.matches("xyz-hr-comp-foo"));
1450 assert!(!matcher.matches("hr-only"));
1451 assert!(!matcher.matches("comp-only"));
1452 }
1453
1454 #[test]
1459 fn test_glob_multi_star_is_equivalent_to_single_star() {
1460 for pattern in &["**", "***", "*****"] {
1461 let matcher = StringOrList::Single(Pattern::new(*pattern));
1462 assert!(
1463 matcher.matches("anything"),
1464 "pattern {} should match",
1465 pattern
1466 );
1467 assert!(
1468 matcher.matches(""),
1469 "pattern {} should match empty",
1470 pattern
1471 );
1472 }
1473 }
1474
1475 #[test]
1478 fn test_pattern_round_trips_through_yaml() {
1479 let yaml = "tool: '*-prod'";
1480 #[derive(Deserialize, Serialize)]
1481 struct Wrap {
1482 tool: StringOrList,
1483 }
1484 let parsed: Wrap = serde_yaml::from_str(yaml).unwrap();
1485 assert!(parsed.tool.matches("foo-prod"));
1486 assert!(!parsed.tool.matches("foo-staging"));
1487 let back = serde_yaml::to_string(&parsed).unwrap();
1488 assert!(
1489 back.contains("*-prod"),
1490 "serialized YAML should preserve pattern: {}",
1491 back
1492 );
1493 }
1494
1495 #[test]
1496 fn test_list_matches_any_member() {
1497 let matcher = StringOrList::List(vec![
1498 "get_compensation".to_string(),
1499 "get_benefits".to_string(),
1500 ]);
1501 assert!(matcher.matches("get_compensation"));
1502 assert!(matcher.matches("get_benefits"));
1503 assert!(!matcher.matches("send_email"));
1504 }
1505
1506 #[test]
1507 fn test_validation_skipped_when_routing_disabled() {
1508 let yaml = r#"
1509plugins:
1510 - name: test
1511 kind: builtin
1512 hooks: [tool_pre_invoke]
1513routes:
1514 - meta:
1515 tags: [pii]
1516"#;
1517 let config = parse_config(yaml);
1518 assert!(config.is_ok());
1519 }
1520
1521 #[test]
1524 fn test_scope_match_selects_scoped_route() {
1525 let yaml = r#"
1526plugin_settings:
1527 routing_enabled: true
1528plugins:
1529 - name: scoped_plugin
1530 kind: builtin
1531 hooks: [tool_pre_invoke]
1532 - name: global_plugin
1533 kind: builtin
1534 hooks: [tool_pre_invoke]
1535routes:
1536 - tool: get_compensation
1537 meta:
1538 scope: hr-services
1539 plugins:
1540 - scoped_plugin
1541 - tool: get_compensation
1542 plugins:
1543 - global_plugin
1544"#;
1545 let config = parse_config(yaml).unwrap();
1546
1547 let resolved = resolve_plugins_for_entity(
1549 &config,
1550 "tool",
1551 "get_compensation",
1552 Some("hr-services"),
1553 &no_tags(),
1554 );
1555 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1556 assert!(names.contains(&"scoped_plugin"));
1557 assert!(!names.contains(&"global_plugin"));
1558
1559 let resolved =
1561 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1562 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1563 assert!(names.contains(&"global_plugin"));
1564 assert!(!names.contains(&"scoped_plugin"));
1565
1566 let resolved = resolve_plugins_for_entity(
1568 &config,
1569 "tool",
1570 "get_compensation",
1571 Some("billing"),
1572 &no_tags(),
1573 );
1574 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1575 assert!(names.contains(&"global_plugin"));
1576 assert!(!names.contains(&"scoped_plugin"));
1577 }
1578
1579 #[test]
1582 fn test_host_tags_merged_with_route_tags() {
1583 let yaml = r#"
1584plugin_settings:
1585 routing_enabled: true
1586global:
1587 policies:
1588 pii:
1589 plugins: [pii_plugin]
1590 runtime_tag:
1591 plugins: [runtime_plugin]
1592plugins:
1593 - name: pii_plugin
1594 kind: builtin
1595 hooks: [tool_pre_invoke]
1596 - name: runtime_plugin
1597 kind: builtin
1598 hooks: [tool_pre_invoke]
1599routes:
1600 - tool: get_compensation
1601 meta:
1602 tags: [pii]
1603"#;
1604 let config = parse_config(yaml).unwrap();
1605
1606 let mut host_tags = HashSet::new();
1608 host_tags.insert("runtime_tag".to_string());
1609
1610 let resolved =
1611 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags);
1612 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1613
1614 assert!(names.contains(&"pii_plugin"));
1616 assert!(names.contains(&"runtime_plugin"));
1617 }
1618
1619 #[test]
1622 fn test_when_clause_carried_on_resolved_plugins() {
1623 let yaml = r#"
1624plugin_settings:
1625 routing_enabled: true
1626plugins:
1627 - name: conditional_plugin
1628 kind: builtin
1629 hooks: [tool_pre_invoke]
1630routes:
1631 - tool: get_compensation
1632 when: "args.include_ssn == true"
1633 plugins:
1634 - conditional_plugin
1635"#;
1636 let config = parse_config(yaml).unwrap();
1637 let resolved =
1638 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1639 assert_eq!(resolved[0].name, "conditional_plugin");
1640 assert_eq!(
1641 resolved[0].when.as_deref(),
1642 Some("args.include_ssn == true")
1643 );
1644 }
1645
1646 #[test]
1647 fn test_when_clause_not_on_policy_group_plugins() {
1648 let yaml = r#"
1649plugin_settings:
1650 routing_enabled: true
1651global:
1652 policies:
1653 all:
1654 plugins: [global_plugin]
1655plugins:
1656 - name: global_plugin
1657 kind: builtin
1658 hooks: [tool_pre_invoke]
1659 - name: route_plugin
1660 kind: builtin
1661 hooks: [tool_pre_invoke]
1662routes:
1663 - tool: get_compensation
1664 when: "args.sensitive == true"
1665 plugins:
1666 - route_plugin
1667"#;
1668 let config = parse_config(yaml).unwrap();
1669 let resolved =
1670 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1671
1672 let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap();
1674 assert!(global.when.is_none());
1675
1676 let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap();
1678 assert_eq!(route.when.as_deref(), Some("args.sensitive == true"));
1679 }
1680
1681 #[test]
1684 fn parse_route_identity_list_form() {
1685 let yaml = r#"
1686plugins:
1687 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1688 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1689routes:
1690 - tool: get_weather
1691 authentication:
1692 - corp-jwt
1693 - spiffe-attestor
1694"#;
1695 let cfg = parse_config(yaml).unwrap();
1696 let route = &cfg.routes[0];
1697 let id = route.identity.as_ref().expect("identity present");
1698 assert!(!id.replace_inherited);
1699 assert_eq!(id.steps.len(), 2);
1700 assert_eq!(id.steps[0].name, "corp-jwt");
1701 assert!(id.steps[0].config_override.is_none());
1702 assert!(id.steps[0].on_error.is_none());
1703 assert_eq!(id.steps[1].name, "spiffe-attestor");
1704 }
1705
1706 #[test]
1707 fn parse_route_identity_object_form_carries_replace_inherited() {
1708 let yaml = r#"
1709plugins:
1710 - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
1711routes:
1712 - tool: legacy
1713 authentication:
1714 replace_inherited: true
1715 steps:
1716 - legacy-basic-auth
1717"#;
1718 let cfg = parse_config(yaml).unwrap();
1719 let id = cfg.routes[0].identity.as_ref().unwrap();
1720 assert!(id.replace_inherited);
1721 assert_eq!(id.steps.len(), 1);
1722 assert_eq!(id.steps[0].name, "legacy-basic-auth");
1723 }
1724
1725 #[test]
1726 fn parse_route_identity_map_step_with_on_error_and_config() {
1727 let yaml = r#"
1728plugins:
1729 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1730routes:
1731 - tool: get_weather
1732 authentication:
1733 - name: corp-jwt
1734 on_error: deny
1735 config:
1736 audience: my-tool
1737"#;
1738 let cfg = parse_config(yaml).unwrap();
1739 let id = cfg.routes[0].identity.as_ref().unwrap();
1740 let s0 = &id.steps[0];
1741 assert_eq!(s0.name, "corp-jwt");
1742 assert_eq!(s0.on_error.as_deref(), Some("deny"));
1743 let cfg_override = s0.config_override.as_ref().expect("config_override set");
1744 assert_eq!(
1745 cfg_override.get("audience").and_then(|v| v.as_str()),
1746 Some("my-tool"),
1747 );
1748 }
1749
1750 #[test]
1751 fn parse_route_identity_mixed_bare_and_map_steps() {
1752 let yaml = r#"
1753plugins:
1754 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1755 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1756routes:
1757 - tool: get_weather
1758 authentication:
1759 - name: corp-jwt
1760 on_error: deny
1761 - spiffe-attestor
1762"#;
1763 let cfg = parse_config(yaml).unwrap();
1764 let steps = &cfg.routes[0].identity.as_ref().unwrap().steps;
1765 assert_eq!(steps.len(), 2);
1766 assert_eq!(steps[0].on_error.as_deref(), Some("deny"));
1767 assert!(steps[1].on_error.is_none());
1768 }
1769
1770 #[test]
1771 fn parse_route_identity_object_form_without_steps_errors() {
1772 let yaml = r#"
1773routes:
1774 - tool: bad
1775 authentication:
1776 replace_inherited: true
1777"#;
1778 let err = parse_config(yaml).expect_err("object form requires steps");
1779 let msg = format!("{err}");
1780 assert!(msg.contains("requires `steps:`"), "got: {msg}");
1781 }
1782
1783 #[test]
1784 fn parse_route_identity_replace_inherited_must_be_boolean() {
1785 let yaml = r#"
1786routes:
1787 - tool: bad
1788 authentication:
1789 replace_inherited: "yes"
1790 steps:
1791 - corp-jwt
1792"#;
1793 let err = parse_config(yaml).expect_err("replace_inherited must be bool");
1794 let msg = format!("{err}");
1795 assert!(msg.contains("boolean"), "got: {msg}");
1796 }
1797
1798 #[test]
1799 fn parse_route_identity_empty_step_name_errors() {
1800 let yaml = r#"
1801routes:
1802 - tool: bad
1803 authentication:
1804 - ""
1805"#;
1806 let err = parse_config(yaml).expect_err("empty step name should fail");
1807 let msg = format!("{err}");
1808 assert!(msg.contains("empty"), "got: {msg}");
1809 }
1810
1811 #[test]
1812 fn legacy_identity_key_is_rejected_at_route_and_global() {
1813 for yaml in [
1817 "routes:\n - tool: t\n identity:\n - corp-jwt\n",
1818 "global:\n identity:\n - corp-jwt\n",
1819 "global:\n policies:\n all:\n identity:\n - corp-jwt\n",
1820 "global:\n defaults:\n tool:\n identity:\n - corp-jwt\n",
1821 ] {
1822 let err = parse_config(yaml).expect_err("legacy identity: must be rejected");
1823 let msg = format!("{err}");
1824 assert!(
1825 msg.contains("identity") && msg.contains("authentication"),
1826 "rejection should name the rename: {msg}"
1827 );
1828 }
1829 }
1830
1831 #[test]
1832 fn parse_route_identity_scalar_shape_errors() {
1833 let yaml = r#"
1834routes:
1835 - tool: bad
1836 authentication: 42
1837"#;
1838 let err = parse_config(yaml).expect_err("scalar identity should fail");
1839 let msg = format!("{err}");
1840 assert!(msg.contains("list of steps"), "got: {msg}");
1841 }
1842
1843 #[test]
1846 fn resolve_identity_returns_empty_when_no_route_matches() {
1847 let yaml = r#"
1848plugins:
1849 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1850routes:
1851 - tool: get_weather
1852 authentication:
1853 - corp-jwt
1854"#;
1855 let cfg = parse_config(yaml).unwrap();
1856 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None);
1857 assert!(resolved.is_empty());
1858 }
1859
1860 #[test]
1861 fn resolve_identity_returns_empty_when_route_has_no_identity_block() {
1862 let yaml = r#"
1863plugins:
1864 - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] }
1865routes:
1866 - tool: get_weather
1867 plugins:
1868 - rate_limiter
1869"#;
1870 let cfg = parse_config(yaml).unwrap();
1871 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1872 assert!(resolved.is_empty());
1873 }
1874
1875 #[test]
1876 fn resolve_identity_preserves_declared_order() {
1877 let yaml = r#"
1878plugins:
1879 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1880 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1881 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1882routes:
1883 - tool: get_weather
1884 authentication:
1885 - spiffe-attestor
1886 - corp-jwt
1887 - agent-context
1888"#;
1889 let cfg = parse_config(yaml).unwrap();
1890 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1891 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1892 assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]);
1893 }
1894
1895 #[test]
1896 fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() {
1897 let yaml = r#"
1902plugins:
1903 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1904routes:
1905 - tool: get_weather
1906 authentication:
1907 - name: corp-jwt
1908 config:
1909 audience: my-tool
1910"#;
1911 let cfg = parse_config(yaml).unwrap();
1912 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1913 assert_eq!(resolved.len(), 1);
1914 let overrides = resolved[0]
1915 .config_overrides
1916 .as_ref()
1917 .expect("overrides wrapped");
1918 let config = overrides.get("config").expect("config key present");
1919 assert_eq!(
1920 config.get("audience").and_then(|v| v.as_str()),
1921 Some("my-tool")
1922 );
1923 }
1924
1925 #[test]
1928 fn resolve_identity_includes_global_layer_when_route_has_no_block() {
1929 let yaml = r#"
1932plugin_settings:
1933 routing_enabled: true
1934plugins:
1935 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1936global:
1937 authentication:
1938 - corp-jwt
1939routes:
1940 - tool: get_weather
1941"#;
1942 let cfg = parse_config(yaml).unwrap();
1943 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1944 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1945 assert_eq!(names, vec!["corp-jwt"]);
1946 }
1947
1948 #[test]
1949 fn resolve_identity_appends_route_steps_after_global_by_default() {
1950 let yaml = r#"
1954plugin_settings:
1955 routing_enabled: true
1956plugins:
1957 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1958 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1959global:
1960 authentication:
1961 - corp-jwt
1962routes:
1963 - tool: get_weather
1964 authentication:
1965 - agent-context
1966"#;
1967 let cfg = parse_config(yaml).unwrap();
1968 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1969 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1970 assert_eq!(names, vec!["corp-jwt", "agent-context"]);
1971 }
1972
1973 #[test]
1974 fn resolve_identity_stacks_global_then_tag_bundle_then_route() {
1975 let yaml = r#"
1979plugin_settings:
1980 routing_enabled: true
1981plugins:
1982 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1983 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1984 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1985global:
1986 authentication:
1987 - corp-jwt
1988 policies:
1989 finance:
1990 authentication:
1991 - workday-saml
1992routes:
1993 - tool: get_compensation
1994 meta:
1995 tags: [finance]
1996 authentication:
1997 - agent-context
1998"#;
1999 let cfg = parse_config(yaml).unwrap();
2000 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None);
2001 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
2002 assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]);
2003 }
2004
2005 #[test]
2006 fn resolve_identity_replace_inherited_drops_global_and_tag_layers() {
2007 let yaml = r#"
2010plugin_settings:
2011 routing_enabled: true
2012plugins:
2013 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
2014 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
2015 - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
2016global:
2017 authentication:
2018 - corp-jwt
2019 policies:
2020 finance:
2021 authentication:
2022 - workday-saml
2023routes:
2024 - tool: legacy_endpoint
2025 meta:
2026 tags: [finance]
2027 authentication:
2028 replace_inherited: true
2029 steps:
2030 - legacy-basic-auth
2031"#;
2032 let cfg = parse_config(yaml).unwrap();
2033 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None);
2034 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
2035 assert_eq!(names, vec!["legacy-basic-auth"]);
2036 }
2037
2038 #[test]
2039 fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() {
2040 let yaml = r#"
2044plugin_settings:
2045 routing_enabled: true
2046plugins:
2047 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
2048global:
2049 authentication:
2050 - corp-jwt
2051routes:
2052 - tool: anonymous_endpoint
2053 authentication:
2054 replace_inherited: true
2055 steps: []
2056"#;
2057 let cfg = parse_config(yaml).unwrap();
2058 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None);
2059 assert!(resolved.is_empty());
2060 }
2061
2062 #[test]
2063 fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() {
2064 let yaml = r#"
2067plugin_settings:
2068 routing_enabled: true
2069plugins:
2070 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
2071global:
2072 policies:
2073 finance:
2074 authentication:
2075 - workday-saml
2076routes:
2077 - tool: with_tag
2078 meta:
2079 tags: [finance]
2080 - tool: without_tag
2081"#;
2082 let cfg = parse_config(yaml).unwrap();
2083
2084 let tagged = resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None);
2085 assert_eq!(
2086 tagged.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2087 vec!["workday-saml"],
2088 );
2089
2090 let untagged = resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None);
2091 assert!(
2092 untagged.is_empty(),
2093 "tag bundle should NOT apply to untagged routes"
2094 );
2095 }
2096
2097 #[test]
2098 fn resolve_identity_scope_filtering_matches_other_route_resolution() {
2099 let yaml = r#"
2104plugins:
2105 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
2106routes:
2107 - tool: get_weather
2108 meta:
2109 scope: tenant-a
2110 authentication:
2111 - corp-jwt
2112"#;
2113 let cfg = parse_config(yaml).unwrap();
2114 let matching =
2115 resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-a"));
2116 assert_eq!(matching.len(), 1);
2117
2118 let non_matching =
2119 resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b"));
2120 assert!(non_matching.is_empty());
2121 }
2122
2123 fn deserialize_cfg(yaml: &str) -> Result<CpexConfig, String> {
2138 serde_yaml::from_str(yaml).map_err(|e| e.to_string())
2139 }
2140
2141 #[test]
2142 fn route_plugins_list_parses_as_activation_list() {
2143 let cfg = deserialize_cfg(
2144 r#"
2145routes:
2146 - tool: get_weather
2147 plugins:
2148 - rate_limiter
2149 - pii_scanner:
2150 config:
2151 sensitivity: high
2152"#,
2153 )
2154 .unwrap();
2155 let plugins = &cfg.routes[0].plugins;
2156 assert_eq!(plugins.len(), 2);
2157 assert_eq!(plugins[0].name(), "rate_limiter");
2158 assert_eq!(plugins[1].name(), "pii_scanner");
2159 }
2160
2161 #[test]
2162 fn route_plugins_map_loads_as_empty_structural_list() {
2163 let cfg = deserialize_cfg(
2164 r#"
2165routes:
2166 - tool: get_weather
2167 plugins:
2168 audit:
2169 on_error: ignore
2170"#,
2171 )
2172 .expect("flat plugins map must deserialize");
2173 assert!(
2174 cfg.routes[0].plugins.is_empty(),
2175 "a plugins map is APL-override data, not a structural activation list",
2176 );
2177 }
2178
2179 #[test]
2180 fn defaults_and_policies_plugins_map_loads() {
2181 let cfg = deserialize_cfg(
2182 r#"
2183global:
2184 defaults:
2185 tool:
2186 plugins:
2187 audit:
2188 on_error: ignore
2189 policies:
2190 sensitive:
2191 plugins:
2192 pii_scanner:
2193 config:
2194 sensitivity: high
2195"#,
2196 )
2197 .expect("defaults/policies plugins map must deserialize");
2198 assert!(cfg.global.defaults["tool"].plugins.is_empty());
2199 assert!(cfg.global.policies["sensitive"].plugins.is_empty());
2200 }
2201
2202 #[test]
2203 fn scalar_plugins_value_is_rejected_with_clear_error() {
2204 let err = deserialize_cfg(
2205 r#"
2206routes:
2207 - tool: get_weather
2208 plugins: nonsense
2209"#,
2210 )
2211 .expect_err("scalar plugins must error");
2212 assert!(
2213 err.contains("sequence") && err.contains("mapping"),
2214 "expected a shape-aware error, got: {err}",
2215 );
2216 }
2217}