Skip to main content

cpex_core/
plugin.rs

1// Location: ./crates/cpex-core/src/plugin.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Plugin trait and supporting types.
7//
8// Defines the core Plugin trait that all plugin implementations satisfy —
9// native Rust, WASM hosts, Python bridge hosts, and dlopen'd shared
10// libraries. Also defines PluginConfig (YAML-declared plugin settings),
11// PluginMode (5-phase execution modes), and OnError (failure behavior).
12//
13// The Plugin trait handles lifecycle only (initialize, shutdown, config).
14// Hook-specific logic is defined by handler traits generated by the
15// define_hook! macro (see hooks/macros.rs). A plugin implements Plugin
16// for lifecycle + one or more handler traits for the hooks it handles.
17//
18// The manager wraps each plugin in a PluginRef with an authoritative
19// config from the config loader — the plugin's own config() is for
20// the plugin's reading only, never used by the executor for scheduling.
21
22use std::collections::HashSet;
23use std::fmt;
24
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27
28use crate::error::PluginError;
29
30// ---------------------------------------------------------------------------
31// Plugin Trait
32// ---------------------------------------------------------------------------
33
34/// Core plugin interface — lifecycle management only.
35///
36/// Every plugin in the CPEX framework — regardless of language or
37/// deployment model — implements this trait. It covers lifecycle
38/// (initialize, shutdown) and identity (config). Hook-specific logic
39/// is defined separately by handler traits generated by `define_hook!`.
40///
41/// # Lifecycle
42///
43/// 1. `initialize()` — called once after loading, before any hooks fire.
44/// 2. Hook handlers — called on each hook invocation (defined by handler traits).
45/// 3. `shutdown()` — called once during graceful teardown.
46///
47/// # Hook Handlers
48///
49/// A plugin implements one or more handler traits alongside Plugin:
50///
51/// ```rust,ignore
52/// impl Plugin for MyPlugin {
53///     fn config(&self) -> &PluginConfig { &self.config }
54///     async fn initialize(&self) -> Result<(), Box<PluginError>> { Ok(()) }
55///     async fn shutdown(&self) -> Result<(), Box<PluginError>> { Ok(()) }
56/// }
57///
58/// impl CmfHookHandler for MyPlugin {
59///     fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult<MessagePayload> {
60///         PluginResult::allow()
61///     }
62/// }
63/// ```
64///
65/// # Trust Model
66///
67/// The manager wraps each plugin in a `PluginRef` with an authoritative
68/// config from the config loader. The executor reads scheduling decisions
69/// (mode, priority, hooks, capabilities) from the `PluginRef` — never
70/// from `plugin.config()`. The plugin's own `config()` is available for
71/// the plugin's reading during hook execution.
72///
73/// # Implementors
74///
75/// - Native Rust plugins (implement directly)
76/// - `cpex-hosts::wasm` (bridges to WASM guest via wasmtime)
77/// - `cpex-hosts::python` (bridges to Python plugin classes via PyO3)
78/// - `cpex-hosts::native` (bridges to dlopen'd shared libraries)
79#[async_trait]
80pub trait Plugin: Send + Sync {
81    /// Returns the plugin's configuration.
82    ///
83    /// Available for the plugin's own reading during hook execution.
84    /// The manager/executor never reads this — they use the authoritative
85    /// config from `PluginRef.trusted_config()`.
86    fn config(&self) -> &PluginConfig;
87
88    /// One-time initialization after loading.
89    ///
90    /// Called before any hook invocations. Use this to establish
91    /// connections, load resources, or validate configuration.
92    /// Default implementation does nothing.
93    async fn initialize(&self) -> Result<(), Box<PluginError>> {
94        Ok(())
95    }
96
97    /// Graceful shutdown.
98    ///
99    /// Called once during teardown. Use this to flush buffers, close
100    /// connections, or release resources.
101    /// Default implementation does nothing.
102    async fn shutdown(&self) -> Result<(), Box<PluginError>> {
103        Ok(())
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Plugin Configuration
109// ---------------------------------------------------------------------------
110
111/// Declared plugin configuration from the unified YAML config.
112///
113/// Controls how the framework loads, schedules, and gates the plugin.
114/// Corresponds to a single entry in the `plugins:` list in config YAML.
115///
116/// The manager holds the authoritative copy in `PluginRef.trusted_config`.
117/// The plugin receives its own copy for reading via `Plugin::config()`.
118///
119/// # Examples
120///
121/// ```yaml
122/// plugins:
123///   - name: apl-policy
124///     kind: builtin
125///     hooks: [tool_pre_invoke, tool_post_invoke]
126///     mode: sequential
127///     priority: 10
128///     on_error: fail
129///     capabilities: [read_security, append_labels]
130///     config:
131///       policy_file: apl/demo/hr_policy.yaml
132/// ```
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct PluginConfig {
135    /// Unique plugin name.
136    pub name: String,
137
138    /// Plugin kind — determines how the framework loads it.
139    ///
140    /// - `"builtin"` — compiled into the runtime
141    /// - `"native://path/to/lib.so"` — dlopen'd shared library
142    /// - `"wasm://path/to/plugin.wasm"` — wasmtime sandbox
143    /// - `"python://module.path.ClassName"` — PyO3 bridge
144    /// - `"external"` — MCP/gRPC/Unix socket transport
145    pub kind: String,
146
147    /// Human-readable description.
148    #[serde(default)]
149    pub description: Option<String>,
150
151    /// Plugin author or team.
152    #[serde(default)]
153    pub author: Option<String>,
154
155    /// Semantic version string.
156    #[serde(default)]
157    pub version: Option<String>,
158
159    /// Hook names this plugin handles.
160    #[serde(default)]
161    pub hooks: Vec<String>,
162
163    /// Execution mode — determines scheduling behavior and authority.
164    #[serde(default)]
165    pub mode: PluginMode,
166
167    /// Execution priority — lower numbers execute first within each mode.
168    #[serde(default = "default_priority")]
169    pub priority: i32,
170
171    /// Error handling behavior when the plugin fails.
172    #[serde(default)]
173    pub on_error: OnError,
174
175    /// Declared capabilities for extension visibility gating.
176    ///
177    /// Controls which extensions the plugin can see and modify.
178    /// Extensions not covered by declared capabilities appear as
179    /// `None` in the filtered view.
180    #[serde(default)]
181    pub capabilities: HashSet<String>,
182
183    /// Tags for categorization and searchability.
184    #[serde(default)]
185    pub tags: Vec<String>,
186
187    /// Legacy conditions for when the plugin should execute.
188    ///
189    /// Each condition narrows the plugin's scope by server, tenant,
190    /// tool name, prompt name, etc. If any condition in the list
191    /// matches, the plugin runs. If the list is empty (default),
192    /// the plugin runs unconditionally.
193    ///
194    /// **Backward compatibility:** Conditions are the legacy mechanism
195    /// for scoping plugins. When the host uses the unified routing
196    /// system (`routes:` in config YAML), routing rules handle scope
197    /// matching and conditions on the plugin are ignored. The two
198    /// mechanisms should not be used together on the same plugin.
199    #[serde(default)]
200    pub conditions: Vec<PluginCondition>,
201
202    /// Plugin-specific configuration (opaque to the framework).
203    #[serde(default)]
204    pub config: Option<serde_json::Value>,
205}
206
207impl PluginConfig {
208    /// Whether this plugin's `conditions` allow it to fire for the given
209    /// request `Extensions`. Used in legacy mode (`routing_enabled: false`)
210    /// to filter which plugins run per request — mirrors the Python
211    /// implementation's per-plugin condition filtering.
212    ///
213    /// Semantics:
214    /// - Empty `conditions` Vec → fire always (no restriction).
215    /// - Non-empty → fire if ANY condition matches (OR across the list,
216    ///   AND within each individual condition).
217    ///
218    /// Field-source mapping (see project memory `project_conditions_field_mapping`):
219    /// - `server_ids` ← `extensions.mcp.{tool|resource|prompt}.server_id`
220    /// - `tenant_ids` ← `extensions.security.subject.claims["tenant"]`
221    /// - `tools|prompts|resources` ← `extensions.meta.entity_name` (when matching `entity_type`)
222    /// - `agents` ← `extensions.agent.agent_id`
223    /// - `user_patterns` ← `extensions.security.subject.id` (glob match)
224    /// - `content_types` ← `extensions.mcp.resource.mime_type`
225    pub fn passes_conditions(&self, extensions: &crate::hooks::payload::Extensions) -> bool {
226        if self.conditions.is_empty() {
227            return true;
228        }
229
230        // Source values once from the extensions tree.
231        let server_id = extensions.mcp.as_ref().and_then(|m| {
232            m.tool
233                .as_ref()
234                .and_then(|t| t.server_id.as_deref())
235                .or_else(|| m.resource.as_ref().and_then(|r| r.server_id.as_deref()))
236                .or_else(|| m.prompt.as_ref().and_then(|p| p.server_id.as_deref()))
237        });
238        let tenant_id = extensions
239            .security
240            .as_ref()
241            .and_then(|s| s.subject.as_ref())
242            .and_then(|sub| sub.claims.get("tenant"))
243            .map(|s| s.as_str());
244        let entity_name = extensions
245            .meta
246            .as_ref()
247            .and_then(|m| m.entity_name.as_deref());
248        let entity_type = extensions
249            .meta
250            .as_ref()
251            .and_then(|m| m.entity_type.as_deref());
252        let (tool, prompt, resource) = match entity_type {
253            Some("tool") => (entity_name, None, None),
254            Some("prompt") => (None, entity_name, None),
255            Some("resource") => (None, None, entity_name),
256            _ => (None, None, None),
257        };
258        let agent = extensions
259            .agent
260            .as_ref()
261            .and_then(|a| a.agent_id.as_deref());
262        let user = extensions
263            .security
264            .as_ref()
265            .and_then(|s| s.subject.as_ref())
266            .and_then(|sub| sub.id.as_deref());
267        let content_type = extensions
268            .mcp
269            .as_ref()
270            .and_then(|m| m.resource.as_ref())
271            .and_then(|r| r.mime_type.as_deref());
272
273        let ctx = MatchContext {
274            server_id,
275            tenant_id,
276            tool,
277            prompt,
278            resource,
279            agent,
280            user,
281            content_type,
282        };
283        self.conditions.iter().any(|c| c.matches(&ctx))
284    }
285}
286
287fn default_priority() -> i32 {
288    100
289}
290
291// ---------------------------------------------------------------------------
292// Plugin Condition (legacy scoping)
293// ---------------------------------------------------------------------------
294
295/// Condition for when a plugin should execute.
296///
297/// Narrows plugin scope to specific servers, tenants, tools, prompts,
298/// resources, or agents. All fields are optional — only specified
299/// fields participate in matching. Within a field, any match suffices
300/// (OR semantics). Across fields, all must match (AND semantics).
301///
302/// This is the legacy scoping mechanism. The unified routing system
303/// (`routes:` in config) supersedes this — when routes are used,
304/// conditions are ignored.
305///
306/// Mirrors Python's `PluginCondition` in `cpex/framework/models.py`.
307///
308/// # Examples
309///
310/// ```
311/// use cpex_core::plugin::PluginCondition;
312///
313/// // Only run for specific tools on specific servers
314/// let cond = PluginCondition {
315///     server_ids: Some(vec!["server-1".into(), "server-2".into()].into_iter().collect()),
316///     tools: Some(vec!["get_compensation".into()].into_iter().collect()),
317///     ..Default::default()
318/// };
319/// assert!(cond.server_ids.as_ref().unwrap().contains("server-1"));
320/// assert!(cond.tools.as_ref().unwrap().contains("get_compensation"));
321/// ```
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct PluginCondition {
324    /// Set of server IDs — plugin runs only on these servers.
325    #[serde(default)]
326    pub server_ids: Option<HashSet<String>>,
327
328    /// Set of tenant IDs — plugin runs only for these tenants.
329    #[serde(default)]
330    pub tenant_ids: Option<HashSet<String>>,
331
332    /// Set of tool names — plugin runs only for these tools.
333    #[serde(default)]
334    pub tools: Option<HashSet<String>>,
335
336    /// Set of prompt names — plugin runs only for these prompts.
337    #[serde(default)]
338    pub prompts: Option<HashSet<String>>,
339
340    /// Set of resource identifiers — plugin runs only for these resources.
341    #[serde(default)]
342    pub resources: Option<HashSet<String>>,
343
344    /// Set of agent identifiers — plugin runs only for these agents.
345    #[serde(default)]
346    pub agents: Option<HashSet<String>>,
347
348    /// User patterns (glob or regex) — plugin runs only for matching users.
349    #[serde(default)]
350    pub user_patterns: Option<Vec<String>>,
351
352    /// Content types — plugin runs only for these content types.
353    #[serde(default)]
354    pub content_types: Option<Vec<String>>,
355}
356
357/// Bundle of optional context values used to evaluate a `PluginCondition`.
358///
359/// Each field corresponds to one of the condition's gates. `None` means
360/// "no value sourced from the extensions tree"; the condition then
361/// rejects when the corresponding `Some(set)` is set on the condition
362/// (i.e., the gate was specified but couldn't be evaluated).
363///
364/// Replaces an 8-arg `matches(...)` call where every arg was
365/// `Option<&str>` and could be misordered silently.
366#[derive(Debug, Default, Clone, Copy)]
367pub struct MatchContext<'a> {
368    pub server_id: Option<&'a str>,
369    pub tenant_id: Option<&'a str>,
370    pub tool: Option<&'a str>,
371    pub prompt: Option<&'a str>,
372    pub resource: Option<&'a str>,
373    pub agent: Option<&'a str>,
374    pub user: Option<&'a str>,
375    pub content_type: Option<&'a str>,
376}
377
378impl PluginCondition {
379    /// Whether this condition matches the given context.
380    ///
381    /// A field that is `None` is treated as "any" (no restriction).
382    /// A `Some(set)` field matches if the given value is in the set
383    /// (exact match for ID-shaped fields; glob match via `wildmatch`
384    /// for `user_patterns`).
385    /// All specified fields must match — AND semantics within one condition.
386    pub fn matches(&self, ctx: &MatchContext<'_>) -> bool {
387        let MatchContext {
388            server_id,
389            tenant_id,
390            tool,
391            prompt,
392            resource,
393            agent,
394            user,
395            content_type,
396        } = *ctx;
397        let check_set = |field: &Option<HashSet<String>>, value: Option<&str>| -> bool {
398            match field {
399                None => true, // not specified — matches anything
400                Some(set) => match value {
401                    Some(v) => set.contains(v),
402                    None => false, // field required but no value provided
403                },
404            }
405        };
406
407        // user_patterns: list of globs. Match if any pattern matches the user.
408        let check_patterns = |field: &Option<Vec<String>>, value: Option<&str>| -> bool {
409            match field {
410                None => true,
411                Some(patterns) => match value {
412                    Some(v) => patterns
413                        .iter()
414                        .any(|p| wildmatch::WildMatch::new(p).matches(v)),
415                    None => false,
416                },
417            }
418        };
419
420        // content_types: list of exact strings.
421        let check_list = |field: &Option<Vec<String>>, value: Option<&str>| -> bool {
422            match field {
423                None => true,
424                Some(list) => match value {
425                    Some(v) => list.iter().any(|s| s == v),
426                    None => false,
427                },
428            }
429        };
430
431        check_set(&self.server_ids, server_id)
432            && check_set(&self.tenant_ids, tenant_id)
433            && check_set(&self.tools, tool)
434            && check_set(&self.prompts, prompt)
435            && check_set(&self.resources, resource)
436            && check_set(&self.agents, agent)
437            && check_patterns(&self.user_patterns, user)
438            && check_list(&self.content_types, content_type)
439    }
440}
441
442// ---------------------------------------------------------------------------
443// Plugin Mode
444// ---------------------------------------------------------------------------
445
446/// Execution mode — determines a plugin's scheduling behavior and authority.
447///
448/// The 5-phase model defines both what a plugin *can do* (block, modify)
449/// and *how it runs* (serial, parallel, background). Scheduling is derived
450/// from mode; plugin authors don't control it directly.
451///
452/// # Execution Order
453///
454/// ```text
455/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
456/// ```
457///
458/// # Mode Capabilities
459///
460/// | Mode           | Can Block? | Can Modify? | Execution       |
461/// |----------------|------------|-------------|-----------------|
462/// | Sequential     | Yes        | Yes         | Serial, chained |
463/// | Transform      | No         | Yes         | Serial, chained |
464/// | Audit          | No         | No          | Serial          |
465/// | Concurrent     | Yes        | No          | Parallel        |
466/// | FireAndForget  | No         | No          | Background      |
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
468#[serde(rename_all = "snake_case")]
469#[non_exhaustive]
470pub enum PluginMode {
471    /// Policy enforcement + transformation. Serial, chained. Can block and modify.
472    #[default]
473    Sequential,
474
475    /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block.
476    Transform,
477
478    /// Observation and logging. Serial, read-only. Cannot block or modify.
479    Audit,
480
481    /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify.
482    Concurrent,
483
484    /// Telemetry and async side effects. Background tasks. Cannot block or modify.
485    FireAndForget,
486
487    /// Plugin is disabled — skipped during execution.
488    Disabled,
489}
490
491impl PluginMode {
492    /// Whether this mode allows the plugin to block the pipeline.
493    pub fn can_block(&self) -> bool {
494        matches!(self, Self::Sequential | Self::Concurrent)
495    }
496
497    /// Whether this mode allows the plugin to modify the payload.
498    pub fn can_modify(&self) -> bool {
499        matches!(self, Self::Sequential | Self::Transform)
500    }
501
502    /// Whether the framework waits for this plugin to complete.
503    pub fn is_awaited(&self) -> bool {
504        !matches!(self, Self::FireAndForget | Self::Disabled)
505    }
506}
507
508impl fmt::Display for PluginMode {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        match self {
511            Self::Sequential => write!(f, "sequential"),
512            Self::Transform => write!(f, "transform"),
513            Self::Audit => write!(f, "audit"),
514            Self::Concurrent => write!(f, "concurrent"),
515            Self::FireAndForget => write!(f, "fire_and_forget"),
516            Self::Disabled => write!(f, "disabled"),
517        }
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Error Handling Mode
523// ---------------------------------------------------------------------------
524
525/// Error handling behavior when a plugin fails.
526///
527/// Independent of [`PluginMode`] — any mode can use any error behavior.
528/// Controls whether plugin failures halt the pipeline, are logged and
529/// skipped, or cause the plugin to be auto-disabled.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
531#[serde(rename_all = "snake_case")]
532#[non_exhaustive]
533pub enum OnError {
534    /// Pipeline halts and error propagates. Fail-safe enforcement.
535    #[default]
536    Fail,
537
538    /// Error logged, pipeline continues. For non-critical plugins.
539    Ignore,
540
541    /// Plugin auto-disabled after error. Prevents repeated failures.
542    Disable,
543}
544
545impl fmt::Display for OnError {
546    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
547        match self {
548            Self::Fail => write!(f, "fail"),
549            Self::Ignore => write!(f, "ignore"),
550            Self::Disable => write!(f, "disable"),
551        }
552    }
553}