// SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2026 Praxis Contributors // Plugin and hook registries. // // PluginRef wraps a plugin implementation with the engine's // authoritative config. The config comes from the config loader, // NOT from the plugin — the plugin never provides its own config // to the engine. This prevents a plugin from tampering with its // own priority, mode, or capabilities. // // Trust flows one direction: // config loader → engine → PluginRef → executor // The plugin is just a recipient, not a source. // // Registration also refuses a handler whose hook family is not the one // the hook name's metadata row records, so a plugin cannot take a hook // whose payload it cannot read. See `check_handler_family`. // // The registry supports two registration paths: // // 1. **Typed** (`register::()`) — for Rust plugins implementing // a handler trait generated by define_hook!. The handler is stored // type-erased alongside the PluginRef. At dispatch time, the typed // path (`invoke::()`) downcasts back; the dynamic path // (`invoke_by_name()`) calls through the type-erased interface. // // 2. **Name-based** (`register_for_names::()`) — same handler // registered under multiple hook names (the CMF pattern). use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use uuid::Uuid; use crate::context::PluginContext; use crate::hooks::HookType; use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::HookTypeDef; use crate::plugin::{Plugin, PluginConfig, PluginMode}; /// Manager-owned wrapper that pairs a plugin with its authoritative config. /// /// The `trusted_config` comes from the config loader / engine — never /// from the plugin itself. The executor reads all scheduling decisions /// (mode, priority, hooks, capabilities, `on_error`) from this config. /// /// The plugin receives a copy of its config at construction time so it /// can read its own settings during hook execution. But the engine/executor /// never reads config back from the plugin. /// /// Trust flow: /// ```text /// config loader → engine → PluginRef.trusted_config → executor /// ↘ plugin (receives a copy, cannot influence scheduling) /// ``` #[derive(Clone)] pub struct PluginRef { /// The plugin implementation. plugin: Arc, /// Authoritative config from the config loader. /// The executor uses this for all scheduling and capability decisions. trusted_config: PluginConfig, /// Unique identifier assigned by the registry. /// Stored as `Uuid` (16 bytes, `Copy`) rather than a 36-char `String` /// to avoid heap allocation per registered plugin and to give /// downstream `HashMap` keys fixed-size hashing. id: Uuid, /// Runtime circuit breaker — set to true when `on_error: Disable` /// triggers. Once set, `mode()` returns `Disabled` and the plugin /// is skipped by `group_by_mode()` on all subsequent invocations. /// Uses `Arc` so clones (in `HookEntry`) share the same flag. disabled: Arc, } impl PluginRef { /// Create a new `PluginRef` with an independently-sourced config. /// /// The `trusted_config` must come from the config loader or engine, /// NOT from `plugin.config()`. The plugin may hold its own copy /// for reading during `execute()`, but the engine never consults it. pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { Self { plugin, trusted_config, id: Uuid::new_v4(), disabled: Arc::new(AtomicBool::new(false)), } } /// The authoritative config used by the executor for all decisions. pub fn trusted_config(&self) -> &PluginConfig { &self.trusted_config } /// The plugin implementation (for calling initialize/shutdown). pub fn plugin(&self) -> &Arc { &self.plugin } /// Unique identifier assigned at registration. /// Returned by value — `Uuid` is `Copy` (16 bytes). pub fn id(&self) -> Uuid { self.id } /// Convenience: plugin name from the trusted config. pub fn name(&self) -> &str { &self.trusted_config.name } /// Effective mode — returns `Disabled` if the runtime circuit breaker /// has tripped, otherwise returns the configured mode. /// /// `Acquire` on the load pairs with `Release` on the `disable()` store /// so weak-memory-ordering hardware (ARM64) propagates the disable /// promptly across threads. pub fn mode(&self) -> PluginMode { if self.disabled.load(Ordering::Acquire) { PluginMode::Disabled } else { self.trusted_config.mode } } /// Runtime-disable this plugin (one-way circuit breaker). /// /// Called by the executor when a plugin errors with `on_error: Disable`. /// All clones of this `PluginRef` (in `HookEntry`, etc.) share the same /// `AtomicBool`, so the disable is visible across the system. /// /// `Release` ordering establishes a happens-before with `Acquire` /// loads in `is_disabled()` and `mode()` — required for correctness /// on weak-memory hardware (ARM64) where `Relaxed` allows the new /// value to remain unobserved by other threads for an unbounded window. pub fn disable(&self) { self.disabled.store(true, Ordering::Release); } /// Whether this plugin has been runtime-disabled. /// `Acquire` pairs with the `Release` in `disable()` (see `mode()`). pub fn is_disabled(&self) -> bool { self.disabled.load(Ordering::Acquire) } /// Convenience: plugin priority from the trusted config. pub fn priority(&self) -> i32 { self.trusted_config.priority } } /// Type-erased interface for calling a hook handler. /// /// The executor uses this to dispatch hooks without knowing the /// concrete handler trait at compile time. Each handler wraps a /// plugin that implements a specific handler trait (e.g., /// `CmfHookHandler`) and translates between type-erased payloads /// and the typed handler method. /// /// The executor dispatches through this trait for all five phases. /// The handler receives a borrowed payload — the framework retains /// ownership. Plugins clone only when modifying. /// /// `invoke` is async so that plugins can perform I/O (HTTP calls, /// Redis, vault lookups) without blocking the tokio runtime, and /// so that `tokio::time::timeout` can actually observe and cancel /// long-running handlers. #[async_trait::async_trait] pub trait AnyHookHandler: Send + Sync { /// Call the handler with a borrowed payload. /// /// Returns an `ErasedResultFields` (see executor module) wrapped /// as `Box`. If the handler modified the payload, the /// modified copy is in `ErasedResultFields.modified_payload`. async fn invoke( &self, payload: &dyn PluginPayload, extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, Box>; /// The hook type name this handler was registered for. fn hook_type_name(&self) -> &'static str; } /// A registered hook handler paired with its `PluginRef`. /// /// The executor uses `plugin_ref` for scheduling decisions (mode, /// priority, capabilities) and `handler` for actual dispatch. /// /// `plugin_ref` is `Arc` so cloning a `HookEntry` is two /// reference-count bumps rather than a deep clone of the embedded /// `PluginConfig`. `group_by_mode` (called once per invoke) clones N /// entries — keeping that cheap matters at high request rates. #[derive(Clone)] pub struct HookEntry { /// The plugin wrapper with authoritative config. pub plugin_ref: Arc, /// The type-erased handler for this specific hook. pub handler: Arc, } /// Manages registered plugin instances and hook handler mappings. /// /// Stores `PluginRef` wrappers by name and `HookEntry` (`PluginRef` + /// handler) by hook name. The executor reads scheduling decisions /// from `PluginRef.trusted_config` and dispatches through the /// type-erased handler. /// /// Supports two registration patterns: /// /// - `register::()` — typed registration for a single hook name /// (derived from `H::NAME`). /// - `register_for_names::()` — typed registration for multiple /// hook names (the CMF pattern where one handler covers /// `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). /// /// `Clone` is cheap-ish: the `HashMaps` duplicate, but their values are all /// `Arc`-counted (`Arc`, `Arc`), so the /// inner data is shared. Used by `PolicyEngine`'s `ArcSwap` snapshot /// pattern, where every mutating method clones the registry, mutates the /// clone, and atomically swaps in a new snapshot. #[derive(Clone)] pub struct PluginRegistry { /// Plugins keyed by name (for lookup and lifecycle). Wrapped in `Arc` /// so the same instance is shared with every `HookEntry` in /// `hook_index` — registering a plugin allocates one `PluginRef`, /// not one per hook. plugins: HashMap>, /// Hook name → list of `HookEntries`, sorted by priority. hook_index: HashMap>, } /// Refuse a handler built for a family other than the one the hook name /// expects. The row's `family` comes off the hook type, and /// [`AnyHookHandler::hook_type_name`] reports the type the handler was /// built for, so the two are comparable without a `TypeId`. /// /// Load-time rather than compile-time: `register_for_names::` never /// consults `H`, and the config-driven factory path names no hook type at /// all, so the registry is where the pairing can be checked. /// /// A row with `family: None`, and a name with no row at all, accept any /// family. The hook registry is open by design and a host declares its own /// hooks, so neither absence is treated as a refusal. fn check_handler_family( plugin_name: &str, hook_name: &str, handler: &dyn AnyHookHandler, ) -> Result<(), String> { let Some(expected) = crate::hooks::lookup_hook_metadata(hook_name).and_then(|meta| meta.family) else { return Ok(()); }; let reported = handler.hook_type_name(); if reported == expected { return Ok(()); } Err(format!( "plugin '{plugin_name}' declares hook '{hook_name}', which belongs to the \ '{expected}' hook family, but its handler serves '{reported}'" )) } impl PluginRegistry { /// Create an empty registry. pub fn new() -> Self { Self { plugins: HashMap::new(), hook_index: HashMap::new(), } } /// Register a typed hook handler for its primary hook name. /// /// The handler is registered under `H::NAME`. The `config` must /// come from the config loader — not from the plugin. The plugin /// must implement the handler trait generated by `define_hook!`. /// /// # Type Parameters /// /// - `H` — the hook type (implements `HookTypeDef`). /// /// # Arguments /// /// - `plugin` — the plugin implementation (must also implement the handler trait). /// - `config` — authoritative config from the config loader. /// - `handler` — type-erased handler wrapping the plugin's handler trait impl. /// # Errors /// /// Returns the conflict message when a plugin of the same name is already /// registered, or when a handler's hook family is not the one a hook name's /// metadata row expects. Registration is all-or-nothing: no hook name is /// recorded when any of them conflicts or is refused. pub fn register( &mut self, plugin: Arc, config: PluginConfig, handler: Arc, ) -> Result<(), String> { self.register_for_names_inner(plugin, config, handler, &[H::NAME]) } /// Register a typed hook handler for multiple hook names. /// /// This is the CMF pattern — one handler trait impl covers multiple /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). /// /// # Arguments /// /// - `plugin` — the plugin implementation. /// - `config` — authoritative config from the config loader. /// - `handler` — type-erased handler. /// - `names` — hook names to register under. #[allow( clippy::extra_unused_type_parameters, reason = "H is a marker rather than an operand: it forces the caller to name \ a hook type, which is what separates this from \ register_for_names_with_handler below" )] /// # Errors /// /// Returns the conflict message when a plugin of the same name is already /// registered, or when a handler's hook family is not the one a hook name's /// metadata row expects. Registration is all-or-nothing: no hook name is /// recorded when any of them conflicts or is refused. pub fn register_for_names( &mut self, plugin: Arc, config: PluginConfig, handler: Arc, names: &[&str], ) -> Result<(), String> { self.register_for_names_inner(plugin, config, handler, names) } /// Register a plugin with a handler for multiple hook names. /// /// Like `register_for_names` but without requiring a `HookTypeDef` /// type parameter. Used by the config-driven factory path where /// the hook type is not known at compile time — the factory /// provides the handler directly. /// # Errors /// /// Returns the conflict message when a plugin of the same name is already /// registered, or when a handler's hook family is not the one a hook name's /// metadata row expects. Registration is all-or-nothing: no hook name is /// recorded when any of them conflicts or is refused. pub fn register_for_names_with_handler( &mut self, plugin: Arc, config: PluginConfig, handler: Arc, names: &[&str], ) -> Result<(), String> { self.register_for_names_inner(plugin, config, handler, names) } /// Register a plugin with multiple handlers, each for a specific hook. /// /// Used when a plugin implements multiple hook types with different /// payloads (e.g., `ToolPreInvoke` and `ToolPostInvoke`). Each /// handler is registered under its paired hook name. /// /// The plugin is registered once in the name index. Each handler /// gets its own `HookEntry` in the hook index under the specified name. /// # Errors /// /// Returns the conflict message when a plugin of the same name is already /// registered, or when a handler's hook family is not the one a hook name's /// metadata row expects. Registration is all-or-nothing: no hook name is /// recorded when any of them conflicts or is refused. pub fn register_multi_handler( &mut self, plugin: Arc, config: PluginConfig, handlers: Vec<(&str, Arc)>, ) -> Result<(), String> { let name = config.name.clone(); if self.plugins.contains_key(&name) { return Err(format!("plugin '{name}' is already registered")); } // Every pair checked before anything is recorded, so a refused // handler leaves the registry as it was. for (hook_name, handler) in &handlers { check_handler_family(&name, hook_name, handler.as_ref())?; } let plugin_ref = Arc::new(PluginRef::new(plugin, config)); for (hook_name, handler) in &handlers { let hook_type = HookType::new(*hook_name); let entry = HookEntry { plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(handler), }; self.hook_index.entry(hook_type).or_default().push(entry); } // Sort each affected hook's entry list by trusted priority for (hook_name, _) in &handlers { let hook_type = HookType::new(*hook_name); if let Some(entries) = self.hook_index.get_mut(&hook_type) { entries.sort_by_key(|e| e.plugin_ref.priority()); } } self.plugins.insert(name, plugin_ref); Ok(()) } /// Internal: register handler under one or more hook names. Both the /// typed paths and `register_for_names_with_handler` funnel through /// here, so the family check covers every one of them. fn register_for_names_inner( &mut self, plugin: Arc, config: PluginConfig, handler: Arc, names: &[&str], ) -> Result<(), String> { let name = config.name.clone(); if self.plugins.contains_key(&name) { return Err(format!("plugin '{name}' is already registered")); } // Every name checked before anything is recorded, so a refused // handler leaves the registry as it was. for hook_name in names { check_handler_family(&name, hook_name, handler.as_ref())?; } let plugin_ref = Arc::new(PluginRef::new(plugin, config)); // Add to hook index for each specified hook name for hook_name in names { let hook_type = HookType::new(*hook_name); let entry = HookEntry { plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(&handler), }; self.hook_index.entry(hook_type).or_default().push(entry); } // Sort each affected hook's entry list by trusted priority for hook_name in names { let hook_type = HookType::new(*hook_name); if let Some(entries) = self.hook_index.get_mut(&hook_type) { entries.sort_by_key(|e| e.plugin_ref.priority()); } } self.plugins.insert(name, plugin_ref); Ok(()) } /// Unregister a plugin by name. /// /// Removes the `PluginRef` from the name index and all `HookEntries` /// from the hook index. Returns the (Arc-wrapped) `PluginRef` if found. pub fn unregister(&mut self, name: &str) -> Option> { let plugin_ref = self.plugins.remove(name)?; for entries in self.hook_index.values_mut() { entries.retain(|e| e.plugin_ref.name() != name); } self.hook_index.retain(|_, entries| !entries.is_empty()); Some(plugin_ref) } /// Look up a `PluginRef` by name. Returns an `Arc` clone so callers /// don't hold borrows on internal storage — works with snapshot-based /// dispatch where the registry may sit behind a transient guard. pub fn get(&self, name: &str) -> Option> { self.plugins.get(name).map(Arc::clone) } /// Returns all `HookEntries` for a given hook name, sorted by priority. /// /// Returns an empty slice if no plugins are registered for the hook. pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] { self.hook_index .get(hook_type) .map(std::vec::Vec::as_slice) .unwrap_or(&[]) } /// Whether any plugins are registered for the given hook name. pub fn has_hooks_for(&self, hook_type: &HookType) -> bool { self.hook_index .get(hook_type) .map(|v| !v.is_empty()) .unwrap_or(false) } /// Total number of registered plugins. pub fn plugin_count(&self) -> usize { self.plugins.len() } /// All registered plugin names. Returns owned `String`s so callers /// don't hold borrows on internal storage — works with snapshot-based /// dispatch where the registry may sit behind a transient guard. pub fn plugin_names(&self) -> Vec { self.plugins.keys().cloned().collect() } /// The audit sinks among the registered plugins. A plugin opts in by /// overriding `Plugin::as_audit_handler`; most return `None`. The engine /// attaches these to the executor's verdict emit. /// /// Each is paired with the capabilities its own `plugins:` entry declared, /// because a sink reaches every invocation and is filtered on the same /// terms as any other plugin. The capabilities come from `trusted_config`, /// the engine's copy, not from anything the plugin reports about itself. /// /// A plugin configured `mode: disabled` is left out. Sinks are called /// beside the phases rather than from one, so the phase dispatch that /// skips a disabled plugin never sees them. pub fn audit_handlers(&self) -> Vec { self.plugins .values() .filter_map(|r| { let handler = attached_audit_handler(r)?; let capabilities = r.trusted_config().capabilities.clone(); Some(crate::audit::AttachedSink::new(handler, capabilities)) }) .collect() } /// Returns whether `name` identifies a sink included in [`audit_handlers`]. /// /// [`audit_handlers`]: Self::audit_handlers pub fn attaches_as_audit_sink(&self, name: &str) -> bool { self.plugins .get(name) .is_some_and(|r| attached_audit_handler(r).is_some()) } /// Returns every (`hook_name`, `HookEntry`) pair where the entry's plugin /// matches the given name. Used by external orchestrators that need /// to build pre-resolved dispatch lineups for a single plugin across /// every hook it registered to (e.g. praxis-policy-apl-runtime deciding which entry /// handles step-style invocations vs field-style invocations for the /// same plugin). Owned tuples — no borrows held on the registry. pub fn entries_for_plugin(&self, plugin_name: &str) -> Vec<(String, HookEntry)> { let mut out = Vec::new(); for (hook_type, entries) in &self.hook_index { for entry in entries { if entry.plugin_ref.name() == plugin_name { out.push((hook_type.as_str().to_owned(), entry.clone())); } } } out } } /// Returns the plugin's audit handler unless the plugin is disabled. fn attached_audit_handler(r: &PluginRef) -> Option> { if r.trusted_config().mode == crate::plugin::PluginMode::Disabled { return None; } Arc::clone(r.plugin()).as_audit_handler() } impl Default for PluginRegistry { fn default() -> Self { Self::new() } } /// Groups a list of `HookEntries` by their execution mode. /// /// Reads mode from `plugin_ref.trusted_config` — never from the plugin. /// Returns a tuple of five vectors in execution order: /// (sequential, transform, audit, concurrent, `fire_and_forget`). /// Disabled plugins are excluded. pub type GroupedHookEntries = ( Vec, Vec, Vec, Vec, Vec, ); /// Partition entries by the phase their mode assigns them to. pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries { let mut sequential = Vec::new(); let mut transform = Vec::new(); let mut audit = Vec::new(); let mut concurrent = Vec::new(); let mut fire_and_forget = Vec::new(); for entry in entries { match entry.plugin_ref.mode() { PluginMode::Sequential => sequential.push(entry.clone()), PluginMode::Transform => transform.push(entry.clone()), PluginMode::Audit => audit.push(entry.clone()), PluginMode::Concurrent => concurrent.push(entry.clone()), PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), PluginMode::Disabled => {}, // skip } } (sequential, transform, audit, concurrent, fire_and_forget) } #[cfg(test)] #[allow( trivial_casts, clippy::expect_used, clippy::indexing_slicing, clippy::panic, clippy::print_stderr, clippy::print_stdout, clippy::unwrap_used, reason = "tests" )] mod tests { use super::*; use crate::cmf::constants::HOOK_CMF_TOOL_PRE_INVOKE; use crate::cmf::{CmfHook, MessagePayload}; use crate::error::PluginError; use crate::hooks::PluginResult; use crate::hooks::adapter::TypedHandlerAdapter; use crate::hooks::metadata::{HookMetadata, HookPhase, register_hook_metadata}; use crate::hooks::trait_def::HookHandler; use crate::http_hook::{HOOK_HTTP_REQUEST, HttpHook}; use async_trait::async_trait; // -- Test payload and hook type -- #[derive(Debug, Clone)] #[allow( dead_code, reason = "test fixture — typed shape is the point, not field reads" )] struct TestPayload { value: String, } crate::impl_plugin_payload!(TestPayload); // -- Test handler (type-erased wrapper) -- /// A simple `AnyHookHandler` that wraps a function for testing. struct TestHandler; #[async_trait] impl AnyHookHandler for TestHandler { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, Box> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } fn hook_type_name(&self) -> &'static str { "test_hook" } } // -- Test plugin -- struct TestPlugin { cfg: PluginConfig, } fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig { PluginConfig { name: name.to_owned(), kind: "test".to_owned(), description: None, author: None, version: None, hooks: hooks.into_iter().map(String::from).collect(), mode: PluginMode::Sequential, priority, on_error: Default::default(), capabilities: Default::default(), tags: Vec::new(), conditions: Vec::new(), config: None, } } impl TestPlugin { fn new(cfg: PluginConfig) -> Self { Self { cfg } } } #[async_trait] impl Plugin for TestPlugin { fn config(&self) -> &PluginConfig { &self.cfg } async fn initialize(&self) -> Result<(), Box> { Ok(()) } async fn shutdown(&self) -> Result<(), Box> { Ok(()) } } impl HookHandler for TestPlugin { async fn handle( &self, _payload: &MessagePayload, _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() } } /// The plugin and the type-erased CMF handler a real registration /// carries: `TypedHandlerAdapter` reports `CmfHook::NAME`, which is what /// the family check compares against a hook name's row. fn cmf_plugin(config: &PluginConfig) -> (Arc, Arc) { let plugin = Arc::new(TestPlugin::new(config.clone())); let handler: Arc = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); (plugin, handler) } /// A handler reporting whichever family a test needs, for the cases /// where no real hook type has that name. struct FamilyHandler(&'static str); #[async_trait] impl AnyHookHandler for FamilyHandler { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, Box> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } fn hook_type_name(&self) -> &'static str { self.0 } } // -- Tests -- #[test] fn test_register_typed_and_lookup() { let mut reg = PluginRegistry::new(); let config = make_config("test-plugin", vec!["test_hook"], 10); let plugin = Arc::new(TestPlugin::new(config.clone())); let handler: Arc = Arc::new(TestHandler); // Use register_for_names_inner directly since we don't have a real HookTypeDef reg.register_for_names_inner(plugin, config, handler, &["test_hook"]) .unwrap(); assert_eq!(reg.plugin_count(), 1); assert!(reg.get("test-plugin").is_some()); assert!(reg.has_hooks_for(&HookType::new("test_hook"))); assert!(!reg.has_hooks_for(&HookType::new("other_hook"))); let entries = reg.entries_for_hook(&HookType::new("test_hook")); assert_eq!(entries.len(), 1); assert_eq!(entries[0].plugin_ref.name(), "test-plugin"); } #[test] fn test_register_for_multiple_names() { let mut reg = PluginRegistry::new(); let config = make_config("cmf-plugin", vec![], 10); // A real CMF handler, since the CMF rows name the family they // carry and registration checks the handler against it. let (plugin, handler) = cmf_plugin(&config); reg.register_for_names_inner( plugin, config, handler, &[ "cmf.tool_pre_invoke", "cmf.tool_post_invoke", "cmf.llm_input", ], ) .unwrap(); assert_eq!(reg.plugin_count(), 1); assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke"))); assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke"))); assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input"))); assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output"))); } #[test] fn test_duplicate_registration_fails() { let mut reg = PluginRegistry::new(); let c1 = make_config("dup", vec![], 10); let c2 = make_config("dup", vec![], 20); let p1 = Arc::new(TestPlugin::new(c1.clone())); let p2 = Arc::new(TestPlugin::new(c2.clone())); let h1: Arc = Arc::new(TestHandler); let h2: Arc = Arc::new(TestHandler); reg.register_for_names_inner(p1, c1, h1, &["hook_a"]) .unwrap(); reg.register_for_names_inner(p2, c2, h2, &["hook_a"]) .unwrap_err(); } #[test] fn test_priority_ordering_uses_trusted_config() { let mut reg = PluginRegistry::new(); let c_low = make_config("low", vec![], 100); let c_high = make_config("high", vec![], 10); let p_low = Arc::new(TestPlugin::new(c_low.clone())); let p_high = Arc::new(TestPlugin::new(c_high.clone())); let h1: Arc = Arc::new(TestHandler); let h2: Arc = Arc::new(TestHandler); reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]) .unwrap(); reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]) .unwrap(); let entries = reg.entries_for_hook(&HookType::new("hook_a")); assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second } #[test] fn test_unregister() { let mut reg = PluginRegistry::new(); let config = make_config("removable", vec![], 10); let plugin = Arc::new(TestPlugin::new(config.clone())); let handler: Arc = Arc::new(TestHandler); reg.register_for_names_inner(plugin, config, handler, &["hook_a"]) .unwrap(); assert_eq!(reg.plugin_count(), 1); reg.unregister("removable"); assert_eq!(reg.plugin_count(), 0); assert!(!reg.has_hooks_for(&HookType::new("hook_a"))); } #[test] fn test_plugin_ref_id_is_unique() { let c1 = make_config("a", vec![], 10); let c2 = make_config("b", vec![], 10); let p1 = Arc::new(TestPlugin::new(c1.clone())); let p2 = Arc::new(TestPlugin::new(c2.clone())); let ref1 = PluginRef::new(p1, c1); let ref2 = PluginRef::new(p2, c2); assert_ne!(ref1.id(), ref2.id()); } #[test] fn test_tampered_plugin_config_ignored() { let trusted = make_config("sneaky", vec![], 100); let mut tampered = trusted.clone(); tampered.priority = 1; let plugin = Arc::new(TestPlugin::new(tampered)); let plugin_ref = PluginRef::new(plugin, trusted); assert_eq!(plugin_ref.priority(), 100); } #[test] fn a_cmf_handler_is_refused_for_an_http_hook_name() { // The hazard the family check closes: the HTTP hooks carry no chat // message, so a CMF handler on one would scan nothing and report // clean. The refusal names the plugin, the hook, and both families. let mut reg = PluginRegistry::new(); let config = make_config("pii-scan", vec![HOOK_HTTP_REQUEST], 10); let (plugin, handler) = cmf_plugin(&config); let err = reg .register_for_names_inner(plugin, config, handler, &[HOOK_HTTP_REQUEST]) .unwrap_err(); assert_eq!( err, "plugin 'pii-scan' declares hook 'http.request', which belongs to the \ 'http' hook family, but its handler serves 'cmf'" ); } #[test] fn a_cmf_handler_registers_for_the_cmf_names_it_serves() { let mut reg = PluginRegistry::new(); let config = make_config("pii-scan", vec![HOOK_CMF_TOOL_PRE_INVOKE], 10); let (plugin, handler) = cmf_plugin(&config); reg.register_for_names_inner(plugin, config, handler, &[HOOK_CMF_TOOL_PRE_INVOKE]) .unwrap(); assert!(reg.has_hooks_for(&HookType::new(HOOK_CMF_TOOL_PRE_INVOKE))); } #[test] fn a_refused_name_leaves_no_part_of_the_registration_behind() { // Registration is all-or-nothing, so the name that would have been // accepted must not be recorded either. let mut reg = PluginRegistry::new(); let config = make_config("pii-scan", vec![], 10); let (plugin, handler) = cmf_plugin(&config); reg.register_for_names_inner( plugin, config, handler, &[HOOK_CMF_TOOL_PRE_INVOKE, HOOK_HTTP_REQUEST], ) .unwrap_err(); assert_eq!(reg.plugin_count(), 0); assert!(!reg.has_hooks_for(&HookType::new(HOOK_CMF_TOOL_PRE_INVOKE))); assert!(!reg.has_hooks_for(&HookType::new(HOOK_HTTP_REQUEST))); } #[test] fn a_host_hook_registers_with_its_own_family_and_its_own_handler() { // The registry is open: a host declaring a hook and a handler for it // registers without praxis-policy-core knowing either. let hook = "test_registry.host_owned"; register_hook_metadata( hook, HookMetadata { family: Some("test_registry"), entity_type: None, phase: HookPhase::Unphased, }, ); let mut reg = PluginRegistry::new(); let config = make_config("host-plugin", vec![hook], 10); let plugin = Arc::new(TestPlugin::new(config.clone())); let handler: Arc = Arc::new(FamilyHandler("test_registry")); reg.register_for_names_inner(plugin, config, handler, &[hook]) .unwrap(); assert!(reg.has_hooks_for(&HookType::new(hook))); } #[test] fn a_permissive_row_accepts_a_handler_of_any_family() { // `permissive()` records no family, which is what lets a host restore // the open behavior for a hook rather than closing the registry. let hook = "test_registry.permissive"; register_hook_metadata(hook, HookMetadata::permissive()); let mut reg = PluginRegistry::new(); for (name, family) in [("as-cmf", CmfHook::NAME), ("as-http", HttpHook::NAME)] { let config = make_config(name, vec![hook], 10); let plugin = Arc::new(TestPlugin::new(config.clone())); let handler: Arc = Arc::new(FamilyHandler(family)); reg.register_for_names_inner(plugin, config, handler, &[hook]) .unwrap(); } assert_eq!(reg.entries_for_hook(&HookType::new(hook)).len(), 2); } #[test] fn a_hook_name_with_no_row_at_all_still_registers() { let hook = "test_registry.no_row"; assert!(crate::hooks::lookup_hook_metadata(hook).is_none()); let mut reg = PluginRegistry::new(); let config = make_config("unrowed", vec![], 10); let (plugin, handler) = cmf_plugin(&config); reg.register_for_names_inner(plugin, config, handler, &[hook]) .unwrap(); assert!(reg.has_hooks_for(&HookType::new(hook))); } #[test] fn a_multi_handler_registration_takes_one_family_per_name() { // The config-driven factory path: each name arrives paired with the // handler that serves it. let mut reg = PluginRegistry::new(); let config = make_config("both-families", vec![], 10); let (plugin, cmf) = cmf_plugin(&config); let http: Arc = Arc::new(FamilyHandler(HttpHook::NAME)); reg.register_multi_handler( plugin, config, vec![(HOOK_CMF_TOOL_PRE_INVOKE, cmf), (HOOK_HTTP_REQUEST, http)], ) .unwrap(); assert!(reg.has_hooks_for(&HookType::new(HOOK_CMF_TOOL_PRE_INVOKE))); assert!(reg.has_hooks_for(&HookType::new(HOOK_HTTP_REQUEST))); } #[test] fn a_multi_handler_registration_is_refused_whole_when_one_pair_mismatches() { // The path a YAML-declared plugin travels: the factory wraps one // handler type for every name the operator listed. let mut reg = PluginRegistry::new(); let config = make_config("pii-scan", vec![], 10); let (plugin, cmf) = cmf_plugin(&config); let err = reg .register_multi_handler( plugin, config, vec![ (HOOK_CMF_TOOL_PRE_INVOKE, Arc::clone(&cmf)), (HOOK_HTTP_REQUEST, cmf), ], ) .unwrap_err(); assert!(err.contains("http.request"), "{err}"); assert_eq!(reg.plugin_count(), 0); assert!(!reg.has_hooks_for(&HookType::new(HOOK_CMF_TOOL_PRE_INVOKE))); } #[tokio::test] async fn test_handler_invoke() { let handler = TestHandler; let payload = TestPayload { value: "test".into(), }; let ext = Extensions::default(); let mut ctx = PluginContext::new(); let result = handler .invoke(&payload as &dyn PluginPayload, &ext, &mut ctx) .await .unwrap(); let fields = crate::executor::extract_erased(result).unwrap(); assert!(fields.continue_processing); } } #[cfg(test)] mod audit_handler_tests { //! `audit_handlers` is the projection the engine attaches to the //! executor. A plugin that forgot to opt in must not appear in it, and //! one that did must, or a configured sink silently sees no verdicts. use async_trait::async_trait; use super::*; use crate::audit::AuditHandler; use crate::decision::DecisionLog; struct Sink(PluginConfig); #[async_trait] impl Plugin for Sink { fn config(&self) -> &PluginConfig { &self.0 } fn as_audit_handler(self: Arc) -> Option> { Some(self) } } #[async_trait] impl AuditHandler for Sink { async fn handle(&self, _p: &dyn PluginPayload, _e: &Extensions, _d: &DecisionLog) {} } struct NotASink(PluginConfig); #[async_trait] impl Plugin for NotASink { fn config(&self) -> &PluginConfig { &self.0 } } fn cfg(name: &str) -> PluginConfig { PluginConfig { name: name.to_owned(), ..Default::default() } } #[test] fn only_plugins_that_opt_in_are_collected_as_sinks() { let mut reg = PluginRegistry::new(); let sink_cfg = cfg("sink"); let plain_cfg = cfg("plain"); reg.plugins.insert( "sink".to_owned(), Arc::new(PluginRef::new(Arc::new(Sink(sink_cfg.clone())), sink_cfg)), ); reg.plugins.insert( "plain".to_owned(), Arc::new(PluginRef::new( Arc::new(NotASink(plain_cfg.clone())), plain_cfg, )), ); assert_eq!(reg.audit_handlers().len(), 1); assert!(reg.attaches_as_audit_sink("sink")); assert!(!reg.attaches_as_audit_sink("plain")); assert!(!reg.attaches_as_audit_sink("unregistered")); } /// `mode: disabled` turns a sink off like any other plugin. #[test] fn a_disabled_sink_is_not_collected() { let mut reg = PluginRegistry::new(); let sink_cfg = PluginConfig { mode: crate::plugin::PluginMode::Disabled, ..cfg("sink") }; reg.plugins.insert( "sink".to_owned(), Arc::new(PluginRef::new(Arc::new(Sink(sink_cfg.clone())), sink_cfg)), ); assert!(reg.audit_handlers().is_empty()); assert!(!reg.attaches_as_audit_sink("sink")); } #[test] fn a_registry_with_no_sinks_yields_none() { assert!(PluginRegistry::new().audit_handlers().is_empty()); } }