Skip to main content

praxis_policy_core/
plugin.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// Plugin trait and supporting types.
5//
6// Defines the core Plugin trait that all plugin implementations satisfy —
7// native Rust, WASM hosts, and dlopen'd shared
8// libraries. Also defines PluginConfig (YAML-declared plugin settings),
9// PluginMode (5-phase execution modes), and OnError (failure behavior).
10//
11// The Plugin trait handles lifecycle only (initialize, shutdown, config).
12// Hook-specific logic is defined by handler traits generated by the
13// define_hook! macro (see hooks/macros.rs). A plugin implements Plugin
14// for lifecycle + one or more handler traits for the hooks it handles.
15//
16// The engine wraps each plugin in a PluginRef with an authoritative
17// config from the config loader — the plugin's own config() is for
18// the plugin's reading only, never used by the executor for scheduling.
19
20use std::collections::HashSet;
21use std::fmt;
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26
27use crate::error::PluginError;
28use crate::host::InitExtensions;
29
30/// Core plugin interface — lifecycle management only.
31///
32/// Every plugin in the PPE framework — regardless of language or
33/// deployment model — implements this trait. It covers lifecycle
34/// (initialize, shutdown) and identity (config). Hook-specific logic
35/// is defined separately by handler traits generated by `define_hook!`.
36///
37/// # Lifecycle
38///
39/// 1. `initialize()` — called once after loading, before any hooks fire.
40/// 2. Hook handlers — called on each hook invocation (defined by handler traits).
41/// 3. `shutdown()` — called once during graceful teardown.
42///
43/// # Hook Handlers
44///
45/// A plugin implements one or more handler traits alongside Plugin:
46///
47/// ```rust,ignore
48/// impl Plugin for MyPlugin {
49///     fn config(&self) -> &PluginConfig { &self.config }
50///     async fn initialize(&self) -> Result<(), Box<PluginError>> { Ok(()) }
51///     async fn shutdown(&self) -> Result<(), Box<PluginError>> { Ok(()) }
52/// }
53///
54/// impl CmfHookHandler for MyPlugin {
55///     fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult<MessagePayload> {
56///         PluginResult::allow()
57///     }
58/// }
59/// ```
60///
61/// # Trust Model
62///
63/// The engine wraps each plugin in a `PluginRef` with an authoritative
64/// config from the config loader. The executor reads scheduling decisions
65/// (mode, priority, hooks, capabilities) from the `PluginRef` — never
66/// from `plugin.config()`. The plugin's own `config()` is available for
67/// the plugin's reading during hook execution.
68///
69/// # Implementors
70///
71/// - Native Rust plugins (implement directly)
72/// - Out-of-process host bridges (WASM, dlopen'd native), which live
73///   outside this repository
74#[async_trait]
75pub trait Plugin: Send + Sync {
76    /// Returns the plugin's configuration.
77    ///
78    /// Available for the plugin's own reading during hook execution.
79    /// The engine/executor never reads this — they use the authoritative
80    /// config from `PluginRef.trusted_config()`.
81    fn config(&self) -> &PluginConfig;
82
83    /// One-time initialization after loading.
84    ///
85    /// Called before any hook invocations. Use this to establish
86    /// connections, load resources, or validate configuration.
87    /// Default implementation does nothing.
88    ///
89    /// Implement this when initialization needs nothing from the host.
90    /// A plugin that must reach a host service — an outbound HTTP call
91    /// to fetch signing keys, say — implements
92    /// [`initialize_with`](Self::initialize_with) instead.
93    async fn initialize(&self) -> Result<(), Box<PluginError>> {
94        Ok(())
95    }
96
97    /// One-time initialization, with the host services this plugin is
98    /// permitted to borrow.
99    ///
100    /// This is the method the engine calls. The default forwards to
101    /// [`initialize`](Self::initialize), so a plugin needing no host
102    /// services implements that one and never names [`InitExtensions`].
103    ///
104    /// `ext` carries only what this plugin's capabilities allow, and
105    /// only for the duration of the call. Borrow per use and store
106    /// nothing: the grants are re-evaluated on every request, so a
107    /// handle stashed here would outlive a capability an operator later
108    /// revoked. At request time the same services arrive through
109    /// `Extensions`, which implements the same
110    /// [`HostServices`](crate::host::HostServices) trait.
111    ///
112    /// # Implementing
113    ///
114    /// Override **exactly one** of this and [`initialize`](Self::initialize).
115    /// The default body here is what calls `initialize`, so overriding
116    /// this one replaces it — if you implement both, call
117    /// `self.initialize().await` yourself or its work silently never
118    /// runs.
119    ///
120    /// # Errors
121    ///
122    /// Returns `PluginError` when initialization fails. Returning an
123    /// error aborts engine startup and shuts down the plugins that
124    /// already initialized, so the engine never comes up half-started.
125    async fn initialize_with(&self, ext: &InitExtensions) -> Result<(), Box<PluginError>> {
126        let _ = ext;
127        self.initialize().await
128    }
129
130    /// Graceful shutdown.
131    ///
132    /// Called once during teardown. Use this to flush buffers, close
133    /// connections, or release resources.
134    /// Default implementation does nothing.
135    async fn shutdown(&self) -> Result<(), Box<PluginError>> {
136        Ok(())
137    }
138
139    /// If this plugin is also an audit sink, return it as one so the engine
140    /// attaches it to the executor's verdict emit when the plugin is
141    /// registered, config-driven registration included.
142    ///
143    /// A sink sees the verdict and every plugin's action but returns nothing,
144    /// so it cannot influence the outcome it records. Default: not a sink.
145    fn as_audit_handler(self: Arc<Self>) -> Option<Arc<dyn crate::audit::AuditHandler>> {
146        None
147    }
148}
149
150/// Declared plugin configuration from the unified YAML config.
151///
152/// Controls how the framework loads, schedules, and gates the plugin.
153/// Corresponds to a single entry in the `plugins:` list in config YAML.
154///
155/// The engine holds the authoritative copy in `PluginRef.trusted_config`.
156/// The plugin receives its own copy for reading via `Plugin::config()`.
157///
158/// # Examples
159///
160/// ```yaml
161/// plugins:
162///   - name: apl-policy
163///     kind: builtin
164///     # Validated at load; see the `hooks` field for the names that
165///     # dispatch and what a host with its own hooks has to do first.
166///     hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke]
167///     mode: sequential
168///     priority: 10
169///     on_error: fail
170///     capabilities: [read_security, append_labels]
171///     config:
172///       policy_file: apl/demo/hr_policy.yaml
173/// ```
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct PluginConfig {
176    /// Unique plugin name.
177    pub name: String,
178
179    /// Plugin kind — determines how the framework loads it.
180    ///
181    /// - `"builtin"` — compiled into the runtime
182    /// - `"native://path/to/lib.so"` — dlopen'd shared library
183    /// - `"wasm://path/to/plugin.wasm"` — wasmtime sandbox
184    /// - `"external"` — MCP/gRPC/Unix socket transport
185    pub kind: String,
186
187    /// Human-readable description.
188    #[serde(default)]
189    pub description: Option<String>,
190
191    /// Plugin author or team.
192    #[serde(default)]
193    pub author: Option<String>,
194
195    /// Semantic version string.
196    #[serde(default)]
197    pub version: Option<String>,
198
199    /// Hook names this plugin handles.
200    ///
201    /// Validated at config load: a name no hook answers to refuses the
202    /// config. What the check guarantees is that the name is one PPE
203    /// dispatches or the host has described, not that this plugin handles
204    /// it: a factory registers its handlers under names of its own
205    /// choosing, and only a factory that reads this field registers under
206    /// what it lists. The names
207    /// PPE dispatches are the constants in
208    /// [`cmf::constants`][crate::cmf::constants] plus
209    /// [`identity::HOOK_IDENTITY_RESOLVE`][crate::identity::HOOK_IDENTITY_RESOLVE],
210    /// [`delegation::HOOK_TOKEN_DELEGATE`][crate::delegation::HOOK_TOKEN_DELEGATE],
211    /// and [`elicitation::HOOK_ELICIT`][crate::elicitation::HOOK_ELICIT].
212    ///
213    /// A host with its own hooks describes them through
214    /// [`register_hook_metadata`][crate::hooks::register_hook_metadata]
215    /// **before** loading config that names them. Registering afterwards is
216    /// too late: the load has already refused.
217    ///
218    /// An empty list is not a typo, and plugins that register their handler
219    /// in Rust need nothing here.
220    #[serde(default)]
221    pub hooks: Vec<String>,
222
223    /// Execution mode — determines scheduling behavior and authority.
224    #[serde(default)]
225    pub mode: PluginMode,
226
227    /// Execution priority, under `dispatch: hooks`. Lower numbers execute first
228    /// within each mode.
229    ///
230    /// **Hook mode only.** Declaring it under `dispatch: policy` is a load error:
231    /// it orders the entries one hook holds, and policy dispatch never runs more
232    /// than one at a time. Effects run in document order and a `run(name)` step
233    /// invokes the one plugin it names, so nothing would consult the priority.
234    #[serde(default = "default_priority")]
235    pub priority: i32,
236
237    /// Error handling behavior when the plugin fails.
238    #[serde(default)]
239    pub on_error: OnError,
240
241    /// Declared capabilities for extension visibility gating.
242    ///
243    /// Controls which extensions the plugin can see and modify.
244    /// Extensions not covered by declared capabilities appear as
245    /// `None` in the filtered view.
246    #[serde(default)]
247    pub capabilities: HashSet<String>,
248
249    /// Tags for categorization and searchability.
250    #[serde(default)]
251    pub tags: Vec<String>,
252
253    /// How a plugin narrows its own scope, under `dispatch: hooks`.
254    ///
255    /// Each condition narrows the plugin's scope by server, tenant,
256    /// tool name, prompt name, etc. If any condition in the list
257    /// matches, the plugin runs. If the list is empty (default),
258    /// the plugin runs unconditionally.
259    ///
260    /// **Hook mode only.** Declaring it under `dispatch: policy` is a load
261    /// error: a policy decides dispatch there, so nothing would consult the
262    /// condition. `priority:` is restricted the same way, for the same reason.
263    #[serde(default)]
264    pub conditions: Vec<PluginCondition>,
265
266    /// Plugin-specific configuration (opaque to the framework).
267    #[serde(default)]
268    pub config: Option<serde_json::Value>,
269}
270
271impl PluginConfig {
272    /// Whether this plugin's `conditions` allow it to fire for the given
273    /// request `Extensions`. Used in hook mode (`dispatch: hooks`)
274    /// to filter which plugins run per request.
275    ///
276    /// Semantics:
277    /// - Empty `conditions` Vec → fire always (no restriction).
278    /// - Non-empty → fire if ANY condition matches (OR across the list,
279    ///   AND within each individual condition).
280    ///
281    /// Field-source mapping (see project memory `project_conditions_field_mapping`):
282    /// - `server_ids` ← `extensions.mcp.{tool|resource|prompt}.server_id`
283    /// - `tenant_ids` ← `extensions.security.subject.claims["tenant"]`
284    /// - `tools|prompts|resources` ← `extensions.meta.entity_name` (when matching `entity_type`)
285    /// - `agents` ← `extensions.agent.agent_id`
286    /// - `user_patterns` ← `extensions.security.subject.id` (glob match)
287    /// - `content_types` ← `extensions.mcp.resource.mime_type`
288    pub fn passes_conditions(&self, extensions: &crate::hooks::payload::Extensions) -> bool {
289        if self.conditions.is_empty() {
290            return true;
291        }
292
293        // Source values once from the extensions tree.
294        let server_id = extensions.mcp.as_ref().and_then(|m| {
295            m.tool
296                .as_ref()
297                .and_then(|t| t.server_id.as_deref())
298                .or_else(|| m.resource.as_ref().and_then(|r| r.server_id.as_deref()))
299                .or_else(|| m.prompt.as_ref().and_then(|p| p.server_id.as_deref()))
300        });
301        // `claim_str` renders a scalar claim, so a numeric tenant id still
302        // matches the text an operator configured. A structured claim names
303        // no single tenant and yields `None`, which `check_set` treats as
304        // no-match.
305        let tenant_claim = extensions
306            .security
307            .as_ref()
308            .and_then(|s| s.subject.as_ref())
309            .and_then(|sub| sub.claim_str("tenant"));
310        let tenant_id = tenant_claim.as_deref();
311        let entity_name = extensions
312            .meta
313            .as_ref()
314            .and_then(|m| m.entity_name.as_deref());
315        let entity_type = extensions
316            .meta
317            .as_ref()
318            .and_then(|m| m.entity_type.as_deref());
319        let (tool, prompt, resource) = match entity_type {
320            Some("tool") => (entity_name, None, None),
321            Some("prompt") => (None, entity_name, None),
322            Some("resource") => (None, None, entity_name),
323            _ => (None, None, None),
324        };
325        let agent = extensions
326            .agent
327            .as_ref()
328            .and_then(|a| a.agent_id.as_deref());
329        let user = extensions
330            .security
331            .as_ref()
332            .and_then(|s| s.subject.as_ref())
333            .and_then(|sub| sub.id.as_deref());
334        let content_type = extensions
335            .mcp
336            .as_ref()
337            .and_then(|m| m.resource.as_ref())
338            .and_then(|r| r.mime_type.as_deref());
339
340        let ctx = MatchContext {
341            server_id,
342            tenant_id,
343            tool,
344            prompt,
345            resource,
346            agent,
347            user,
348            content_type,
349        };
350        self.conditions.iter().any(|c| c.matches(&ctx))
351    }
352}
353
354fn default_priority() -> i32 {
355    100
356}
357
358/// Condition for when a plugin should execute.
359///
360/// Narrows plugin scope to specific servers, tenants, tools, prompts,
361/// resources, or agents. All fields are optional — only specified
362/// fields participate in matching. Within a field, any match suffices
363/// (OR semantics). Across fields, all must match (AND semantics).
364///
365/// Hook mode's scoping mechanism. Under `dispatch: policy` a policy step's
366/// `run(name)` scopes the plugin instead, and a `conditions:` block there is a
367/// load error rather than an ignored one.
368///
369///
370/// # Examples
371///
372/// ```
373/// use praxis_policy_core::plugin::PluginCondition;
374///
375/// // Only run for specific tools on specific servers
376/// let cond = PluginCondition {
377///     server_ids: Some(vec!["server-1".into(), "server-2".into()].into_iter().collect()),
378///     tools: Some(vec!["get_compensation".into()].into_iter().collect()),
379///     ..Default::default()
380/// };
381/// assert!(cond.server_ids.as_ref().unwrap().contains("server-1"));
382/// assert!(cond.tools.as_ref().unwrap().contains("get_compensation"));
383/// ```
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct PluginCondition {
386    /// Set of server IDs — plugin runs only on these servers.
387    #[serde(default)]
388    pub server_ids: Option<HashSet<String>>,
389
390    /// Set of tenant IDs — plugin runs only for these tenants.
391    #[serde(default)]
392    pub tenant_ids: Option<HashSet<String>>,
393
394    /// Set of tool names — plugin runs only for these tools.
395    #[serde(default)]
396    pub tools: Option<HashSet<String>>,
397
398    /// Set of prompt names — plugin runs only for these prompts.
399    #[serde(default)]
400    pub prompts: Option<HashSet<String>>,
401
402    /// Set of resource identifiers — plugin runs only for these resources.
403    #[serde(default)]
404    pub resources: Option<HashSet<String>>,
405
406    /// Set of agent identifiers — plugin runs only for these agents.
407    #[serde(default)]
408    pub agents: Option<HashSet<String>>,
409
410    /// User patterns (glob or regex) — plugin runs only for matching users.
411    #[serde(default)]
412    pub user_patterns: Option<Vec<String>>,
413
414    /// Content types — plugin runs only for these content types.
415    #[serde(default)]
416    pub content_types: Option<Vec<String>>,
417}
418
419/// Bundle of optional context values used to evaluate a `PluginCondition`.
420///
421/// Each field corresponds to one of the condition's gates. `None` means
422/// "no value sourced from the extensions tree"; the condition then
423/// rejects when the corresponding `Some(set)` is set on the condition
424/// (i.e., the gate was specified but couldn't be evaluated).
425///
426/// Replaces an 8-arg `matches(...)` call where every arg was
427/// `Option<&str>` and could be misordered silently.
428#[derive(Debug, Default, Clone, Copy)]
429pub struct MatchContext<'a> {
430    /// The server the request is bound for.
431    pub server_id: Option<&'a str>,
432    /// The tenant the request belongs to.
433    pub tenant_id: Option<&'a str>,
434    /// The tool being invoked.
435    pub tool: Option<&'a str>,
436    /// The prompt being rendered.
437    pub prompt: Option<&'a str>,
438    /// The resource being fetched.
439    pub resource: Option<&'a str>,
440    /// The calling agent.
441    pub agent: Option<&'a str>,
442    /// The acting user.
443    pub user: Option<&'a str>,
444    /// The content type in play.
445    pub content_type: Option<&'a str>,
446}
447
448impl PluginCondition {
449    /// Whether this condition matches the given context.
450    ///
451    /// A field that is `None` is treated as "any" (no restriction).
452    /// A `Some(set)` field matches if the given value is in the set
453    /// (exact match for ID-shaped fields; glob match via `wildmatch`
454    /// for `user_patterns`).
455    /// All specified fields must match — AND semantics within one condition.
456    pub fn matches(&self, ctx: &MatchContext<'_>) -> bool {
457        let MatchContext {
458            server_id,
459            tenant_id,
460            tool,
461            prompt,
462            resource,
463            agent,
464            user,
465            content_type,
466        } = *ctx;
467        let check_set = |field: &Option<HashSet<String>>, value: Option<&str>| -> bool {
468            match field {
469                None => true, // not specified — matches anything
470                Some(set) => match value {
471                    Some(v) => set.contains(v),
472                    None => false, // field required but no value provided
473                },
474            }
475        };
476
477        // user_patterns: list of globs. Match if any pattern matches the user.
478        let check_patterns = |field: &Option<Vec<String>>, value: Option<&str>| -> bool {
479            match field {
480                None => true,
481                Some(patterns) => match value {
482                    Some(v) => patterns
483                        .iter()
484                        .any(|p| wildmatch::WildMatch::new(p).matches(v)),
485                    None => false,
486                },
487            }
488        };
489
490        // content_types: list of exact strings.
491        let check_list = |field: &Option<Vec<String>>, value: Option<&str>| -> bool {
492            match field {
493                None => true,
494                Some(list) => match value {
495                    Some(v) => list.iter().any(|s| s == v),
496                    None => false,
497                },
498            }
499        };
500
501        check_set(&self.server_ids, server_id)
502            && check_set(&self.tenant_ids, tenant_id)
503            && check_set(&self.tools, tool)
504            && check_set(&self.prompts, prompt)
505            && check_set(&self.resources, resource)
506            && check_set(&self.agents, agent)
507            && check_patterns(&self.user_patterns, user)
508            && check_list(&self.content_types, content_type)
509    }
510}
511
512/// Execution mode — determines a plugin's scheduling behavior and authority.
513///
514/// The 5-phase model defines both what a plugin *can do* (block, modify)
515/// and *how it runs* (serial, parallel, background). Scheduling is derived
516/// from mode; plugin authors don't control it directly.
517///
518/// # Execution Order
519///
520/// ```text
521/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
522/// ```
523///
524/// # Mode Capabilities
525///
526/// | Mode           | Can Block? | Can Modify? | Execution       |
527/// |----------------|------------|-------------|-----------------|
528/// | Sequential     | Yes        | Yes         | Serial, chained |
529/// | Transform      | No         | Yes         | Serial, chained |
530/// | Audit          | No         | No          | Serial          |
531/// | Concurrent     | Yes        | No          | Parallel        |
532/// | FireAndForget  | No         | No          | Background      |
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
534#[serde(rename_all = "snake_case")]
535#[non_exhaustive]
536pub enum PluginMode {
537    /// Policy enforcement + transformation. Serial, chained. Can block and modify.
538    #[default]
539    Sequential,
540
541    /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block.
542    Transform,
543
544    /// Observation and logging. Serial, read-only. Cannot block or modify.
545    Audit,
546
547    /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify.
548    Concurrent,
549
550    /// Telemetry and async side effects. Background tasks. Cannot block or modify.
551    FireAndForget,
552
553    /// Plugin is disabled — skipped during execution.
554    Disabled,
555}
556
557impl PluginMode {
558    /// Whether this mode allows the plugin to block the pipeline.
559    pub fn can_block(&self) -> bool {
560        match self {
561            Self::Sequential | Self::Concurrent => true,
562            Self::Transform | Self::Audit | Self::FireAndForget | Self::Disabled => false,
563        }
564    }
565
566    /// Whether this mode allows the plugin to modify the payload.
567    pub fn can_modify(&self) -> bool {
568        match self {
569            Self::Sequential | Self::Transform => true,
570            Self::Audit | Self::Concurrent | Self::FireAndForget | Self::Disabled => false,
571        }
572    }
573
574    /// Whether the framework waits for this plugin to complete.
575    pub fn is_awaited(&self) -> bool {
576        match self {
577            Self::FireAndForget | Self::Disabled => false,
578            Self::Sequential | Self::Transform | Self::Audit | Self::Concurrent => true,
579        }
580    }
581
582    /// Whether the executor dispatches this mode as a phase.
583    ///
584    /// `Disabled` is skipped. The match is exhaustive so a new variant is a
585    /// compile error until the catalog in `docs/dev/safety-invariants.md` gains a
586    /// cell for it.
587    pub fn is_dispatch_phase(&self) -> bool {
588        match self {
589            Self::Disabled => false,
590            Self::Sequential
591            | Self::Transform
592            | Self::Audit
593            | Self::Concurrent
594            | Self::FireAndForget => true,
595        }
596    }
597
598    /// Whether a plugin in this mode may perform an irreversible external
599    /// effect (a token mint, an approval grant).
600    ///
601    /// Only the serial phases qualify. A concurrent branch is cancelled when
602    /// another branch short-circuits the phase, an audit-phase plugin is
603    /// read-only by contract, and fire-and-forget runs after the verdict is
604    /// already returned. In each of those an external act would happen for
605    /// work the pipeline discarded, and an external act cannot be discarded.
606    pub fn permits_effects(&self) -> bool {
607        matches!(self, Self::Sequential | Self::Transform)
608    }
609}
610
611impl fmt::Display for PluginMode {
612    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613        match self {
614            Self::Sequential => write!(f, "sequential"),
615            Self::Transform => write!(f, "transform"),
616            Self::Audit => write!(f, "audit"),
617            Self::Concurrent => write!(f, "concurrent"),
618            Self::FireAndForget => write!(f, "fire_and_forget"),
619            Self::Disabled => write!(f, "disabled"),
620        }
621    }
622}
623
624/// Error handling behavior when a plugin fails.
625///
626/// Independent of [`PluginMode`] — any mode can use any error behavior.
627/// Controls whether plugin failures halt the pipeline, are logged and
628/// skipped, or cause the plugin to be auto-disabled.
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
630#[serde(rename_all = "snake_case")]
631#[non_exhaustive]
632pub enum OnError {
633    /// Pipeline halts and error propagates. Fail-safe enforcement.
634    #[default]
635    Fail,
636
637    /// Error logged, pipeline continues. For non-critical plugins.
638    Ignore,
639
640    /// Plugin auto-disabled after error. Prevents repeated failures.
641    Disable,
642}
643
644impl fmt::Display for OnError {
645    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646        match self {
647            Self::Fail => write!(f, "fail"),
648            Self::Ignore => write!(f, "ignore"),
649            Self::Disable => write!(f, "disable"),
650        }
651    }
652}
653
654#[cfg(test)]
655#[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")]
656mod tests {
657    use super::*;
658    use crate::extensions::{MCPExtension, MetaExtension, SecurityExtension, SubjectExtension};
659    use crate::hooks::payload::Extensions;
660
661    // The mode predicates decide whether a plugin is allowed to block the
662    // pipeline or rewrite the payload. Nothing called them, so a wrong answer
663    // would either let an audit-only plugin deny a request or stop a sequential
664    // one from enforcing anything.
665
666    #[test]
667    fn disabled_is_the_only_mode_that_is_not_a_dispatch_phase() {
668        assert!(!PluginMode::Disabled.is_dispatch_phase());
669        for mode in [
670            PluginMode::Sequential,
671            PluginMode::Transform,
672            PluginMode::Audit,
673            PluginMode::Concurrent,
674            PluginMode::FireAndForget,
675        ] {
676            assert!(mode.is_dispatch_phase(), "{mode} must be a dispatch phase");
677        }
678    }
679
680    #[test]
681    fn only_blocking_modes_can_block() {
682        assert!(PluginMode::Sequential.can_block());
683        assert!(PluginMode::Concurrent.can_block());
684        for mode in [
685            PluginMode::Transform,
686            PluginMode::Audit,
687            PluginMode::FireAndForget,
688            PluginMode::Disabled,
689        ] {
690            assert!(!mode.can_block(), "{mode} must not be able to block");
691        }
692    }
693
694    /// `Concurrent` can block but must not modify: concurrent plugins race, so a
695    /// payload rewrite would depend on completion order.
696    #[test]
697    fn only_sequential_and_transform_can_modify() {
698        assert!(PluginMode::Sequential.can_modify());
699        assert!(PluginMode::Transform.can_modify());
700        for mode in [
701            PluginMode::Concurrent,
702            PluginMode::Audit,
703            PluginMode::FireAndForget,
704            PluginMode::Disabled,
705        ] {
706            assert!(!mode.can_modify(), "{mode} must not be able to modify");
707        }
708    }
709
710    #[test]
711    fn fire_and_forget_and_disabled_are_not_awaited() {
712        assert!(!PluginMode::FireAndForget.is_awaited());
713        assert!(!PluginMode::Disabled.is_awaited());
714        for mode in [
715            PluginMode::Sequential,
716            PluginMode::Transform,
717            PluginMode::Audit,
718            PluginMode::Concurrent,
719        ] {
720            assert!(mode.is_awaited(), "{mode} must be awaited");
721        }
722    }
723
724    /// The `Display` strings are the serde wire names, so they appear in config
725    /// and in logs. A drift here silently changes what an operator has to write.
726    #[test]
727    fn mode_and_on_error_display_as_their_config_spellings() {
728        assert_eq!(PluginMode::Sequential.to_string(), "sequential");
729        assert_eq!(PluginMode::Transform.to_string(), "transform");
730        assert_eq!(PluginMode::Audit.to_string(), "audit");
731        assert_eq!(PluginMode::Concurrent.to_string(), "concurrent");
732        assert_eq!(PluginMode::FireAndForget.to_string(), "fire_and_forget");
733        assert_eq!(PluginMode::Disabled.to_string(), "disabled");
734
735        assert_eq!(OnError::Fail.to_string(), "fail");
736        assert_eq!(OnError::Ignore.to_string(), "ignore");
737        assert_eq!(OnError::Disable.to_string(), "disable");
738    }
739
740    // ---- conditions -------------------------------------------------------
741
742    fn cfg_with(conditions: Vec<PluginCondition>) -> PluginConfig {
743        PluginConfig {
744            name: "p".into(),
745            conditions,
746            ..Default::default()
747        }
748    }
749
750    fn ext_for_entity(entity_type: &str, entity_name: &str) -> Extensions {
751        Extensions {
752            meta: Some(Arc::new(MetaExtension {
753                entity_type: Some(entity_type.to_owned()),
754                entity_name: Some(entity_name.to_owned()),
755                ..Default::default()
756            })),
757            ..Default::default()
758        }
759    }
760
761    /// No conditions means the plugin always runs. This is the default an
762    /// operator gets by omitting the block, so it must not accidentally gate.
763    #[test]
764    fn no_conditions_always_passes() {
765        assert!(cfg_with(vec![]).passes_conditions(&Extensions::default()));
766    }
767
768    /// The entity name is read from the slot matching `entity_type`, so a tool
769    /// condition must not be satisfied by a prompt of the same name. Otherwise a
770    /// plugin scoped to one entity kind would fire on another.
771    #[test]
772    fn an_entity_name_only_matches_within_its_own_entity_type() {
773        let cfg = cfg_with(vec![PluginCondition {
774            tools: Some(["search".to_owned()].into()),
775            ..Default::default()
776        }]);
777        assert!(cfg.passes_conditions(&ext_for_entity("tool", "search")));
778        assert!(
779            !cfg.passes_conditions(&ext_for_entity("prompt", "search")),
780            "a prompt named `search` must not satisfy a tools condition"
781        );
782        assert!(
783            !cfg.passes_conditions(&ext_for_entity("resource", "search")),
784            "a resource named `search` must not satisfy a tools condition"
785        );
786    }
787
788    #[test]
789    fn prompt_and_resource_conditions_read_their_own_slots() {
790        let prompt_cfg = cfg_with(vec![PluginCondition {
791            prompts: Some(["summarize".to_owned()].into()),
792            ..Default::default()
793        }]);
794        assert!(prompt_cfg.passes_conditions(&ext_for_entity("prompt", "summarize")));
795        assert!(!prompt_cfg.passes_conditions(&ext_for_entity("tool", "summarize")));
796
797        let resource_cfg = cfg_with(vec![PluginCondition {
798            resources: Some(["doc-1".to_owned()].into()),
799            ..Default::default()
800        }]);
801        assert!(resource_cfg.passes_conditions(&ext_for_entity("resource", "doc-1")));
802        assert!(!resource_cfg.passes_conditions(&ext_for_entity("tool", "doc-1")));
803    }
804
805    /// An unknown `entity_type` yields no name in any slot, so a name-based
806    /// condition cannot match. Fail closed rather than matching everything.
807    #[test]
808    fn an_unknown_entity_type_satisfies_no_name_condition() {
809        let cfg = cfg_with(vec![PluginCondition {
810            tools: Some(["search".to_owned()].into()),
811            ..Default::default()
812        }]);
813        assert!(!cfg.passes_conditions(&ext_for_entity("webhook", "search")));
814    }
815
816    /// A condition naming a value the request does not carry at all must not
817    /// pass. This is the case that decides whether an absent field reads as
818    /// "no constraint" or as "does not match", and it has to be the latter.
819    #[test]
820    fn a_condition_on_an_absent_field_does_not_pass() {
821        let cfg = cfg_with(vec![PluginCondition {
822            server_ids: Some(["srv-1".to_owned()].into()),
823            ..Default::default()
824        }]);
825        assert!(
826            !cfg.passes_conditions(&Extensions::default()),
827            "no server_id present, so a server_ids condition must not match"
828        );
829    }
830
831    /// `server_id` is sourced from whichever of tool, resource or prompt carries
832    /// one, so each fallback needs to work.
833    #[test]
834    fn server_id_is_sourced_from_tool_resource_or_prompt() {
835        let cfg = cfg_with(vec![PluginCondition {
836            server_ids: Some(["srv-1".to_owned()].into()),
837            ..Default::default()
838        }]);
839        for slot in ["tool", "resource", "prompt"] {
840            let mut mcp = MCPExtension::default();
841            match slot {
842                "tool" => {
843                    mcp.tool = Some(crate::extensions::mcp::ToolMetadata {
844                        server_id: Some("srv-1".to_owned()),
845                        ..Default::default()
846                    });
847                },
848                "resource" => {
849                    mcp.resource = Some(crate::extensions::mcp::ResourceMetadata {
850                        server_id: Some("srv-1".to_owned()),
851                        ..Default::default()
852                    });
853                },
854                _ => {
855                    mcp.prompt = Some(crate::extensions::mcp::PromptMetadata {
856                        server_id: Some("srv-1".to_owned()),
857                        ..Default::default()
858                    });
859                },
860            }
861            let ext = Extensions {
862                mcp: Some(Arc::new(mcp)),
863                ..Default::default()
864            };
865            assert!(
866                cfg.passes_conditions(&ext),
867                "server_id on the {slot} slot must satisfy the condition"
868            );
869        }
870    }
871
872    /// The tenant is read out of the subject's `tenant` claim, which is how a
873    /// multi-tenant deployment scopes a plugin to one tenant.
874    #[test]
875    fn tenant_is_read_from_the_subject_tenant_claim() {
876        let cfg = cfg_with(vec![PluginCondition {
877            tenant_ids: Some(["acme".to_owned()].into()),
878            ..Default::default()
879        }]);
880        let with_tenant = |tenant: &str| {
881            let mut sub = SubjectExtension::default();
882            sub.claims
883                .insert("tenant".to_owned(), serde_json::json!(tenant));
884            Extensions {
885                security: Some(Arc::new(SecurityExtension {
886                    subject: Some(sub),
887                    ..Default::default()
888                })),
889                ..Default::default()
890            }
891        };
892        assert!(cfg.passes_conditions(&with_tenant("acme")));
893        assert!(
894            !cfg.passes_conditions(&with_tenant("other")),
895            "a different tenant must not satisfy the condition"
896        );
897    }
898
899    /// A numeric tenant claim still matches the text an operator configured.
900    /// Before subject claims held `Value` the claim map stringified it, so a
901    /// deployment whose `IdP` mints `"tenant": 42` matched `tenant_ids: ["42"]`.
902    /// A condition that quietly stopped matching would stop running the plugin
903    /// it scopes — including one that enforces something.
904    #[test]
905    fn a_numeric_tenant_claim_still_matches_configured_text() {
906        let cfg = cfg_with(vec![PluginCondition {
907            tenant_ids: Some(["42".to_owned()].into()),
908            ..Default::default()
909        }]);
910        let mut sub = SubjectExtension::default();
911        sub.claims
912            .insert("tenant".to_owned(), serde_json::json!(42));
913        let ext = Extensions {
914            security: Some(Arc::new(SecurityExtension {
915                subject: Some(sub),
916                ..Default::default()
917            })),
918            ..Default::default()
919        };
920        assert!(cfg.passes_conditions(&ext));
921    }
922}