use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::PluginError;
use crate::plugin::PluginConfig;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CpexConfig {
#[serde(default)]
pub global: GlobalConfig,
#[serde(default)]
pub plugin_dirs: Vec<String>,
#[serde(default)]
pub plugins: Vec<PluginConfig>,
#[serde(default)]
pub routes: Vec<RouteEntry>,
#[serde(default)]
pub plugin_settings: PluginSettings,
}
impl CpexConfig {
pub fn routing_enabled(&self) -> bool {
self.plugin_settings.routing_enabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSettings {
#[serde(default)]
pub routing_enabled: bool,
#[serde(default = "default_timeout")]
pub plugin_timeout: u64,
#[serde(default = "default_true")]
pub short_circuit_on_deny: bool,
#[serde(default)]
pub parallel_execution_within_band: bool,
#[serde(default)]
pub fail_on_plugin_error: bool,
#[serde(default = "default_route_cache_max_entries")]
pub route_cache_max_entries: usize,
}
impl Default for PluginSettings {
fn default() -> Self {
Self {
routing_enabled: false,
plugin_timeout: 30,
short_circuit_on_deny: true,
parallel_execution_within_band: false,
fail_on_plugin_error: false,
route_cache_max_entries: default_route_cache_max_entries(),
}
}
}
fn default_route_cache_max_entries() -> usize {
10_000
}
fn default_timeout() -> u64 {
30
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default)]
pub policies: HashMap<String, PolicyGroup>,
#[serde(default)]
pub defaults: HashMap<String, PolicyGroup>,
#[serde(
default,
rename = "authentication",
deserialize_with = "deserialize_route_identity"
)]
pub identity: Option<crate::identity::RouteIdentityConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyGroup {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(default, deserialize_with = "deserialize_plugin_refs")]
pub plugins: Vec<PluginRouteRef>,
#[serde(
default,
rename = "authentication",
deserialize_with = "deserialize_route_identity"
)]
pub identity: Option<crate::identity::RouteIdentityConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginRouteRef {
Name(String),
WithOverrides(HashMap<String, serde_json::Value>),
}
impl PluginRouteRef {
pub fn name(&self) -> &str {
match self {
Self::Name(name) => name,
Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""),
}
}
pub fn overrides(&self) -> Option<&serde_json::Value> {
match self {
Self::Name(_) => None,
Self::WithOverrides(map) => map.values().next(),
}
}
}
fn deserialize_plugin_refs<'de, D>(deserializer: D) -> Result<Vec<PluginRouteRef>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
match serde_yaml::Value::deserialize(deserializer)? {
serde_yaml::Value::Sequence(items) => items
.into_iter()
.map(|item| serde_yaml::from_value(item).map_err(D::Error::custom))
.collect(),
serde_yaml::Value::Mapping(_) => Ok(Vec::new()),
serde_yaml::Value::Null => Ok(Vec::new()),
other => Err(D::Error::custom(format!(
"`plugins:` must be a sequence (activation list) or a mapping \
(APL per-plugin overrides), got {:?}",
other
))),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RouteEntry {
#[serde(default)]
pub tool: Option<StringOrList>,
#[serde(default)]
pub resource: Option<StringOrList>,
#[serde(default)]
pub prompt: Option<StringOrList>,
#[serde(default)]
pub llm: Option<StringOrList>,
#[serde(default)]
pub meta: Option<RouteMeta>,
#[serde(default)]
pub when: Option<String>,
#[serde(default, deserialize_with = "deserialize_plugin_refs")]
pub plugins: Vec<PluginRouteRef>,
#[serde(
default,
rename = "authentication",
deserialize_with = "deserialize_route_identity"
)]
pub identity: Option<crate::identity::RouteIdentityConfig>,
}
fn deserialize_route_identity<'de, D>(
deserializer: D,
) -> Result<Option<crate::identity::RouteIdentityConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
use crate::identity::RouteIdentityConfig;
use serde::de::Error;
let raw = match Option::<serde_yaml::Value>::deserialize(deserializer)? {
None => return Ok(None),
Some(serde_yaml::Value::Null) => return Ok(None),
Some(v) => v,
};
let (replace_inherited, raw_steps): (bool, Vec<serde_yaml::Value>) = match raw {
serde_yaml::Value::Sequence(items) => (false, items),
serde_yaml::Value::Mapping(map) => {
let replace_inherited =
match map.get(serde_yaml::Value::String("replace_inherited".to_string())) {
Some(v) => v.as_bool().ok_or_else(|| {
D::Error::custom("`identity.replace_inherited` must be a boolean")
})?,
None => false,
};
let steps_val = map
.get(serde_yaml::Value::String("steps".to_string()))
.ok_or_else(|| {
D::Error::custom(
"`authentication:` object form requires `steps:` (a list of \
authentication steps); did you mean to write the list form?",
)
})?;
let items = steps_val
.as_sequence()
.ok_or_else(|| D::Error::custom("`authentication.steps` must be a list"))?
.clone();
(replace_inherited, items)
},
_ => {
return Err(D::Error::custom(
"`authentication:` must be a list of steps or an object with \
`steps:` (and optional `replace_inherited:`)",
));
},
};
let mut steps = Vec::with_capacity(raw_steps.len());
for (i, raw) in raw_steps.into_iter().enumerate() {
steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?);
}
Ok(Some(RouteIdentityConfig {
steps,
replace_inherited,
}))
}
fn parse_identity_step(
raw: serde_yaml::Value,
index: usize,
) -> Result<crate::identity::RouteIdentityStep, String> {
use crate::identity::RouteIdentityStep;
match raw {
serde_yaml::Value::String(name) => {
if name.is_empty() {
return Err(format!(
"identity step [{index}] plugin name cannot be empty"
));
}
Ok(RouteIdentityStep {
name,
..Default::default()
})
},
serde_yaml::Value::Mapping(_) => {
#[derive(serde::Deserialize)]
struct StepYaml {
name: String,
#[serde(default)]
on_error: Option<String>,
#[serde(default)]
config: Option<serde_json::Value>,
#[serde(default, flatten)]
extra: std::collections::HashMap<String, serde_json::Value>,
}
let parsed: StepYaml =
serde_yaml::from_value(raw).map_err(|e| format!("identity step [{index}]: {e}"))?;
if parsed.name.is_empty() {
return Err(format!("identity step [{index}] `name:` cannot be empty"));
}
Ok(RouteIdentityStep {
name: parsed.name,
config_override: parsed.config,
on_error: parsed.on_error,
extra: parsed.extra,
})
},
_ => Err(format!(
"identity step [{index}] must be a plugin name (string) or a map \
with `name:` (and optional `on_error:` / `config:`)"
)),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RouteMeta {
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub properties: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct Pattern {
pattern: String,
matcher: wildmatch::WildMatch,
}
impl Pattern {
pub fn new(pattern: impl Into<String>) -> Self {
let pattern = pattern.into();
let matcher = wildmatch::WildMatch::new(&pattern);
Self { pattern, matcher }
}
pub fn matches(&self, name: &str) -> bool {
self.matcher.matches(name)
}
pub fn as_str(&self) -> &str {
&self.pattern
}
}
impl Default for Pattern {
fn default() -> Self {
Self::new("")
}
}
impl Serialize for Pattern {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.pattern)
}
}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Pattern::new(s))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrList {
Single(Pattern),
List(Vec<String>),
}
impl Default for StringOrList {
fn default() -> Self {
Self::Single(Pattern::default())
}
}
impl StringOrList {
pub fn matches(&self, name: &str) -> bool {
match self {
Self::Single(pattern) => pattern.matches(name),
Self::List(names) => names.iter().any(|n| n == name),
}
}
}
pub fn load_config(path: &Path) -> Result<CpexConfig, Box<PluginError>> {
let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config {
message: format!("failed to read config file '{}': {}", path.display(), e),
})?;
parse_config(&content)
}
pub fn parse_config(yaml: &str) -> Result<CpexConfig, Box<PluginError>> {
let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config {
message: format!("failed to parse config YAML: {}", e),
})?;
reject_renamed_identity_key(&raw)?;
let config: CpexConfig = serde_yaml::from_value(raw).map_err(|e| PluginError::Config {
message: format!("failed to parse config YAML: {}", e),
})?;
validate_config(&config)?;
Ok(config)
}
fn reject_renamed_identity_key(raw: &serde_yaml::Value) -> Result<(), Box<PluginError>> {
fn renamed(scope: &str) -> Box<PluginError> {
Box::new(PluginError::Config {
message: format!(
"in `{scope}`: config field `identity` was renamed to `authentication` — update your config"
),
})
}
if let Some(global) = raw.get("global") {
if global.get("identity").is_some() {
return Err(renamed("global"));
}
for section in ["policies", "defaults"] {
if let Some(map) = global.get(section).and_then(|m| m.as_mapping()) {
for (name, group) in map {
if group.get("identity").is_some() {
let n = name.as_str().unwrap_or("?");
return Err(renamed(&format!("global.{section}.{n}")));
}
}
}
}
}
if let Some(routes) = raw.get("routes").and_then(|r| r.as_sequence()) {
for (i, route) in routes.iter().enumerate() {
if route.get("identity").is_some() {
return Err(renamed(&format!("routes[{i}]")));
}
}
}
Ok(())
}
fn validate_config(config: &CpexConfig) -> Result<(), Box<PluginError>> {
let mut seen_names = HashSet::new();
for plugin in &config.plugins {
if !seen_names.insert(&plugin.name) {
return Err(Box::new(PluginError::Config {
message: format!("duplicate plugin name: '{}'", plugin.name),
}));
}
}
if config.routing_enabled() {
let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect();
for (i, route) in config.routes.iter().enumerate() {
let count = [
route.tool.is_some(),
route.resource.is_some(),
route.prompt.is_some(),
route.llm.is_some(),
]
.iter()
.filter(|&&m| m)
.count();
if count == 0 {
return Err(Box::new(PluginError::Config {
message: format!(
"route {} has no entity matcher (need tool, resource, prompt, or llm)",
i
),
}));
}
if count > 1 {
return Err(Box::new(PluginError::Config {
message: format!(
"route {} has multiple entity matchers (need exactly one)",
i
),
}));
}
for plugin_ref in &route.plugins {
if !plugin_names.contains(plugin_ref.name()) {
return Err(Box::new(PluginError::Config {
message: format!(
"route {} references unknown plugin '{}'",
i,
plugin_ref.name()
),
}));
}
}
}
for (group_name, group) in &config.global.policies {
for plugin_ref in &group.plugins {
if !plugin_names.contains(plugin_ref.name()) {
return Err(Box::new(PluginError::Config {
message: format!(
"policy group '{}' references unknown plugin '{}'",
group_name,
plugin_ref.name()
),
}));
}
}
}
}
Ok(())
}
const SPECIFICITY_EXACT_NAME: usize = 1000;
const SPECIFICITY_NAME_LIST: usize = 500;
const SPECIFICITY_GLOB: usize = 300;
const SPECIFICITY_WHEN_ONLY: usize = 10;
const SPECIFICITY_WILDCARD: usize = 0;
fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option<usize> {
let matcher = matcher?;
if !matcher.matches(entity_name) {
return None;
}
let score = match matcher {
StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD,
StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB,
StringOrList::List(_) => SPECIFICITY_NAME_LIST,
StringOrList::Single(_) => SPECIFICITY_EXACT_NAME,
};
Some(score)
}
pub fn resolve_plugins_for_entity(
config: &CpexConfig,
entity_type: &str,
entity_name: &str,
request_scope: Option<&str>,
request_tags: &HashSet<String>,
) -> Vec<ResolvedPlugin> {
if !config.routing_enabled() {
return config
.plugins
.iter()
.map(|p| ResolvedPlugin {
name: p.name.clone(),
config_overrides: None,
when: None,
})
.collect();
}
let mut resolved = Vec::new();
if let Some(all_group) = config.global.policies.get("all") {
collect_plugin_refs(&all_group.plugins, &mut resolved, None);
}
if let Some(default_group) = config.global.defaults.get(entity_type) {
collect_plugin_refs(&default_group.plugins, &mut resolved, None);
}
if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) {
let mut merged_tags: HashSet<String> = request_tags.clone();
if let Some(meta) = &route.meta {
for tag in &meta.tags {
merged_tags.insert(tag.clone());
}
}
for tag in &merged_tags {
if tag == "all" {
continue; }
if let Some(group) = config.global.policies.get(tag.as_str()) {
collect_plugin_refs(&group.plugins, &mut resolved, None);
}
}
collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref());
}
let mut seen = HashSet::new();
let mut deduped = Vec::new();
for rp in resolved.into_iter().rev() {
if seen.insert(rp.name.clone()) {
deduped.push(rp);
}
}
deduped.reverse();
deduped
}
pub fn resolve_identity_plugins_for_route(
config: &CpexConfig,
entity_type: &str,
entity_name: &str,
request_scope: Option<&str>,
) -> Vec<ResolvedPlugin> {
let route = find_matching_route(config, entity_type, entity_name, request_scope);
let route_identity = route.and_then(|r| r.identity.as_ref());
let replace_inherited = route_identity
.map(|id| id.replace_inherited)
.unwrap_or(false);
let mut steps: Vec<crate::identity::RouteIdentityStep> = Vec::new();
if !replace_inherited {
if let Some(global_identity) = config.global.identity.as_ref() {
steps.extend(global_identity.steps.iter().cloned());
}
if let Some(route) = route {
if let Some(meta) = &route.meta {
for tag in &meta.tags {
if let Some(bundle) = config.global.policies.get(tag) {
if let Some(bundle_identity) = bundle.identity.as_ref() {
steps.extend(bundle_identity.steps.iter().cloned());
}
}
}
}
}
}
if let Some(id) = route_identity {
steps.extend(id.steps.iter().cloned());
}
steps
.into_iter()
.map(|step| ResolvedPlugin {
name: step.name.clone(),
config_overrides: step.config_override.as_ref().map(|cfg| {
let mut wrapper = serde_json::Map::new();
wrapper.insert("config".to_string(), cfg.clone());
serde_json::Value::Object(wrapper)
}),
when: None,
})
.collect()
}
#[derive(Debug, Clone)]
pub struct ResolvedPlugin {
pub name: String,
pub config_overrides: Option<serde_json::Value>,
pub when: Option<String>,
}
fn collect_plugin_refs(
refs: &[PluginRouteRef],
resolved: &mut Vec<ResolvedPlugin>,
route_when: Option<&str>,
) {
for plugin_ref in refs {
resolved.push(ResolvedPlugin {
name: plugin_ref.name().to_string(),
config_overrides: plugin_ref.overrides().cloned(),
when: route_when.map(String::from),
});
}
}
fn find_matching_route<'a>(
config: &'a CpexConfig,
entity_type: &str,
entity_name: &str,
request_scope: Option<&str>,
) -> Option<&'a RouteEntry> {
let mut best: Option<(usize, &RouteEntry)> = None;
for route in &config.routes {
let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref());
let scope_bonus = match (route_scope, request_scope) {
(None, _) => 0, (Some(rs), Some(rq)) if rs == rq => 100, (Some(_), _) => continue, };
let entity_matcher = match entity_type {
"tool" => route.tool.as_ref(),
"resource" => route.resource.as_ref(),
"prompt" => route.prompt.as_ref(),
"llm" => route.llm.as_ref(),
_ => continue,
};
let base_specificity = match score_entity_match(entity_matcher, entity_name) {
Some(score) => score,
None => continue,
};
let when_bonus = if route.when.is_some() {
SPECIFICITY_WHEN_ONLY
} else {
0
};
let total = base_specificity + scope_bonus + when_bonus;
if best.is_none_or(|(s, _)| total > s) {
best = Some((total, route));
}
}
best.map(|(_, route)| route)
}
#[cfg(test)]
mod tests {
use super::*;
fn no_tags() -> HashSet<String> {
HashSet::new()
}
#[test]
fn test_parse_minimal_config() {
let yaml = r#"
plugins:
- name: rate_limiter
kind: builtin
hooks: [tool_pre_invoke]
mode: sequential
priority: 5
config:
max_requests: 100
"#;
let config = parse_config(yaml).unwrap();
assert!(!config.routing_enabled());
assert_eq!(config.plugins.len(), 1);
assert_eq!(config.plugins[0].name, "rate_limiter");
}
#[test]
fn test_no_plugin_settings_defaults_routing_disabled() {
let yaml = r#"
plugins:
- name: test
kind: builtin
hooks: [tool_pre_invoke]
"#;
let config = parse_config(yaml).unwrap();
assert!(!config.routing_enabled());
assert_eq!(config.plugin_settings.plugin_timeout, 30);
}
#[test]
fn test_routing_enabled() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins: [identity]
plugins:
- name: identity
kind: builtin
hooks: [identity_resolve]
routes:
- tool: get_compensation
meta:
tags: [pii]
"#;
let config = parse_config(yaml).unwrap();
assert!(config.routing_enabled());
}
#[test]
fn test_duplicate_plugin_names_rejected() {
let yaml = r#"
plugins:
- name: dup
kind: builtin
hooks: [tool_pre_invoke]
- name: dup
kind: builtin
hooks: [tool_post_invoke]
"#;
assert!(parse_config(yaml)
.unwrap_err()
.to_string()
.contains("duplicate plugin name"));
}
#[test]
fn test_route_requires_one_entity_matcher() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins: []
routes:
- meta:
tags: [pii]
"#;
assert!(parse_config(yaml)
.unwrap_err()
.to_string()
.contains("no entity matcher"));
}
#[test]
fn test_route_rejects_multiple_entity_matchers() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins: []
routes:
- tool: get_compensation
resource: "hr://employees/*"
"#;
assert!(parse_config(yaml)
.unwrap_err()
.to_string()
.contains("multiple entity matchers"));
}
#[test]
fn test_route_unknown_plugin_rejected() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: known
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
plugins:
- unknown
"#;
assert!(parse_config(yaml)
.unwrap_err()
.to_string()
.contains("unknown plugin 'unknown'"));
}
#[test]
fn test_policy_group_unknown_plugin_rejected() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins: [nonexistent]
plugins: []
routes: []
"#;
assert!(parse_config(yaml)
.unwrap_err()
.to_string()
.contains("unknown plugin 'nonexistent'"));
}
#[test]
fn test_resolve_conditions_mode_returns_all() {
let yaml = r#"
plugins:
- name: a
kind: builtin
hooks: [tool_pre_invoke]
- name: b
kind: builtin
hooks: [tool_post_invoke]
"#;
let config = parse_config(yaml).unwrap();
let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn test_resolve_routes_inherits_policy_groups() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins:
- identity
pii:
plugins:
- apl_policy
plugins:
- name: identity
kind: builtin
hooks: [identity_resolve]
- name: apl_policy
kind: builtin
hooks: [cmf.tool_pre_invoke]
routes:
- tool: get_compensation
meta:
tags: [pii]
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"identity"));
assert!(names.contains(&"apl_policy"));
}
#[test]
fn test_resolve_no_matching_route_gets_all_only() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins:
- identity
plugins:
- name: identity
kind: builtin
hooks: [identity_resolve]
routes:
- tool: get_compensation
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["identity"]);
}
#[test]
fn test_exact_match_beats_glob() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: specific
kind: builtin
hooks: [tool_pre_invoke]
- name: general
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: "hr-*"
plugins:
- general
- tool: hr-compensation
plugins:
- specific
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"specific"));
assert!(!names.contains(&"general"));
}
#[test]
fn test_plugin_ref_bare_name() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: rate_limiter
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
plugins:
- rate_limiter
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
assert_eq!(resolved[0].name, "rate_limiter");
assert!(resolved[0].config_overrides.is_none());
}
#[test]
fn test_plugin_ref_with_overrides() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: rate_limiter
kind: builtin
hooks: [tool_pre_invoke]
config:
max_requests: 100
routes:
- tool: get_compensation
plugins:
- rate_limiter:
config:
max_requests: 10
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
assert_eq!(resolved[0].name, "rate_limiter");
assert!(resolved[0].config_overrides.is_some());
let overrides = resolved[0].config_overrides.as_ref().unwrap();
assert_eq!(overrides["config"]["max_requests"], 10);
}
#[test]
fn test_plugin_ref_mixed_bare_and_overrides() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: rate_limiter
kind: builtin
hooks: [tool_pre_invoke]
- name: pii_scanner
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
plugins:
- rate_limiter
- pii_scanner:
config:
sensitivity: high
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "rate_limiter");
assert!(resolved[0].config_overrides.is_none());
assert_eq!(resolved[1].name, "pii_scanner");
assert!(resolved[1].config_overrides.is_some());
}
#[test]
fn test_deduplication_preserves_order() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins: [a, b]
pii:
plugins: [b, c]
plugins:
- name: a
kind: builtin
hooks: [tool_pre_invoke]
- name: b
kind: builtin
hooks: [tool_pre_invoke]
- name: c
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
meta:
tags: [pii]
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c"]);
}
#[test]
fn test_glob_trailing_wildcard() {
let matcher = StringOrList::Single(Pattern::new("hr-*"));
assert!(matcher.matches("hr-compensation"));
assert!(matcher.matches("hr-benefits"));
assert!(matcher.matches("hr-")); assert!(!matcher.matches("finance-report"));
assert!(!matcher.matches("hr"));
}
#[test]
fn test_wildcard_matches_everything() {
let matcher = StringOrList::Single(Pattern::new("*"));
assert!(matcher.matches("anything"));
assert!(matcher.matches(""));
}
#[test]
fn test_glob_leading_wildcard() {
let matcher = StringOrList::Single(Pattern::new("*-prod"));
assert!(matcher.matches("foo-prod"));
assert!(matcher.matches("-prod")); assert!(!matcher.matches("foo-staging"));
assert!(!matcher.matches("prod"));
}
#[test]
fn test_glob_mid_wildcard() {
let matcher = StringOrList::Single(Pattern::new("hr-*-v1"));
assert!(matcher.matches("hr-comp-v1"));
assert!(matcher.matches("hr--v1")); assert!(!matcher.matches("hr-comp-v2"));
assert!(!matcher.matches("finance-comp-v1"));
}
#[test]
fn test_glob_multiple_wildcards() {
let matcher = StringOrList::Single(Pattern::new("*hr*comp*"));
assert!(matcher.matches("hr-comp"));
assert!(matcher.matches("xyz-hr-comp-foo"));
assert!(!matcher.matches("hr-only"));
assert!(!matcher.matches("comp-only"));
}
#[test]
fn test_glob_multi_star_is_equivalent_to_single_star() {
for pattern in &["**", "***", "*****"] {
let matcher = StringOrList::Single(Pattern::new(*pattern));
assert!(
matcher.matches("anything"),
"pattern {} should match",
pattern
);
assert!(
matcher.matches(""),
"pattern {} should match empty",
pattern
);
}
}
#[test]
fn test_pattern_round_trips_through_yaml() {
let yaml = "tool: '*-prod'";
#[derive(Deserialize, Serialize)]
struct Wrap {
tool: StringOrList,
}
let parsed: Wrap = serde_yaml::from_str(yaml).unwrap();
assert!(parsed.tool.matches("foo-prod"));
assert!(!parsed.tool.matches("foo-staging"));
let back = serde_yaml::to_string(&parsed).unwrap();
assert!(
back.contains("*-prod"),
"serialized YAML should preserve pattern: {}",
back
);
}
#[test]
fn test_list_matches_any_member() {
let matcher = StringOrList::List(vec![
"get_compensation".to_string(),
"get_benefits".to_string(),
]);
assert!(matcher.matches("get_compensation"));
assert!(matcher.matches("get_benefits"));
assert!(!matcher.matches("send_email"));
}
#[test]
fn test_validation_skipped_when_routing_disabled() {
let yaml = r#"
plugins:
- name: test
kind: builtin
hooks: [tool_pre_invoke]
routes:
- meta:
tags: [pii]
"#;
let config = parse_config(yaml);
assert!(config.is_ok());
}
#[test]
fn test_scope_match_selects_scoped_route() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: scoped_plugin
kind: builtin
hooks: [tool_pre_invoke]
- name: global_plugin
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
meta:
scope: hr-services
plugins:
- scoped_plugin
- tool: get_compensation
plugins:
- global_plugin
"#;
let config = parse_config(yaml).unwrap();
let resolved = resolve_plugins_for_entity(
&config,
"tool",
"get_compensation",
Some("hr-services"),
&no_tags(),
);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"scoped_plugin"));
assert!(!names.contains(&"global_plugin"));
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"global_plugin"));
assert!(!names.contains(&"scoped_plugin"));
let resolved = resolve_plugins_for_entity(
&config,
"tool",
"get_compensation",
Some("billing"),
&no_tags(),
);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"global_plugin"));
assert!(!names.contains(&"scoped_plugin"));
}
#[test]
fn test_host_tags_merged_with_route_tags() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
pii:
plugins: [pii_plugin]
runtime_tag:
plugins: [runtime_plugin]
plugins:
- name: pii_plugin
kind: builtin
hooks: [tool_pre_invoke]
- name: runtime_plugin
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
meta:
tags: [pii]
"#;
let config = parse_config(yaml).unwrap();
let mut host_tags = HashSet::new();
host_tags.insert("runtime_tag".to_string());
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"pii_plugin"));
assert!(names.contains(&"runtime_plugin"));
}
#[test]
fn test_when_clause_carried_on_resolved_plugins() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- name: conditional_plugin
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
when: "args.include_ssn == true"
plugins:
- conditional_plugin
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
assert_eq!(resolved[0].name, "conditional_plugin");
assert_eq!(
resolved[0].when.as_deref(),
Some("args.include_ssn == true")
);
}
#[test]
fn test_when_clause_not_on_policy_group_plugins() {
let yaml = r#"
plugin_settings:
routing_enabled: true
global:
policies:
all:
plugins: [global_plugin]
plugins:
- name: global_plugin
kind: builtin
hooks: [tool_pre_invoke]
- name: route_plugin
kind: builtin
hooks: [tool_pre_invoke]
routes:
- tool: get_compensation
when: "args.sensitive == true"
plugins:
- route_plugin
"#;
let config = parse_config(yaml).unwrap();
let resolved =
resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap();
assert!(global.when.is_none());
let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap();
assert_eq!(route.when.as_deref(), Some("args.sensitive == true"));
}
#[test]
fn parse_route_identity_list_form() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- corp-jwt
- spiffe-attestor
"#;
let cfg = parse_config(yaml).unwrap();
let route = &cfg.routes[0];
let id = route.identity.as_ref().expect("identity present");
assert!(!id.replace_inherited);
assert_eq!(id.steps.len(), 2);
assert_eq!(id.steps[0].name, "corp-jwt");
assert!(id.steps[0].config_override.is_none());
assert!(id.steps[0].on_error.is_none());
assert_eq!(id.steps[1].name, "spiffe-attestor");
}
#[test]
fn parse_route_identity_object_form_carries_replace_inherited() {
let yaml = r#"
plugins:
- { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: legacy
authentication:
replace_inherited: true
steps:
- legacy-basic-auth
"#;
let cfg = parse_config(yaml).unwrap();
let id = cfg.routes[0].identity.as_ref().unwrap();
assert!(id.replace_inherited);
assert_eq!(id.steps.len(), 1);
assert_eq!(id.steps[0].name, "legacy-basic-auth");
}
#[test]
fn parse_route_identity_map_step_with_on_error_and_config() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- name: corp-jwt
on_error: deny
config:
audience: my-tool
"#;
let cfg = parse_config(yaml).unwrap();
let id = cfg.routes[0].identity.as_ref().unwrap();
let s0 = &id.steps[0];
assert_eq!(s0.name, "corp-jwt");
assert_eq!(s0.on_error.as_deref(), Some("deny"));
let cfg_override = s0.config_override.as_ref().expect("config_override set");
assert_eq!(
cfg_override.get("audience").and_then(|v| v.as_str()),
Some("my-tool"),
);
}
#[test]
fn parse_route_identity_mixed_bare_and_map_steps() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- name: corp-jwt
on_error: deny
- spiffe-attestor
"#;
let cfg = parse_config(yaml).unwrap();
let steps = &cfg.routes[0].identity.as_ref().unwrap().steps;
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].on_error.as_deref(), Some("deny"));
assert!(steps[1].on_error.is_none());
}
#[test]
fn parse_route_identity_object_form_without_steps_errors() {
let yaml = r#"
routes:
- tool: bad
authentication:
replace_inherited: true
"#;
let err = parse_config(yaml).expect_err("object form requires steps");
let msg = format!("{err}");
assert!(msg.contains("requires `steps:`"), "got: {msg}");
}
#[test]
fn parse_route_identity_replace_inherited_must_be_boolean() {
let yaml = r#"
routes:
- tool: bad
authentication:
replace_inherited: "yes"
steps:
- corp-jwt
"#;
let err = parse_config(yaml).expect_err("replace_inherited must be bool");
let msg = format!("{err}");
assert!(msg.contains("boolean"), "got: {msg}");
}
#[test]
fn parse_route_identity_empty_step_name_errors() {
let yaml = r#"
routes:
- tool: bad
authentication:
- ""
"#;
let err = parse_config(yaml).expect_err("empty step name should fail");
let msg = format!("{err}");
assert!(msg.contains("empty"), "got: {msg}");
}
#[test]
fn legacy_identity_key_is_rejected_at_route_and_global() {
for yaml in [
"routes:\n - tool: t\n identity:\n - corp-jwt\n",
"global:\n identity:\n - corp-jwt\n",
"global:\n policies:\n all:\n identity:\n - corp-jwt\n",
"global:\n defaults:\n tool:\n identity:\n - corp-jwt\n",
] {
let err = parse_config(yaml).expect_err("legacy identity: must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("identity") && msg.contains("authentication"),
"rejection should name the rename: {msg}"
);
}
}
#[test]
fn parse_route_identity_scalar_shape_errors() {
let yaml = r#"
routes:
- tool: bad
authentication: 42
"#;
let err = parse_config(yaml).expect_err("scalar identity should fail");
let msg = format!("{err}");
assert!(msg.contains("list of steps"), "got: {msg}");
}
#[test]
fn resolve_identity_returns_empty_when_no_route_matches() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- corp-jwt
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None);
assert!(resolved.is_empty());
}
#[test]
fn resolve_identity_returns_empty_when_route_has_no_identity_block() {
let yaml = r#"
plugins:
- { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] }
routes:
- tool: get_weather
plugins:
- rate_limiter
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
assert!(resolved.is_empty());
}
#[test]
fn resolve_identity_preserves_declared_order() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
- { name: agent-context, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- spiffe-attestor
- corp-jwt
- agent-context
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]);
}
#[test]
fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
authentication:
- name: corp-jwt
config:
audience: my-tool
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
assert_eq!(resolved.len(), 1);
let overrides = resolved[0]
.config_overrides
.as_ref()
.expect("overrides wrapped");
let config = overrides.get("config").expect("config key present");
assert_eq!(
config.get("audience").and_then(|v| v.as_str()),
Some("my-tool")
);
}
#[test]
fn resolve_identity_includes_global_layer_when_route_has_no_block() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
global:
authentication:
- corp-jwt
routes:
- tool: get_weather
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["corp-jwt"]);
}
#[test]
fn resolve_identity_appends_route_steps_after_global_by_default() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: agent-context, kind: builtin, hooks: [identity.resolve] }
global:
authentication:
- corp-jwt
routes:
- tool: get_weather
authentication:
- agent-context
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["corp-jwt", "agent-context"]);
}
#[test]
fn resolve_identity_stacks_global_then_tag_bundle_then_route() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
- { name: agent-context, kind: builtin, hooks: [identity.resolve] }
global:
authentication:
- corp-jwt
policies:
finance:
authentication:
- workday-saml
routes:
- tool: get_compensation
meta:
tags: [finance]
authentication:
- agent-context
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]);
}
#[test]
fn resolve_identity_replace_inherited_drops_global_and_tag_layers() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
- { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
- { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
global:
authentication:
- corp-jwt
policies:
finance:
authentication:
- workday-saml
routes:
- tool: legacy_endpoint
meta:
tags: [finance]
authentication:
replace_inherited: true
steps:
- legacy-basic-auth
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None);
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["legacy-basic-auth"]);
}
#[test]
fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
global:
authentication:
- corp-jwt
routes:
- tool: anonymous_endpoint
authentication:
replace_inherited: true
steps: []
"#;
let cfg = parse_config(yaml).unwrap();
let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None);
assert!(resolved.is_empty());
}
#[test]
fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() {
let yaml = r#"
plugin_settings:
routing_enabled: true
plugins:
- { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
global:
policies:
finance:
authentication:
- workday-saml
routes:
- tool: with_tag
meta:
tags: [finance]
- tool: without_tag
"#;
let cfg = parse_config(yaml).unwrap();
let tagged = resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None);
assert_eq!(
tagged.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["workday-saml"],
);
let untagged = resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None);
assert!(
untagged.is_empty(),
"tag bundle should NOT apply to untagged routes"
);
}
#[test]
fn resolve_identity_scope_filtering_matches_other_route_resolution() {
let yaml = r#"
plugins:
- { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
routes:
- tool: get_weather
meta:
scope: tenant-a
authentication:
- corp-jwt
"#;
let cfg = parse_config(yaml).unwrap();
let matching =
resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-a"));
assert_eq!(matching.len(), 1);
let non_matching =
resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b"));
assert!(non_matching.is_empty());
}
fn deserialize_cfg(yaml: &str) -> Result<CpexConfig, String> {
serde_yaml::from_str(yaml).map_err(|e| e.to_string())
}
#[test]
fn route_plugins_list_parses_as_activation_list() {
let cfg = deserialize_cfg(
r#"
routes:
- tool: get_weather
plugins:
- rate_limiter
- pii_scanner:
config:
sensitivity: high
"#,
)
.unwrap();
let plugins = &cfg.routes[0].plugins;
assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].name(), "rate_limiter");
assert_eq!(plugins[1].name(), "pii_scanner");
}
#[test]
fn route_plugins_map_loads_as_empty_structural_list() {
let cfg = deserialize_cfg(
r#"
routes:
- tool: get_weather
plugins:
audit:
on_error: ignore
"#,
)
.expect("flat plugins map must deserialize");
assert!(
cfg.routes[0].plugins.is_empty(),
"a plugins map is APL-override data, not a structural activation list",
);
}
#[test]
fn defaults_and_policies_plugins_map_loads() {
let cfg = deserialize_cfg(
r#"
global:
defaults:
tool:
plugins:
audit:
on_error: ignore
policies:
sensitive:
plugins:
pii_scanner:
config:
sensitivity: high
"#,
)
.expect("defaults/policies plugins map must deserialize");
assert!(cfg.global.defaults["tool"].plugins.is_empty());
assert!(cfg.global.policies["sensitive"].plugins.is_empty());
}
#[test]
fn scalar_plugins_value_is_rejected_with_clear_error() {
let err = deserialize_cfg(
r#"
routes:
- tool: get_weather
plugins: nonsense
"#,
)
.expect_err("scalar plugins must error");
assert!(
err.contains("sequence") && err.contains("mapping"),
"expected a shape-aware error, got: {err}",
);
}
}