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(default, deserialize_with = "deserialize_route_identity")]
171 pub identity: Option<crate::identity::RouteIdentityConfig>,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct PolicyGroup {
183 #[serde(default)]
185 pub description: Option<String>,
186
187 #[serde(default)]
189 pub metadata: HashMap<String, String>,
190
191 #[serde(default, deserialize_with = "deserialize_plugin_refs")]
193 pub plugins: Vec<PluginRouteRef>,
194
195 #[serde(default, deserialize_with = "deserialize_route_identity")]
201 pub identity: Option<crate::identity::RouteIdentityConfig>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(untagged)]
219pub enum PluginRouteRef {
220 Name(String),
222 WithOverrides(HashMap<String, serde_json::Value>),
224}
225
226impl PluginRouteRef {
227 pub fn name(&self) -> &str {
229 match self {
230 Self::Name(name) => name,
231 Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""),
232 }
233 }
234
235 pub fn overrides(&self) -> Option<&serde_json::Value> {
237 match self {
238 Self::Name(_) => None,
239 Self::WithOverrides(map) => map.values().next(),
240 }
241 }
242}
243
244fn deserialize_plugin_refs<'de, D>(deserializer: D) -> Result<Vec<PluginRouteRef>, D::Error>
263where
264 D: serde::Deserializer<'de>,
265{
266 use serde::de::Error;
267
268 match serde_yaml::Value::deserialize(deserializer)? {
269 serde_yaml::Value::Sequence(items) => items
271 .into_iter()
272 .map(|item| serde_yaml::from_value(item).map_err(D::Error::custom))
273 .collect(),
274 serde_yaml::Value::Mapping(_) => Ok(Vec::new()),
277 serde_yaml::Value::Null => Ok(Vec::new()),
279 other => Err(D::Error::custom(format!(
280 "`plugins:` must be a sequence (activation list) or a mapping \
281 (APL per-plugin overrides), got {:?}",
282 other
283 ))),
284 }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct RouteEntry {
297 #[serde(default)]
299 pub tool: Option<StringOrList>,
300
301 #[serde(default)]
303 pub resource: Option<StringOrList>,
304
305 #[serde(default)]
307 pub prompt: Option<StringOrList>,
308
309 #[serde(default)]
311 pub llm: Option<StringOrList>,
312
313 #[serde(default)]
315 pub meta: Option<RouteMeta>,
316
317 #[serde(default)]
321 pub when: Option<String>,
322
323 #[serde(default, deserialize_with = "deserialize_plugin_refs")]
325 pub plugins: Vec<PluginRouteRef>,
326
327 #[serde(default, deserialize_with = "deserialize_route_identity")]
350 pub identity: Option<crate::identity::RouteIdentityConfig>,
351}
352
353fn deserialize_route_identity<'de, D>(
364 deserializer: D,
365) -> Result<Option<crate::identity::RouteIdentityConfig>, D::Error>
366where
367 D: serde::Deserializer<'de>,
368{
369 use crate::identity::RouteIdentityConfig;
370 use serde::de::Error;
371
372 let raw = match Option::<serde_yaml::Value>::deserialize(deserializer)? {
375 None => return Ok(None),
376 Some(serde_yaml::Value::Null) => return Ok(None),
377 Some(v) => v,
378 };
379
380 let (replace_inherited, raw_steps): (bool, Vec<serde_yaml::Value>) = match raw {
381 serde_yaml::Value::Sequence(items) => (false, items),
382 serde_yaml::Value::Mapping(map) => {
383 let replace_inherited =
384 match map.get(serde_yaml::Value::String("replace_inherited".to_string())) {
385 Some(v) => v.as_bool().ok_or_else(|| {
386 D::Error::custom("`identity.replace_inherited` must be a boolean")
387 })?,
388 None => false,
389 };
390 let steps_val = map
391 .get(serde_yaml::Value::String("steps".to_string()))
392 .ok_or_else(|| {
393 D::Error::custom(
394 "`identity:` object form requires `steps:` (a list of \
395 identity steps); did you mean to write the list form?",
396 )
397 })?;
398 let items = steps_val
399 .as_sequence()
400 .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))?
401 .clone();
402 (replace_inherited, items)
403 },
404 _ => {
405 return Err(D::Error::custom(
406 "`identity:` must be a list of steps or an object with \
407 `steps:` (and optional `replace_inherited:`)",
408 ));
409 },
410 };
411
412 let mut steps = Vec::with_capacity(raw_steps.len());
413 for (i, raw) in raw_steps.into_iter().enumerate() {
414 steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?);
415 }
416
417 Ok(Some(RouteIdentityConfig {
418 steps,
419 replace_inherited,
420 }))
421}
422
423fn parse_identity_step(
427 raw: serde_yaml::Value,
428 index: usize,
429) -> Result<crate::identity::RouteIdentityStep, String> {
430 use crate::identity::RouteIdentityStep;
431
432 match raw {
433 serde_yaml::Value::String(name) => {
434 if name.is_empty() {
435 return Err(format!(
436 "identity step [{index}] plugin name cannot be empty"
437 ));
438 }
439 Ok(RouteIdentityStep {
440 name,
441 ..Default::default()
442 })
443 },
444 serde_yaml::Value::Mapping(_) => {
445 #[derive(serde::Deserialize)]
452 struct StepYaml {
453 name: String,
454 #[serde(default)]
455 on_error: Option<String>,
456 #[serde(default)]
457 config: Option<serde_json::Value>,
458 #[serde(default, flatten)]
459 extra: std::collections::HashMap<String, serde_json::Value>,
460 }
461 let parsed: StepYaml =
462 serde_yaml::from_value(raw).map_err(|e| format!("identity step [{index}]: {e}"))?;
463 if parsed.name.is_empty() {
464 return Err(format!("identity step [{index}] `name:` cannot be empty"));
465 }
466 Ok(RouteIdentityStep {
467 name: parsed.name,
468 config_override: parsed.config,
469 on_error: parsed.on_error,
470 extra: parsed.extra,
471 })
472 },
473 _ => Err(format!(
474 "identity step [{index}] must be a plugin name (string) or a map \
475 with `name:` (and optional `on_error:` / `config:`)"
476 )),
477 }
478}
479
480#[derive(Debug, Clone, Default, Serialize, Deserialize)]
486pub struct RouteMeta {
487 #[serde(default)]
489 pub tags: Vec<String>,
490
491 #[serde(default)]
494 pub scope: Option<String>,
495
496 #[serde(default)]
498 pub properties: HashMap<String, String>,
499}
500
501#[derive(Debug, Clone)]
522pub struct Pattern {
523 pattern: String,
524 matcher: wildmatch::WildMatch,
525}
526
527impl Pattern {
528 pub fn new(pattern: impl Into<String>) -> Self {
531 let pattern = pattern.into();
532 let matcher = wildmatch::WildMatch::new(&pattern);
533 Self { pattern, matcher }
534 }
535
536 pub fn matches(&self, name: &str) -> bool {
538 self.matcher.matches(name)
539 }
540
541 pub fn as_str(&self) -> &str {
543 &self.pattern
544 }
545}
546
547impl Default for Pattern {
548 fn default() -> Self {
549 Self::new("")
550 }
551}
552
553impl Serialize for Pattern {
554 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
555 serializer.serialize_str(&self.pattern)
556 }
557}
558
559impl<'de> Deserialize<'de> for Pattern {
560 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
561 let s = String::deserialize(deserializer)?;
562 Ok(Pattern::new(s))
563 }
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
568#[serde(untagged)]
569pub enum StringOrList {
570 Single(Pattern),
574 List(Vec<String>),
576}
577
578impl Default for StringOrList {
579 fn default() -> Self {
580 Self::Single(Pattern::default())
581 }
582}
583
584impl StringOrList {
585 pub fn matches(&self, name: &str) -> bool {
587 match self {
588 Self::Single(pattern) => pattern.matches(name),
589 Self::List(names) => names.iter().any(|n| n == name),
590 }
591 }
592}
593
594pub fn load_config(path: &Path) -> Result<CpexConfig, Box<PluginError>> {
600 let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config {
601 message: format!("failed to read config file '{}': {}", path.display(), e),
602 })?;
603 parse_config(&content)
604}
605
606pub fn parse_config(yaml: &str) -> Result<CpexConfig, Box<PluginError>> {
608 let config: CpexConfig = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config {
609 message: format!("failed to parse config YAML: {}", e),
610 })?;
611 validate_config(&config)?;
612 Ok(config)
613}
614
615fn validate_config(config: &CpexConfig) -> Result<(), Box<PluginError>> {
631 let mut seen_names = HashSet::new();
632 for plugin in &config.plugins {
633 if !seen_names.insert(&plugin.name) {
634 return Err(Box::new(PluginError::Config {
635 message: format!("duplicate plugin name: '{}'", plugin.name),
636 }));
637 }
638 }
639
640 if config.routing_enabled() {
641 let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect();
642
643 for (i, route) in config.routes.iter().enumerate() {
644 let count = [
645 route.tool.is_some(),
646 route.resource.is_some(),
647 route.prompt.is_some(),
648 route.llm.is_some(),
649 ]
650 .iter()
651 .filter(|&&m| m)
652 .count();
653
654 if count == 0 {
655 return Err(Box::new(PluginError::Config {
656 message: format!(
657 "route {} has no entity matcher (need tool, resource, prompt, or llm)",
658 i
659 ),
660 }));
661 }
662 if count > 1 {
663 return Err(Box::new(PluginError::Config {
664 message: format!(
665 "route {} has multiple entity matchers (need exactly one)",
666 i
667 ),
668 }));
669 }
670
671 for plugin_ref in &route.plugins {
672 if !plugin_names.contains(plugin_ref.name()) {
673 return Err(Box::new(PluginError::Config {
674 message: format!(
675 "route {} references unknown plugin '{}'",
676 i,
677 plugin_ref.name()
678 ),
679 }));
680 }
681 }
682 }
683
684 for (group_name, group) in &config.global.policies {
685 for plugin_ref in &group.plugins {
686 if !plugin_names.contains(plugin_ref.name()) {
687 return Err(Box::new(PluginError::Config {
688 message: format!(
689 "policy group '{}' references unknown plugin '{}'",
690 group_name,
691 plugin_ref.name()
692 ),
693 }));
694 }
695 }
696 }
697 }
698
699 Ok(())
700}
701
702const SPECIFICITY_EXACT_NAME: usize = 1000;
708const SPECIFICITY_NAME_LIST: usize = 500;
709const SPECIFICITY_GLOB: usize = 300;
710const SPECIFICITY_WHEN_ONLY: usize = 10;
711const SPECIFICITY_WILDCARD: usize = 0;
712
713fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option<usize> {
718 let matcher = matcher?;
719 if !matcher.matches(entity_name) {
720 return None;
721 }
722 let score = match matcher {
723 StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD,
724 StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB,
725 StringOrList::List(_) => SPECIFICITY_NAME_LIST,
726 StringOrList::Single(_) => SPECIFICITY_EXACT_NAME,
727 };
728 Some(score)
729}
730
731pub fn resolve_plugins_for_entity(
741 config: &CpexConfig,
742 entity_type: &str,
743 entity_name: &str,
744 request_scope: Option<&str>,
745 request_tags: &HashSet<String>,
746) -> Vec<ResolvedPlugin> {
747 if !config.routing_enabled() {
748 return config
749 .plugins
750 .iter()
751 .map(|p| ResolvedPlugin {
752 name: p.name.clone(),
753 config_overrides: None,
754 when: None,
755 })
756 .collect();
757 }
758
759 let mut resolved = Vec::new();
760
761 if let Some(all_group) = config.global.policies.get("all") {
763 collect_plugin_refs(&all_group.plugins, &mut resolved, None);
764 }
765
766 if let Some(default_group) = config.global.defaults.get(entity_type) {
768 collect_plugin_refs(&default_group.plugins, &mut resolved, None);
769 }
770
771 if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) {
773 let mut merged_tags: HashSet<String> = request_tags.clone();
775 if let Some(meta) = &route.meta {
776 for tag in &meta.tags {
777 merged_tags.insert(tag.clone());
778 }
779 }
780
781 for tag in &merged_tags {
783 if tag == "all" {
784 continue; }
786 if let Some(group) = config.global.policies.get(tag.as_str()) {
787 collect_plugin_refs(&group.plugins, &mut resolved, None);
788 }
789 }
790
791 collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref());
793 }
794
795 let mut seen = HashSet::new();
797 let mut deduped = Vec::new();
798 for rp in resolved.into_iter().rev() {
799 if seen.insert(rp.name.clone()) {
800 deduped.push(rp);
801 }
802 }
803 deduped.reverse();
804 deduped
805}
806
807pub fn resolve_identity_plugins_for_route(
840 config: &CpexConfig,
841 entity_type: &str,
842 entity_name: &str,
843 request_scope: Option<&str>,
844) -> Vec<ResolvedPlugin> {
845 let route = find_matching_route(config, entity_type, entity_name, request_scope);
851 let route_identity = route.and_then(|r| r.identity.as_ref());
852
853 let replace_inherited = route_identity
856 .map(|id| id.replace_inherited)
857 .unwrap_or(false);
858
859 let mut steps: Vec<crate::identity::RouteIdentityStep> = Vec::new();
860
861 if !replace_inherited {
862 if let Some(global_identity) = config.global.identity.as_ref() {
864 steps.extend(global_identity.steps.iter().cloned());
865 }
866
867 if let Some(route) = route {
873 if let Some(meta) = &route.meta {
874 for tag in &meta.tags {
875 if let Some(bundle) = config.global.policies.get(tag) {
876 if let Some(bundle_identity) = bundle.identity.as_ref() {
877 steps.extend(bundle_identity.steps.iter().cloned());
878 }
879 }
880 }
881 }
882 }
883 }
884
885 if let Some(id) = route_identity {
887 steps.extend(id.steps.iter().cloned());
888 }
889
890 steps
891 .into_iter()
892 .map(|step| ResolvedPlugin {
893 name: step.name.clone(),
894 config_overrides: step.config_override.as_ref().map(|cfg| {
899 let mut wrapper = serde_json::Map::new();
900 wrapper.insert("config".to_string(), cfg.clone());
901 serde_json::Value::Object(wrapper)
902 }),
903 when: None,
904 })
905 .collect()
906}
907
908#[derive(Debug, Clone)]
910pub struct ResolvedPlugin {
911 pub name: String,
913
914 pub config_overrides: Option<serde_json::Value>,
916
917 pub when: Option<String>,
919}
920
921fn collect_plugin_refs(
923 refs: &[PluginRouteRef],
924 resolved: &mut Vec<ResolvedPlugin>,
925 route_when: Option<&str>,
926) {
927 for plugin_ref in refs {
928 resolved.push(ResolvedPlugin {
929 name: plugin_ref.name().to_string(),
930 config_overrides: plugin_ref.overrides().cloned(),
931 when: route_when.map(String::from),
932 });
933 }
934}
935
936fn find_matching_route<'a>(
941 config: &'a CpexConfig,
942 entity_type: &str,
943 entity_name: &str,
944 request_scope: Option<&str>,
945) -> Option<&'a RouteEntry> {
946 let mut best: Option<(usize, &RouteEntry)> = None;
947
948 for route in &config.routes {
949 let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref());
951 let scope_bonus = match (route_scope, request_scope) {
952 (None, _) => 0, (Some(rs), Some(rq)) if rs == rq => 100, (Some(_), _) => continue, };
956
957 let entity_matcher = match entity_type {
958 "tool" => route.tool.as_ref(),
959 "resource" => route.resource.as_ref(),
960 "prompt" => route.prompt.as_ref(),
961 "llm" => route.llm.as_ref(),
962 _ => continue,
963 };
964 let base_specificity = match score_entity_match(entity_matcher, entity_name) {
965 Some(score) => score,
966 None => continue,
967 };
968
969 let when_bonus = if route.when.is_some() {
970 SPECIFICITY_WHEN_ONLY
971 } else {
972 0
973 };
974 let total = base_specificity + scope_bonus + when_bonus;
975
976 if best.is_none_or(|(s, _)| total > s) {
977 best = Some((total, route));
978 }
979 }
980
981 best.map(|(_, route)| route)
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987
988 fn no_tags() -> HashSet<String> {
990 HashSet::new()
991 }
992
993 #[test]
994 fn test_parse_minimal_config() {
995 let yaml = r#"
996plugins:
997 - name: rate_limiter
998 kind: builtin
999 hooks: [tool_pre_invoke]
1000 mode: sequential
1001 priority: 5
1002 config:
1003 max_requests: 100
1004"#;
1005 let config = parse_config(yaml).unwrap();
1006 assert!(!config.routing_enabled());
1007 assert_eq!(config.plugins.len(), 1);
1008 assert_eq!(config.plugins[0].name, "rate_limiter");
1009 }
1010
1011 #[test]
1012 fn test_no_plugin_settings_defaults_routing_disabled() {
1013 let yaml = r#"
1014plugins:
1015 - name: test
1016 kind: builtin
1017 hooks: [tool_pre_invoke]
1018"#;
1019 let config = parse_config(yaml).unwrap();
1020 assert!(!config.routing_enabled());
1021 assert_eq!(config.plugin_settings.plugin_timeout, 30);
1022 }
1023
1024 #[test]
1025 fn test_routing_enabled() {
1026 let yaml = r#"
1027plugin_settings:
1028 routing_enabled: true
1029global:
1030 policies:
1031 all:
1032 plugins: [identity]
1033plugins:
1034 - name: identity
1035 kind: builtin
1036 hooks: [identity_resolve]
1037routes:
1038 - tool: get_compensation
1039 meta:
1040 tags: [pii]
1041"#;
1042 let config = parse_config(yaml).unwrap();
1043 assert!(config.routing_enabled());
1044 }
1045
1046 #[test]
1047 fn test_duplicate_plugin_names_rejected() {
1048 let yaml = r#"
1049plugins:
1050 - name: dup
1051 kind: builtin
1052 hooks: [tool_pre_invoke]
1053 - name: dup
1054 kind: builtin
1055 hooks: [tool_post_invoke]
1056"#;
1057 assert!(parse_config(yaml)
1058 .unwrap_err()
1059 .to_string()
1060 .contains("duplicate plugin name"));
1061 }
1062
1063 #[test]
1064 fn test_route_requires_one_entity_matcher() {
1065 let yaml = r#"
1066plugin_settings:
1067 routing_enabled: true
1068plugins: []
1069routes:
1070 - meta:
1071 tags: [pii]
1072"#;
1073 assert!(parse_config(yaml)
1074 .unwrap_err()
1075 .to_string()
1076 .contains("no entity matcher"));
1077 }
1078
1079 #[test]
1080 fn test_route_rejects_multiple_entity_matchers() {
1081 let yaml = r#"
1082plugin_settings:
1083 routing_enabled: true
1084plugins: []
1085routes:
1086 - tool: get_compensation
1087 resource: "hr://employees/*"
1088"#;
1089 assert!(parse_config(yaml)
1090 .unwrap_err()
1091 .to_string()
1092 .contains("multiple entity matchers"));
1093 }
1094
1095 #[test]
1096 fn test_route_unknown_plugin_rejected() {
1097 let yaml = r#"
1098plugin_settings:
1099 routing_enabled: true
1100plugins:
1101 - name: known
1102 kind: builtin
1103 hooks: [tool_pre_invoke]
1104routes:
1105 - tool: get_compensation
1106 plugins:
1107 - unknown
1108"#;
1109 assert!(parse_config(yaml)
1110 .unwrap_err()
1111 .to_string()
1112 .contains("unknown plugin 'unknown'"));
1113 }
1114
1115 #[test]
1116 fn test_policy_group_unknown_plugin_rejected() {
1117 let yaml = r#"
1118plugin_settings:
1119 routing_enabled: true
1120global:
1121 policies:
1122 all:
1123 plugins: [nonexistent]
1124plugins: []
1125routes: []
1126"#;
1127 assert!(parse_config(yaml)
1128 .unwrap_err()
1129 .to_string()
1130 .contains("unknown plugin 'nonexistent'"));
1131 }
1132
1133 #[test]
1134 fn test_resolve_conditions_mode_returns_all() {
1135 let yaml = r#"
1136plugins:
1137 - name: a
1138 kind: builtin
1139 hooks: [tool_pre_invoke]
1140 - name: b
1141 kind: builtin
1142 hooks: [tool_post_invoke]
1143"#;
1144 let config = parse_config(yaml).unwrap();
1145 let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags());
1146 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1147 assert_eq!(names, vec!["a", "b"]);
1148 }
1149
1150 #[test]
1151 fn test_resolve_routes_inherits_policy_groups() {
1152 let yaml = r#"
1153plugin_settings:
1154 routing_enabled: true
1155global:
1156 policies:
1157 all:
1158 plugins:
1159 - identity
1160 pii:
1161 plugins:
1162 - apl_policy
1163plugins:
1164 - name: identity
1165 kind: builtin
1166 hooks: [identity_resolve]
1167 - name: apl_policy
1168 kind: builtin
1169 hooks: [cmf.tool_pre_invoke]
1170routes:
1171 - tool: get_compensation
1172 meta:
1173 tags: [pii]
1174"#;
1175 let config = parse_config(yaml).unwrap();
1176 let resolved =
1177 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1178 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1179 assert!(names.contains(&"identity"));
1180 assert!(names.contains(&"apl_policy"));
1181 }
1182
1183 #[test]
1184 fn test_resolve_no_matching_route_gets_all_only() {
1185 let yaml = r#"
1186plugin_settings:
1187 routing_enabled: true
1188global:
1189 policies:
1190 all:
1191 plugins:
1192 - identity
1193plugins:
1194 - name: identity
1195 kind: builtin
1196 hooks: [identity_resolve]
1197routes:
1198 - tool: get_compensation
1199"#;
1200 let config = parse_config(yaml).unwrap();
1201 let resolved =
1202 resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags());
1203 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1204 assert_eq!(names, vec!["identity"]);
1205 }
1206
1207 #[test]
1208 fn test_exact_match_beats_glob() {
1209 let yaml = r#"
1210plugin_settings:
1211 routing_enabled: true
1212plugins:
1213 - name: specific
1214 kind: builtin
1215 hooks: [tool_pre_invoke]
1216 - name: general
1217 kind: builtin
1218 hooks: [tool_pre_invoke]
1219routes:
1220 - tool: "hr-*"
1221 plugins:
1222 - general
1223 - tool: hr-compensation
1224 plugins:
1225 - specific
1226"#;
1227 let config = parse_config(yaml).unwrap();
1228 let resolved =
1229 resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags());
1230 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1231 assert!(names.contains(&"specific"));
1232 assert!(!names.contains(&"general"));
1233 }
1234
1235 #[test]
1236 fn test_plugin_ref_bare_name() {
1237 let yaml = r#"
1238plugin_settings:
1239 routing_enabled: true
1240plugins:
1241 - name: rate_limiter
1242 kind: builtin
1243 hooks: [tool_pre_invoke]
1244routes:
1245 - tool: get_compensation
1246 plugins:
1247 - rate_limiter
1248"#;
1249 let config = parse_config(yaml).unwrap();
1250 let resolved =
1251 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1252 assert_eq!(resolved[0].name, "rate_limiter");
1253 assert!(resolved[0].config_overrides.is_none());
1254 }
1255
1256 #[test]
1257 fn test_plugin_ref_with_overrides() {
1258 let yaml = r#"
1259plugin_settings:
1260 routing_enabled: true
1261plugins:
1262 - name: rate_limiter
1263 kind: builtin
1264 hooks: [tool_pre_invoke]
1265 config:
1266 max_requests: 100
1267routes:
1268 - tool: get_compensation
1269 plugins:
1270 - rate_limiter:
1271 config:
1272 max_requests: 10
1273"#;
1274 let config = parse_config(yaml).unwrap();
1275 let resolved =
1276 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1277 assert_eq!(resolved[0].name, "rate_limiter");
1278 assert!(resolved[0].config_overrides.is_some());
1279 let overrides = resolved[0].config_overrides.as_ref().unwrap();
1280 assert_eq!(overrides["config"]["max_requests"], 10);
1281 }
1282
1283 #[test]
1284 fn test_plugin_ref_mixed_bare_and_overrides() {
1285 let yaml = r#"
1286plugin_settings:
1287 routing_enabled: true
1288plugins:
1289 - name: rate_limiter
1290 kind: builtin
1291 hooks: [tool_pre_invoke]
1292 - name: pii_scanner
1293 kind: builtin
1294 hooks: [tool_pre_invoke]
1295routes:
1296 - tool: get_compensation
1297 plugins:
1298 - rate_limiter
1299 - pii_scanner:
1300 config:
1301 sensitivity: high
1302"#;
1303 let config = parse_config(yaml).unwrap();
1304 let resolved =
1305 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1306 assert_eq!(resolved.len(), 2);
1307 assert_eq!(resolved[0].name, "rate_limiter");
1308 assert!(resolved[0].config_overrides.is_none());
1309 assert_eq!(resolved[1].name, "pii_scanner");
1310 assert!(resolved[1].config_overrides.is_some());
1311 }
1312
1313 #[test]
1314 fn test_deduplication_preserves_order() {
1315 let yaml = r#"
1316plugin_settings:
1317 routing_enabled: true
1318global:
1319 policies:
1320 all:
1321 plugins: [a, b]
1322 pii:
1323 plugins: [b, c]
1324plugins:
1325 - name: a
1326 kind: builtin
1327 hooks: [tool_pre_invoke]
1328 - name: b
1329 kind: builtin
1330 hooks: [tool_pre_invoke]
1331 - name: c
1332 kind: builtin
1333 hooks: [tool_pre_invoke]
1334routes:
1335 - tool: get_compensation
1336 meta:
1337 tags: [pii]
1338"#;
1339 let config = parse_config(yaml).unwrap();
1340 let resolved =
1341 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1342 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1343 assert_eq!(names, vec!["a", "b", "c"]);
1344 }
1345
1346 #[test]
1347 fn test_glob_trailing_wildcard() {
1348 let matcher = StringOrList::Single(Pattern::new("hr-*"));
1349 assert!(matcher.matches("hr-compensation"));
1350 assert!(matcher.matches("hr-benefits"));
1351 assert!(matcher.matches("hr-")); assert!(!matcher.matches("finance-report"));
1353 assert!(!matcher.matches("hr"));
1354 }
1355
1356 #[test]
1357 fn test_wildcard_matches_everything() {
1358 let matcher = StringOrList::Single(Pattern::new("*"));
1359 assert!(matcher.matches("anything"));
1360 assert!(matcher.matches(""));
1361 }
1362
1363 #[test]
1367 fn test_glob_leading_wildcard() {
1368 let matcher = StringOrList::Single(Pattern::new("*-prod"));
1369 assert!(matcher.matches("foo-prod"));
1370 assert!(matcher.matches("-prod")); assert!(!matcher.matches("foo-staging"));
1372 assert!(!matcher.matches("prod"));
1373 }
1374
1375 #[test]
1377 fn test_glob_mid_wildcard() {
1378 let matcher = StringOrList::Single(Pattern::new("hr-*-v1"));
1379 assert!(matcher.matches("hr-comp-v1"));
1380 assert!(matcher.matches("hr--v1")); assert!(!matcher.matches("hr-comp-v2"));
1382 assert!(!matcher.matches("finance-comp-v1"));
1383 }
1384
1385 #[test]
1387 fn test_glob_multiple_wildcards() {
1388 let matcher = StringOrList::Single(Pattern::new("*hr*comp*"));
1389 assert!(matcher.matches("hr-comp"));
1390 assert!(matcher.matches("xyz-hr-comp-foo"));
1391 assert!(!matcher.matches("hr-only"));
1392 assert!(!matcher.matches("comp-only"));
1393 }
1394
1395 #[test]
1400 fn test_glob_multi_star_is_equivalent_to_single_star() {
1401 for pattern in &["**", "***", "*****"] {
1402 let matcher = StringOrList::Single(Pattern::new(*pattern));
1403 assert!(
1404 matcher.matches("anything"),
1405 "pattern {} should match",
1406 pattern
1407 );
1408 assert!(
1409 matcher.matches(""),
1410 "pattern {} should match empty",
1411 pattern
1412 );
1413 }
1414 }
1415
1416 #[test]
1419 fn test_pattern_round_trips_through_yaml() {
1420 let yaml = "tool: '*-prod'";
1421 #[derive(Deserialize, Serialize)]
1422 struct Wrap {
1423 tool: StringOrList,
1424 }
1425 let parsed: Wrap = serde_yaml::from_str(yaml).unwrap();
1426 assert!(parsed.tool.matches("foo-prod"));
1427 assert!(!parsed.tool.matches("foo-staging"));
1428 let back = serde_yaml::to_string(&parsed).unwrap();
1429 assert!(
1430 back.contains("*-prod"),
1431 "serialized YAML should preserve pattern: {}",
1432 back
1433 );
1434 }
1435
1436 #[test]
1437 fn test_list_matches_any_member() {
1438 let matcher = StringOrList::List(vec![
1439 "get_compensation".to_string(),
1440 "get_benefits".to_string(),
1441 ]);
1442 assert!(matcher.matches("get_compensation"));
1443 assert!(matcher.matches("get_benefits"));
1444 assert!(!matcher.matches("send_email"));
1445 }
1446
1447 #[test]
1448 fn test_validation_skipped_when_routing_disabled() {
1449 let yaml = r#"
1450plugins:
1451 - name: test
1452 kind: builtin
1453 hooks: [tool_pre_invoke]
1454routes:
1455 - meta:
1456 tags: [pii]
1457"#;
1458 let config = parse_config(yaml);
1459 assert!(config.is_ok());
1460 }
1461
1462 #[test]
1465 fn test_scope_match_selects_scoped_route() {
1466 let yaml = r#"
1467plugin_settings:
1468 routing_enabled: true
1469plugins:
1470 - name: scoped_plugin
1471 kind: builtin
1472 hooks: [tool_pre_invoke]
1473 - name: global_plugin
1474 kind: builtin
1475 hooks: [tool_pre_invoke]
1476routes:
1477 - tool: get_compensation
1478 meta:
1479 scope: hr-services
1480 plugins:
1481 - scoped_plugin
1482 - tool: get_compensation
1483 plugins:
1484 - global_plugin
1485"#;
1486 let config = parse_config(yaml).unwrap();
1487
1488 let resolved = resolve_plugins_for_entity(
1490 &config,
1491 "tool",
1492 "get_compensation",
1493 Some("hr-services"),
1494 &no_tags(),
1495 );
1496 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1497 assert!(names.contains(&"scoped_plugin"));
1498 assert!(!names.contains(&"global_plugin"));
1499
1500 let resolved =
1502 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1503 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1504 assert!(names.contains(&"global_plugin"));
1505 assert!(!names.contains(&"scoped_plugin"));
1506
1507 let resolved = resolve_plugins_for_entity(
1509 &config,
1510 "tool",
1511 "get_compensation",
1512 Some("billing"),
1513 &no_tags(),
1514 );
1515 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1516 assert!(names.contains(&"global_plugin"));
1517 assert!(!names.contains(&"scoped_plugin"));
1518 }
1519
1520 #[test]
1523 fn test_host_tags_merged_with_route_tags() {
1524 let yaml = r#"
1525plugin_settings:
1526 routing_enabled: true
1527global:
1528 policies:
1529 pii:
1530 plugins: [pii_plugin]
1531 runtime_tag:
1532 plugins: [runtime_plugin]
1533plugins:
1534 - name: pii_plugin
1535 kind: builtin
1536 hooks: [tool_pre_invoke]
1537 - name: runtime_plugin
1538 kind: builtin
1539 hooks: [tool_pre_invoke]
1540routes:
1541 - tool: get_compensation
1542 meta:
1543 tags: [pii]
1544"#;
1545 let config = parse_config(yaml).unwrap();
1546
1547 let mut host_tags = HashSet::new();
1549 host_tags.insert("runtime_tag".to_string());
1550
1551 let resolved =
1552 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags);
1553 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1554
1555 assert!(names.contains(&"pii_plugin"));
1557 assert!(names.contains(&"runtime_plugin"));
1558 }
1559
1560 #[test]
1563 fn test_when_clause_carried_on_resolved_plugins() {
1564 let yaml = r#"
1565plugin_settings:
1566 routing_enabled: true
1567plugins:
1568 - name: conditional_plugin
1569 kind: builtin
1570 hooks: [tool_pre_invoke]
1571routes:
1572 - tool: get_compensation
1573 when: "args.include_ssn == true"
1574 plugins:
1575 - conditional_plugin
1576"#;
1577 let config = parse_config(yaml).unwrap();
1578 let resolved =
1579 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1580 assert_eq!(resolved[0].name, "conditional_plugin");
1581 assert_eq!(
1582 resolved[0].when.as_deref(),
1583 Some("args.include_ssn == true")
1584 );
1585 }
1586
1587 #[test]
1588 fn test_when_clause_not_on_policy_group_plugins() {
1589 let yaml = r#"
1590plugin_settings:
1591 routing_enabled: true
1592global:
1593 policies:
1594 all:
1595 plugins: [global_plugin]
1596plugins:
1597 - name: global_plugin
1598 kind: builtin
1599 hooks: [tool_pre_invoke]
1600 - name: route_plugin
1601 kind: builtin
1602 hooks: [tool_pre_invoke]
1603routes:
1604 - tool: get_compensation
1605 when: "args.sensitive == true"
1606 plugins:
1607 - route_plugin
1608"#;
1609 let config = parse_config(yaml).unwrap();
1610 let resolved =
1611 resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1612
1613 let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap();
1615 assert!(global.when.is_none());
1616
1617 let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap();
1619 assert_eq!(route.when.as_deref(), Some("args.sensitive == true"));
1620 }
1621
1622 #[test]
1625 fn parse_route_identity_list_form() {
1626 let yaml = r#"
1627plugins:
1628 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1629 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1630routes:
1631 - tool: get_weather
1632 identity:
1633 - corp-jwt
1634 - spiffe-attestor
1635"#;
1636 let cfg = parse_config(yaml).unwrap();
1637 let route = &cfg.routes[0];
1638 let id = route.identity.as_ref().expect("identity present");
1639 assert!(!id.replace_inherited);
1640 assert_eq!(id.steps.len(), 2);
1641 assert_eq!(id.steps[0].name, "corp-jwt");
1642 assert!(id.steps[0].config_override.is_none());
1643 assert!(id.steps[0].on_error.is_none());
1644 assert_eq!(id.steps[1].name, "spiffe-attestor");
1645 }
1646
1647 #[test]
1648 fn parse_route_identity_object_form_carries_replace_inherited() {
1649 let yaml = r#"
1650plugins:
1651 - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
1652routes:
1653 - tool: legacy
1654 identity:
1655 replace_inherited: true
1656 steps:
1657 - legacy-basic-auth
1658"#;
1659 let cfg = parse_config(yaml).unwrap();
1660 let id = cfg.routes[0].identity.as_ref().unwrap();
1661 assert!(id.replace_inherited);
1662 assert_eq!(id.steps.len(), 1);
1663 assert_eq!(id.steps[0].name, "legacy-basic-auth");
1664 }
1665
1666 #[test]
1667 fn parse_route_identity_map_step_with_on_error_and_config() {
1668 let yaml = r#"
1669plugins:
1670 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1671routes:
1672 - tool: get_weather
1673 identity:
1674 - name: corp-jwt
1675 on_error: deny
1676 config:
1677 audience: my-tool
1678"#;
1679 let cfg = parse_config(yaml).unwrap();
1680 let id = cfg.routes[0].identity.as_ref().unwrap();
1681 let s0 = &id.steps[0];
1682 assert_eq!(s0.name, "corp-jwt");
1683 assert_eq!(s0.on_error.as_deref(), Some("deny"));
1684 let cfg_override = s0.config_override.as_ref().expect("config_override set");
1685 assert_eq!(
1686 cfg_override.get("audience").and_then(|v| v.as_str()),
1687 Some("my-tool"),
1688 );
1689 }
1690
1691 #[test]
1692 fn parse_route_identity_mixed_bare_and_map_steps() {
1693 let yaml = r#"
1694plugins:
1695 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1696 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1697routes:
1698 - tool: get_weather
1699 identity:
1700 - name: corp-jwt
1701 on_error: deny
1702 - spiffe-attestor
1703"#;
1704 let cfg = parse_config(yaml).unwrap();
1705 let steps = &cfg.routes[0].identity.as_ref().unwrap().steps;
1706 assert_eq!(steps.len(), 2);
1707 assert_eq!(steps[0].on_error.as_deref(), Some("deny"));
1708 assert!(steps[1].on_error.is_none());
1709 }
1710
1711 #[test]
1712 fn parse_route_identity_object_form_without_steps_errors() {
1713 let yaml = r#"
1714routes:
1715 - tool: bad
1716 identity:
1717 replace_inherited: true
1718"#;
1719 let err = parse_config(yaml).expect_err("object form requires steps");
1720 let msg = format!("{err}");
1721 assert!(msg.contains("requires `steps:`"), "got: {msg}");
1722 }
1723
1724 #[test]
1725 fn parse_route_identity_replace_inherited_must_be_boolean() {
1726 let yaml = r#"
1727routes:
1728 - tool: bad
1729 identity:
1730 replace_inherited: "yes"
1731 steps:
1732 - corp-jwt
1733"#;
1734 let err = parse_config(yaml).expect_err("replace_inherited must be bool");
1735 let msg = format!("{err}");
1736 assert!(msg.contains("boolean"), "got: {msg}");
1737 }
1738
1739 #[test]
1740 fn parse_route_identity_empty_step_name_errors() {
1741 let yaml = r#"
1742routes:
1743 - tool: bad
1744 identity:
1745 - ""
1746"#;
1747 let err = parse_config(yaml).expect_err("empty step name should fail");
1748 let msg = format!("{err}");
1749 assert!(msg.contains("empty"), "got: {msg}");
1750 }
1751
1752 #[test]
1753 fn parse_route_identity_scalar_shape_errors() {
1754 let yaml = r#"
1755routes:
1756 - tool: bad
1757 identity: 42
1758"#;
1759 let err = parse_config(yaml).expect_err("scalar identity should fail");
1760 let msg = format!("{err}");
1761 assert!(msg.contains("list of steps"), "got: {msg}");
1762 }
1763
1764 #[test]
1767 fn resolve_identity_returns_empty_when_no_route_matches() {
1768 let yaml = r#"
1769plugins:
1770 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1771routes:
1772 - tool: get_weather
1773 identity:
1774 - corp-jwt
1775"#;
1776 let cfg = parse_config(yaml).unwrap();
1777 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None);
1778 assert!(resolved.is_empty());
1779 }
1780
1781 #[test]
1782 fn resolve_identity_returns_empty_when_route_has_no_identity_block() {
1783 let yaml = r#"
1784plugins:
1785 - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] }
1786routes:
1787 - tool: get_weather
1788 plugins:
1789 - rate_limiter
1790"#;
1791 let cfg = parse_config(yaml).unwrap();
1792 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1793 assert!(resolved.is_empty());
1794 }
1795
1796 #[test]
1797 fn resolve_identity_preserves_declared_order() {
1798 let yaml = r#"
1799plugins:
1800 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1801 - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1802 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1803routes:
1804 - tool: get_weather
1805 identity:
1806 - spiffe-attestor
1807 - corp-jwt
1808 - agent-context
1809"#;
1810 let cfg = parse_config(yaml).unwrap();
1811 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1812 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1813 assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]);
1814 }
1815
1816 #[test]
1817 fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() {
1818 let yaml = r#"
1823plugins:
1824 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1825routes:
1826 - tool: get_weather
1827 identity:
1828 - name: corp-jwt
1829 config:
1830 audience: my-tool
1831"#;
1832 let cfg = parse_config(yaml).unwrap();
1833 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1834 assert_eq!(resolved.len(), 1);
1835 let overrides = resolved[0]
1836 .config_overrides
1837 .as_ref()
1838 .expect("overrides wrapped");
1839 let config = overrides.get("config").expect("config key present");
1840 assert_eq!(
1841 config.get("audience").and_then(|v| v.as_str()),
1842 Some("my-tool")
1843 );
1844 }
1845
1846 #[test]
1849 fn resolve_identity_includes_global_layer_when_route_has_no_block() {
1850 let yaml = r#"
1853plugin_settings:
1854 routing_enabled: true
1855plugins:
1856 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1857global:
1858 identity:
1859 - corp-jwt
1860routes:
1861 - tool: get_weather
1862"#;
1863 let cfg = parse_config(yaml).unwrap();
1864 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1865 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1866 assert_eq!(names, vec!["corp-jwt"]);
1867 }
1868
1869 #[test]
1870 fn resolve_identity_appends_route_steps_after_global_by_default() {
1871 let yaml = r#"
1875plugin_settings:
1876 routing_enabled: true
1877plugins:
1878 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1879 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1880global:
1881 identity:
1882 - corp-jwt
1883routes:
1884 - tool: get_weather
1885 identity:
1886 - agent-context
1887"#;
1888 let cfg = parse_config(yaml).unwrap();
1889 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1890 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1891 assert_eq!(names, vec!["corp-jwt", "agent-context"]);
1892 }
1893
1894 #[test]
1895 fn resolve_identity_stacks_global_then_tag_bundle_then_route() {
1896 let yaml = r#"
1900plugin_settings:
1901 routing_enabled: true
1902plugins:
1903 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1904 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1905 - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1906global:
1907 identity:
1908 - corp-jwt
1909 policies:
1910 finance:
1911 identity:
1912 - workday-saml
1913routes:
1914 - tool: get_compensation
1915 meta:
1916 tags: [finance]
1917 identity:
1918 - agent-context
1919"#;
1920 let cfg = parse_config(yaml).unwrap();
1921 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None);
1922 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1923 assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]);
1924 }
1925
1926 #[test]
1927 fn resolve_identity_replace_inherited_drops_global_and_tag_layers() {
1928 let yaml = r#"
1931plugin_settings:
1932 routing_enabled: true
1933plugins:
1934 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1935 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1936 - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
1937global:
1938 identity:
1939 - corp-jwt
1940 policies:
1941 finance:
1942 identity:
1943 - workday-saml
1944routes:
1945 - tool: legacy_endpoint
1946 meta:
1947 tags: [finance]
1948 identity:
1949 replace_inherited: true
1950 steps:
1951 - legacy-basic-auth
1952"#;
1953 let cfg = parse_config(yaml).unwrap();
1954 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None);
1955 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1956 assert_eq!(names, vec!["legacy-basic-auth"]);
1957 }
1958
1959 #[test]
1960 fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() {
1961 let yaml = r#"
1965plugin_settings:
1966 routing_enabled: true
1967plugins:
1968 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1969global:
1970 identity:
1971 - corp-jwt
1972routes:
1973 - tool: anonymous_endpoint
1974 identity:
1975 replace_inherited: true
1976 steps: []
1977"#;
1978 let cfg = parse_config(yaml).unwrap();
1979 let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None);
1980 assert!(resolved.is_empty());
1981 }
1982
1983 #[test]
1984 fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() {
1985 let yaml = r#"
1988plugin_settings:
1989 routing_enabled: true
1990plugins:
1991 - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1992global:
1993 policies:
1994 finance:
1995 identity:
1996 - workday-saml
1997routes:
1998 - tool: with_tag
1999 meta:
2000 tags: [finance]
2001 - tool: without_tag
2002"#;
2003 let cfg = parse_config(yaml).unwrap();
2004
2005 let tagged = resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None);
2006 assert_eq!(
2007 tagged.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2008 vec!["workday-saml"],
2009 );
2010
2011 let untagged = resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None);
2012 assert!(
2013 untagged.is_empty(),
2014 "tag bundle should NOT apply to untagged routes"
2015 );
2016 }
2017
2018 #[test]
2019 fn resolve_identity_scope_filtering_matches_other_route_resolution() {
2020 let yaml = r#"
2025plugins:
2026 - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
2027routes:
2028 - tool: get_weather
2029 meta:
2030 scope: tenant-a
2031 identity:
2032 - corp-jwt
2033"#;
2034 let cfg = parse_config(yaml).unwrap();
2035 let matching =
2036 resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-a"));
2037 assert_eq!(matching.len(), 1);
2038
2039 let non_matching =
2040 resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b"));
2041 assert!(non_matching.is_empty());
2042 }
2043
2044 fn deserialize_cfg(yaml: &str) -> Result<CpexConfig, String> {
2059 serde_yaml::from_str(yaml).map_err(|e| e.to_string())
2060 }
2061
2062 #[test]
2063 fn route_plugins_list_parses_as_activation_list() {
2064 let cfg = deserialize_cfg(
2065 r#"
2066routes:
2067 - tool: get_weather
2068 plugins:
2069 - rate_limiter
2070 - pii_scanner:
2071 config:
2072 sensitivity: high
2073"#,
2074 )
2075 .unwrap();
2076 let plugins = &cfg.routes[0].plugins;
2077 assert_eq!(plugins.len(), 2);
2078 assert_eq!(plugins[0].name(), "rate_limiter");
2079 assert_eq!(plugins[1].name(), "pii_scanner");
2080 }
2081
2082 #[test]
2083 fn route_plugins_map_loads_as_empty_structural_list() {
2084 let cfg = deserialize_cfg(
2085 r#"
2086routes:
2087 - tool: get_weather
2088 plugins:
2089 audit:
2090 on_error: ignore
2091"#,
2092 )
2093 .expect("flat plugins map must deserialize");
2094 assert!(
2095 cfg.routes[0].plugins.is_empty(),
2096 "a plugins map is APL-override data, not a structural activation list",
2097 );
2098 }
2099
2100 #[test]
2101 fn defaults_and_policies_plugins_map_loads() {
2102 let cfg = deserialize_cfg(
2103 r#"
2104global:
2105 defaults:
2106 tool:
2107 plugins:
2108 audit:
2109 on_error: ignore
2110 policies:
2111 sensitive:
2112 plugins:
2113 pii_scanner:
2114 config:
2115 sensitivity: high
2116"#,
2117 )
2118 .expect("defaults/policies plugins map must deserialize");
2119 assert!(cfg.global.defaults["tool"].plugins.is_empty());
2120 assert!(cfg.global.policies["sensitive"].plugins.is_empty());
2121 }
2122
2123 #[test]
2124 fn scalar_plugins_value_is_rejected_with_clear_error() {
2125 let err = deserialize_cfg(
2126 r#"
2127routes:
2128 - tool: get_weather
2129 plugins: nonsense
2130"#,
2131 )
2132 .expect_err("scalar plugins must error");
2133 assert!(
2134 err.contains("sequence") && err.contains("mapping"),
2135 "expected a shape-aware error, got: {err}",
2136 );
2137 }
2138}