Skip to main content

sen_plugin_host/
registry.rs

1//! Plugin registry with hot reload support
2//!
3//! Provides a thread-safe registry for managing loaded plugins with
4//! support for dynamic addition, removal, and updates.
5//!
6//! # Permission System Integration
7//!
8//! The registry supports an optional permission system for capability-based
9//! access control. When enabled, plugins are checked against their declared
10//! capabilities before execution.
11//!
12//! ```rust,ignore
13//! use sen_plugin_host::{PluginRegistry, PermissionPresets};
14//!
15//! // With permission checking
16//! let config = PermissionPresets::interactive("myapp")?;
17//! let registry = PluginRegistry::with_permissions(config)?;
18//!
19//! // Execution will check capabilities and prompt if needed
20//! registry.execute("hello", &["World"]).await?;
21//! ```
22
23use crate::audit::{self, TrustLevel};
24use crate::permission::{
25    PermissionConfig, PermissionContext, PermissionDecision, StoredPermission, StoredTrustLevel,
26};
27use crate::{LoadedPlugin, LoaderError, PluginLoader};
28use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use tokio::sync::RwLock;
32
33/// A thread-safe registry for managing loaded plugins
34#[derive(Clone)]
35pub struct PluginRegistry {
36    inner: Arc<RwLock<RegistryInner>>,
37    loader: Arc<PluginLoader>,
38    permission: Option<Arc<PermissionConfig>>,
39}
40
41struct RegistryInner {
42    /// Plugins indexed by command name
43    plugins: HashMap<String, PluginEntry>,
44    /// Map from file path to command name for reload tracking
45    path_to_command: HashMap<PathBuf, String>,
46}
47
48struct PluginEntry {
49    plugin: LoadedPlugin,
50    source_path: Option<PathBuf>,
51}
52
53impl PluginRegistry {
54    /// Create a new empty plugin registry
55    pub fn new() -> Result<Self, LoaderError> {
56        Ok(Self {
57            inner: Arc::new(RwLock::new(RegistryInner {
58                plugins: HashMap::new(),
59                path_to_command: HashMap::new(),
60            })),
61            loader: Arc::new(PluginLoader::new()?),
62            permission: None,
63        })
64    }
65
66    /// Create with an existing loader
67    pub fn with_loader(loader: PluginLoader) -> Self {
68        Self {
69            inner: Arc::new(RwLock::new(RegistryInner {
70                plugins: HashMap::new(),
71                path_to_command: HashMap::new(),
72            })),
73            loader: Arc::new(loader),
74            permission: None,
75        }
76    }
77
78    /// Create with permission configuration
79    ///
80    /// When permission config is set, the registry will check plugin
81    /// capabilities before execution and prompt users as needed.
82    pub fn with_permissions(config: PermissionConfig) -> Result<Self, LoaderError> {
83        Ok(Self {
84            inner: Arc::new(RwLock::new(RegistryInner {
85                plugins: HashMap::new(),
86                path_to_command: HashMap::new(),
87            })),
88            loader: Arc::new(PluginLoader::new()?),
89            permission: Some(Arc::new(config)),
90        })
91    }
92
93    /// Add permission configuration to an existing registry
94    pub fn set_permissions(&mut self, config: PermissionConfig) {
95        self.permission = Some(Arc::new(config));
96    }
97
98    /// Load and register a plugin from a file path
99    pub async fn load_plugin(&self, path: impl AsRef<Path>) -> Result<String, LoaderError> {
100        let path = path.as_ref();
101        let wasm_bytes = tokio::fs::read(path).await.map_err(|e| {
102            LoaderError::MemoryAccess(format!("Failed to read file {}: {}", path.display(), e))
103        })?;
104
105        let plugin = self.loader.load(&wasm_bytes)?;
106        let command_name = plugin.manifest.command.name.clone();
107
108        let mut inner = self.inner.write().await;
109
110        // Remove old mapping if exists
111        if let Some(old_cmd) = inner.path_to_command.remove(path) {
112            inner.plugins.remove(&old_cmd);
113        }
114
115        // Add new mappings
116        inner
117            .path_to_command
118            .insert(path.to_path_buf(), command_name.clone());
119        inner.plugins.insert(
120            command_name.clone(),
121            PluginEntry {
122                plugin,
123                source_path: Some(path.to_path_buf()),
124            },
125        );
126
127        tracing::info!(command = %command_name, path = %path.display(), "Plugin loaded");
128        Ok(command_name)
129    }
130
131    /// Register a pre-loaded plugin (without file path tracking)
132    pub async fn register(&self, plugin: LoadedPlugin) -> String {
133        let command_name = plugin.manifest.command.name.clone();
134
135        let mut inner = self.inner.write().await;
136        inner.plugins.insert(
137            command_name.clone(),
138            PluginEntry {
139                plugin,
140                source_path: None,
141            },
142        );
143
144        tracing::info!(command = %command_name, "Plugin registered");
145        command_name
146    }
147
148    /// Unload a plugin by file path
149    pub async fn unload_by_path(&self, path: impl AsRef<Path>) -> Option<String> {
150        let path = path.as_ref();
151        let mut inner = self.inner.write().await;
152
153        if let Some(command_name) = inner.path_to_command.remove(path) {
154            inner.plugins.remove(&command_name);
155            tracing::info!(command = %command_name, path = %path.display(), "Plugin unloaded");
156            Some(command_name)
157        } else {
158            None
159        }
160    }
161
162    /// Unload a plugin by command name
163    pub async fn unload(&self, command_name: &str) -> bool {
164        let mut inner = self.inner.write().await;
165
166        if let Some(entry) = inner.plugins.remove(command_name) {
167            if let Some(path) = entry.source_path {
168                inner.path_to_command.remove(&path);
169            }
170            tracing::info!(command = %command_name, "Plugin unloaded");
171            true
172        } else {
173            false
174        }
175    }
176
177    /// Reload a plugin from its source path
178    pub async fn reload_by_path(&self, path: impl AsRef<Path>) -> Result<String, LoaderError> {
179        self.load_plugin(path).await
180    }
181
182    /// Get a list of all registered command names
183    pub async fn list_commands(&self) -> Vec<String> {
184        let inner = self.inner.read().await;
185        inner.plugins.keys().cloned().collect()
186    }
187
188    /// Check if a command exists
189    pub async fn has_command(&self, command_name: &str) -> bool {
190        let inner = self.inner.read().await;
191        inner.plugins.contains_key(command_name)
192    }
193
194    /// Execute a plugin command
195    ///
196    /// If permission configuration is set, this will:
197    /// 1. Check stored permissions for the plugin
198    /// 2. Apply the permission strategy to decide allow/deny/prompt
199    /// 3. Prompt the user if needed (for interactive mode)
200    /// 4. Record audit events
201    /// 5. Execute the plugin if permitted
202    pub async fn execute(
203        &self,
204        command_name: &str,
205        args: &[String],
206    ) -> Result<sen_plugin_api::ExecuteResult, RegistryError> {
207        let mut inner = self.inner.write().await;
208
209        let entry = inner
210            .plugins
211            .get_mut(command_name)
212            .ok_or_else(|| RegistryError::CommandNotFound(command_name.to_string()))?;
213
214        // Check permissions if configured
215        if let Some(ref perm_config) = self.permission {
216            let capabilities = &entry.plugin.manifest.capabilities;
217
218            // Record permission request audit event
219            let _ = perm_config
220                .audit
221                .record(audit::permission_requested(command_name, capabilities));
222
223            // Get stored permission
224            let key =
225                perm_config
226                    .store
227                    .make_key(command_name, None, perm_config.strategy.granularity());
228            let stored = perm_config.store.get(&key).ok().flatten();
229
230            // Build context for strategy
231            let ctx = PermissionContext {
232                plugin_name: command_name,
233                command_path: &[],
234                requested: capabilities,
235                granted: stored.as_ref().map(|s| &s.capabilities),
236                interactive: perm_config.prompt.is_interactive(),
237            };
238
239            // Check for escalation
240            let decision = if let Some(ref stored_perm) = stored {
241                if stored_perm.has_escalated(capabilities) {
242                    // Record escalation audit event
243                    let _ = perm_config.audit.record(audit::escalation_detected(
244                        command_name,
245                        &stored_perm.capabilities,
246                        capabilities,
247                    ));
248                    perm_config.strategy.on_escalation(&ctx)
249                } else {
250                    perm_config.strategy.check(&ctx)
251                }
252            } else {
253                perm_config.strategy.check(&ctx)
254            };
255
256            // Handle decision
257            match decision {
258                PermissionDecision::Allow => {
259                    let _ = perm_config.audit.record(audit::permission_granted(
260                        command_name,
261                        capabilities,
262                        TrustLevel::Permanent,
263                    ));
264                }
265                PermissionDecision::Deny(reason) => {
266                    let _ = perm_config.audit.record(audit::permission_denied(
267                        command_name,
268                        capabilities,
269                        &reason,
270                    ));
271                    return Err(RegistryError::PermissionDenied {
272                        plugin: command_name.to_string(),
273                        reason,
274                    });
275                }
276                PermissionDecision::Prompt => {
277                    // Prompt user
278                    let prompt_result = if let Some(ref stored_perm) = stored {
279                        perm_config.prompt.prompt_escalation(
280                            command_name,
281                            &stored_perm.capabilities,
282                            capabilities,
283                        )
284                    } else {
285                        perm_config.prompt.prompt(command_name, capabilities)
286                    };
287
288                    match prompt_result {
289                        Ok(result) if result.is_allowed() => {
290                            // Store permission if should persist
291                            if result.should_persist() {
292                                let trust_level =
293                                    result.to_trust_level().unwrap_or(StoredTrustLevel::Session);
294                                let stored_perm =
295                                    StoredPermission::new(capabilities.clone(), trust_level);
296                                let _ = perm_config.store.set(&key, stored_perm);
297                            }
298
299                            let audit_trust = match result.to_trust_level() {
300                                Some(StoredTrustLevel::Permanent) => TrustLevel::Permanent,
301                                Some(StoredTrustLevel::Session) => TrustLevel::Session,
302                                None => TrustLevel::Once,
303                            };
304                            let _ = perm_config.audit.record(audit::permission_granted(
305                                command_name,
306                                capabilities,
307                                audit_trust,
308                            ));
309                        }
310                        Ok(_) | Err(_) => {
311                            let _ = perm_config.audit.record(audit::permission_denied(
312                                command_name,
313                                capabilities,
314                                "User denied permission",
315                            ));
316                            return Err(RegistryError::PermissionDenied {
317                                plugin: command_name.to_string(),
318                                reason: "User denied permission".to_string(),
319                            });
320                        }
321                    }
322                }
323                PermissionDecision::AllowPartial(_reduced) => {
324                    // For now, treat partial as full allow
325                    // Future: could pass reduced capabilities to plugin
326                    let _ = perm_config.audit.record(audit::permission_granted(
327                        command_name,
328                        capabilities,
329                        TrustLevel::Once,
330                    ));
331                }
332            }
333        }
334
335        entry
336            .plugin
337            .instance
338            .execute(args)
339            .map_err(RegistryError::Execution)
340    }
341
342    /// Get plugin manifest for a command
343    pub async fn get_manifest(&self, command_name: &str) -> Option<sen_plugin_api::PluginManifest> {
344        let inner = self.inner.read().await;
345        inner
346            .plugins
347            .get(command_name)
348            .map(|e| e.plugin.manifest.clone())
349    }
350
351    /// Get all plugin manifests
352    pub async fn get_all_manifests(&self) -> Vec<sen_plugin_api::PluginManifest> {
353        let inner = self.inner.read().await;
354        inner
355            .plugins
356            .values()
357            .map(|e| e.plugin.manifest.clone())
358            .collect()
359    }
360
361    /// Get the number of loaded plugins
362    pub async fn len(&self) -> usize {
363        let inner = self.inner.read().await;
364        inner.plugins.len()
365    }
366
367    /// Check if the registry is empty
368    pub async fn is_empty(&self) -> bool {
369        let inner = self.inner.read().await;
370        inner.plugins.is_empty()
371    }
372}
373
374/// Errors that can occur during registry operations
375#[derive(Debug, thiserror::Error)]
376pub enum RegistryError {
377    #[error("Command not found: {0}")]
378    CommandNotFound(String),
379
380    #[error("Plugin execution failed: {0}")]
381    Execution(#[source] LoaderError),
382
383    #[error("Permission denied for plugin '{plugin}': {reason}")]
384    PermissionDenied { plugin: String, reason: String },
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::audit::MemoryAuditSink;
391    use crate::permission::{
392        AutoPromptHandler, MemoryPermissionStore, PermissionPresets, PermissionStore, PromptResult,
393        RecordingPromptHandler,
394    };
395
396    const HELLO_PLUGIN_WASM: &[u8] = include_bytes!(
397        "../../examples/hello-plugin/target/wasm32-unknown-unknown/release/hello_plugin.wasm"
398    );
399
400    // ========================================================================
401    // Basic Registry Tests (without permissions)
402    // ========================================================================
403
404    #[tokio::test]
405    async fn test_registry_register_and_execute() {
406        let registry = PluginRegistry::new().unwrap();
407        let loader = PluginLoader::new().unwrap();
408        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
409
410        let cmd = registry.register(plugin).await;
411        assert_eq!(cmd, "hello");
412        assert!(registry.has_command("hello").await);
413
414        let result = registry
415            .execute("hello", &["World".to_string()])
416            .await
417            .unwrap();
418        match result {
419            sen_plugin_api::ExecuteResult::Success(output) => {
420                assert_eq!(output, "Hello, World!");
421            }
422            _ => panic!("Expected success"),
423        }
424    }
425
426    #[tokio::test]
427    async fn test_registry_unload() {
428        let registry = PluginRegistry::new().unwrap();
429        let loader = PluginLoader::new().unwrap();
430        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
431
432        registry.register(plugin).await;
433        assert!(registry.has_command("hello").await);
434
435        registry.unload("hello").await;
436        assert!(!registry.has_command("hello").await);
437    }
438
439    #[tokio::test]
440    async fn test_registry_list_commands() {
441        let registry = PluginRegistry::new().unwrap();
442        let loader = PluginLoader::new().unwrap();
443        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
444
445        registry.register(plugin).await;
446
447        let commands = registry.list_commands().await;
448        assert_eq!(commands, vec!["hello"]);
449    }
450
451    // ========================================================================
452    // Permission Integration Tests
453    // ========================================================================
454
455    #[tokio::test]
456    async fn test_registry_with_permissions_trust_all() {
457        // TrustAll strategy should allow execution without prompts
458        let config = PermissionPresets::trust_all_dangerous();
459        let registry = PluginRegistry::with_permissions(config).unwrap();
460
461        let loader = PluginLoader::new().unwrap();
462        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
463        registry.register(plugin).await;
464
465        let result = registry
466            .execute("hello", &["World".to_string()])
467            .await
468            .unwrap();
469
470        match result {
471            sen_plugin_api::ExecuteResult::Success(output) => {
472                assert_eq!(output, "Hello, World!");
473            }
474            _ => panic!("Expected success with trust_all"),
475        }
476    }
477
478    #[tokio::test]
479    async fn test_registry_with_permissions_testing_preset() {
480        // Testing preset uses auto-approve, should allow execution
481        let config = PermissionPresets::testing();
482        let registry = PluginRegistry::with_permissions(config).unwrap();
483
484        let loader = PluginLoader::new().unwrap();
485        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
486        registry.register(plugin).await;
487
488        let result = registry
489            .execute("hello", &["World".to_string()])
490            .await
491            .unwrap();
492
493        match result {
494            sen_plugin_api::ExecuteResult::Success(output) => {
495                assert_eq!(output, "Hello, World!");
496            }
497            _ => panic!("Expected success with testing preset"),
498        }
499    }
500
501    #[tokio::test]
502    async fn test_registry_with_permissions_deny_on_prompt() {
503        // Custom config with auto-deny prompt handler
504        let config = PermissionConfig::new(
505            crate::permission::DefaultPermissionStrategy,
506            MemoryPermissionStore::new(),
507            AutoPromptHandler::always_deny(),
508            crate::audit::NullAuditSink,
509            crate::permission::TrustFlagConfig::default(),
510        );
511
512        let registry = PluginRegistry::with_permissions(config).unwrap();
513
514        let loader = PluginLoader::new().unwrap();
515        let mut plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
516
517        // Add capabilities to trigger permission check
518        plugin.manifest.capabilities = sen_plugin_api::Capabilities::default()
519            .with_stdio(sen_plugin_api::StdioCapability::stdout_only());
520
521        registry.register(plugin).await;
522
523        let result = registry.execute("hello", &["World".to_string()]).await;
524
525        match result {
526            Err(RegistryError::PermissionDenied { plugin, reason }) => {
527                assert_eq!(plugin, "hello");
528                assert!(reason.contains("denied"));
529            }
530            Ok(_) => panic!("Expected PermissionDenied error"),
531            Err(e) => panic!("Unexpected error: {:?}", e),
532        }
533    }
534
535    #[tokio::test]
536    async fn test_registry_with_permissions_audit_logging() {
537        // Verify audit events are recorded
538        let audit_sink = std::sync::Arc::new(MemoryAuditSink::new());
539
540        let config = PermissionConfig {
541            strategy: std::sync::Arc::new(crate::permission::DefaultPermissionStrategy),
542            store: std::sync::Arc::new(MemoryPermissionStore::new()),
543            prompt: std::sync::Arc::new(AutoPromptHandler::always_allow()),
544            audit: audit_sink.clone(),
545            trust_flags: crate::permission::TrustFlagConfig::default(),
546        };
547
548        let registry = PluginRegistry::with_permissions(config).unwrap();
549
550        let loader = PluginLoader::new().unwrap();
551        let mut plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
552        plugin.manifest.capabilities = sen_plugin_api::Capabilities::default()
553            .with_stdio(sen_plugin_api::StdioCapability::stdout_only());
554
555        registry.register(plugin).await;
556        let _ = registry.execute("hello", &["World".to_string()]).await;
557
558        // Verify audit events were recorded
559        let events = audit_sink.events();
560        assert!(!events.is_empty(), "Should have audit events");
561
562        // Should have at least a permission request event
563        let request_events =
564            audit_sink.find_by_type(crate::audit::AuditEventType::PermissionRequested);
565        assert!(
566            !request_events.is_empty(),
567            "Should have permission request event"
568        );
569    }
570
571    #[tokio::test]
572    async fn test_registry_with_permissions_stores_grant() {
573        // Verify that granted permissions are stored
574        let store = std::sync::Arc::new(MemoryPermissionStore::new());
575
576        let config = PermissionConfig {
577            strategy: std::sync::Arc::new(crate::permission::DefaultPermissionStrategy),
578            store: store.clone(),
579            prompt: std::sync::Arc::new(AutoPromptHandler::always_allow()),
580            audit: std::sync::Arc::new(crate::audit::NullAuditSink),
581            trust_flags: crate::permission::TrustFlagConfig::default(),
582        };
583
584        let registry = PluginRegistry::with_permissions(config).unwrap();
585
586        let loader = PluginLoader::new().unwrap();
587        let mut plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
588        plugin.manifest.capabilities = sen_plugin_api::Capabilities::default()
589            .with_stdio(sen_plugin_api::StdioCapability::stdout_only());
590
591        registry.register(plugin).await;
592        let _ = registry.execute("hello", &["World".to_string()]).await;
593
594        // Verify permission was stored
595        let stored = store.get("hello").unwrap();
596        assert!(stored.is_some(), "Permission should be stored after grant");
597    }
598
599    #[tokio::test]
600    async fn test_registry_with_permissions_prompt_recording() {
601        // Verify prompts are triggered correctly
602        let prompt_handler =
603            std::sync::Arc::new(RecordingPromptHandler::new(PromptResult::AllowAlways));
604
605        let config = PermissionConfig {
606            strategy: std::sync::Arc::new(crate::permission::DefaultPermissionStrategy),
607            store: std::sync::Arc::new(MemoryPermissionStore::new()),
608            prompt: prompt_handler.clone(),
609            audit: std::sync::Arc::new(crate::audit::NullAuditSink),
610            trust_flags: crate::permission::TrustFlagConfig::default(),
611        };
612
613        let registry = PluginRegistry::with_permissions(config).unwrap();
614
615        let loader = PluginLoader::new().unwrap();
616        let mut plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
617        plugin.manifest.capabilities = sen_plugin_api::Capabilities::default()
618            .with_stdio(sen_plugin_api::StdioCapability::stdout_only());
619
620        registry.register(plugin).await;
621        let _ = registry.execute("hello", &["World".to_string()]).await;
622
623        // Verify prompt was called
624        assert_eq!(
625            prompt_handler.prompt_count(),
626            1,
627            "Should have prompted once"
628        );
629        let prompts = prompt_handler.prompts();
630        assert_eq!(prompts[0].plugin, "hello");
631    }
632
633    #[tokio::test]
634    async fn test_registry_without_permissions_skips_check() {
635        // Registry without permission config should skip all checks
636        let registry = PluginRegistry::new().unwrap();
637
638        let loader = PluginLoader::new().unwrap();
639        let mut plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
640
641        // Even with capabilities declared, no check should happen
642        plugin.manifest.capabilities = sen_plugin_api::Capabilities::default()
643            .with_fs_read(vec![sen_plugin_api::PathPattern::new("/")]);
644
645        registry.register(plugin).await;
646
647        // Should execute without any permission checks
648        let result = registry
649            .execute("hello", &["World".to_string()])
650            .await
651            .unwrap();
652
653        match result {
654            sen_plugin_api::ExecuteResult::Success(output) => {
655                assert_eq!(output, "Hello, World!");
656            }
657            _ => panic!("Expected success without permission config"),
658        }
659    }
660
661    #[tokio::test]
662    async fn test_registry_empty_capabilities_allowed() {
663        // Plugins with no capabilities should be allowed without prompt
664        let prompt_handler = std::sync::Arc::new(RecordingPromptHandler::new(PromptResult::Deny));
665
666        let config = PermissionConfig {
667            strategy: std::sync::Arc::new(crate::permission::DefaultPermissionStrategy),
668            store: std::sync::Arc::new(MemoryPermissionStore::new()),
669            prompt: prompt_handler.clone(),
670            audit: std::sync::Arc::new(crate::audit::NullAuditSink),
671            trust_flags: crate::permission::TrustFlagConfig::default(),
672        };
673
674        let registry = PluginRegistry::with_permissions(config).unwrap();
675
676        let loader = PluginLoader::new().unwrap();
677        let plugin = loader.load(HELLO_PLUGIN_WASM).unwrap();
678        // Default capabilities are empty
679
680        registry.register(plugin).await;
681        let result = registry.execute("hello", &["World".to_string()]).await;
682
683        // Should succeed without prompting (empty caps = no permissions needed)
684        assert!(result.is_ok(), "Empty capabilities should be allowed");
685        assert_eq!(
686            prompt_handler.prompt_count(),
687            0,
688            "Should not prompt for empty caps"
689        );
690    }
691}