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}