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 identity dispatch list. Inherited by every route as
164    /// the first layer of identity resolution. Routes can append
165    /// to it (additive, the default) or replace it (with
166    /// `identity.replace_inherited: true` on the route).
167    ///
168    /// Same YAML shape as the route-level `identity:` block — see
169    /// `RouteEntry.identity` for the accepted forms.
170    #[serde(default, deserialize_with = "deserialize_route_identity")]
171    pub identity: Option<crate::identity::RouteIdentityConfig>,
172}
173
174// ---------------------------------------------------------------------------
175// Policy Group
176// ---------------------------------------------------------------------------
177
178/// A named policy group — plugins to activate and optional metadata.
179///
180/// The `all` group is reserved and always applied.
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct PolicyGroup {
183    /// Human-readable description.
184    #[serde(default)]
185    pub description: Option<String>,
186
187    /// Arbitrary metadata for tooling and audit.
188    #[serde(default)]
189    pub metadata: HashMap<String, String>,
190
191    /// Plugin references to activate when this group matches.
192    #[serde(default, deserialize_with = "deserialize_plugin_refs")]
193    pub plugins: Vec<PluginRouteRef>,
194
195    /// Identity dispatch list contributed by this tag bundle.
196    /// Inherited by routes that carry this tag in `meta.tags`,
197    /// stacked between the global identity (first) and the route's
198    /// own identity (last). Same YAML shape as the route-level
199    /// `identity:` block.
200    #[serde(default, deserialize_with = "deserialize_route_identity")]
201    pub identity: Option<crate::identity::RouteIdentityConfig>,
202}
203
204// ---------------------------------------------------------------------------
205// Plugin Ref (route/group plugin reference)
206// ---------------------------------------------------------------------------
207
208/// A reference to a plugin in a route or policy group.
209///
210/// ```yaml
211/// plugins:
212///   - rate_limiter                     # bare name
213///   - pii_scanner:                     # name with config overrides
214///       config:
215///         sensitivity: high
216/// ```
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(untagged)]
219pub enum PluginRouteRef {
220    /// Just the name — activate the plugin with no config overrides.
221    Name(String),
222    /// Name with config overrides — single-key map.
223    WithOverrides(HashMap<String, serde_json::Value>),
224}
225
226impl PluginRouteRef {
227    /// Extract the plugin name from this reference.
228    pub fn name(&self) -> &str {
229        match self {
230            Self::Name(name) => name,
231            Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""),
232        }
233    }
234
235    /// Extract config overrides, if any.
236    pub fn overrides(&self) -> Option<&serde_json::Value> {
237        match self {
238            Self::Name(_) => None,
239            Self::WithOverrides(map) => map.values().next(),
240        }
241    }
242}
243
244/// Deserialize a `plugins:` field that may take either of two YAML
245/// shapes, so the `apl:` wrapper is genuinely optional everywhere.
246///
247/// - A **sequence** is the structural activation list — each item is a
248///   [`PluginRouteRef`] (bare name or single-key override map). It
249///   deserializes into the `Vec` as usual.
250/// - A **mapping** is the APL per-plugin *override* form, written
251///   directly on the section when the `apl:` wrapper is omitted (e.g.
252///   `plugins: { audit: { on_error: ignore } }`). It is **not** a
253///   structural activation list: the override map is consumed
254///   separately by the APL visitor straight from the raw YAML, so here
255///   it deserializes to an empty `Vec`. This mirrors the explicit
256///   `apl: { plugins: {...} }` wrapper form, where the map never
257///   reaches this field at all — keeping the two forms behaviorally
258///   identical (the map supplies overrides; policy steps still do the
259///   activating).
260///
261/// Null / absent → empty `Vec` (same as `#[serde(default)]`).
262fn deserialize_plugin_refs<'de, D>(deserializer: D) -> Result<Vec<PluginRouteRef>, D::Error>
263where
264    D: serde::Deserializer<'de>,
265{
266    use serde::de::Error;
267
268    match serde_yaml::Value::deserialize(deserializer)? {
269        // Structural activation list.
270        serde_yaml::Value::Sequence(items) => items
271            .into_iter()
272            .map(|item| serde_yaml::from_value(item).map_err(D::Error::custom))
273            .collect(),
274        // APL override map — owned by the APL visitor, not the
275        // structural parse. See doc comment above.
276        serde_yaml::Value::Mapping(_) => Ok(Vec::new()),
277        // Null / absent → no structural plugins.
278        serde_yaml::Value::Null => Ok(Vec::new()),
279        other => Err(D::Error::custom(format!(
280            "`plugins:` must be a sequence (activation list) or a mapping \
281             (APL per-plugin overrides), got {:?}",
282            other
283        ))),
284    }
285}
286
287// ---------------------------------------------------------------------------
288// Route Entry
289// ---------------------------------------------------------------------------
290
291/// A per-entity routing rule.
292///
293/// Matches one entity type (tool, resource, prompt, or LLM) and
294/// determines which plugins fire.
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct RouteEntry {
297    /// Match a tool by exact name, list, or glob.
298    #[serde(default)]
299    pub tool: Option<StringOrList>,
300
301    /// Match a resource by exact URI, list, or glob.
302    #[serde(default)]
303    pub resource: Option<StringOrList>,
304
305    /// Match a prompt by exact name, list, or glob.
306    #[serde(default)]
307    pub prompt: Option<StringOrList>,
308
309    /// Match an LLM by exact model name, list, or glob.
310    #[serde(default)]
311    pub llm: Option<StringOrList>,
312
313    /// Operational metadata — tags, scope, properties.
314    #[serde(default)]
315    pub meta: Option<RouteMeta>,
316
317    /// Conditional match expression — carried but not evaluated
318    /// during static resolution. Evaluated at runtime when payload
319    /// data is available (future: APL evaluator).
320    #[serde(default)]
321    pub when: Option<String>,
322
323    /// Plugin references to activate for this route.
324    #[serde(default, deserialize_with = "deserialize_plugin_refs")]
325    pub plugins: Vec<PluginRouteRef>,
326
327    /// Identity-resolve dispatch list for this route. **Hook-specific**:
328    /// applies ONLY to the `identity.resolve` hook, independent of the
329    /// `plugins:` block above (which is hook-agnostic and means
330    /// different things depending on whether APL is annotating the
331    /// route — `identity:` always means "these plugins fire on
332    /// identity.resolve in this order").
333    ///
334    /// Accepts two YAML shapes; both deserialize to the same IR.
335    /// See `crate::identity::route_config::RouteIdentityConfig`.
336    ///
337    /// ```yaml
338    /// # List form — common case, additive default
339    /// identity:
340    ///   - corp-jwt
341    ///   - spiffe-attestor
342    ///
343    /// # Object form — when the override flag is needed
344    /// identity:
345    ///   replace_inherited: true
346    ///   steps:
347    ///     - legacy-basic-auth
348    /// ```
349    #[serde(default, deserialize_with = "deserialize_route_identity")]
350    pub identity: Option<crate::identity::RouteIdentityConfig>,
351}
352
353// ---------------------------------------------------------------------------
354// Custom Deserialize for RouteEntry.identity
355// ---------------------------------------------------------------------------
356
357/// Deserialize `identity:` in a `RouteEntry`. Accepts either a YAML
358/// list (treated as additive — `replace_inherited: false`) or a
359/// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each
360/// step is either a bare plugin name (string) or a map with
361/// `name:` + optional `on_error:` / `config:`. Produces friendlier
362/// error messages than `#[serde(untagged)]` would.
363fn deserialize_route_identity<'de, D>(
364    deserializer: D,
365) -> Result<Option<crate::identity::RouteIdentityConfig>, D::Error>
366where
367    D: serde::Deserializer<'de>,
368{
369    use crate::identity::RouteIdentityConfig;
370    use serde::de::Error;
371
372    // Two-stage: deserialize as opaque YAML so we can discriminate
373    // list vs object shape with operator-friendly errors.
374    let raw = match Option::<serde_yaml::Value>::deserialize(deserializer)? {
375        None => return Ok(None),
376        Some(serde_yaml::Value::Null) => return Ok(None),
377        Some(v) => v,
378    };
379
380    let (replace_inherited, raw_steps): (bool, Vec<serde_yaml::Value>) = match raw {
381        serde_yaml::Value::Sequence(items) => (false, items),
382        serde_yaml::Value::Mapping(map) => {
383            let replace_inherited =
384                match map.get(serde_yaml::Value::String("replace_inherited".to_string())) {
385                    Some(v) => v.as_bool().ok_or_else(|| {
386                        D::Error::custom("`identity.replace_inherited` must be a boolean")
387                    })?,
388                    None => false,
389                };
390            let steps_val = map
391                .get(serde_yaml::Value::String("steps".to_string()))
392                .ok_or_else(|| {
393                    D::Error::custom(
394                        "`identity:` object form requires `steps:` (a list of \
395                         identity steps); did you mean to write the list form?",
396                    )
397                })?;
398            let items = steps_val
399                .as_sequence()
400                .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))?
401                .clone();
402            (replace_inherited, items)
403        },
404        _ => {
405            return Err(D::Error::custom(
406                "`identity:` must be a list of steps or an object with \
407                 `steps:` (and optional `replace_inherited:`)",
408            ));
409        },
410    };
411
412    let mut steps = Vec::with_capacity(raw_steps.len());
413    for (i, raw) in raw_steps.into_iter().enumerate() {
414        steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?);
415    }
416
417    Ok(Some(RouteIdentityConfig {
418        steps,
419        replace_inherited,
420    }))
421}
422
423/// Parse one identity step from raw YAML. Accepts either a bare
424/// plugin name (string) or a map with `name:` + optional
425/// `on_error:` / `config:` (and any forward-compat extras).
426fn parse_identity_step(
427    raw: serde_yaml::Value,
428    index: usize,
429) -> Result<crate::identity::RouteIdentityStep, String> {
430    use crate::identity::RouteIdentityStep;
431
432    match raw {
433        serde_yaml::Value::String(name) => {
434            if name.is_empty() {
435                return Err(format!(
436                    "identity step [{index}] plugin name cannot be empty"
437                ));
438            }
439            Ok(RouteIdentityStep {
440                name,
441                ..Default::default()
442            })
443        },
444        serde_yaml::Value::Mapping(_) => {
445            // Lean on serde's derived Deserialize for the map shape —
446            // `RouteIdentityStep` already handles `name` / `on_error` /
447            // `config_override` and flattens extras into `extra`.
448            // Translate the operator-facing key `config` → IR field
449            // `config_override` (the IR uses a more explicit name to
450            // distinguish from the plugin's runtime config).
451            #[derive(serde::Deserialize)]
452            struct StepYaml {
453                name: String,
454                #[serde(default)]
455                on_error: Option<String>,
456                #[serde(default)]
457                config: Option<serde_json::Value>,
458                #[serde(default, flatten)]
459                extra: std::collections::HashMap<String, serde_json::Value>,
460            }
461            let parsed: StepYaml =
462                serde_yaml::from_value(raw).map_err(|e| format!("identity step [{index}]: {e}"))?;
463            if parsed.name.is_empty() {
464                return Err(format!("identity step [{index}] `name:` cannot be empty"));
465            }
466            Ok(RouteIdentityStep {
467                name: parsed.name,
468                config_override: parsed.config,
469                on_error: parsed.on_error,
470                extra: parsed.extra,
471            })
472        },
473        _ => Err(format!(
474            "identity step [{index}] must be a plugin name (string) or a map \
475             with `name:` (and optional `on_error:` / `config:`)"
476        )),
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Route Meta
482// ---------------------------------------------------------------------------
483
484/// Operational metadata on a route entry.
485#[derive(Debug, Clone, Default, Serialize, Deserialize)]
486pub struct RouteMeta {
487    /// Entity tags — drive policy group inheritance.
488    #[serde(default)]
489    pub tags: Vec<String>,
490
491    /// Host-defined grouping (virtual server ID, namespace, etc.).
492    /// Used for scope matching: route scope must match request scope.
493    #[serde(default)]
494    pub scope: Option<String>,
495
496    /// Arbitrary key-value metadata.
497    #[serde(default)]
498    pub properties: HashMap<String, String>,
499}
500
501// ---------------------------------------------------------------------------
502// String or List (for tool matching)
503// ---------------------------------------------------------------------------
504
505/// An entity-name pattern. Holds the original pattern string (for
506/// serialization round-tripping and operator-facing diagnostics) plus a
507/// `WildMatch` matcher pre-compiled at deserialize time so route resolution
508/// doesn't re-parse the pattern on every request. Custom `Serialize` /
509/// `Deserialize` make this transparent to YAML — it serializes as a plain
510/// string, just like the previous `String` field did.
511///
512/// Glob syntax (via `wildmatch`):
513/// - `*` matches any sequence of characters (including empty).
514/// - `?` matches any single character.
515///
516/// The previous hand-rolled matcher only handled trailing-`*` correctly:
517/// `*suffix` patterns silently matched almost nothing, and multi-star
518/// patterns like `**` accidentally matched everything. Both shapes are
519/// real security footguns for scope/tool restriction rules — switching to
520/// `wildmatch` gives us full single-segment glob semantics.
521#[derive(Debug, Clone)]
522pub struct Pattern {
523    pattern: String,
524    matcher: wildmatch::WildMatch,
525}
526
527impl Pattern {
528    /// Compile a pattern. Done once at config load; subsequent `matches()`
529    /// calls reuse the compiled `WildMatch`.
530    pub fn new(pattern: impl Into<String>) -> Self {
531        let pattern = pattern.into();
532        let matcher = wildmatch::WildMatch::new(&pattern);
533        Self { pattern, matcher }
534    }
535
536    /// Match the given name against the compiled pattern.
537    pub fn matches(&self, name: &str) -> bool {
538        self.matcher.matches(name)
539    }
540
541    /// The original pattern string (e.g., `"hr-*"`).
542    pub fn as_str(&self) -> &str {
543        &self.pattern
544    }
545}
546
547impl Default for Pattern {
548    fn default() -> Self {
549        Self::new("")
550    }
551}
552
553impl Serialize for Pattern {
554    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
555        serializer.serialize_str(&self.pattern)
556    }
557}
558
559impl<'de> Deserialize<'de> for Pattern {
560    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
561        let s = String::deserialize(deserializer)?;
562        Ok(Pattern::new(s))
563    }
564}
565
566/// A tool matcher — single name, list of names, or glob pattern.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568#[serde(untagged)]
569pub enum StringOrList {
570    /// Single string (exact name or glob pattern). Pre-compiled at
571    /// deserialize time so the route-resolution slow path doesn't re-parse
572    /// on each request.
573    Single(Pattern),
574    /// List of exact names.
575    List(Vec<String>),
576}
577
578impl Default for StringOrList {
579    fn default() -> Self {
580        Self::Single(Pattern::default())
581    }
582}
583
584impl StringOrList {
585    /// Check if this matcher matches the given name.
586    pub fn matches(&self, name: &str) -> bool {
587        match self {
588            Self::Single(pattern) => pattern.matches(name),
589            Self::List(names) => names.iter().any(|n| n == name),
590        }
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Config Loading
596// ---------------------------------------------------------------------------
597
598/// Load and parse a CPEX config from a YAML file.
599pub fn load_config(path: &Path) -> Result<CpexConfig, Box<PluginError>> {
600    let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config {
601        message: format!("failed to read config file '{}': {}", path.display(), e),
602    })?;
603    parse_config(&content)
604}
605
606/// Parse a CPEX config from a YAML string.
607pub fn parse_config(yaml: &str) -> Result<CpexConfig, Box<PluginError>> {
608    let config: CpexConfig = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config {
609        message: format!("failed to parse config YAML: {}", e),
610    })?;
611    validate_config(&config)?;
612    Ok(config)
613}
614
615// ---------------------------------------------------------------------------
616// Validation
617// ---------------------------------------------------------------------------
618
619/// Validate a parsed config for structural correctness.
620///
621/// This checks only the *structural* plugin activation lists
622/// (`route.plugins` / `policy_group.plugins` sequences). It deliberately
623/// does NOT validate APL plugin references — neither `plugin(...)` / `run(...)`
624/// policy steps nor the APL per-plugin override *map* (which
625/// [`deserialize_plugin_refs`] folds into an empty structural `Vec`, leaving
626/// it for the APL visitor to consume). Those are resolved and validated at
627/// dispatch-plan build time, where an unknown or unreferenced plugin is logged
628/// and skipped (see `apl-cpex::dispatch_plan`). Keeping cpex-core's validation
629/// free of APL semantics is intentional.
630fn validate_config(config: &CpexConfig) -> Result<(), Box<PluginError>> {
631    let mut seen_names = HashSet::new();
632    for plugin in &config.plugins {
633        if !seen_names.insert(&plugin.name) {
634            return Err(Box::new(PluginError::Config {
635                message: format!("duplicate plugin name: '{}'", plugin.name),
636            }));
637        }
638    }
639
640    if config.routing_enabled() {
641        let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect();
642
643        for (i, route) in config.routes.iter().enumerate() {
644            let count = [
645                route.tool.is_some(),
646                route.resource.is_some(),
647                route.prompt.is_some(),
648                route.llm.is_some(),
649            ]
650            .iter()
651            .filter(|&&m| m)
652            .count();
653
654            if count == 0 {
655                return Err(Box::new(PluginError::Config {
656                    message: format!(
657                        "route {} has no entity matcher (need tool, resource, prompt, or llm)",
658                        i
659                    ),
660                }));
661            }
662            if count > 1 {
663                return Err(Box::new(PluginError::Config {
664                    message: format!(
665                        "route {} has multiple entity matchers (need exactly one)",
666                        i
667                    ),
668                }));
669            }
670
671            for plugin_ref in &route.plugins {
672                if !plugin_names.contains(plugin_ref.name()) {
673                    return Err(Box::new(PluginError::Config {
674                        message: format!(
675                            "route {} references unknown plugin '{}'",
676                            i,
677                            plugin_ref.name()
678                        ),
679                    }));
680                }
681            }
682        }
683
684        for (group_name, group) in &config.global.policies {
685            for plugin_ref in &group.plugins {
686                if !plugin_names.contains(plugin_ref.name()) {
687                    return Err(Box::new(PluginError::Config {
688                        message: format!(
689                            "policy group '{}' references unknown plugin '{}'",
690                            group_name,
691                            plugin_ref.name()
692                        ),
693                    }));
694                }
695            }
696        }
697    }
698
699    Ok(())
700}
701
702// ---------------------------------------------------------------------------
703// Route Resolution
704// ---------------------------------------------------------------------------
705
706/// Specificity scores for route matching.
707const SPECIFICITY_EXACT_NAME: usize = 1000;
708const SPECIFICITY_NAME_LIST: usize = 500;
709const SPECIFICITY_GLOB: usize = 300;
710const SPECIFICITY_WHEN_ONLY: usize = 10;
711const SPECIFICITY_WILDCARD: usize = 0;
712
713/// Score a single entity matcher (tool / resource / prompt / llm) against
714/// a request entity name, returning the specificity bucket if it matches
715/// or `None` if it doesn't (or the matcher is absent). Replaces four
716/// copy-pasted match arms in `resolve_plugins_for_entity`.
717fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option<usize> {
718    let matcher = matcher?;
719    if !matcher.matches(entity_name) {
720        return None;
721    }
722    let score = match matcher {
723        StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD,
724        StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB,
725        StringOrList::List(_) => SPECIFICITY_NAME_LIST,
726        StringOrList::Single(_) => SPECIFICITY_EXACT_NAME,
727    };
728    Some(score)
729}
730
731/// Resolve which plugins should fire for a given entity.
732///
733/// When routing is disabled, returns all plugin names. When enabled,
734/// matches the entity against routes and collects plugins from the
735/// `all` group, defaults, matching policy groups (via merged tags),
736/// and the route itself.
737///
738/// `request_scope` and `request_tags` come from the host's
739/// `MetaExtension` on the request.
740pub fn resolve_plugins_for_entity(
741    config: &CpexConfig,
742    entity_type: &str,
743    entity_name: &str,
744    request_scope: Option<&str>,
745    request_tags: &HashSet<String>,
746) -> Vec<ResolvedPlugin> {
747    if !config.routing_enabled() {
748        return config
749            .plugins
750            .iter()
751            .map(|p| ResolvedPlugin {
752                name: p.name.clone(),
753                config_overrides: None,
754                when: None,
755            })
756            .collect();
757    }
758
759    let mut resolved = Vec::new();
760
761    // 1. Always include plugins from the "all" policy group
762    if let Some(all_group) = config.global.policies.get("all") {
763        collect_plugin_refs(&all_group.plugins, &mut resolved, None);
764    }
765
766    // 2. Include plugins from matching defaults
767    if let Some(default_group) = config.global.defaults.get(entity_type) {
768        collect_plugin_refs(&default_group.plugins, &mut resolved, None);
769    }
770
771    // 3. Find matching route (with scope check)
772    if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) {
773        // Merge tags: route's static tags + host's runtime tags
774        let mut merged_tags: HashSet<String> = request_tags.clone();
775        if let Some(meta) = &route.meta {
776            for tag in &meta.tags {
777                merged_tags.insert(tag.clone());
778            }
779        }
780
781        // Include plugins from all matching policy groups (merged tags)
782        for tag in &merged_tags {
783            if tag == "all" {
784                continue; // already handled above
785            }
786            if let Some(group) = config.global.policies.get(tag.as_str()) {
787                collect_plugin_refs(&group.plugins, &mut resolved, None);
788            }
789        }
790
791        // Include route-level plugins, carrying the route's when clause
792        collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref());
793    }
794
795    // Deduplicate by name, preserving order. Later overrides win.
796    let mut seen = HashSet::new();
797    let mut deduped = Vec::new();
798    for rp in resolved.into_iter().rev() {
799        if seen.insert(rp.name.clone()) {
800            deduped.push(rp);
801        }
802    }
803    deduped.reverse();
804    deduped
805}
806
807/// Resolve the identity-resolve dispatch list for a specific
808/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`]
809/// — consults `global.identity`, tag-bundle `identity` blocks, and
810/// the route's own `identity:` block to determine which plugins fire
811/// on the `identity.resolve` hook for this route.
812///
813/// # Inheritance / merge order
814///
815/// Layers are stacked **global → tag bundles → route**, in that
816/// order. Within tags, the order is determined by the request's
817/// `meta.tags` (which combines static route tags + runtime request
818/// tags). Each layer is appended to the running list unless the
819/// **route's** block has `replace_inherited: true`, in which case
820/// inherited layers (global + tags) are dropped and only the route's
821/// steps remain. Tag-bundle `replace_inherited` is parsed but not
822/// honored — only the route layer can opt out of inheritance.
823///
824/// Order matters: returned plugins fire in the order they were
825/// merged. The first plugin's resolved `IdentityPayload` flows into
826/// the second plugin's input via the executor's Sequential-phase
827/// semantics, so global identity contributions land first, then
828/// tag-bundle, then route-specific overrides / additions.
829///
830/// Per-step `config_override` is surfaced as
831/// `ResolvedPlugin.config_overrides` so the standard
832/// `filter_entries_by_route` override pathway
833/// (`create_override_instance`) applies — same mechanism the
834/// `plugins:` block uses.
835///
836/// Returns an empty `Vec` when no layer contributed any steps
837/// (e.g. anonymous routes that explicitly opt out via
838/// `replace_inherited: true` + empty `steps: []`).
839pub fn resolve_identity_plugins_for_route(
840    config: &CpexConfig,
841    entity_type: &str,
842    entity_name: &str,
843    request_scope: Option<&str>,
844) -> Vec<ResolvedPlugin> {
845    // Route-level block is the override authority. Find the matching
846    // route up-front; absence means there's no route to inherit
847    // identity FOR (still consult global identity though, since the
848    // host might be doing per-route hook routing on entity_type
849    // alone with no specific route).
850    let route = find_matching_route(config, entity_type, entity_name, request_scope);
851    let route_identity = route.and_then(|r| r.identity.as_ref());
852
853    // Check the override flag before doing any inheritance work —
854    // if the route opts out, inherited layers are dropped.
855    let replace_inherited = route_identity
856        .map(|id| id.replace_inherited)
857        .unwrap_or(false);
858
859    let mut steps: Vec<crate::identity::RouteIdentityStep> = Vec::new();
860
861    if !replace_inherited {
862        // Global layer first — applies to every route.
863        if let Some(global_identity) = config.global.identity.as_ref() {
864            steps.extend(global_identity.steps.iter().cloned());
865        }
866
867        // Tag-bundle layers next. Walk the route's tags (static +
868        // any runtime tags would compose here too, but resolve_*
869        // currently doesn't take runtime tags as a parameter for
870        // identity — symmetry with the existing `plugins:` resolver
871        // would extend the signature; deferred until needed).
872        if let Some(route) = route {
873            if let Some(meta) = &route.meta {
874                for tag in &meta.tags {
875                    if let Some(bundle) = config.global.policies.get(tag) {
876                        if let Some(bundle_identity) = bundle.identity.as_ref() {
877                            steps.extend(bundle_identity.steps.iter().cloned());
878                        }
879                    }
880                }
881            }
882        }
883    }
884
885    // Route layer last (or only, when replace_inherited).
886    if let Some(id) = route_identity {
887        steps.extend(id.steps.iter().cloned());
888    }
889
890    steps
891        .into_iter()
892        .map(|step| ResolvedPlugin {
893            name: step.name.clone(),
894            // Surface config_override under the `config:` key shape
895            // that `create_override_instance` already understands —
896            // it reads `overrides.get("config")` to find the merge
897            // target. Wrapping like this avoids a special-case path.
898            config_overrides: step.config_override.as_ref().map(|cfg| {
899                let mut wrapper = serde_json::Map::new();
900                wrapper.insert("config".to_string(), cfg.clone());
901                serde_json::Value::Object(wrapper)
902            }),
903            when: None,
904        })
905        .collect()
906}
907
908/// A resolved plugin with optional config overrides and when clause.
909#[derive(Debug, Clone)]
910pub struct ResolvedPlugin {
911    /// Plugin name.
912    pub name: String,
913
914    /// Config overrides from the route.
915    pub config_overrides: Option<serde_json::Value>,
916
917    /// When clause from the route — carried but not evaluated here.
918    pub when: Option<String>,
919}
920
921/// Collect plugin refs into the resolved list.
922fn collect_plugin_refs(
923    refs: &[PluginRouteRef],
924    resolved: &mut Vec<ResolvedPlugin>,
925    route_when: Option<&str>,
926) {
927    for plugin_ref in refs {
928        resolved.push(ResolvedPlugin {
929            name: plugin_ref.name().to_string(),
930            config_overrides: plugin_ref.overrides().cloned(),
931            when: route_when.map(String::from),
932        });
933    }
934}
935
936/// Find the best matching route for an entity by specificity.
937///
938/// Scope matching: if a route declares a scope, the request must
939/// have the same scope. No scope on the route matches any request.
940fn find_matching_route<'a>(
941    config: &'a CpexConfig,
942    entity_type: &str,
943    entity_name: &str,
944    request_scope: Option<&str>,
945) -> Option<&'a RouteEntry> {
946    let mut best: Option<(usize, &RouteEntry)> = None;
947
948    for route in &config.routes {
949        // Check scope compatibility
950        let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref());
951        let scope_bonus = match (route_scope, request_scope) {
952            (None, _) => 0,                          // route is global
953            (Some(rs), Some(rq)) if rs == rq => 100, // scopes match
954            (Some(_), _) => continue,                // scope mismatch — skip
955        };
956
957        let entity_matcher = match entity_type {
958            "tool" => route.tool.as_ref(),
959            "resource" => route.resource.as_ref(),
960            "prompt" => route.prompt.as_ref(),
961            "llm" => route.llm.as_ref(),
962            _ => continue,
963        };
964        let base_specificity = match score_entity_match(entity_matcher, entity_name) {
965            Some(score) => score,
966            None => continue,
967        };
968
969        let when_bonus = if route.when.is_some() {
970            SPECIFICITY_WHEN_ONLY
971        } else {
972            0
973        };
974        let total = base_specificity + scope_bonus + when_bonus;
975
976        if best.is_none_or(|(s, _)| total > s) {
977            best = Some((total, route));
978        }
979    }
980
981    best.map(|(_, route)| route)
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    // Helper: empty tags for tests that don't need them
989    fn no_tags() -> HashSet<String> {
990        HashSet::new()
991    }
992
993    #[test]
994    fn test_parse_minimal_config() {
995        let yaml = r#"
996plugins:
997  - name: rate_limiter
998    kind: builtin
999    hooks: [tool_pre_invoke]
1000    mode: sequential
1001    priority: 5
1002    config:
1003      max_requests: 100
1004"#;
1005        let config = parse_config(yaml).unwrap();
1006        assert!(!config.routing_enabled());
1007        assert_eq!(config.plugins.len(), 1);
1008        assert_eq!(config.plugins[0].name, "rate_limiter");
1009    }
1010
1011    #[test]
1012    fn test_no_plugin_settings_defaults_routing_disabled() {
1013        let yaml = r#"
1014plugins:
1015  - name: test
1016    kind: builtin
1017    hooks: [tool_pre_invoke]
1018"#;
1019        let config = parse_config(yaml).unwrap();
1020        assert!(!config.routing_enabled());
1021        assert_eq!(config.plugin_settings.plugin_timeout, 30);
1022    }
1023
1024    #[test]
1025    fn test_routing_enabled() {
1026        let yaml = r#"
1027plugin_settings:
1028  routing_enabled: true
1029global:
1030  policies:
1031    all:
1032      plugins: [identity]
1033plugins:
1034  - name: identity
1035    kind: builtin
1036    hooks: [identity_resolve]
1037routes:
1038  - tool: get_compensation
1039    meta:
1040      tags: [pii]
1041"#;
1042        let config = parse_config(yaml).unwrap();
1043        assert!(config.routing_enabled());
1044    }
1045
1046    #[test]
1047    fn test_duplicate_plugin_names_rejected() {
1048        let yaml = r#"
1049plugins:
1050  - name: dup
1051    kind: builtin
1052    hooks: [tool_pre_invoke]
1053  - name: dup
1054    kind: builtin
1055    hooks: [tool_post_invoke]
1056"#;
1057        assert!(parse_config(yaml)
1058            .unwrap_err()
1059            .to_string()
1060            .contains("duplicate plugin name"));
1061    }
1062
1063    #[test]
1064    fn test_route_requires_one_entity_matcher() {
1065        let yaml = r#"
1066plugin_settings:
1067  routing_enabled: true
1068plugins: []
1069routes:
1070  - meta:
1071      tags: [pii]
1072"#;
1073        assert!(parse_config(yaml)
1074            .unwrap_err()
1075            .to_string()
1076            .contains("no entity matcher"));
1077    }
1078
1079    #[test]
1080    fn test_route_rejects_multiple_entity_matchers() {
1081        let yaml = r#"
1082plugin_settings:
1083  routing_enabled: true
1084plugins: []
1085routes:
1086  - tool: get_compensation
1087    resource: "hr://employees/*"
1088"#;
1089        assert!(parse_config(yaml)
1090            .unwrap_err()
1091            .to_string()
1092            .contains("multiple entity matchers"));
1093    }
1094
1095    #[test]
1096    fn test_route_unknown_plugin_rejected() {
1097        let yaml = r#"
1098plugin_settings:
1099  routing_enabled: true
1100plugins:
1101  - name: known
1102    kind: builtin
1103    hooks: [tool_pre_invoke]
1104routes:
1105  - tool: get_compensation
1106    plugins:
1107      - unknown
1108"#;
1109        assert!(parse_config(yaml)
1110            .unwrap_err()
1111            .to_string()
1112            .contains("unknown plugin 'unknown'"));
1113    }
1114
1115    #[test]
1116    fn test_policy_group_unknown_plugin_rejected() {
1117        let yaml = r#"
1118plugin_settings:
1119  routing_enabled: true
1120global:
1121  policies:
1122    all:
1123      plugins: [nonexistent]
1124plugins: []
1125routes: []
1126"#;
1127        assert!(parse_config(yaml)
1128            .unwrap_err()
1129            .to_string()
1130            .contains("unknown plugin 'nonexistent'"));
1131    }
1132
1133    #[test]
1134    fn test_resolve_conditions_mode_returns_all() {
1135        let yaml = r#"
1136plugins:
1137  - name: a
1138    kind: builtin
1139    hooks: [tool_pre_invoke]
1140  - name: b
1141    kind: builtin
1142    hooks: [tool_post_invoke]
1143"#;
1144        let config = parse_config(yaml).unwrap();
1145        let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags());
1146        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1147        assert_eq!(names, vec!["a", "b"]);
1148    }
1149
1150    #[test]
1151    fn test_resolve_routes_inherits_policy_groups() {
1152        let yaml = r#"
1153plugin_settings:
1154  routing_enabled: true
1155global:
1156  policies:
1157    all:
1158      plugins:
1159        - identity
1160    pii:
1161      plugins:
1162        - apl_policy
1163plugins:
1164  - name: identity
1165    kind: builtin
1166    hooks: [identity_resolve]
1167  - name: apl_policy
1168    kind: builtin
1169    hooks: [cmf.tool_pre_invoke]
1170routes:
1171  - tool: get_compensation
1172    meta:
1173      tags: [pii]
1174"#;
1175        let config = parse_config(yaml).unwrap();
1176        let resolved =
1177            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1178        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1179        assert!(names.contains(&"identity"));
1180        assert!(names.contains(&"apl_policy"));
1181    }
1182
1183    #[test]
1184    fn test_resolve_no_matching_route_gets_all_only() {
1185        let yaml = r#"
1186plugin_settings:
1187  routing_enabled: true
1188global:
1189  policies:
1190    all:
1191      plugins:
1192        - identity
1193plugins:
1194  - name: identity
1195    kind: builtin
1196    hooks: [identity_resolve]
1197routes:
1198  - tool: get_compensation
1199"#;
1200        let config = parse_config(yaml).unwrap();
1201        let resolved =
1202            resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags());
1203        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1204        assert_eq!(names, vec!["identity"]);
1205    }
1206
1207    #[test]
1208    fn test_exact_match_beats_glob() {
1209        let yaml = r#"
1210plugin_settings:
1211  routing_enabled: true
1212plugins:
1213  - name: specific
1214    kind: builtin
1215    hooks: [tool_pre_invoke]
1216  - name: general
1217    kind: builtin
1218    hooks: [tool_pre_invoke]
1219routes:
1220  - tool: "hr-*"
1221    plugins:
1222      - general
1223  - tool: hr-compensation
1224    plugins:
1225      - specific
1226"#;
1227        let config = parse_config(yaml).unwrap();
1228        let resolved =
1229            resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags());
1230        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1231        assert!(names.contains(&"specific"));
1232        assert!(!names.contains(&"general"));
1233    }
1234
1235    #[test]
1236    fn test_plugin_ref_bare_name() {
1237        let yaml = r#"
1238plugin_settings:
1239  routing_enabled: true
1240plugins:
1241  - name: rate_limiter
1242    kind: builtin
1243    hooks: [tool_pre_invoke]
1244routes:
1245  - tool: get_compensation
1246    plugins:
1247      - rate_limiter
1248"#;
1249        let config = parse_config(yaml).unwrap();
1250        let resolved =
1251            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1252        assert_eq!(resolved[0].name, "rate_limiter");
1253        assert!(resolved[0].config_overrides.is_none());
1254    }
1255
1256    #[test]
1257    fn test_plugin_ref_with_overrides() {
1258        let yaml = r#"
1259plugin_settings:
1260  routing_enabled: true
1261plugins:
1262  - name: rate_limiter
1263    kind: builtin
1264    hooks: [tool_pre_invoke]
1265    config:
1266      max_requests: 100
1267routes:
1268  - tool: get_compensation
1269    plugins:
1270      - rate_limiter:
1271          config:
1272            max_requests: 10
1273"#;
1274        let config = parse_config(yaml).unwrap();
1275        let resolved =
1276            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1277        assert_eq!(resolved[0].name, "rate_limiter");
1278        assert!(resolved[0].config_overrides.is_some());
1279        let overrides = resolved[0].config_overrides.as_ref().unwrap();
1280        assert_eq!(overrides["config"]["max_requests"], 10);
1281    }
1282
1283    #[test]
1284    fn test_plugin_ref_mixed_bare_and_overrides() {
1285        let yaml = r#"
1286plugin_settings:
1287  routing_enabled: true
1288plugins:
1289  - name: rate_limiter
1290    kind: builtin
1291    hooks: [tool_pre_invoke]
1292  - name: pii_scanner
1293    kind: builtin
1294    hooks: [tool_pre_invoke]
1295routes:
1296  - tool: get_compensation
1297    plugins:
1298      - rate_limiter
1299      - pii_scanner:
1300          config:
1301            sensitivity: high
1302"#;
1303        let config = parse_config(yaml).unwrap();
1304        let resolved =
1305            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1306        assert_eq!(resolved.len(), 2);
1307        assert_eq!(resolved[0].name, "rate_limiter");
1308        assert!(resolved[0].config_overrides.is_none());
1309        assert_eq!(resolved[1].name, "pii_scanner");
1310        assert!(resolved[1].config_overrides.is_some());
1311    }
1312
1313    #[test]
1314    fn test_deduplication_preserves_order() {
1315        let yaml = r#"
1316plugin_settings:
1317  routing_enabled: true
1318global:
1319  policies:
1320    all:
1321      plugins: [a, b]
1322    pii:
1323      plugins: [b, c]
1324plugins:
1325  - name: a
1326    kind: builtin
1327    hooks: [tool_pre_invoke]
1328  - name: b
1329    kind: builtin
1330    hooks: [tool_pre_invoke]
1331  - name: c
1332    kind: builtin
1333    hooks: [tool_pre_invoke]
1334routes:
1335  - tool: get_compensation
1336    meta:
1337      tags: [pii]
1338"#;
1339        let config = parse_config(yaml).unwrap();
1340        let resolved =
1341            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1342        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1343        assert_eq!(names, vec!["a", "b", "c"]);
1344    }
1345
1346    #[test]
1347    fn test_glob_trailing_wildcard() {
1348        let matcher = StringOrList::Single(Pattern::new("hr-*"));
1349        assert!(matcher.matches("hr-compensation"));
1350        assert!(matcher.matches("hr-benefits"));
1351        assert!(matcher.matches("hr-")); // empty match for *
1352        assert!(!matcher.matches("finance-report"));
1353        assert!(!matcher.matches("hr"));
1354    }
1355
1356    #[test]
1357    fn test_wildcard_matches_everything() {
1358        let matcher = StringOrList::Single(Pattern::new("*"));
1359        assert!(matcher.matches("anything"));
1360        assert!(matcher.matches(""));
1361    }
1362
1363    /// Regression for the security footgun: `*suffix` patterns were
1364    /// silently matching almost nothing because the previous matcher
1365    /// looked for `"*suffix"` as a literal prefix.
1366    #[test]
1367    fn test_glob_leading_wildcard() {
1368        let matcher = StringOrList::Single(Pattern::new("*-prod"));
1369        assert!(matcher.matches("foo-prod"));
1370        assert!(matcher.matches("-prod")); // empty match for *
1371        assert!(!matcher.matches("foo-staging"));
1372        assert!(!matcher.matches("prod"));
1373    }
1374
1375    /// Regression for `prefix*suffix` patterns also broken before.
1376    #[test]
1377    fn test_glob_mid_wildcard() {
1378        let matcher = StringOrList::Single(Pattern::new("hr-*-v1"));
1379        assert!(matcher.matches("hr-comp-v1"));
1380        assert!(matcher.matches("hr--v1")); // empty match for *
1381        assert!(!matcher.matches("hr-comp-v2"));
1382        assert!(!matcher.matches("finance-comp-v1"));
1383    }
1384
1385    /// Multiple-wildcard patterns must work everywhere `*` appears.
1386    #[test]
1387    fn test_glob_multiple_wildcards() {
1388        let matcher = StringOrList::Single(Pattern::new("*hr*comp*"));
1389        assert!(matcher.matches("hr-comp"));
1390        assert!(matcher.matches("xyz-hr-comp-foo"));
1391        assert!(!matcher.matches("hr-only"));
1392        assert!(!matcher.matches("comp-only"));
1393    }
1394
1395    /// Regression for the OTHER security footgun: multi-star patterns
1396    /// like `**` were `trim_end_matches('*')`'d to `""` and then matched
1397    /// every name via `starts_with("")`. With wildmatch this is a
1398    /// degenerate-but-correct "match anything" pattern, equivalent to `*`.
1399    #[test]
1400    fn test_glob_multi_star_is_equivalent_to_single_star() {
1401        for pattern in &["**", "***", "*****"] {
1402            let matcher = StringOrList::Single(Pattern::new(*pattern));
1403            assert!(
1404                matcher.matches("anything"),
1405                "pattern {} should match",
1406                pattern
1407            );
1408            assert!(
1409                matcher.matches(""),
1410                "pattern {} should match empty",
1411                pattern
1412            );
1413        }
1414    }
1415
1416    /// `WildMatch` is built once at deserialize / `Pattern::new` time and
1417    /// reused; this test just sanity-checks the round-trip through serde.
1418    #[test]
1419    fn test_pattern_round_trips_through_yaml() {
1420        let yaml = "tool: '*-prod'";
1421        #[derive(Deserialize, Serialize)]
1422        struct Wrap {
1423            tool: StringOrList,
1424        }
1425        let parsed: Wrap = serde_yaml::from_str(yaml).unwrap();
1426        assert!(parsed.tool.matches("foo-prod"));
1427        assert!(!parsed.tool.matches("foo-staging"));
1428        let back = serde_yaml::to_string(&parsed).unwrap();
1429        assert!(
1430            back.contains("*-prod"),
1431            "serialized YAML should preserve pattern: {}",
1432            back
1433        );
1434    }
1435
1436    #[test]
1437    fn test_list_matches_any_member() {
1438        let matcher = StringOrList::List(vec![
1439            "get_compensation".to_string(),
1440            "get_benefits".to_string(),
1441        ]);
1442        assert!(matcher.matches("get_compensation"));
1443        assert!(matcher.matches("get_benefits"));
1444        assert!(!matcher.matches("send_email"));
1445    }
1446
1447    #[test]
1448    fn test_validation_skipped_when_routing_disabled() {
1449        let yaml = r#"
1450plugins:
1451  - name: test
1452    kind: builtin
1453    hooks: [tool_pre_invoke]
1454routes:
1455  - meta:
1456      tags: [pii]
1457"#;
1458        let config = parse_config(yaml);
1459        assert!(config.is_ok());
1460    }
1461
1462    // -- Scope matching tests --
1463
1464    #[test]
1465    fn test_scope_match_selects_scoped_route() {
1466        let yaml = r#"
1467plugin_settings:
1468  routing_enabled: true
1469plugins:
1470  - name: scoped_plugin
1471    kind: builtin
1472    hooks: [tool_pre_invoke]
1473  - name: global_plugin
1474    kind: builtin
1475    hooks: [tool_pre_invoke]
1476routes:
1477  - tool: get_compensation
1478    meta:
1479      scope: hr-services
1480    plugins:
1481      - scoped_plugin
1482  - tool: get_compensation
1483    plugins:
1484      - global_plugin
1485"#;
1486        let config = parse_config(yaml).unwrap();
1487
1488        // With matching scope — scoped route wins (more specific)
1489        let resolved = resolve_plugins_for_entity(
1490            &config,
1491            "tool",
1492            "get_compensation",
1493            Some("hr-services"),
1494            &no_tags(),
1495        );
1496        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1497        assert!(names.contains(&"scoped_plugin"));
1498        assert!(!names.contains(&"global_plugin"));
1499
1500        // Without scope — global route matches
1501        let resolved =
1502            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1503        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1504        assert!(names.contains(&"global_plugin"));
1505        assert!(!names.contains(&"scoped_plugin"));
1506
1507        // With different scope — global route matches (scoped doesn't)
1508        let resolved = resolve_plugins_for_entity(
1509            &config,
1510            "tool",
1511            "get_compensation",
1512            Some("billing"),
1513            &no_tags(),
1514        );
1515        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1516        assert!(names.contains(&"global_plugin"));
1517        assert!(!names.contains(&"scoped_plugin"));
1518    }
1519
1520    // -- Tag merging tests --
1521
1522    #[test]
1523    fn test_host_tags_merged_with_route_tags() {
1524        let yaml = r#"
1525plugin_settings:
1526  routing_enabled: true
1527global:
1528  policies:
1529    pii:
1530      plugins: [pii_plugin]
1531    runtime_tag:
1532      plugins: [runtime_plugin]
1533plugins:
1534  - name: pii_plugin
1535    kind: builtin
1536    hooks: [tool_pre_invoke]
1537  - name: runtime_plugin
1538    kind: builtin
1539    hooks: [tool_pre_invoke]
1540routes:
1541  - tool: get_compensation
1542    meta:
1543      tags: [pii]
1544"#;
1545        let config = parse_config(yaml).unwrap();
1546
1547        // Host provides a runtime tag that matches a policy group
1548        let mut host_tags = HashSet::new();
1549        host_tags.insert("runtime_tag".to_string());
1550
1551        let resolved =
1552            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags);
1553        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1554
1555        // Both route's static tag (pii) and host's runtime tag activate their groups
1556        assert!(names.contains(&"pii_plugin"));
1557        assert!(names.contains(&"runtime_plugin"));
1558    }
1559
1560    // -- When clause carried tests --
1561
1562    #[test]
1563    fn test_when_clause_carried_on_resolved_plugins() {
1564        let yaml = r#"
1565plugin_settings:
1566  routing_enabled: true
1567plugins:
1568  - name: conditional_plugin
1569    kind: builtin
1570    hooks: [tool_pre_invoke]
1571routes:
1572  - tool: get_compensation
1573    when: "args.include_ssn == true"
1574    plugins:
1575      - conditional_plugin
1576"#;
1577        let config = parse_config(yaml).unwrap();
1578        let resolved =
1579            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1580        assert_eq!(resolved[0].name, "conditional_plugin");
1581        assert_eq!(
1582            resolved[0].when.as_deref(),
1583            Some("args.include_ssn == true")
1584        );
1585    }
1586
1587    #[test]
1588    fn test_when_clause_not_on_policy_group_plugins() {
1589        let yaml = r#"
1590plugin_settings:
1591  routing_enabled: true
1592global:
1593  policies:
1594    all:
1595      plugins: [global_plugin]
1596plugins:
1597  - name: global_plugin
1598    kind: builtin
1599    hooks: [tool_pre_invoke]
1600  - name: route_plugin
1601    kind: builtin
1602    hooks: [tool_pre_invoke]
1603routes:
1604  - tool: get_compensation
1605    when: "args.sensitive == true"
1606    plugins:
1607      - route_plugin
1608"#;
1609        let config = parse_config(yaml).unwrap();
1610        let resolved =
1611            resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags());
1612
1613        // global_plugin has no when clause (from all group)
1614        let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap();
1615        assert!(global.when.is_none());
1616
1617        // route_plugin carries the route's when clause
1618        let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap();
1619        assert_eq!(route.when.as_deref(), Some("args.sensitive == true"));
1620    }
1621
1622    // ---- route-level `identity:` block ----
1623
1624    #[test]
1625    fn parse_route_identity_list_form() {
1626        let yaml = r#"
1627plugins:
1628  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1629  - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1630routes:
1631  - tool: get_weather
1632    identity:
1633      - corp-jwt
1634      - spiffe-attestor
1635"#;
1636        let cfg = parse_config(yaml).unwrap();
1637        let route = &cfg.routes[0];
1638        let id = route.identity.as_ref().expect("identity present");
1639        assert!(!id.replace_inherited);
1640        assert_eq!(id.steps.len(), 2);
1641        assert_eq!(id.steps[0].name, "corp-jwt");
1642        assert!(id.steps[0].config_override.is_none());
1643        assert!(id.steps[0].on_error.is_none());
1644        assert_eq!(id.steps[1].name, "spiffe-attestor");
1645    }
1646
1647    #[test]
1648    fn parse_route_identity_object_form_carries_replace_inherited() {
1649        let yaml = r#"
1650plugins:
1651  - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
1652routes:
1653  - tool: legacy
1654    identity:
1655      replace_inherited: true
1656      steps:
1657        - legacy-basic-auth
1658"#;
1659        let cfg = parse_config(yaml).unwrap();
1660        let id = cfg.routes[0].identity.as_ref().unwrap();
1661        assert!(id.replace_inherited);
1662        assert_eq!(id.steps.len(), 1);
1663        assert_eq!(id.steps[0].name, "legacy-basic-auth");
1664    }
1665
1666    #[test]
1667    fn parse_route_identity_map_step_with_on_error_and_config() {
1668        let yaml = r#"
1669plugins:
1670  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1671routes:
1672  - tool: get_weather
1673    identity:
1674      - name: corp-jwt
1675        on_error: deny
1676        config:
1677          audience: my-tool
1678"#;
1679        let cfg = parse_config(yaml).unwrap();
1680        let id = cfg.routes[0].identity.as_ref().unwrap();
1681        let s0 = &id.steps[0];
1682        assert_eq!(s0.name, "corp-jwt");
1683        assert_eq!(s0.on_error.as_deref(), Some("deny"));
1684        let cfg_override = s0.config_override.as_ref().expect("config_override set");
1685        assert_eq!(
1686            cfg_override.get("audience").and_then(|v| v.as_str()),
1687            Some("my-tool"),
1688        );
1689    }
1690
1691    #[test]
1692    fn parse_route_identity_mixed_bare_and_map_steps() {
1693        let yaml = r#"
1694plugins:
1695  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1696  - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1697routes:
1698  - tool: get_weather
1699    identity:
1700      - name: corp-jwt
1701        on_error: deny
1702      - spiffe-attestor
1703"#;
1704        let cfg = parse_config(yaml).unwrap();
1705        let steps = &cfg.routes[0].identity.as_ref().unwrap().steps;
1706        assert_eq!(steps.len(), 2);
1707        assert_eq!(steps[0].on_error.as_deref(), Some("deny"));
1708        assert!(steps[1].on_error.is_none());
1709    }
1710
1711    #[test]
1712    fn parse_route_identity_object_form_without_steps_errors() {
1713        let yaml = r#"
1714routes:
1715  - tool: bad
1716    identity:
1717      replace_inherited: true
1718"#;
1719        let err = parse_config(yaml).expect_err("object form requires steps");
1720        let msg = format!("{err}");
1721        assert!(msg.contains("requires `steps:`"), "got: {msg}");
1722    }
1723
1724    #[test]
1725    fn parse_route_identity_replace_inherited_must_be_boolean() {
1726        let yaml = r#"
1727routes:
1728  - tool: bad
1729    identity:
1730      replace_inherited: "yes"
1731      steps:
1732        - corp-jwt
1733"#;
1734        let err = parse_config(yaml).expect_err("replace_inherited must be bool");
1735        let msg = format!("{err}");
1736        assert!(msg.contains("boolean"), "got: {msg}");
1737    }
1738
1739    #[test]
1740    fn parse_route_identity_empty_step_name_errors() {
1741        let yaml = r#"
1742routes:
1743  - tool: bad
1744    identity:
1745      - ""
1746"#;
1747        let err = parse_config(yaml).expect_err("empty step name should fail");
1748        let msg = format!("{err}");
1749        assert!(msg.contains("empty"), "got: {msg}");
1750    }
1751
1752    #[test]
1753    fn parse_route_identity_scalar_shape_errors() {
1754        let yaml = r#"
1755routes:
1756  - tool: bad
1757    identity: 42
1758"#;
1759        let err = parse_config(yaml).expect_err("scalar identity should fail");
1760        let msg = format!("{err}");
1761        assert!(msg.contains("list of steps"), "got: {msg}");
1762    }
1763
1764    // ---- resolve_identity_plugins_for_route ----
1765
1766    #[test]
1767    fn resolve_identity_returns_empty_when_no_route_matches() {
1768        let yaml = r#"
1769plugins:
1770  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1771routes:
1772  - tool: get_weather
1773    identity:
1774      - corp-jwt
1775"#;
1776        let cfg = parse_config(yaml).unwrap();
1777        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None);
1778        assert!(resolved.is_empty());
1779    }
1780
1781    #[test]
1782    fn resolve_identity_returns_empty_when_route_has_no_identity_block() {
1783        let yaml = r#"
1784plugins:
1785  - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] }
1786routes:
1787  - tool: get_weather
1788    plugins:
1789      - rate_limiter
1790"#;
1791        let cfg = parse_config(yaml).unwrap();
1792        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1793        assert!(resolved.is_empty());
1794    }
1795
1796    #[test]
1797    fn resolve_identity_preserves_declared_order() {
1798        let yaml = r#"
1799plugins:
1800  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1801  - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] }
1802  - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1803routes:
1804  - tool: get_weather
1805    identity:
1806      - spiffe-attestor
1807      - corp-jwt
1808      - agent-context
1809"#;
1810        let cfg = parse_config(yaml).unwrap();
1811        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1812        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1813        assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]);
1814    }
1815
1816    #[test]
1817    fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() {
1818        // `create_override_instance` reads `overrides.get("config")`
1819        // — `resolve_identity_plugins_for_route` wraps the step's
1820        // `config_override` under that key so the existing override
1821        // pathway picks it up without a special case.
1822        let yaml = r#"
1823plugins:
1824  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1825routes:
1826  - tool: get_weather
1827    identity:
1828      - name: corp-jwt
1829        config:
1830          audience: my-tool
1831"#;
1832        let cfg = parse_config(yaml).unwrap();
1833        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1834        assert_eq!(resolved.len(), 1);
1835        let overrides = resolved[0]
1836            .config_overrides
1837            .as_ref()
1838            .expect("overrides wrapped");
1839        let config = overrides.get("config").expect("config key present");
1840        assert_eq!(
1841            config.get("audience").and_then(|v| v.as_str()),
1842            Some("my-tool")
1843        );
1844    }
1845
1846    // ---- Slice C: global + tag-bundle inheritance ----
1847
1848    #[test]
1849    fn resolve_identity_includes_global_layer_when_route_has_no_block() {
1850        // global.identity defined; route declares no identity. The
1851        // route should inherit the global steps unchanged.
1852        let yaml = r#"
1853plugin_settings:
1854  routing_enabled: true
1855plugins:
1856  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1857global:
1858  identity:
1859    - corp-jwt
1860routes:
1861  - tool: get_weather
1862"#;
1863        let cfg = parse_config(yaml).unwrap();
1864        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1865        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1866        assert_eq!(names, vec!["corp-jwt"]);
1867    }
1868
1869    #[test]
1870    fn resolve_identity_appends_route_steps_after_global_by_default() {
1871        // global → route is the standard stacking. Route's `identity:`
1872        // is the list form (implicit replace_inherited=false), so
1873        // its steps APPEND after the global's.
1874        let yaml = r#"
1875plugin_settings:
1876  routing_enabled: true
1877plugins:
1878  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1879  - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1880global:
1881  identity:
1882    - corp-jwt
1883routes:
1884  - tool: get_weather
1885    identity:
1886      - agent-context
1887"#;
1888        let cfg = parse_config(yaml).unwrap();
1889        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None);
1890        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1891        assert_eq!(names, vec!["corp-jwt", "agent-context"]);
1892    }
1893
1894    #[test]
1895    fn resolve_identity_stacks_global_then_tag_bundle_then_route() {
1896        // Full stack: global + tag bundle + route, all contributing.
1897        // Order is global first, then the matching tag's bundle,
1898        // then the route's own steps.
1899        let yaml = r#"
1900plugin_settings:
1901  routing_enabled: true
1902plugins:
1903  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1904  - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1905  - { name: agent-context, kind: builtin, hooks: [identity.resolve] }
1906global:
1907  identity:
1908    - corp-jwt
1909  policies:
1910    finance:
1911      identity:
1912        - workday-saml
1913routes:
1914  - tool: get_compensation
1915    meta:
1916      tags: [finance]
1917    identity:
1918      - agent-context
1919"#;
1920        let cfg = parse_config(yaml).unwrap();
1921        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None);
1922        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1923        assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]);
1924    }
1925
1926    #[test]
1927    fn resolve_identity_replace_inherited_drops_global_and_tag_layers() {
1928        // Route says `replace_inherited: true` → only route's steps
1929        // survive. Global and tag-bundle contributions get dropped.
1930        let yaml = r#"
1931plugin_settings:
1932  routing_enabled: true
1933plugins:
1934  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1935  - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1936  - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] }
1937global:
1938  identity:
1939    - corp-jwt
1940  policies:
1941    finance:
1942      identity:
1943        - workday-saml
1944routes:
1945  - tool: legacy_endpoint
1946    meta:
1947      tags: [finance]
1948    identity:
1949      replace_inherited: true
1950      steps:
1951        - legacy-basic-auth
1952"#;
1953        let cfg = parse_config(yaml).unwrap();
1954        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None);
1955        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1956        assert_eq!(names, vec!["legacy-basic-auth"]);
1957    }
1958
1959    #[test]
1960    fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() {
1961        // `replace_inherited: true` + `steps: []` is the explicit
1962        // opt-out — anonymous routes use this to suppress inherited
1963        // identity entirely.
1964        let yaml = r#"
1965plugin_settings:
1966  routing_enabled: true
1967plugins:
1968  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
1969global:
1970  identity:
1971    - corp-jwt
1972routes:
1973  - tool: anonymous_endpoint
1974    identity:
1975      replace_inherited: true
1976      steps: []
1977"#;
1978        let cfg = parse_config(yaml).unwrap();
1979        let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None);
1980        assert!(resolved.is_empty());
1981    }
1982
1983    #[test]
1984    fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() {
1985        // The tag bundle's identity only contributes when the route
1986        // declares the matching tag — not for unrelated routes.
1987        let yaml = r#"
1988plugin_settings:
1989  routing_enabled: true
1990plugins:
1991  - { name: workday-saml, kind: builtin, hooks: [identity.resolve] }
1992global:
1993  policies:
1994    finance:
1995      identity:
1996        - workday-saml
1997routes:
1998  - tool: with_tag
1999    meta:
2000      tags: [finance]
2001  - tool: without_tag
2002"#;
2003        let cfg = parse_config(yaml).unwrap();
2004
2005        let tagged = resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None);
2006        assert_eq!(
2007            tagged.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
2008            vec!["workday-saml"],
2009        );
2010
2011        let untagged = resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None);
2012        assert!(
2013            untagged.is_empty(),
2014            "tag bundle should NOT apply to untagged routes"
2015        );
2016    }
2017
2018    #[test]
2019    fn resolve_identity_scope_filtering_matches_other_route_resolution() {
2020        // Identity routing uses the same `find_matching_route`
2021        // scope-aware matcher as the generic `plugins:` resolution,
2022        // so requests for a different scope shouldn't pick up
2023        // identity from this route.
2024        let yaml = r#"
2025plugins:
2026  - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] }
2027routes:
2028  - tool: get_weather
2029    meta:
2030      scope: tenant-a
2031    identity:
2032      - corp-jwt
2033"#;
2034        let cfg = parse_config(yaml).unwrap();
2035        let matching =
2036            resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-a"));
2037        assert_eq!(matching.len(), 1);
2038
2039        let non_matching =
2040            resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b"));
2041        assert!(non_matching.is_empty());
2042    }
2043
2044    // -----------------------------------------------------------------
2045    // `plugins:` accepts both shapes (map-tolerant deserializer)
2046    //
2047    // A *sequence* is the structural activation list. A *mapping* is the
2048    // APL per-plugin override form (consumed by the APL visitor from the
2049    // raw YAML), so it deserializes to an empty structural list here.
2050    // Before this, a map at route/defaults/policy scope failed the whole
2051    // `CpexConfig` parse with "invalid type: map, expected a sequence".
2052    //
2053    // These exercise deserialization directly (not `parse_config`, which
2054    // also runs `validate_config`'s plugin-reference checks) because the
2055    // bug being fixed was a *deserialize-time* failure.
2056    // -----------------------------------------------------------------
2057
2058    fn deserialize_cfg(yaml: &str) -> Result<CpexConfig, String> {
2059        serde_yaml::from_str(yaml).map_err(|e| e.to_string())
2060    }
2061
2062    #[test]
2063    fn route_plugins_list_parses_as_activation_list() {
2064        let cfg = deserialize_cfg(
2065            r#"
2066routes:
2067  - tool: get_weather
2068    plugins:
2069      - rate_limiter
2070      - pii_scanner:
2071          config:
2072            sensitivity: high
2073"#,
2074        )
2075        .unwrap();
2076        let plugins = &cfg.routes[0].plugins;
2077        assert_eq!(plugins.len(), 2);
2078        assert_eq!(plugins[0].name(), "rate_limiter");
2079        assert_eq!(plugins[1].name(), "pii_scanner");
2080    }
2081
2082    #[test]
2083    fn route_plugins_map_loads_as_empty_structural_list() {
2084        let cfg = deserialize_cfg(
2085            r#"
2086routes:
2087  - tool: get_weather
2088    plugins:
2089      audit:
2090        on_error: ignore
2091"#,
2092        )
2093        .expect("flat plugins map must deserialize");
2094        assert!(
2095            cfg.routes[0].plugins.is_empty(),
2096            "a plugins map is APL-override data, not a structural activation list",
2097        );
2098    }
2099
2100    #[test]
2101    fn defaults_and_policies_plugins_map_loads() {
2102        let cfg = deserialize_cfg(
2103            r#"
2104global:
2105  defaults:
2106    tool:
2107      plugins:
2108        audit:
2109          on_error: ignore
2110  policies:
2111    sensitive:
2112      plugins:
2113        pii_scanner:
2114          config:
2115            sensitivity: high
2116"#,
2117        )
2118        .expect("defaults/policies plugins map must deserialize");
2119        assert!(cfg.global.defaults["tool"].plugins.is_empty());
2120        assert!(cfg.global.policies["sensitive"].plugins.is_empty());
2121    }
2122
2123    #[test]
2124    fn scalar_plugins_value_is_rejected_with_clear_error() {
2125        let err = deserialize_cfg(
2126            r#"
2127routes:
2128  - tool: get_weather
2129    plugins: nonsense
2130"#,
2131        )
2132        .expect_err("scalar plugins must error");
2133        assert!(
2134            err.contains("sequence") && err.contains("mapping"),
2135            "expected a shape-aware error, got: {err}",
2136        );
2137    }
2138}