Skip to main content

cpex_core/
config.rs

1// Location: ./crates/cpex-core/src/config.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Unified YAML configuration parsing.
7//
8// Parses the config format that combines global settings, plugin
9// declarations, and per-entity routes into a single YAML document.
10//
11// Supports two modes controlled by `plugin_settings.routing_enabled`:
12//   - false (default, backward compatible): plugins declare their
13//     own conditions for when they fire.
14//   - true: per-entity routing rules determine which plugins fire,
15//     with plugin selection via policy groups and meta.tags.
16//
17// The two modes are mutually exclusive. When routing is disabled,
18// the routes and global sections are ignored. When routing is
19// enabled, conditions on individual plugins are ignored.
20
21use 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// ---------------------------------------------------------------------------
30// Top-Level Config
31// ---------------------------------------------------------------------------
32
33/// Top-level CPEX configuration.
34///
35/// Parsed from a single YAML file. Plugin scoping mode is controlled
36/// by `plugin_settings.routing_enabled` — if absent or false, plugins
37/// use their own `conditions:` field (backward compatible). If true,
38/// the `routes:` and `global:` sections take over.
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct CpexConfig {
41    /// Global configuration — policies, defaults.
42    /// Only used when `plugin_settings.routing_enabled` is true.
43    #[serde(default)]
44    pub global: GlobalConfig,
45
46    /// Directories to scan for plugin modules.
47    #[serde(default)]
48    pub plugin_dirs: Vec<String>,
49
50    /// Plugin declarations.
51    #[serde(default)]
52    pub plugins: Vec<PluginConfig>,
53
54    /// Per-entity routing rules.
55    /// Only used when `plugin_settings.routing_enabled` is true.
56    #[serde(default)]
57    pub routes: Vec<RouteEntry>,
58
59    /// Global plugin settings (timeout, error behavior, routing mode).
60    #[serde(default)]
61    pub plugin_settings: PluginSettings,
62}
63
64impl CpexConfig {
65    /// Whether route-based plugin selection is enabled.
66    pub fn routing_enabled(&self) -> bool {
67        self.plugin_settings.routing_enabled
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Plugin Settings
73// ---------------------------------------------------------------------------
74
75/// Global plugin settings.
76///
77/// Controls executor behavior and routing mode. All fields have
78/// sensible defaults — a missing `plugin_settings:` section is valid.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PluginSettings {
81    /// Enable route-based plugin selection.
82    /// When false (default), plugins use their own `conditions:` field.
83    /// When true, the `routes:` and `global:` sections determine which
84    /// plugins fire per entity.
85    #[serde(default)]
86    pub routing_enabled: bool,
87
88    /// Default timeout per plugin in seconds.
89    #[serde(default = "default_timeout")]
90    pub plugin_timeout: u64,
91
92    /// Whether to halt on first deny in concurrent mode.
93    #[serde(default = "default_true")]
94    pub short_circuit_on_deny: bool,
95
96    /// Whether plugins can execute in parallel within a mode band.
97    #[serde(default)]
98    pub parallel_execution_within_band: bool,
99
100    /// Whether to halt the pipeline on any plugin error.
101    #[serde(default)]
102    pub fail_on_plugin_error: bool,
103
104    /// Maximum number of entries in the routing cache.
105    ///
106    /// When the cache reaches this size, new resolutions are computed
107    /// normally but not memoized — the cache rejects further inserts
108    /// and emits a warning. This bounds memory growth from
109    /// attacker-controlled entity names without the reasoning hazards
110    /// of eviction (silently dropped entries, stale-vs-current
111    /// confusion). Operators see the warning and tune the cap or
112    /// investigate the entity-name growth.
113    #[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// ---------------------------------------------------------------------------
143// Global Config
144// ---------------------------------------------------------------------------
145
146/// Global configuration — applies across all routes.
147///
148/// Only used when routing is enabled. Contains named policy groups
149/// (including the reserved `all` group) and per-entity-type defaults.
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct GlobalConfig {
152    /// Named policy groups. The reserved name `all` is applied to
153    /// every request unconditionally. Other groups are inherited
154    /// by routes via `meta.tags`.
155    #[serde(default)]
156    pub policies: HashMap<String, PolicyGroup>,
157
158    /// Per-entity-type default policy groups.
159    /// Keys are `tool`, `resource`, `prompt`, `llm`.
160    #[serde(default)]
161    pub defaults: HashMap<String, PolicyGroup>,
162
163    /// Global authentication dispatch list (YAML key `authentication:`).
164    /// Inherited by every route as the first layer of identity
165    /// resolution. Routes can append to it (additive, the default) or
166    /// replace it (with `authentication.replace_inherited: true` on the
167    /// route).
168    ///
169    /// Same YAML shape as the route-level `authentication:` block — see
170    /// `RouteEntry.identity` for the accepted forms.
171    #[serde(
172        default,
173        rename = "authentication",
174        deserialize_with = "deserialize_route_identity"
175    )]
176    pub identity: Option<crate::identity::RouteIdentityConfig>,
177}
178
179// ---------------------------------------------------------------------------
180// Policy Group
181// ---------------------------------------------------------------------------
182
183/// A named policy group — plugins to activate and optional metadata.
184///
185/// The `all` group is reserved and always applied.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct PolicyGroup {
188    /// Human-readable description.
189    #[serde(default)]
190    pub description: Option<String>,
191
192    /// Arbitrary metadata for tooling and audit.
193    #[serde(default)]
194    pub metadata: HashMap<String, String>,
195
196    /// Plugin references to activate when this group matches.
197    #[serde(default, deserialize_with = "deserialize_plugin_refs")]
198    pub plugins: Vec<PluginRouteRef>,
199
200    /// Authentication dispatch list contributed by this tag bundle
201    /// (YAML key `authentication:`). Inherited by routes that carry this
202    /// tag in `meta.tags`, stacked between the global authentication
203    /// (first) and the route's own authentication (last). Same YAML shape
204    /// as the route-level `authentication:` block.
205    #[serde(
206        default,
207        rename = "authentication",
208        deserialize_with = "deserialize_route_identity"
209    )]
210    pub identity: Option<crate::identity::RouteIdentityConfig>,
211}
212
213// ---------------------------------------------------------------------------
214// Plugin Ref (route/group plugin reference)
215// ---------------------------------------------------------------------------
216
217/// A reference to a plugin in a route or policy group.
218///
219/// ```yaml
220/// plugins:
221///   - rate_limiter                     # bare name
222///   - pii_scanner:                     # name with config overrides
223///       config:
224///         sensitivity: high
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(untagged)]
228pub enum PluginRouteRef {
229    /// Just the name — activate the plugin with no config overrides.
230    Name(String),
231    /// Name with config overrides — single-key map.
232    WithOverrides(HashMap<String, serde_json::Value>),
233}
234
235impl PluginRouteRef {
236    /// Extract the plugin name from this reference.
237    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    /// Extract config overrides, if any.
245    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
253/// Deserialize a `plugins:` field that may take either of two YAML
254/// shapes, so the `apl:` wrapper is genuinely optional everywhere.
255///
256/// - A **sequence** is the structural activation list — each item is a
257///   [`PluginRouteRef`] (bare name or single-key override map). It
258///   deserializes into the `Vec` as usual.
259/// - A **mapping** is the APL per-plugin *override* form, written
260///   directly on the section when the `apl:` wrapper is omitted (e.g.
261///   `plugins: { audit: { on_error: ignore } }`). It is **not** a
262///   structural activation list: the override map is consumed
263///   separately by the APL visitor straight from the raw YAML, so here
264///   it deserializes to an empty `Vec`. This mirrors the explicit
265///   `apl: { plugins: {...} }` wrapper form, where the map never
266///   reaches this field at all — keeping the two forms behaviorally
267///   identical (the map supplies overrides; policy steps still do the
268///   activating).
269///
270/// Null / absent → empty `Vec` (same as `#[serde(default)]`).
271fn 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        // Structural activation list.
279        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        // APL override map — owned by the APL visitor, not the
284        // structural parse. See doc comment above.
285        serde_yaml::Value::Mapping(_) => Ok(Vec::new()),
286        // Null / absent → no structural plugins.
287        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// ---------------------------------------------------------------------------
297// Route Entry
298// ---------------------------------------------------------------------------
299
300/// A per-entity routing rule.
301///
302/// Matches one entity type (tool, resource, prompt, or LLM) and
303/// determines which plugins fire.
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305pub struct RouteEntry {
306    /// Match a tool by exact name, list, or glob.
307    #[serde(default)]
308    pub tool: Option<StringOrList>,
309
310    /// Match a resource by exact URI, list, or glob.
311    #[serde(default)]
312    pub resource: Option<StringOrList>,
313
314    /// Match a prompt by exact name, list, or glob.
315    #[serde(default)]
316    pub prompt: Option<StringOrList>,
317
318    /// Match an LLM by exact model name, list, or glob.
319    #[serde(default)]
320    pub llm: Option<StringOrList>,
321
322    /// Operational metadata — tags, scope, properties.
323    #[serde(default)]
324    pub meta: Option<RouteMeta>,
325
326    /// Conditional match expression — carried but not evaluated
327    /// during static resolution. Evaluated at runtime when payload
328    /// data is available (future: APL evaluator).
329    #[serde(default)]
330    pub when: Option<String>,
331
332    /// Plugin references to activate for this route.
333    #[serde(default, deserialize_with = "deserialize_plugin_refs")]
334    pub plugins: Vec<PluginRouteRef>,
335
336    /// Authentication dispatch list for this route (YAML key
337    /// `authentication:`). **Hook-specific**: applies ONLY to the
338    /// `identity.resolve` hook, independent of the `plugins:` block above
339    /// (which is hook-agnostic and means different things depending on
340    /// whether APL is annotating the route — `authentication:` always
341    /// means "these plugins fire on identity.resolve in this order").
342    ///
343    /// Accepts two YAML shapes; both deserialize to the same IR.
344    /// See `crate::identity::route_config::RouteIdentityConfig`.
345    ///
346    /// ```yaml
347    /// # List form — common case, additive default
348    /// authentication:
349    ///   - corp-jwt
350    ///   - spiffe-attestor
351    ///
352    /// # Object form — when the override flag is needed
353    /// authentication:
354    ///   replace_inherited: true
355    ///   steps:
356    ///     - legacy-basic-auth
357    /// ```
358    #[serde(
359        default,
360        rename = "authentication",
361        deserialize_with = "deserialize_route_identity"
362    )]
363    pub identity: Option<crate::identity::RouteIdentityConfig>,
364}
365
366// ---------------------------------------------------------------------------
367// Custom Deserialize for RouteEntry.identity
368// ---------------------------------------------------------------------------
369
370/// Deserialize the `authentication:` block in a `RouteEntry`. Accepts either a YAML
371/// list (treated as additive — `replace_inherited: false`) or a
372/// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each
373/// step is either a bare plugin name (string) or a map with
374/// `name:` + optional `on_error:` / `config:`. Produces friendlier
375/// error messages than `#[serde(untagged)]` would.
376fn 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    // Two-stage: deserialize as opaque YAML so we can discriminate
386    // list vs object shape with operator-friendly errors.
387    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
436/// Parse one identity step from raw YAML. Accepts either a bare
437/// plugin name (string) or a map with `name:` + optional
438/// `on_error:` / `config:` (and any forward-compat extras).
439fn 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            // Lean on serde's derived Deserialize for the map shape —
459            // `RouteIdentityStep` already handles `name` / `on_error` /
460            // `config_override` and flattens extras into `extra`.
461            // Translate the operator-facing key `config` → IR field
462            // `config_override` (the IR uses a more explicit name to
463            // distinguish from the plugin's runtime config).
464            #[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// ---------------------------------------------------------------------------
494// Route Meta
495// ---------------------------------------------------------------------------
496
497/// Operational metadata on a route entry.
498#[derive(Debug, Clone, Default, Serialize, Deserialize)]
499pub struct RouteMeta {
500    /// Entity tags — drive policy group inheritance.
501    #[serde(default)]
502    pub tags: Vec<String>,
503
504    /// Host-defined grouping (virtual server ID, namespace, etc.).
505    /// Used for scope matching: route scope must match request scope.
506    #[serde(default)]
507    pub scope: Option<String>,
508
509    /// Arbitrary key-value metadata.
510    #[serde(default)]
511    pub properties: HashMap<String, String>,
512}
513
514// ---------------------------------------------------------------------------
515// String or List (for tool matching)
516// ---------------------------------------------------------------------------
517
518/// An entity-name pattern. Holds the original pattern string (for
519/// serialization round-tripping and operator-facing diagnostics) plus a
520/// `WildMatch` matcher pre-compiled at deserialize time so route resolution
521/// doesn't re-parse the pattern on every request. Custom `Serialize` /
522/// `Deserialize` make this transparent to YAML — it serializes as a plain
523/// string, just like the previous `String` field did.
524///
525/// Glob syntax (via `wildmatch`):
526/// - `*` matches any sequence of characters (including empty).
527/// - `?` matches any single character.
528///
529/// The previous hand-rolled matcher only handled trailing-`*` correctly:
530/// `*suffix` patterns silently matched almost nothing, and multi-star
531/// patterns like `**` accidentally matched everything. Both shapes are
532/// real security footguns for scope/tool restriction rules — switching to
533/// `wildmatch` gives us full single-segment glob semantics.
534#[derive(Debug, Clone)]
535pub struct Pattern {
536    pattern: String,
537    matcher: wildmatch::WildMatch,
538}
539
540impl Pattern {
541    /// Compile a pattern. Done once at config load; subsequent `matches()`
542    /// calls reuse the compiled `WildMatch`.
543    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    /// Match the given name against the compiled pattern.
550    pub fn matches(&self, name: &str) -> bool {
551        self.matcher.matches(name)
552    }
553
554    /// The original pattern string (e.g., `"hr-*"`).
555    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/// A tool matcher — single name, list of names, or glob pattern.
580#[derive(Debug, Clone, Serialize, Deserialize)]
581#[serde(untagged)]
582pub enum StringOrList {
583    /// Single string (exact name or glob pattern). Pre-compiled at
584    /// deserialize time so the route-resolution slow path doesn't re-parse
585    /// on each request.
586    Single(Pattern),
587    /// List of exact names.
588    List(Vec<String>),
589}
590
591impl Default for StringOrList {
592    fn default() -> Self {
593        Self::Single(Pattern::default())
594    }
595}
596
597impl StringOrList {
598    /// Check if this matcher matches the given name.
599    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
607// ---------------------------------------------------------------------------
608// Config Loading
609// ---------------------------------------------------------------------------
610
611/// Load and parse a CPEX config from a YAML file.
612pub 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
619/// Parse a CPEX config from a YAML string.
620pub fn parse_config(yaml: &str) -> Result<CpexConfig, Box<PluginError>> {
621    // Scan the raw YAML for renamed legacy keys before the typed parse:
622    // `RouteEntry` / `GlobalConfig` / `PolicyGroup` silently ignore unknown
623    // fields, so a stale `identity:` would otherwise be dropped and its
624    // authentication steps never run — a fail-open.
625    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
636/// Reject the pre-rename `identity:` key (now `authentication:`) at every
637/// scope it could appear — `global`, `global.policies.<name>`,
638/// `global.defaults.<name>`, and each `routes[]` entry — so a stale config
639/// fails loudly rather than silently dropping its authentication steps.
640fn 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
673// ---------------------------------------------------------------------------
674// Validation
675// ---------------------------------------------------------------------------
676
677/// Validate a parsed config for structural correctness.
678///
679/// This checks only the *structural* plugin activation lists
680/// (`route.plugins` / `policy_group.plugins` sequences). It deliberately
681/// does NOT validate APL plugin references — neither `plugin(...)` / `run(...)`
682/// policy steps nor the APL per-plugin override *map* (which
683/// [`deserialize_plugin_refs`] folds into an empty structural `Vec`, leaving
684/// it for the APL visitor to consume). Those are resolved and validated at
685/// dispatch-plan build time, where an unknown or unreferenced plugin is logged
686/// and skipped (see `apl-cpex::dispatch_plan`). Keeping cpex-core's validation
687/// free of APL semantics is intentional.
688fn 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
760// ---------------------------------------------------------------------------
761// Route Resolution
762// ---------------------------------------------------------------------------
763
764/// Specificity scores for route matching.
765const 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
771/// Score a single entity matcher (tool / resource / prompt / llm) against
772/// a request entity name, returning the specificity bucket if it matches
773/// or `None` if it doesn't (or the matcher is absent). Replaces four
774/// copy-pasted match arms in `resolve_plugins_for_entity`.
775fn 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
789/// Resolve which plugins should fire for a given entity.
790///
791/// When routing is disabled, returns all plugin names. When enabled,
792/// matches the entity against routes and collects plugins from the
793/// `all` group, defaults, matching policy groups (via merged tags),
794/// and the route itself.
795///
796/// `request_scope` and `request_tags` come from the host's
797/// `MetaExtension` on the request.
798pub 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    // 1. Always include plugins from the "all" policy group
820    if let Some(all_group) = config.global.policies.get("all") {
821        collect_plugin_refs(&all_group.plugins, &mut resolved, None);
822    }
823
824    // 2. Include plugins from matching defaults
825    if let Some(default_group) = config.global.defaults.get(entity_type) {
826        collect_plugin_refs(&default_group.plugins, &mut resolved, None);
827    }
828
829    // 3. Find matching route (with scope check)
830    if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) {
831        // Merge tags: route's static tags + host's runtime tags
832        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        // Include plugins from all matching policy groups (merged tags)
840        for tag in &merged_tags {
841            if tag == "all" {
842                continue; // already handled above
843            }
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        // Include route-level plugins, carrying the route's when clause
850        collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref());
851    }
852
853    // Deduplicate by name, preserving order. Later overrides win.
854    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
865/// Resolve the identity-resolve dispatch list for a specific
866/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`]
867/// — consults the global `authentication:` block, tag-bundle
868/// `authentication:` blocks, and the route's own `authentication:` block
869/// to determine which plugins fire on the `identity.resolve` hook for
870/// this route.
871///
872/// # Inheritance / merge order
873///
874/// Layers are stacked **global → tag bundles → route**, in that
875/// order. Within tags, the order is determined by the request's
876/// `meta.tags` (which combines static route tags + runtime request
877/// tags). Each layer is appended to the running list unless the
878/// **route's** block has `replace_inherited: true`, in which case
879/// inherited layers (global + tags) are dropped and only the route's
880/// steps remain. Tag-bundle `replace_inherited` is parsed but not
881/// honored — only the route layer can opt out of inheritance.
882///
883/// Order matters: returned plugins fire in the order they were
884/// merged. The first plugin's resolved `IdentityPayload` flows into
885/// the second plugin's input via the executor's Sequential-phase
886/// semantics, so global identity contributions land first, then
887/// tag-bundle, then route-specific overrides / additions.
888///
889/// Per-step `config_override` is surfaced as
890/// `ResolvedPlugin.config_overrides` so the standard
891/// `filter_entries_by_route` override pathway
892/// (`create_override_instance`) applies — same mechanism the
893/// `plugins:` block uses.
894///
895/// Returns an empty `Vec` when no layer contributed any steps
896/// (e.g. anonymous routes that explicitly opt out via
897/// `replace_inherited: true` + empty `steps: []`).
898pub 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    // Route-level block is the override authority. Find the matching
905    // route up-front; absence means there's no route to inherit
906    // identity FOR (still consult global identity though, since the
907    // host might be doing per-route hook routing on entity_type
908    // alone with no specific route).
909    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    // Check the override flag before doing any inheritance work —
913    // if the route opts out, inherited layers are dropped.
914    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        // Global layer first — applies to every route.
922        if let Some(global_identity) = config.global.identity.as_ref() {
923            steps.extend(global_identity.steps.iter().cloned());
924        }
925
926        // Tag-bundle layers next. Walk the route's tags (static +
927        // any runtime tags would compose here too, but resolve_*
928        // currently doesn't take runtime tags as a parameter for
929        // identity — symmetry with the existing `plugins:` resolver
930        // would extend the signature; deferred until needed).
931        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    // Route layer last (or only, when replace_inherited).
945    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            // Surface config_override under the `config:` key shape
954            // that `create_override_instance` already understands —
955            // it reads `overrides.get("config")` to find the merge
956            // target. Wrapping like this avoids a special-case path.
957            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/// A resolved plugin with optional config overrides and when clause.
968#[derive(Debug, Clone)]
969pub struct ResolvedPlugin {
970    /// Plugin name.
971    pub name: String,
972
973    /// Config overrides from the route.
974    pub config_overrides: Option<serde_json::Value>,
975
976    /// When clause from the route — carried but not evaluated here.
977    pub when: Option<String>,
978}
979
980/// Collect plugin refs into the resolved list.
981fn 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
995/// Find the best matching route for an entity by specificity.
996///
997/// Scope matching: if a route declares a scope, the request must
998/// have the same scope. No scope on the route matches any request.
999fn 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        // Check scope compatibility
1009        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,                          // route is global
1012            (Some(rs), Some(rq)) if rs == rq => 100, // scopes match
1013            (Some(_), _) => continue,                // scope mismatch — skip
1014        };
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    // Helper: empty tags for tests that don't need them
1048    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-")); // empty match for *
1411        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    /// Regression for the security footgun: `*suffix` patterns were
1423    /// silently matching almost nothing because the previous matcher
1424    /// looked for `"*suffix"` as a literal prefix.
1425    #[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")); // empty match for *
1430        assert!(!matcher.matches("foo-staging"));
1431        assert!(!matcher.matches("prod"));
1432    }
1433
1434    /// Regression for `prefix*suffix` patterns also broken before.
1435    #[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")); // empty match for *
1440        assert!(!matcher.matches("hr-comp-v2"));
1441        assert!(!matcher.matches("finance-comp-v1"));
1442    }
1443
1444    /// Multiple-wildcard patterns must work everywhere `*` appears.
1445    #[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    /// Regression for the OTHER security footgun: multi-star patterns
1455    /// like `**` were `trim_end_matches('*')`'d to `""` and then matched
1456    /// every name via `starts_with("")`. With wildmatch this is a
1457    /// degenerate-but-correct "match anything" pattern, equivalent to `*`.
1458    #[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    /// `WildMatch` is built once at deserialize / `Pattern::new` time and
1476    /// reused; this test just sanity-checks the round-trip through serde.
1477    #[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    // -- Scope matching tests --
1522
1523    #[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        // With matching scope — scoped route wins (more specific)
1548        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        // Without scope — global route matches
1560        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        // With different scope — global route matches (scoped doesn't)
1567        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    // -- Tag merging tests --
1580
1581    #[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        // Host provides a runtime tag that matches a policy group
1607        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        // Both route's static tag (pii) and host's runtime tag activate their groups
1615        assert!(names.contains(&"pii_plugin"));
1616        assert!(names.contains(&"runtime_plugin"));
1617    }
1618
1619    // -- When clause carried tests --
1620
1621    #[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        // global_plugin has no when clause (from all group)
1673        let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap();
1674        assert!(global.when.is_none());
1675
1676        // route_plugin carries the route's when clause
1677        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    // ---- route-level `authentication:` block ----
1682
1683    #[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        // Breaking rename: `identity:` was renamed to `authentication:`.
1814        // A stale key must fail loudly, never be silently dropped (which
1815        // would skip authentication — a fail-open).
1816        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    // ---- resolve_identity_plugins_for_route ----
1844
1845    #[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        // `create_override_instance` reads `overrides.get("config")`
1898        // — `resolve_identity_plugins_for_route` wraps the step's
1899        // `config_override` under that key so the existing override
1900        // pathway picks it up without a special case.
1901        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    // ---- Slice C: global + tag-bundle inheritance ----
1926
1927    #[test]
1928    fn resolve_identity_includes_global_layer_when_route_has_no_block() {
1929        // global.identity defined; route declares no identity. The
1930        // route should inherit the global steps unchanged.
1931        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        // global → route is the standard stacking. Route's `identity:`
1951        // is the list form (implicit replace_inherited=false), so
1952        // its steps APPEND after the global's.
1953        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        // Full stack: global + tag bundle + route, all contributing.
1976        // Order is global first, then the matching tag's bundle,
1977        // then the route's own steps.
1978        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        // Route says `replace_inherited: true` → only route's steps
2008        // survive. Global and tag-bundle contributions get dropped.
2009        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        // `replace_inherited: true` + `steps: []` is the explicit
2041        // opt-out — anonymous routes use this to suppress inherited
2042        // identity entirely.
2043        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        // The tag bundle's identity only contributes when the route
2065        // declares the matching tag — not for unrelated routes.
2066        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        // Identity routing uses the same `find_matching_route`
2100        // scope-aware matcher as the generic `plugins:` resolution,
2101        // so requests for a different scope shouldn't pick up
2102        // identity from this route.
2103        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    // -----------------------------------------------------------------
2124    // `plugins:` accepts both shapes (map-tolerant deserializer)
2125    //
2126    // A *sequence* is the structural activation list. A *mapping* is the
2127    // APL per-plugin override form (consumed by the APL visitor from the
2128    // raw YAML), so it deserializes to an empty structural list here.
2129    // Before this, a map at route/defaults/policy scope failed the whole
2130    // `CpexConfig` parse with "invalid type: map, expected a sequence".
2131    //
2132    // These exercise deserialization directly (not `parse_config`, which
2133    // also runs `validate_config`'s plugin-reference checks) because the
2134    // bug being fixed was a *deserialize-time* failure.
2135    // -----------------------------------------------------------------
2136
2137    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}