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}