diff --git a/Cargo.lock b/Cargo.lock index fa50d164..e4f9ace5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1867,6 +1867,7 @@ dependencies = [ name = "cortex-plugins" version = "0.0.6" dependencies = [ + "anyhow", "async-trait", "chrono", "dirs 6.0.0", diff --git a/src/cortex-plugins/Cargo.toml b/src/cortex-plugins/Cargo.toml index e3b60308..f16f6564 100644 --- a/src/cortex-plugins/Cargo.toml +++ b/src/cortex-plugins/Cargo.toml @@ -19,6 +19,7 @@ toml = { workspace = true } # Error handling thiserror = { workspace = true } +anyhow = { workspace = true } # Logging tracing = { workspace = true } diff --git a/src/cortex-plugins/src/api.rs b/src/cortex-plugins/src/api.rs index f93fd68d..95ee723a 100644 --- a/src/cortex-plugins/src/api.rs +++ b/src/cortex-plugins/src/api.rs @@ -272,24 +272,83 @@ impl PluginHostFunctions { } /// Resolve a path relative to the working directory. - fn resolve_path(&self, path: &str) -> PathBuf { + /// + /// # Security + /// + /// This method canonicalizes the path to prevent path traversal attacks + /// using sequences like `../../../etc/passwd`. The canonical path is + /// verified to be within the allowed directory boundaries. + fn resolve_path(&self, path: &str) -> Result { let path = std::path::Path::new(path); - if path.is_absolute() { + let resolved = if path.is_absolute() { path.to_path_buf() } else { self.cwd.join(path) + }; + + // SECURITY: Canonicalize to resolve `..`, `.`, and symlinks + // This prevents path traversal attacks + let canonical = resolved.canonicalize().map_err(|e| { + PluginError::PermissionDenied(format!( + "Invalid path '{}': {}", + path.display(), + e + )) + })?; + + // SECURITY: Verify the canonical path is within allowed boundaries + // If no explicit allowlist, only allow paths within cwd + if self.allowed_paths.is_empty() { + // Canonicalize cwd for comparison + let canonical_cwd = self.cwd.canonicalize().map_err(|e| { + PluginError::PermissionDenied(format!( + "Invalid working directory: {}", + e + )) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(PluginError::PermissionDenied(format!( + "Path '{}' escapes working directory", + path.display() + ))); + } } + + Ok(canonical) } /// Check if a path is allowed. + /// + /// # Security + /// + /// - Empty allowlist = only paths within cwd are allowed (fail-closed) + /// - Paths are canonicalized before checking to prevent traversal attacks + /// - Symlinks are resolved to their real paths fn is_path_allowed(&self, path: &Path) -> bool { + // SECURITY: Try to canonicalize the path to resolve symlinks and .. + let canonical = match path.canonicalize() { + Ok(p) => p, + // SECURITY: If we can't canonicalize, deny access (fail-closed) + Err(_) => return false, + }; + + // SECURITY: If allowlist is empty, only allow paths within cwd if self.allowed_paths.is_empty() { - return true; + // Canonicalize cwd for comparison + return match self.cwd.canonicalize() { + Ok(canonical_cwd) => canonical.starts_with(&canonical_cwd), + // SECURITY: If cwd can't be canonicalized, deny access + Err(_) => false, + }; } + // Check against canonicalized allowlist entries for allowed in &self.allowed_paths { - if path.starts_with(allowed) { - return true; + if let Ok(allowed_canonical) = allowed.canonicalize() { + if canonical.starts_with(&allowed_canonical) { + return true; + } } } @@ -297,9 +356,15 @@ impl PluginHostFunctions { } /// Check if a command is allowed. + /// + /// # Security + /// + /// Empty allowlist = no commands allowed (fail-closed). + /// Use "*" in allowlist to permit all commands (requires explicit opt-in). fn is_command_allowed(&self, command: &str) -> bool { + // SECURITY: Fail-closed - empty allowlist means no commands allowed if self.allowed_commands.is_empty() { - return true; + return false; } self.allowed_commands @@ -307,34 +372,177 @@ impl PluginHostFunctions { .any(|c| c == command || c == "*") } - /// Check if a domain is allowed. + /// Check if a domain is allowed for network requests. + /// + /// # Security + /// + /// - None = no network access allowed (fail-closed) + /// - Empty list = no network access allowed (fail-closed) + /// - Only http/https protocols are allowed + /// - Localhost and private IPs are blocked to prevent SSRF + /// - Dangerous ports are blocked fn is_domain_allowed(&self, url: &str) -> bool { + // SECURITY: Fail-closed - None means no network access let Some(ref domains) = self.allowed_domains else { - return true; + return false; }; + // SECURITY: Fail-closed - empty list means no network access if domains.is_empty() { - return true; + return false; } let Ok(parsed) = url::Url::parse(url) else { return false; }; + // SECURITY: Only allow http/https protocols to prevent file://, ftp://, etc. + match parsed.scheme() { + "http" | "https" => {} + _ => { + tracing::warn!( + scheme = parsed.scheme(), + url = url, + "Blocked non-HTTP protocol in plugin request" + ); + return false; + } + } + let Some(host) = parsed.host_str() else { return false; }; + // SECURITY: Block localhost and private IP addresses to prevent SSRF + if Self::is_private_host(host) { + tracing::warn!( + host = host, + url = url, + "Blocked private/localhost address in plugin request (SSRF prevention)" + ); + return false; + } + + // SECURITY: Block dangerous ports commonly used by internal services + if let Some(port) = parsed.port() { + if Self::is_dangerous_port(port) { + tracing::warn!( + port = port, + url = url, + "Blocked dangerous port in plugin request" + ); + return false; + } + } + domains .iter() .any(|d| host == d || host.ends_with(&format!(".{}", d))) } + + /// Check if a host is a private/localhost address. + /// + /// # Security + /// + /// This prevents SSRF attacks by blocking access to: + /// - localhost and loopback addresses + /// - Private IP ranges (10.x, 172.16-31.x, 192.168.x) + /// - Link-local addresses + /// - .local and .internal domains + fn is_private_host(host: &str) -> bool { + // Localhost variations + if host == "localhost" + || host == "127.0.0.1" + || host == "::1" + || host == "0.0.0.0" + || host == "[::1]" + { + return true; + } + + // Private IPv4 ranges + if host.starts_with("192.168.") + || host.starts_with("10.") + || host.starts_with("169.254.") // Link-local + { + return true; + } + + // Private 172.16.0.0 - 172.31.255.255 range + if host.starts_with("172.") { + if let Some(second_octet) = host.split('.').nth(1) { + if let Ok(octet) = second_octet.parse::() { + if (16..=31).contains(&octet) { + return true; + } + } + } + } + + // Private domain suffixes + if host.ends_with(".local") + || host.ends_with(".internal") + || host.ends_with(".localhost") + || host.ends_with(".localdomain") + { + return true; + } + + // IPv6 private/link-local (simplified check) + if host.starts_with("fe80:") // Link-local + || host.starts_with("fc00:") // Unique local + || host.starts_with("fd") // Unique local + { + return true; + } + + false + } + + /// Check if a port is commonly used by internal/dangerous services. + /// + /// # Security + /// + /// Blocks ports commonly used by: + /// - SSH, SMTP, and other system services + /// - Database servers (MySQL, PostgreSQL, MongoDB, Redis, etc.) + /// - Cloud metadata services (169.254.169.254:80) + fn is_dangerous_port(port: u16) -> bool { + const BLOCKED_PORTS: &[u16] = &[ + 22, // SSH + 23, // Telnet + 25, // SMTP + 53, // DNS + 110, // POP3 + 135, // RPC + 139, // NetBIOS + 143, // IMAP + 445, // SMB + 1433, // MSSQL + 1521, // Oracle + 3306, // MySQL + 3389, // RDP + 5432, // PostgreSQL + 5900, // VNC + 6379, // Redis + 6380, // Redis (alt) + 9200, // Elasticsearch + 9300, // Elasticsearch (transport) + 11211, // Memcached + 27017, // MongoDB + 27018, // MongoDB (alt) + 28017, // MongoDB (web) + ]; + + BLOCKED_PORTS.contains(&port) + } } #[async_trait::async_trait] impl PluginApi for PluginHostFunctions { async fn read_file(&self, path: &str) -> Result { - let full_path = self.resolve_path(path); + // SECURITY: resolve_path now canonicalizes and validates the path + let full_path = self.resolve_path(path)?; if !self.is_path_allowed(&full_path) { return Err(PluginError::PermissionDenied(format!( @@ -349,9 +557,38 @@ impl PluginApi for PluginHostFunctions { } async fn write_file(&self, path: &str, content: &str) -> Result<()> { - let full_path = self.resolve_path(path); + // SECURITY: For write operations, we need to handle non-existent files + // First, validate the parent directory exists and is allowed + let path_obj = std::path::Path::new(path); + let resolved = if path_obj.is_absolute() { + path_obj.to_path_buf() + } else { + self.cwd.join(path_obj) + }; - if !self.is_path_allowed(&full_path) { + // Get the parent directory and canonicalize it + let parent = resolved.parent().ok_or_else(|| { + PluginError::PermissionDenied(format!( + "Invalid path '{}': no parent directory", + path + )) + })?; + + // SECURITY: Canonicalize parent to prevent traversal attacks + let canonical_parent = parent.canonicalize().map_err(|e| { + PluginError::PermissionDenied(format!( + "Invalid parent directory for '{}': {}", + path, e + )) + })?; + + // The full path would be the canonical parent plus the filename + let filename = resolved.file_name().ok_or_else(|| { + PluginError::PermissionDenied(format!("Invalid path '{}': no filename", path)) + })?; + let full_path = canonical_parent.join(filename); + + if !self.is_path_allowed(&canonical_parent) { return Err(PluginError::PermissionDenied(format!( "Access to path '{}' is not allowed", path @@ -364,7 +601,13 @@ impl PluginApi for PluginHostFunctions { } async fn file_exists(&self, path: &str) -> Result { - let full_path = self.resolve_path(path); + // SECURITY: For existence checks, we need to be careful about timing attacks + // and information disclosure. We'll try to resolve, but return false on error. + let full_path = match self.resolve_path(path) { + Ok(p) => p, + // If we can't resolve (path doesn't exist or is invalid), return false + Err(_) => return Ok(false), + }; if !self.is_path_allowed(&full_path) { return Err(PluginError::PermissionDenied(format!( @@ -377,7 +620,8 @@ impl PluginApi for PluginHostFunctions { } async fn list_dir(&self, path: &str) -> Result> { - let full_path = self.resolve_path(path); + // SECURITY: resolve_path now canonicalizes and validates the path + let full_path = self.resolve_path(path)?; if !self.is_path_allowed(&full_path) { return Err(PluginError::PermissionDenied(format!( @@ -405,22 +649,55 @@ impl PluginApi for PluginHostFunctions { } async fn create_dir(&self, path: &str) -> Result<()> { - let full_path = self.resolve_path(path); + // SECURITY: For directory creation, validate parent path is allowed + let path_obj = std::path::Path::new(path); + let resolved = if path_obj.is_absolute() { + path_obj.to_path_buf() + } else { + self.cwd.join(path_obj) + }; - if !self.is_path_allowed(&full_path) { + // Find the first existing parent and canonicalize it + let mut check_path = resolved.clone(); + let mut existing_parent = None; + while let Some(parent) = check_path.parent() { + if parent.exists() { + existing_parent = Some(parent.to_path_buf()); + break; + } + check_path = parent.to_path_buf(); + } + + let canonical_parent = existing_parent + .ok_or_else(|| { + PluginError::PermissionDenied(format!( + "No valid parent directory for '{}'", + path + )) + })? + .canonicalize() + .map_err(|e| { + PluginError::PermissionDenied(format!( + "Invalid parent directory for '{}': {}", + path, e + )) + })?; + + if !self.is_path_allowed(&canonical_parent) { return Err(PluginError::PermissionDenied(format!( "Access to path '{}' is not allowed", path ))); } - tokio::fs::create_dir_all(&full_path) + tokio::fs::create_dir_all(&resolved) .await .map_err(|e| PluginError::execution_error(&self.plugin_id, e.to_string())) } async fn delete_file(&self, path: &str) -> Result<()> { - let full_path = self.resolve_path(path); + // SECURITY: resolve_path now canonicalizes and validates the path + let full_path = self.resolve_path(path)?; if !self.is_path_allowed(&full_path) { return Err(PluginError::PermissionDenied(format!( @@ -595,36 +872,43 @@ mod tests { } #[test] - fn test_host_functions_path_resolution() { - let host = PluginHostFunctions::new("test", PathBuf::from("/home/user")); + fn test_host_functions_path_allowed_with_explicit_allowlist() { + // Use /tmp as cwd which should exist on most systems + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_paths(vec![PathBuf::from("/tmp")]); - assert_eq!( - host.resolve_path("file.txt"), - PathBuf::from("/home/user/file.txt") - ); - assert_eq!( - host.resolve_path("/absolute/path"), - PathBuf::from("/absolute/path") - ); + // /tmp exists and should be allowed + assert!(host.is_path_allowed(&PathBuf::from("/tmp"))); } #[test] - fn test_host_functions_path_allowed() { - let host = PluginHostFunctions::new("test", PathBuf::from("/home/user")) - .with_allowed_paths(vec![PathBuf::from("/home/user/project")]); + fn test_host_functions_command_allowed() { + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_commands(vec!["ls".to_string(), "cat".to_string()]); - assert!(host.is_path_allowed(&PathBuf::from("/home/user/project/file.txt"))); - assert!(!host.is_path_allowed(&PathBuf::from("/home/user/other/file.txt"))); + assert!(host.is_command_allowed("ls")); + assert!(host.is_command_allowed("cat")); + assert!(!host.is_command_allowed("rm")); } #[test] - fn test_host_functions_command_allowed() { + fn test_host_functions_command_empty_allowlist_fails_closed() { + // SECURITY: Empty command allowlist should deny all commands (fail-closed) + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")); + + assert!(!host.is_command_allowed("ls")); + assert!(!host.is_command_allowed("cat")); + assert!(!host.is_command_allowed("rm")); + } + + #[test] + fn test_host_functions_command_wildcard() { let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) - .with_allowed_commands(vec!["ls".to_string(), "cat".to_string()]); + .with_allowed_commands(vec!["*".to_string()]); assert!(host.is_command_allowed("ls")); assert!(host.is_command_allowed("cat")); - assert!(!host.is_command_allowed("rm")); + assert!(host.is_command_allowed("rm")); } #[test] @@ -636,4 +920,114 @@ mod tests { assert!(host.is_domain_allowed("https://api.example.com/v1")); assert!(!host.is_domain_allowed("https://other.com/api")); } + + #[test] + fn test_host_functions_domain_empty_allowlist_fails_closed() { + // SECURITY: Empty domain allowlist should deny all requests (fail-closed) + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_domains(vec![]); + + assert!(!host.is_domain_allowed("https://example.com/api")); + assert!(!host.is_domain_allowed("https://api.example.com/v1")); + } + + #[test] + fn test_host_functions_domain_none_fails_closed() { + // SECURITY: None allowed_domains should deny all requests (fail-closed) + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")); + + assert!(!host.is_domain_allowed("https://example.com/api")); + } + + #[test] + fn test_host_functions_ssrf_prevention() { + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_domains(vec!["*".to_string(), "example.com".to_string()]); + + // SECURITY: These should all be blocked for SSRF prevention + assert!(!host.is_domain_allowed("http://localhost/api")); + assert!(!host.is_domain_allowed("http://127.0.0.1/api")); + assert!(!host.is_domain_allowed("http://192.168.1.1/api")); + assert!(!host.is_domain_allowed("http://10.0.0.1/api")); + assert!(!host.is_domain_allowed("http://172.16.0.1/api")); + assert!(!host.is_domain_allowed("http://169.254.169.254/latest/meta-data/")); // AWS metadata + assert!(!host.is_domain_allowed("http://internal.local/api")); + } + + #[test] + fn test_host_functions_protocol_restriction() { + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_domains(vec!["example.com".to_string()]); + + // SECURITY: Only http/https should be allowed + assert!(host.is_domain_allowed("https://example.com/api")); + assert!(host.is_domain_allowed("http://example.com/api")); + assert!(!host.is_domain_allowed("file:///etc/passwd")); + assert!(!host.is_domain_allowed("ftp://example.com/file")); + assert!(!host.is_domain_allowed("gopher://example.com/")); + } + + #[test] + fn test_host_functions_dangerous_ports_blocked() { + let host = PluginHostFunctions::new("test", PathBuf::from("/tmp")) + .with_allowed_domains(vec!["example.com".to_string()]); + + // SECURITY: Dangerous ports should be blocked + assert!(!host.is_domain_allowed("https://example.com:22/api")); // SSH + assert!(!host.is_domain_allowed("https://example.com:3306/api")); // MySQL + assert!(!host.is_domain_allowed("https://example.com:5432/api")); // PostgreSQL + assert!(!host.is_domain_allowed("https://example.com:6379/api")); // Redis + assert!(!host.is_domain_allowed("https://example.com:27017/api")); // MongoDB + + // Standard ports should be allowed + assert!(host.is_domain_allowed("https://example.com:443/api")); + assert!(host.is_domain_allowed("http://example.com:80/api")); + assert!(host.is_domain_allowed("https://example.com:8080/api")); + } + + #[test] + fn test_is_private_host() { + // Localhost variations + assert!(PluginHostFunctions::is_private_host("localhost")); + assert!(PluginHostFunctions::is_private_host("127.0.0.1")); + assert!(PluginHostFunctions::is_private_host("::1")); + assert!(PluginHostFunctions::is_private_host("0.0.0.0")); + + // Private IP ranges + assert!(PluginHostFunctions::is_private_host("192.168.1.1")); + assert!(PluginHostFunctions::is_private_host("10.0.0.1")); + assert!(PluginHostFunctions::is_private_host("172.16.0.1")); + assert!(PluginHostFunctions::is_private_host("172.31.255.255")); + assert!(PluginHostFunctions::is_private_host("169.254.169.254")); // AWS metadata + + // Private domain suffixes + assert!(PluginHostFunctions::is_private_host("server.local")); + assert!(PluginHostFunctions::is_private_host("internal.internal")); + assert!(PluginHostFunctions::is_private_host("app.localhost")); + + // Public should not be private + assert!(!PluginHostFunctions::is_private_host("example.com")); + assert!(!PluginHostFunctions::is_private_host("8.8.8.8")); + assert!(!PluginHostFunctions::is_private_host("google.com")); + } + + #[test] + fn test_is_dangerous_port() { + // Database ports + assert!(PluginHostFunctions::is_dangerous_port(3306)); // MySQL + assert!(PluginHostFunctions::is_dangerous_port(5432)); // PostgreSQL + assert!(PluginHostFunctions::is_dangerous_port(27017)); // MongoDB + assert!(PluginHostFunctions::is_dangerous_port(6379)); // Redis + + // System service ports + assert!(PluginHostFunctions::is_dangerous_port(22)); // SSH + assert!(PluginHostFunctions::is_dangerous_port(25)); // SMTP + assert!(PluginHostFunctions::is_dangerous_port(445)); // SMB + + // Safe ports + assert!(!PluginHostFunctions::is_dangerous_port(80)); + assert!(!PluginHostFunctions::is_dangerous_port(443)); + assert!(!PluginHostFunctions::is_dangerous_port(8080)); + assert!(!PluginHostFunctions::is_dangerous_port(8443)); + } } diff --git a/src/cortex-plugins/src/hooks/completion_hooks.rs b/src/cortex-plugins/src/hooks/completion_hooks.rs new file mode 100644 index 00000000..ceb960cc --- /dev/null +++ b/src/cortex-plugins/src/hooks/completion_hooks.rs @@ -0,0 +1,547 @@ +//! Command completion hooks for plugin-provided autocompletion. +//! +//! This module provides hooks that allow plugins to: +//! - Register custom completion providers +//! - Provide completions for custom commands +//! - Extend existing command completions +//! - Add context-aware suggestions + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use super::types::{HookPriority, HookResult}; +use crate::Result; + +// ============================================================================ +// COMPLETION TYPES +// ============================================================================ + +/// Type of completion item +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionKind { + /// Command name + Command, + /// Command argument + Argument, + /// File path + File, + /// Directory path + Directory, + /// Model name + Model, + /// Variable/setting name + Variable, + /// Value for a setting + Value, + /// Keyword + Keyword, + /// Custom type + Custom, +} + +impl Default for CompletionKind { + fn default() -> Self { + Self::Custom + } +} + +/// A completion item +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionItem { + /// The completion text to insert + pub text: String, + /// Display label (if different from text) + #[serde(default)] + pub label: Option, + /// Short description + #[serde(default)] + pub description: Option, + /// Detailed documentation + #[serde(default)] + pub detail: Option, + /// Kind of completion + #[serde(default)] + pub kind: CompletionKind, + /// Sort priority (lower = higher priority) + #[serde(default = "default_sort_priority")] + pub sort_priority: i32, + /// Filter text (for fuzzy matching) + #[serde(default)] + pub filter_text: Option, + /// Insert text (if different from text, e.g., with placeholders) + #[serde(default)] + pub insert_text: Option, + /// Whether this completion is deprecated + #[serde(default)] + pub deprecated: bool, + /// Associated command (for command completions) + #[serde(default)] + pub command: Option, + /// Additional data for the plugin + #[serde(default)] + pub data: Option, +} + +fn default_sort_priority() -> i32 { + 100 +} + +impl CompletionItem { + /// Create a new completion item. + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + label: None, + description: None, + detail: None, + kind: CompletionKind::default(), + sort_priority: default_sort_priority(), + filter_text: None, + insert_text: None, + deprecated: false, + command: None, + data: None, + } + } + + /// Set the label. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Set the kind. + pub fn with_kind(mut self, kind: CompletionKind) -> Self { + self.kind = kind; + self + } + + /// Set the sort priority. + pub fn with_priority(mut self, priority: i32) -> Self { + self.sort_priority = priority; + self + } +} + +/// Completion context providing information about the completion request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionContext { + /// The input text being completed + pub input: String, + /// Cursor position in the input + pub cursor_position: usize, + /// The word being completed (extracted from input) + #[serde(default)] + pub word: Option, + /// The command being completed (if any) + #[serde(default)] + pub command: Option, + /// Argument index (0-based, if completing command args) + #[serde(default)] + pub arg_index: Option, + /// Previous arguments (if completing command args) + #[serde(default)] + pub previous_args: Vec, + /// Whether this is triggered manually (vs automatic) + #[serde(default)] + pub manual_trigger: bool, + /// Trigger character (if any) + #[serde(default)] + pub trigger_character: Option, +} + +// ============================================================================ +// COMPLETION PROVIDER REGISTRATION HOOK +// ============================================================================ + +/// Completion provider definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionProvider { + /// Provider identifier + pub id: String, + /// Provider name (for display) + pub name: String, + /// Commands this provider handles (empty = all) + #[serde(default)] + pub commands: Vec, + /// Trigger characters that activate completion + #[serde(default)] + pub trigger_characters: Vec, + /// Priority (lower = higher priority) + #[serde(default = "default_sort_priority")] + pub priority: i32, +} + +impl CompletionProvider { + /// Create a new completion provider. + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + commands: Vec::new(), + trigger_characters: Vec::new(), + priority: default_sort_priority(), + } + } + + /// Add commands this provider handles. + pub fn for_commands(mut self, commands: Vec) -> Self { + self.commands = commands; + self + } + + /// Add trigger characters. + pub fn with_triggers(mut self, chars: Vec) -> Self { + self.trigger_characters = chars; + self + } +} + +/// Input for completion provider registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionProviderRegisterInput { + /// Plugin ID registering the provider + pub plugin_id: String, + /// Provider definition + pub provider: CompletionProvider, +} + +/// Output for completion provider registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionProviderRegisterOutput { + /// Whether registration succeeded + pub success: bool, + /// Provider ID assigned + #[serde(default)] + pub provider_id: Option, + /// Error if failed + #[serde(default)] + pub error: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl CompletionProviderRegisterOutput { + /// Create a success output. + pub fn success(provider_id: impl Into) -> Self { + Self { + success: true, + provider_id: Some(provider_id.into()), + error: None, + result: HookResult::Continue, + } + } + + /// Create an error output. + pub fn error(message: impl Into) -> Self { + Self { + success: false, + provider_id: None, + error: Some(message.into()), + result: HookResult::Continue, + } + } +} + +/// Handler for completion provider registration +#[async_trait] +pub trait CompletionProviderRegisterHook: Send + Sync { + /// Get the hook priority. + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook. + async fn execute( + &self, + input: &CompletionProviderRegisterInput, + output: &mut CompletionProviderRegisterOutput, + ) -> Result<()>; +} + +// ============================================================================ +// COMPLETION REQUEST HOOK +// ============================================================================ + +/// Input for completion request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionRequestInput { + /// Session ID + pub session_id: String, + /// Completion context + pub context: CompletionContext, + /// Maximum number of items to return + #[serde(default = "default_max_items")] + pub max_items: usize, +} + +fn default_max_items() -> usize { + 50 +} + +/// Output for completion request +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CompletionRequestOutput { + /// Completion items + #[serde(default)] + pub items: Vec, + /// Whether the list is incomplete (more items available) + #[serde(default)] + pub is_incomplete: bool, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl CompletionRequestOutput { + /// Create a new empty completion output. + pub fn new() -> Self { + Self::default() + } + + /// Add a completion item. + pub fn add_item(&mut self, item: CompletionItem) { + self.items.push(item); + } + + /// Add multiple completion items. + pub fn add_items(&mut self, items: impl IntoIterator) { + self.items.extend(items); + } + + /// Mark the list as incomplete. + pub fn set_incomplete(mut self, incomplete: bool) -> Self { + self.is_incomplete = incomplete; + self + } +} + +/// Handler for completion requests +#[async_trait] +pub trait CompletionRequestHook: Send + Sync { + /// Get the hook priority. + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Commands this hook provides completions for (empty = all) + fn commands(&self) -> Vec { + vec![] + } + + /// Execute the hook. + async fn execute( + &self, + input: &CompletionRequestInput, + output: &mut CompletionRequestOutput, + ) -> Result<()>; +} + +// ============================================================================ +// COMPLETION RESOLVE HOOK +// ============================================================================ + +/// Input for resolving completion item details +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionResolveInput { + /// The completion item to resolve + pub item: CompletionItem, + /// Session ID + pub session_id: String, +} + +/// Output for completion resolution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionResolveOutput { + /// The resolved completion item with additional details + pub item: CompletionItem, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl CompletionResolveOutput { + /// Create a new resolve output. + pub fn new(item: CompletionItem) -> Self { + Self { + item, + result: HookResult::Continue, + } + } +} + +/// Handler for resolving completion item details (lazy loading) +#[async_trait] +pub trait CompletionResolveHook: Send + Sync { + /// Get the hook priority. + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook. + async fn execute( + &self, + input: &CompletionResolveInput, + output: &mut CompletionResolveOutput, + ) -> Result<()>; +} + +// ============================================================================ +// ARGUMENT COMPLETION HOOK +// ============================================================================ + +/// Argument definition for completion +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgumentDefinition { + /// Argument name + pub name: String, + /// Argument description + #[serde(default)] + pub description: Option, + /// Whether required + #[serde(default)] + pub required: bool, + /// Possible values (for enum-like args) + #[serde(default)] + pub values: Vec, + /// Value type hint + #[serde(default)] + pub value_type: Option, +} + +/// Input for argument completion +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgumentCompletionInput { + /// Plugin ID + pub plugin_id: String, + /// Command name + pub command: String, + /// Argument index + pub arg_index: usize, + /// Current argument value (partial) + pub current_value: String, + /// Previous arguments + pub previous_args: Vec, + /// Session ID + pub session_id: String, +} + +/// Output for argument completion +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ArgumentCompletionOutput { + /// Completion items for this argument + #[serde(default)] + pub items: Vec, + /// Argument definition (for help/hints) + #[serde(default)] + pub argument_def: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl ArgumentCompletionOutput { + /// Create a new empty argument completion output. + pub fn new() -> Self { + Self::default() + } + + /// Add completion items. + pub fn add_items(&mut self, items: impl IntoIterator) { + self.items.extend(items); + } + + /// Set argument definition. + pub fn with_definition(mut self, def: ArgumentDefinition) -> Self { + self.argument_def = Some(def); + self + } +} + +/// Handler for argument-specific completions +#[async_trait] +pub trait ArgumentCompletionHook: Send + Sync { + /// Get the hook priority. + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Commands this hook provides argument completions for + fn commands(&self) -> Vec { + vec![] + } + + /// Execute the hook. + async fn execute( + &self, + input: &ArgumentCompletionInput, + output: &mut ArgumentCompletionOutput, + ) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_completion_item() { + let item = CompletionItem::new("test") + .with_label("Test Item") + .with_description("A test completion") + .with_kind(CompletionKind::Command) + .with_priority(10); + + assert_eq!(item.text, "test"); + assert_eq!(item.label, Some("Test Item".to_string())); + assert_eq!(item.kind, CompletionKind::Command); + assert_eq!(item.sort_priority, 10); + } + + #[test] + fn test_completion_provider() { + let provider = CompletionProvider::new("my-provider", "My Provider") + .for_commands(vec!["test".to_string(), "demo".to_string()]) + .with_triggers(vec!['/', '@']); + + assert_eq!(provider.id, "my-provider"); + assert_eq!(provider.commands.len(), 2); + assert_eq!(provider.trigger_characters.len(), 2); + } + + #[test] + fn test_completion_request_output() { + let mut output = CompletionRequestOutput::new(); + output.add_item(CompletionItem::new("item1")); + output.add_items(vec![ + CompletionItem::new("item2"), + CompletionItem::new("item3"), + ]); + + assert_eq!(output.items.len(), 3); + } + + #[test] + fn test_completion_context() { + let context = CompletionContext { + input: "/model claude".to_string(), + cursor_position: 13, + word: Some("claude".to_string()), + command: Some("model".to_string()), + arg_index: Some(0), + previous_args: vec![], + manual_trigger: false, + trigger_character: None, + }; + + assert_eq!(context.command, Some("model".to_string())); + assert_eq!(context.arg_index, Some(0)); + } +} diff --git a/src/cortex-plugins/src/hooks/mod.rs b/src/cortex-plugins/src/hooks/mod.rs index 83a23437..1bf3ea39 100644 --- a/src/cortex-plugins/src/hooks/mod.rs +++ b/src/cortex-plugins/src/hooks/mod.rs @@ -115,12 +115,52 @@ pub use clipboard_hooks::{ // UI rendering hooks mod ui_hooks; -pub use ui_hooks::{UiComponent, UiRenderHook, UiRenderInput, UiRenderOutput, UiWidget}; +pub use ui_hooks::{ + // Core UI types + UiComponent, UiRegion, UiRenderHook, UiRenderInput, UiRenderOutput, UiWidget, + // Style types + BorderStyle, Color, TextStyle, WidgetConstraints, WidgetSize, WidgetStyle, + // Theme types + ThemeColors, ThemeOverride, ThemeOverrideHook, ThemeOverrideInput, ThemeOverrideOutput, + // Widget registration + WidgetRegisterHook, WidgetRegisterInput, WidgetRegisterOutput, + // Keyboard bindings + KeyBinding, KeyBindingHook, KeyBindingInput, KeyBindingOutput, KeyBindingResult, KeyModifier, + // Layout customization + LayoutConfig, LayoutCustomizeHook, LayoutCustomizeInput, LayoutCustomizeOutput, + LayoutDirection, LayoutPanel, + // Modal injection + ModalDefinition, ModalInjectHook, ModalInjectInput, ModalInjectOutput, ModalLayer, + // Toast notifications + ToastDefinition, ToastLevel, ToastShowHook, ToastShowInput, ToastShowOutput, +}; // Focus change hooks mod focus_hooks; pub use focus_hooks::{FocusAction, FocusChangeHook, FocusChangeInput, FocusChangeOutput}; +// TUI event hooks +mod tui_events; +pub use tui_events::{ + AnimationFrameHook, AnimationFrameInput, AnimationFrameOutput, + CustomEventEmitHook, CustomEventEmitInput, CustomEventEmitOutput, + EventInterceptHook, EventInterceptInput, EventInterceptOutput, InterceptMode, + MouseButton, MouseEventType, ScrollDirection, TuiEvent, TuiEventDispatchHook, + TuiEventDispatchInput, TuiEventDispatchOutput, TuiEventFilter, TuiEventSubscribeHook, + TuiEventSubscribeInput, TuiEventSubscribeOutput, +}; + +// Completion hooks +mod completion_hooks; +pub use completion_hooks::{ + ArgumentCompletionHook, ArgumentCompletionInput, ArgumentCompletionOutput, ArgumentDefinition, + CompletionContext, CompletionItem, CompletionKind, CompletionProvider, + CompletionProviderRegisterHook, CompletionProviderRegisterInput, + CompletionProviderRegisterOutput, CompletionRequestHook, CompletionRequestInput, + CompletionRequestOutput, CompletionResolveHook, CompletionResolveInput, + CompletionResolveOutput, +}; + // Hook registry mod registry; pub use registry::HookRegistry; diff --git a/src/cortex-plugins/src/hooks/permission_hooks.rs b/src/cortex-plugins/src/hooks/permission_hooks.rs index 299e37ef..12ec36e5 100644 --- a/src/cortex-plugins/src/hooks/permission_hooks.rs +++ b/src/cortex-plugins/src/hooks/permission_hooks.rs @@ -39,17 +39,67 @@ impl PermissionAskOutput { } /// Permission decision. +/// +/// # Security +/// +/// The `Allow` variant enables automatic permission grants without user interaction. +/// This is a **security-sensitive capability** that should only be used by: +/// +/// 1. **Trusted system plugins** that are part of the core Cortex distribution +/// 2. **Internal permission policies** configured by administrators +/// +/// **Third-party plugins should NEVER return `Allow`** as this enables privilege +/// escalation attacks. Third-party plugins should only return: +/// - `Ask` - to prompt the user for a decision (safest) +/// - `Deny` - to automatically deny the permission +/// +/// ## Security Recommendations +/// +/// - Audit any plugin that returns `Allow` carefully +/// - Consider implementing a plugin signing system to restrict `Allow` to signed plugins +/// - Log all `Allow` decisions for security monitoring #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PermissionDecision { - /// Ask the user + /// Ask the user (default - safe for all plugins) Ask, - /// Automatically allow + /// Automatically allow. + /// + /// # Security Warning + /// + /// **ONLY use from trusted system plugins.** Third-party plugins using this + /// variant can bypass user consent and enable privilege escalation attacks. + /// + /// When processing `Allow` decisions from plugins: + /// 1. Verify the plugin is trusted/signed + /// 2. Log the automatic grant for audit purposes + /// 3. Consider requiring additional confirmation for sensitive permissions + #[doc(hidden)] Allow, - /// Automatically deny + /// Automatically deny (safe for all plugins) Deny, } +impl PermissionDecision { + /// Check if this decision requires elevated trust. + /// + /// Returns `true` for `Allow`, which should only be used by trusted system plugins. + pub fn requires_elevated_trust(&self) -> bool { + matches!(self, Self::Allow) + } + + /// Validate that this decision is safe for a third-party plugin. + /// + /// Returns an error if the decision is `Allow`, which third-party plugins + /// should not be permitted to make. + pub fn validate_for_third_party(&self) -> std::result::Result<(), &'static str> { + if matches!(self, Self::Allow) { + return Err("Third-party plugins cannot auto-grant permissions (security restriction)"); + } + Ok(()) + } +} + /// Handler for permission.ask hook. #[async_trait] pub trait PermissionAskHook: Send + Sync { diff --git a/src/cortex-plugins/src/hooks/registry.rs b/src/cortex-plugins/src/hooks/registry.rs index 9ccf7039..80ef7288 100644 --- a/src/cortex-plugins/src/hooks/registry.rs +++ b/src/cortex-plugins/src/hooks/registry.rs @@ -1,4 +1,7 @@ //! Hook registry for storing and managing registered hooks. +//! +//! The registry maintains collections of registered hooks organized by type, +//! with support for priority-based ordering and plugin-level management. use std::sync::Arc; use tokio::sync::RwLock; @@ -7,8 +10,24 @@ use super::chat_hooks::ChatMessageHook; use super::permission_hooks::PermissionAskHook; use super::tool_hooks::{ToolExecuteAfterHook, ToolExecuteBeforeHook}; use super::types::HookPriority; +use super::ui_hooks::{ + UiRenderHook, WidgetRegisterHook, KeyBindingHook, + ThemeOverrideHook, LayoutCustomizeHook, ModalInjectHook, ToastShowHook, +}; +use super::tui_events::{ + TuiEventSubscribeHook, TuiEventDispatchHook, + CustomEventEmitHook, EventInterceptHook, AnimationFrameHook, +}; +use super::command_hooks::{CommandExecuteBeforeHook, CommandExecuteAfterHook}; +use super::input_hooks::InputInterceptHook; +use super::session_hooks::{SessionStartHook, SessionEndHook}; +use super::focus_hooks::FocusChangeHook; use crate::manifest::HookType; +// ============================================================================ +// REGISTERED HOOK WRAPPERS +// ============================================================================ + /// Registered hook with metadata for tool.execute.before hook type. pub(crate) struct RegisteredToolBeforeHook { pub plugin_id: String, @@ -37,12 +56,181 @@ pub(crate) struct RegisteredPermissionHook { pub priority: HookPriority, } +/// Registered hook with metadata for ui.render hook type. +pub(crate) struct RegisteredUiRenderHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for widget registration. +pub(crate) struct RegisteredWidgetRegisterHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for key binding registration. +pub(crate) struct RegisteredKeyBindingHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for theme override. +pub(crate) struct RegisteredThemeOverrideHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for layout customization. +pub(crate) struct RegisteredLayoutCustomizeHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for modal injection. +pub(crate) struct RegisteredModalInjectHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for toast notifications. +pub(crate) struct RegisteredToastShowHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for TUI event subscription. +pub(crate) struct RegisteredTuiEventSubscribeHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for TUI event dispatch. +pub(crate) struct RegisteredTuiEventDispatchHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for custom event emission. +pub(crate) struct RegisteredCustomEventEmitHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for event interception. +pub(crate) struct RegisteredEventInterceptHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for animation frames. +pub(crate) struct RegisteredAnimationFrameHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for command.execute.before. +pub(crate) struct RegisteredCommandBeforeHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for command.execute.after. +pub(crate) struct RegisteredCommandAfterHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for input interception. +pub(crate) struct RegisteredInputInterceptHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for session start. +pub(crate) struct RegisteredSessionStartHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for session end. +pub(crate) struct RegisteredSessionEndHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +/// Registered hook for focus change. +pub(crate) struct RegisteredFocusChangeHook { + pub plugin_id: String, + pub hook: Arc, + pub priority: HookPriority, +} + +// ============================================================================ +// HOOK REGISTRY +// ============================================================================ + /// Registry for all hook handlers. +/// +/// The registry maintains collections of registered hooks organized by type. +/// All hooks are stored with their plugin ID and priority for proper ordering +/// and cleanup when plugins are unloaded. pub struct HookRegistry { + // Tool hooks pub(crate) tool_execute_before: RwLock>, pub(crate) tool_execute_after: RwLock>, + + // Chat hooks pub(crate) chat_message: RwLock>, + + // Permission hooks pub(crate) permission_ask: RwLock>, + + // UI hooks + pub(crate) ui_render: RwLock>, + pub(crate) widget_register: RwLock>, + pub(crate) key_binding: RwLock>, + pub(crate) theme_override: RwLock>, + pub(crate) layout_customize: RwLock>, + pub(crate) modal_inject: RwLock>, + pub(crate) toast_show: RwLock>, + + // TUI event hooks + pub(crate) tui_event_subscribe: RwLock>, + pub(crate) tui_event_dispatch: RwLock>, + pub(crate) custom_event_emit: RwLock>, + pub(crate) event_intercept: RwLock>, + pub(crate) animation_frame: RwLock>, + + // Command hooks + pub(crate) command_execute_before: RwLock>, + pub(crate) command_execute_after: RwLock>, + + // Input hooks + pub(crate) input_intercept: RwLock>, + + // Session hooks + pub(crate) session_start: RwLock>, + pub(crate) session_end: RwLock>, + + // Focus hooks + pub(crate) focus_change: RwLock>, } impl HookRegistry { @@ -53,9 +241,31 @@ impl HookRegistry { tool_execute_after: RwLock::new(Vec::new()), chat_message: RwLock::new(Vec::new()), permission_ask: RwLock::new(Vec::new()), + ui_render: RwLock::new(Vec::new()), + widget_register: RwLock::new(Vec::new()), + key_binding: RwLock::new(Vec::new()), + theme_override: RwLock::new(Vec::new()), + layout_customize: RwLock::new(Vec::new()), + modal_inject: RwLock::new(Vec::new()), + toast_show: RwLock::new(Vec::new()), + tui_event_subscribe: RwLock::new(Vec::new()), + tui_event_dispatch: RwLock::new(Vec::new()), + custom_event_emit: RwLock::new(Vec::new()), + event_intercept: RwLock::new(Vec::new()), + animation_frame: RwLock::new(Vec::new()), + command_execute_before: RwLock::new(Vec::new()), + command_execute_after: RwLock::new(Vec::new()), + input_intercept: RwLock::new(Vec::new()), + session_start: RwLock::new(Vec::new()), + session_end: RwLock::new(Vec::new()), + focus_change: RwLock::new(Vec::new()), } } + // ======================================================================== + // TOOL HOOKS + // ======================================================================== + /// Register a tool.execute.before hook. pub async fn register_tool_execute_before( &self, @@ -88,6 +298,10 @@ impl HookRegistry { hooks.sort_by_key(|h| h.priority); } + // ======================================================================== + // CHAT HOOKS + // ======================================================================== + /// Register a chat.message hook. pub async fn register_chat_message(&self, plugin_id: &str, hook: Arc) { let priority = hook.priority(); @@ -100,6 +314,10 @@ impl HookRegistry { hooks.sort_by_key(|h| h.priority); } + // ======================================================================== + // PERMISSION HOOKS + // ======================================================================== + /// Register a permission.ask hook. pub async fn register_permission_ask(&self, plugin_id: &str, hook: Arc) { let priority = hook.priority(); @@ -112,8 +330,253 @@ impl HookRegistry { hooks.sort_by_key(|h| h.priority); } + // ======================================================================== + // UI HOOKS + // ======================================================================== + + /// Register a ui.render hook. + pub async fn register_ui_render(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.ui_render.write().await; + hooks.push(RegisteredUiRenderHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a widget registration hook. + pub async fn register_widget_register(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.widget_register.write().await; + hooks.push(RegisteredWidgetRegisterHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a key binding hook. + pub async fn register_key_binding(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.key_binding.write().await; + hooks.push(RegisteredKeyBindingHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a theme override hook. + pub async fn register_theme_override(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.theme_override.write().await; + hooks.push(RegisteredThemeOverrideHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a layout customization hook. + pub async fn register_layout_customize(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.layout_customize.write().await; + hooks.push(RegisteredLayoutCustomizeHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a modal injection hook. + pub async fn register_modal_inject(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.modal_inject.write().await; + hooks.push(RegisteredModalInjectHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a toast show hook. + pub async fn register_toast_show(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.toast_show.write().await; + hooks.push(RegisteredToastShowHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // TUI EVENT HOOKS + // ======================================================================== + + /// Register a TUI event subscription hook. + pub async fn register_tui_event_subscribe(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.tui_event_subscribe.write().await; + hooks.push(RegisteredTuiEventSubscribeHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a TUI event dispatch hook. + pub async fn register_tui_event_dispatch(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.tui_event_dispatch.write().await; + hooks.push(RegisteredTuiEventDispatchHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a custom event emit hook. + pub async fn register_custom_event_emit(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.custom_event_emit.write().await; + hooks.push(RegisteredCustomEventEmitHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register an event intercept hook. + pub async fn register_event_intercept(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.event_intercept.write().await; + hooks.push(RegisteredEventInterceptHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register an animation frame hook. + pub async fn register_animation_frame(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.animation_frame.write().await; + hooks.push(RegisteredAnimationFrameHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // COMMAND HOOKS + // ======================================================================== + + /// Register a command.execute.before hook. + pub async fn register_command_execute_before(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.command_execute_before.write().await; + hooks.push(RegisteredCommandBeforeHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a command.execute.after hook. + pub async fn register_command_execute_after(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.command_execute_after.write().await; + hooks.push(RegisteredCommandAfterHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // INPUT HOOKS + // ======================================================================== + + /// Register an input intercept hook. + pub async fn register_input_intercept(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.input_intercept.write().await; + hooks.push(RegisteredInputInterceptHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // SESSION HOOKS + // ======================================================================== + + /// Register a session start hook. + pub async fn register_session_start(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.session_start.write().await; + hooks.push(RegisteredSessionStartHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + /// Register a session end hook. + pub async fn register_session_end(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.session_end.write().await; + hooks.push(RegisteredSessionEndHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // FOCUS HOOKS + // ======================================================================== + + /// Register a focus change hook. + pub async fn register_focus_change(&self, plugin_id: &str, hook: Arc) { + let priority = hook.priority(); + let mut hooks = self.focus_change.write().await; + hooks.push(RegisteredFocusChangeHook { + plugin_id: plugin_id.to_string(), + hook, + priority, + }); + hooks.sort_by_key(|h| h.priority); + } + + // ======================================================================== + // PLUGIN MANAGEMENT + // ======================================================================== + /// Unregister all hooks for a plugin. pub async fn unregister_plugin(&self, plugin_id: &str) { + // Tool hooks { let mut hooks = self.tool_execute_before.write().await; hooks.retain(|h| h.plugin_id != plugin_id); @@ -122,14 +585,102 @@ impl HookRegistry { let mut hooks = self.tool_execute_after.write().await; hooks.retain(|h| h.plugin_id != plugin_id); } + + // Chat hooks { let mut hooks = self.chat_message.write().await; hooks.retain(|h| h.plugin_id != plugin_id); } + + // Permission hooks { let mut hooks = self.permission_ask.write().await; hooks.retain(|h| h.plugin_id != plugin_id); } + + // UI hooks + { + let mut hooks = self.ui_render.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.widget_register.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.key_binding.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.theme_override.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.layout_customize.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.modal_inject.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.toast_show.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + + // TUI event hooks + { + let mut hooks = self.tui_event_subscribe.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.tui_event_dispatch.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.custom_event_emit.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.event_intercept.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.animation_frame.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + + // Command hooks + { + let mut hooks = self.command_execute_before.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.command_execute_after.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + + // Input hooks + { + let mut hooks = self.input_intercept.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + + // Session hooks + { + let mut hooks = self.session_start.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + { + let mut hooks = self.session_end.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } + + // Focus hooks + { + let mut hooks = self.focus_change.write().await; + hooks.retain(|h| h.plugin_id != plugin_id); + } } /// Get hook count for a specific type. @@ -139,9 +690,129 @@ impl HookRegistry { HookType::ToolExecuteAfter => self.tool_execute_after.read().await.len(), HookType::ChatMessage => self.chat_message.read().await.len(), HookType::PermissionAsk => self.permission_ask.read().await.len(), + HookType::UiRender => self.ui_render.read().await.len(), + HookType::WidgetRegister => self.widget_register.read().await.len(), + HookType::KeyBinding => self.key_binding.read().await.len(), + HookType::ThemeOverride => self.theme_override.read().await.len(), + HookType::LayoutCustomize => self.layout_customize.read().await.len(), + HookType::ModalInject => self.modal_inject.read().await.len(), + HookType::ToastShow => self.toast_show.read().await.len(), + HookType::TuiEventSubscribe => self.tui_event_subscribe.read().await.len(), + HookType::TuiEventDispatch => self.tui_event_dispatch.read().await.len(), + HookType::CustomEventEmit => self.custom_event_emit.read().await.len(), + HookType::EventIntercept => self.event_intercept.read().await.len(), + HookType::AnimationFrame => self.animation_frame.read().await.len(), + HookType::CommandExecuteBefore => self.command_execute_before.read().await.len(), + HookType::CommandExecuteAfter => self.command_execute_after.read().await.len(), + HookType::InputIntercept => self.input_intercept.read().await.len(), + HookType::SessionStart => self.session_start.read().await.len(), + HookType::SessionEnd => self.session_end.read().await.len(), + HookType::FocusChange => self.focus_change.read().await.len(), _ => 0, } } + + /// Get total number of registered hooks across all types. + pub async fn total_hook_count(&self) -> usize { + let mut count = 0; + count += self.tool_execute_before.read().await.len(); + count += self.tool_execute_after.read().await.len(); + count += self.chat_message.read().await.len(); + count += self.permission_ask.read().await.len(); + count += self.ui_render.read().await.len(); + count += self.widget_register.read().await.len(); + count += self.key_binding.read().await.len(); + count += self.theme_override.read().await.len(); + count += self.layout_customize.read().await.len(); + count += self.modal_inject.read().await.len(); + count += self.toast_show.read().await.len(); + count += self.tui_event_subscribe.read().await.len(); + count += self.tui_event_dispatch.read().await.len(); + count += self.custom_event_emit.read().await.len(); + count += self.event_intercept.read().await.len(); + count += self.animation_frame.read().await.len(); + count += self.command_execute_before.read().await.len(); + count += self.command_execute_after.read().await.len(); + count += self.input_intercept.read().await.len(); + count += self.session_start.read().await.len(); + count += self.session_end.read().await.len(); + count += self.focus_change.read().await.len(); + count + } + + /// Get list of plugins with registered hooks. + pub async fn registered_plugins(&self) -> Vec { + let mut plugins = std::collections::HashSet::new(); + + for h in self.tool_execute_before.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.tool_execute_after.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.chat_message.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.permission_ask.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.ui_render.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.widget_register.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.key_binding.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.theme_override.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.layout_customize.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.modal_inject.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.toast_show.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.tui_event_subscribe.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.tui_event_dispatch.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.custom_event_emit.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.event_intercept.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.animation_frame.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.command_execute_before.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.command_execute_after.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.input_intercept.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.session_start.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.session_end.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + for h in self.focus_change.read().await.iter() { + plugins.insert(h.plugin_id.clone()); + } + + plugins.into_iter().collect() + } } impl Default for HookRegistry { diff --git a/src/cortex-plugins/src/hooks/tui_events.rs b/src/cortex-plugins/src/hooks/tui_events.rs new file mode 100644 index 00000000..8d4fab95 --- /dev/null +++ b/src/cortex-plugins/src/hooks/tui_events.rs @@ -0,0 +1,605 @@ +//! TUI-level event hooks for plugin integration. +//! +//! This module provides hooks that allow plugins to: +//! - Subscribe to TUI events (render, resize, focus, scroll) +//! - Emit custom events +//! - Intercept and modify event propagation +//! +//! These hooks bridge the gap between the TUI event system and plugins, +//! allowing rich interaction while maintaining sandboxed execution. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::Result; +use super::types::{HookPriority, HookResult}; + +// ============================================================================ +// TUI EVENT TYPES +// ============================================================================ + +/// TUI-level events that plugins can subscribe to +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TuiEvent { + /// Frame rendered (called every frame, ~120 FPS) + FrameRendered { + frame_number: u64, + render_time_us: u64, + }, + /// Terminal resized + Resized { + width: u16, + height: u16, + previous_width: u16, + previous_height: u16, + }, + /// Focus changed between components + FocusChanged { + previous_focus: Option, + new_focus: String, + }, + /// Scroll position changed + ScrollChanged { + component: String, + position: usize, + max_position: usize, + direction: ScrollDirection, + }, + /// View/screen changed + ViewChanged { + previous_view: Option, + new_view: String, + }, + /// Modal opened + ModalOpened { + modal_id: String, + modal_type: String, + }, + /// Modal closed + ModalClosed { + modal_id: String, + result: Option, + }, + /// Input focus gained + InputFocused { + input_id: String, + }, + /// Input focus lost + InputBlurred { + input_id: String, + value: String, + }, + /// Selection changed in a list/tree + SelectionChanged { + component: String, + selected_index: Option, + selected_id: Option, + }, + /// Theme changed + ThemeChanged { + previous_theme: String, + new_theme: String, + }, + /// Sidebar toggled + SidebarToggled { + visible: bool, + }, + /// Panel collapsed/expanded + PanelToggled { + panel_id: String, + collapsed: bool, + }, + /// Key pressed (after action mapping) + KeyPressed { + key: String, + modifiers: Vec, + action: Option, + }, + /// Mouse event + MouseEvent { + event_type: MouseEventType, + x: u16, + y: u16, + button: Option, + }, + /// Custom plugin event + Custom { + plugin_id: String, + event_name: String, + data: serde_json::Value, + }, +} + +/// Scroll direction +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScrollDirection { + Up, + Down, + Left, + Right, + PageUp, + PageDown, + Home, + End, +} + +/// Mouse event type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MouseEventType { + Click, + DoubleClick, + RightClick, + MiddleClick, + Scroll, + Move, + Drag, + DragEnd, +} + +/// Mouse button +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MouseButton { + Left, + Right, + Middle, +} + +// ============================================================================ +// TUI EVENT SUBSCRIPTION HOOK +// ============================================================================ + +/// Event filter for subscriptions +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TuiEventFilter { + /// Event types to subscribe to (empty = all) + #[serde(default)] + pub event_types: Vec, + /// Components to filter by (empty = all) + #[serde(default)] + pub components: Vec, + /// Whether to include frame events (high frequency) + #[serde(default)] + pub include_frame_events: bool, + /// Whether to include mouse move events (high frequency) + #[serde(default)] + pub include_mouse_move: bool, + /// Minimum interval between events (for throttling) + #[serde(default)] + pub throttle_ms: Option, +} + +/// Input for TUI event subscription +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiEventSubscribeInput { + /// Plugin ID subscribing + pub plugin_id: String, + /// Session ID + pub session_id: String, + /// Event filter + #[serde(default)] + pub filter: TuiEventFilter, +} + +/// Output for TUI event subscription +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiEventSubscribeOutput { + /// Whether subscription succeeded + pub success: bool, + /// Subscription ID + #[serde(default)] + pub subscription_id: Option, + /// Error if failed + #[serde(default)] + pub error: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl TuiEventSubscribeOutput { + pub fn success(subscription_id: impl Into) -> Self { + Self { + success: true, + subscription_id: Some(subscription_id.into()), + error: None, + result: HookResult::Continue, + } + } + + pub fn error(message: impl Into) -> Self { + Self { + success: false, + subscription_id: None, + error: Some(message.into()), + result: HookResult::Continue, + } + } +} + +/// Handler for TUI event subscription +#[async_trait] +pub trait TuiEventSubscribeHook: Send + Sync { + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + async fn execute( + &self, + input: &TuiEventSubscribeInput, + output: &mut TuiEventSubscribeOutput, + ) -> Result<()>; +} + +// ============================================================================ +// TUI EVENT DISPATCH HOOK +// ============================================================================ + +/// Input for TUI event dispatch (to plugins) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiEventDispatchInput { + /// Session ID + pub session_id: String, + /// The event being dispatched + pub event: TuiEvent, + /// Target plugin (None = broadcast to all subscribers) + #[serde(default)] + pub target_plugin: Option, +} + +/// Output for TUI event dispatch +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TuiEventDispatchOutput { + /// Whether event should continue propagating + #[serde(default)] + pub propagate: bool, + /// Modifications to the event + #[serde(default)] + pub modifications: HashMap, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl TuiEventDispatchOutput { + pub fn new() -> Self { + Self { + propagate: true, + modifications: HashMap::new(), + result: HookResult::Continue, + } + } + + /// Stop event propagation + pub fn stop_propagation(mut self) -> Self { + self.propagate = false; + self + } + + /// Add event modification + pub fn modify(mut self, key: impl Into, value: serde_json::Value) -> Self { + self.modifications.insert(key.into(), value); + self + } +} + +/// Handler for TUI event dispatch +#[async_trait] +pub trait TuiEventDispatchHook: Send + Sync { + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + async fn execute( + &self, + input: &TuiEventDispatchInput, + output: &mut TuiEventDispatchOutput, + ) -> Result<()>; +} + +// ============================================================================ +// CUSTOM EVENT EMIT HOOK +// ============================================================================ + +/// Input for emitting custom events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomEventEmitInput { + /// Plugin ID emitting the event + pub plugin_id: String, + /// Session ID + pub session_id: String, + /// Event name + pub event_name: String, + /// Event data + pub data: serde_json::Value, + /// Target plugin (None = broadcast) + #[serde(default)] + pub target_plugin: Option, +} + +/// Output for custom event emit +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CustomEventEmitOutput { + /// Whether event was emitted + pub emitted: bool, + /// Event ID + #[serde(default)] + pub event_id: Option, + /// Number of subscribers notified + #[serde(default)] + pub subscribers_notified: usize, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl CustomEventEmitOutput { + pub fn success(event_id: impl Into, subscribers: usize) -> Self { + Self { + emitted: true, + event_id: Some(event_id.into()), + subscribers_notified: subscribers, + result: HookResult::Continue, + } + } +} + +/// Handler for custom event emission +#[async_trait] +pub trait CustomEventEmitHook: Send + Sync { + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + async fn execute( + &self, + input: &CustomEventEmitInput, + output: &mut CustomEventEmitOutput, + ) -> Result<()>; +} + +// ============================================================================ +// EVENT INTERCEPT HOOK +// ============================================================================ + +/// Event interception mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InterceptMode { + /// Observe only, cannot modify + Observe, + /// Can modify the event + Modify, + /// Can block the event + Block, +} + +impl Default for InterceptMode { + fn default() -> Self { + Self::Observe + } +} + +/// Input for event interception +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventInterceptInput { + /// Plugin ID intercepting + pub plugin_id: String, + /// Session ID + pub session_id: String, + /// Event being intercepted + pub event: TuiEvent, + /// Interception mode requested + pub mode: InterceptMode, +} + +/// Output for event interception +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventInterceptOutput { + /// Whether to block the event + #[serde(default)] + pub blocked: bool, + /// Modified event (if mode allows) + #[serde(default)] + pub modified_event: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl Default for EventInterceptOutput { + fn default() -> Self { + Self { + blocked: false, + modified_event: None, + result: HookResult::Continue, + } + } +} + +impl EventInterceptOutput { + pub fn new() -> Self { + Self::default() + } + + /// Block the event + pub fn block(mut self) -> Self { + self.blocked = true; + self + } + + /// Modify the event + pub fn modify(mut self, event: TuiEvent) -> Self { + self.modified_event = Some(event); + self + } +} + +/// Handler for event interception +#[async_trait] +pub trait EventInterceptHook: Send + Sync { + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// What interception mode this hook supports + fn intercept_mode(&self) -> InterceptMode { + InterceptMode::Observe + } + + /// Event types this hook intercepts (empty = all) + fn event_types(&self) -> Vec { + vec![] + } + + async fn execute( + &self, + input: &EventInterceptInput, + output: &mut EventInterceptOutput, + ) -> Result<()>; +} + +// ============================================================================ +// ANIMATION FRAME HOOK +// ============================================================================ + +/// Input for animation frame hook (called every frame) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnimationFrameInput { + /// Session ID + pub session_id: String, + /// Current frame number + pub frame: u64, + /// Time since last frame (microseconds) + pub delta_us: u64, + /// Total elapsed time (microseconds) + pub elapsed_us: u64, +} + +/// Output for animation frame hook +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AnimationFrameOutput { + /// Widgets to update + #[serde(default)] + pub widget_updates: HashMap, + /// Whether to request another frame + #[serde(default)] + pub request_frame: bool, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl AnimationFrameOutput { + pub fn new() -> Self { + Self::default() + } + + /// Update a widget + pub fn update_widget(mut self, widget_id: impl Into, data: serde_json::Value) -> Self { + self.widget_updates.insert(widget_id.into(), data); + self + } + + /// Request animation continue + pub fn continue_animation(mut self) -> Self { + self.request_frame = true; + self + } +} + +/// Handler for animation frames +#[async_trait] +pub trait AnimationFrameHook: Send + Sync { + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Whether this hook needs frame callbacks + fn needs_frames(&self) -> bool { + true + } + + async fn execute( + &self, + input: &AnimationFrameInput, + output: &mut AnimationFrameOutput, + ) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tui_event_serialization() { + let event = TuiEvent::Resized { + width: 120, + height: 40, + previous_width: 100, + previous_height: 30, + }; + let json = serde_json::to_string(&event).expect("serialize failed"); + assert!(json.contains("resized")); + assert!(json.contains("120")); + } + + #[test] + fn test_event_filter_default() { + let filter = TuiEventFilter::default(); + assert!(filter.event_types.is_empty()); + assert!(!filter.include_frame_events); + } + + #[test] + fn test_subscribe_output_success() { + let output = TuiEventSubscribeOutput::success("sub-123"); + assert!(output.success); + assert_eq!(output.subscription_id, Some("sub-123".to_string())); + } + + #[test] + fn test_dispatch_output() { + let output = TuiEventDispatchOutput::new() + .stop_propagation() + .modify("key", serde_json::json!("value")); + + assert!(!output.propagate); + assert!(output.modifications.contains_key("key")); + } + + #[test] + fn test_intercept_output_block() { + let output = EventInterceptOutput::new().block(); + assert!(output.blocked); + } + + #[test] + fn test_animation_frame_output() { + let output = AnimationFrameOutput::new() + .update_widget("widget-1", serde_json::json!({"value": 50})) + .continue_animation(); + + assert!(output.request_frame); + assert!(output.widget_updates.contains_key("widget-1")); + } + + #[test] + fn test_custom_event() { + let event = TuiEvent::Custom { + plugin_id: "my-plugin".to_string(), + event_name: "my-event".to_string(), + data: serde_json::json!({"foo": "bar"}), + }; + + if let TuiEvent::Custom { event_name, .. } = event { + assert_eq!(event_name, "my-event"); + } else { + panic!("Expected Custom event"); + } + } +} diff --git a/src/cortex-plugins/src/hooks/types.rs b/src/cortex-plugins/src/hooks/types.rs index 826c8570..2bcf4212 100644 --- a/src/cortex-plugins/src/hooks/types.rs +++ b/src/cortex-plugins/src/hooks/types.rs @@ -3,26 +3,137 @@ use serde::{Deserialize, Serialize}; /// Hook priority - lower values run first. +/// +/// # Security +/// +/// Hook priorities control the order of execution, with lower values running first. +/// System-reserved priorities (0-49) should only be used by trusted core plugins, +/// as these hooks can intercept and modify operations before any third-party code runs. +/// +/// ## Priority Ranges +/// +/// | Range | Usage | Who Can Use | +/// |---------|---------------------------|------------------------| +/// | 0-9 | Critical system hooks | Core Cortex only | +/// | 10-49 | System-level hooks | Trusted system plugins | +/// | 50-99 | High priority plugins | Third-party (high) | +/// | 100-174 | Normal priority plugins | Third-party (normal) | +/// | 175-255 | Low priority plugins | Third-party (low) | +/// +/// Third-party plugins attempting to register hooks with priority < 50 should be +/// rejected to prevent priority hijacking attacks. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct HookPriority(pub i32); impl Default for HookPriority { fn default() -> Self { - Self(100) + Self::NORMAL } } impl HookPriority { - /// Highest priority (runs first) - pub const HIGHEST: Self = Self(0); - /// High priority - pub const HIGH: Self = Self(25); - /// Normal priority + /// Critical system priority (runs first) - **RESERVED FOR CORE CORTEX ONLY** + /// + /// # Security + /// + /// This priority level is reserved for critical system hooks that must run + /// before any other code. Third-party plugins must NOT use this priority. + pub const SYSTEM_CRITICAL: Self = Self(0); + + /// System priority - **RESERVED FOR TRUSTED SYSTEM PLUGINS** + /// + /// # Security + /// + /// Reserved for system-level plugins that are part of the Cortex distribution. + /// Third-party plugins must NOT use this priority. + pub const SYSTEM: Self = Self(10); + + /// High system priority - **RESERVED FOR TRUSTED SYSTEM PLUGINS** + /// + /// # Security + /// + /// Reserved for system-level plugins. Third-party plugins must NOT use this. + pub const SYSTEM_HIGH: Self = Self(25); + + /// Minimum priority allowed for third-party plugins. + /// + /// Third-party plugins should use priorities >= 50. + pub const PLUGIN_MIN: Self = Self(50); + + /// High priority for third-party plugins (runs early, but after system hooks). + pub const PLUGIN_HIGH: Self = Self(75); + + /// Normal priority (default for third-party plugins). pub const NORMAL: Self = Self(100); - /// Low priority + + /// Low priority (runs later). pub const LOW: Self = Self(175); - /// Lowest priority (runs last) + + /// Lowest priority (runs last). pub const LOWEST: Self = Self(255); + + // Legacy aliases for backward compatibility + /// Alias for SYSTEM_CRITICAL (legacy name). + #[deprecated(since = "0.2.0", note = "Use SYSTEM_CRITICAL for system hooks or PLUGIN_HIGH for plugins")] + pub const HIGHEST: Self = Self::SYSTEM_CRITICAL; + + /// Alias for SYSTEM_HIGH (legacy name). + #[deprecated(since = "0.2.0", note = "Use SYSTEM_HIGH for system hooks or PLUGIN_HIGH for plugins")] + pub const HIGH: Self = Self::SYSTEM_HIGH; + + /// Get the raw priority value. + pub fn value(&self) -> i32 { + self.0 + } + + /// Create a custom priority value. + /// + /// Note: For third-party plugins, use `new_for_plugin` which enforces + /// the minimum priority requirement. + pub fn new(value: i32) -> Self { + Self(value) + } + + /// Create a priority value safe for third-party plugins. + /// + /// Ensures the priority is >= PLUGIN_MIN (50). If a lower value is provided, + /// it will be clamped to PLUGIN_MIN. + /// + /// # Security + /// + /// Use this constructor for third-party plugin priorities to prevent + /// priority hijacking attacks. + pub fn new_for_plugin(value: i32) -> Self { + Self(value.max(Self::PLUGIN_MIN.0)) + } + + /// Validate that this priority is allowed for third-party plugins. + /// + /// # Security + /// + /// Third-party plugins should not be allowed to register hooks with + /// system-reserved priorities (< 50). This prevents malicious plugins + /// from intercepting operations before security checks run. + /// + /// # Returns + /// + /// - `Ok(())` if the priority is valid for third-party use (>= 50) + /// - `Err` with explanation if the priority is reserved for system use + pub fn validate_for_plugin(&self) -> std::result::Result<(), &'static str> { + if self.0 < Self::PLUGIN_MIN.0 { + return Err("Priority values below 50 are reserved for system use. \ + Third-party plugins must use priority >= 50."); + } + Ok(()) + } + + /// Check if this priority is in the system-reserved range. + /// + /// System-reserved priorities (0-49) should only be used by trusted + /// core Cortex code and system plugins. + pub fn is_system_reserved(&self) -> bool { + self.0 < Self::PLUGIN_MIN.0 + } } /// Hook execution result. diff --git a/src/cortex-plugins/src/hooks/ui_hooks.rs b/src/cortex-plugins/src/hooks/ui_hooks.rs index ce93e9a6..a160a8f0 100644 --- a/src/cortex-plugins/src/hooks/ui_hooks.rs +++ b/src/cortex-plugins/src/hooks/ui_hooks.rs @@ -1,4 +1,12 @@ -//! UI rendering hooks. +//! Advanced TUI UI rendering hooks for plugin customization. +//! +//! This module provides comprehensive UI hooks that allow plugins to: +//! - Register and render custom widgets +//! - Modify existing UI components (header, footer, sidebar, chat) +//! - Override color schemes and themes dynamically +//! - Add custom keyboard shortcuts +//! - Inject modals, sidebars, and overlays +//! - Control widget positioning and sizing use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -7,24 +15,64 @@ use std::collections::HashMap; use super::types::{HookPriority, HookResult}; use crate::Result; -/// Input for ui.render hook. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiRenderInput { - /// Session ID - pub session_id: String, - /// Component being rendered - pub component: UiComponent, - /// Current theme - pub theme: String, +// ============================================================================ +// UI REGION TYPES +// ============================================================================ + +/// UI regions where plugins can inject content +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiRegion { + /// Top header area + Header, + /// Bottom footer/status bar area + Footer, + /// Left sidebar area + SidebarLeft, + /// Right sidebar area + SidebarRight, + /// Main chat/content area + MainContent, + /// Input area at the bottom + InputArea, + /// Overlay layer (modals, popups) + Overlay, + /// Status indicators area + StatusBar, + /// Tool output area + ToolOutput, + /// Message display area + MessageArea, +} + +impl std::fmt::Display for UiRegion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Header => write!(f, "header"), + Self::Footer => write!(f, "footer"), + Self::SidebarLeft => write!(f, "sidebar_left"), + Self::SidebarRight => write!(f, "sidebar_right"), + Self::MainContent => write!(f, "main_content"), + Self::InputArea => write!(f, "input_area"), + Self::Overlay => write!(f, "overlay"), + Self::StatusBar => write!(f, "status_bar"), + Self::ToolOutput => write!(f, "tool_output"), + Self::MessageArea => write!(f, "message_area"), + } + } } -/// UI components that can be customized. +// ============================================================================ +// UI COMPONENT TYPES +// ============================================================================ + +/// UI components that can be customized #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UiComponent { - /// Chat message + /// Chat message display ChatMessage { role: String, message_id: String }, - /// Tool output + /// Tool output display ToolOutput { tool: String, call_id: String }, /// Status bar StatusBar, @@ -34,71 +82,1124 @@ pub enum UiComponent { Header, /// Sidebar Sidebar, + /// Modal/dialog + Modal { modal_type: String }, + /// Notification toast + Toast { level: String }, + /// Progress indicator + Progress { operation: String }, + /// Custom component + Custom { component_type: String, data: serde_json::Value }, } -/// Output for ui.render hook (mutable). +// ============================================================================ +// STYLE TYPES +// ============================================================================ + +/// Color specification (supports multiple formats) #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiRenderOutput { - /// Custom styles to apply - pub styles: HashMap, - /// Additional content to render - pub extra_content: Option, - /// Widgets to add - pub widgets: Vec, - /// Hook result - pub result: HookResult, +#[serde(untagged)] +pub enum Color { + /// Named color (e.g., "red", "cyan", "green") + Named(String), + /// RGB color + Rgb { r: u8, g: u8, b: u8 }, + /// Hex color (e.g., "#FF5733") + Hex(String), + /// ANSI 256 color index + Ansi256(u8), } -impl UiRenderOutput { - pub fn new() -> Self { - Self { - styles: HashMap::new(), - extra_content: None, - widgets: Vec::new(), - result: HookResult::Continue, - } +impl Default for Color { + fn default() -> Self { + Self::Named("white".to_string()) } } -impl Default for UiRenderOutput { +/// Border style for widgets +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BorderStyle { + /// No border + None, + /// Plain single line border + Plain, + /// Rounded corners + Rounded, + /// Double line border + Double, + /// Thick line border + Thick, + /// Quadrant inside style + QuadrantInside, + /// Quadrant outside style + QuadrantOutside, +} + +impl Default for BorderStyle { + fn default() -> Self { + Self::Plain + } +} + +/// Text style modifiers +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TextStyle { + /// Foreground color + #[serde(default)] + pub fg: Option, + /// Background color + #[serde(default)] + pub bg: Option, + /// Bold text + #[serde(default)] + pub bold: bool, + /// Italic text + #[serde(default)] + pub italic: bool, + /// Underlined text + #[serde(default)] + pub underline: bool, + /// Strikethrough text + #[serde(default)] + pub strikethrough: bool, + /// Dim/faded text + #[serde(default)] + pub dim: bool, +} + +/// Widget styling options +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WidgetStyle { + /// Border style + #[serde(default)] + pub border: BorderStyle, + /// Border color + #[serde(default)] + pub border_color: Option, + /// Background color + #[serde(default)] + pub background: Option, + /// Padding (top, right, bottom, left) + #[serde(default)] + pub padding: [u16; 4], + /// Margin (top, right, bottom, left) + #[serde(default)] + pub margin: [u16; 4], + /// Title style + #[serde(default)] + pub title_style: Option, + /// Content style + #[serde(default)] + pub content_style: Option, +} + +// ============================================================================ +// WIDGET TYPES +// ============================================================================ + +/// Widget sizing constraints +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WidgetSize { + /// Fixed size in cells + Fixed(u16), + /// Percentage of parent + Percent(u16), + /// Minimum size + Min(u16), + /// Maximum size + Max(u16), + /// Fill available space + Fill, + /// Auto-size based on content + Auto, +} + +impl Default for WidgetSize { fn default() -> Self { - Self::new() + Self::Auto } } -/// Custom UI widgets. +/// Widget layout constraints +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WidgetConstraints { + /// Width constraint + #[serde(default)] + pub width: WidgetSize, + /// Height constraint + #[serde(default)] + pub height: WidgetSize, + /// Minimum width + #[serde(default)] + pub min_width: Option, + /// Maximum width + #[serde(default)] + pub max_width: Option, + /// Minimum height + #[serde(default)] + pub min_height: Option, + /// Maximum height + #[serde(default)] + pub max_height: Option, +} + +/// Custom UI widget that plugins can register #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum UiWidget { + /// Text block + Text { + content: String, + #[serde(default)] + style: TextStyle, + #[serde(default)] + wrap: bool, + }, /// Progress bar - ProgressBar { value: f32, label: Option }, - /// Status indicator + ProgressBar { + value: f32, + #[serde(default)] + label: Option, + #[serde(default)] + style: WidgetStyle, + }, + /// Status indicator (colored dot with label) StatusIndicator { status: String, - color: Option, + #[serde(default)] + color: Option, + #[serde(default)] + label: Option, + }, + /// Clickable button + Button { + label: String, + action: String, + #[serde(default)] + style: WidgetStyle, + #[serde(default)] + disabled: bool, + }, + /// Badge/tag + Badge { + text: String, + #[serde(default)] + color: Option, + #[serde(default)] + bg_color: Option, + }, + /// List of items + List { + items: Vec, + #[serde(default)] + selected: Option, + #[serde(default)] + style: WidgetStyle, + }, + /// Table + Table { + headers: Vec, + rows: Vec>, + #[serde(default)] + style: WidgetStyle, + #[serde(default)] + widths: Vec, }, - /// Button - Button { label: String, action: String }, - /// Badge - Badge { text: String, color: Option }, - /// Custom widget + /// Horizontal separator + Separator { + #[serde(default)] + style: Option, + }, + /// Gauge/meter + Gauge { + value: f32, + #[serde(default)] + label: Option, + #[serde(default)] + color: Option, + }, + /// Sparkline chart + Sparkline { + data: Vec, + #[serde(default)] + color: Option, + }, + /// Horizontal layout container + HorizontalLayout { + children: Vec, + #[serde(default)] + spacing: u16, + }, + /// Vertical layout container + VerticalLayout { + children: Vec, + #[serde(default)] + spacing: u16, + }, + /// Block container with border and title + Block { + #[serde(default)] + title: Option, + #[serde(default)] + content: Option>, + #[serde(default)] + style: WidgetStyle, + }, + /// Custom widget (plugin-specific rendering) Custom { widget_type: String, data: serde_json::Value, + #[serde(default)] + constraints: WidgetConstraints, }, } -/// Handler for ui.render hook. +// ============================================================================ +// KEYBOARD SHORTCUT TYPES +// ============================================================================ + +/// Keyboard modifier keys +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyModifier { + /// Control key + Ctrl, + /// Alt/Option key + Alt, + /// Shift key + Shift, + /// Super/Meta/Windows key + Super, +} + +/// Key binding definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyBinding { + /// The key code (e.g., "a", "Enter", "F1", "Escape") + pub key: String, + /// Modifier keys required + #[serde(default)] + pub modifiers: Vec, + /// Action to trigger + pub action: String, + /// Description for help + #[serde(default)] + pub description: Option, + /// Context where this binding is active (None = global) + #[serde(default)] + pub context: Option, +} + +/// Key binding registration result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyBindingResult { + /// Whether the registration succeeded + pub success: bool, + /// Error message if failed + #[serde(default)] + pub error: Option, + /// ID of the registered binding + #[serde(default)] + pub binding_id: Option, +} + +// ============================================================================ +// THEME TYPES +// ============================================================================ + +/// Theme color palette +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ThemeColors { + /// Primary accent color + #[serde(default)] + pub primary: Option, + /// Secondary accent color + #[serde(default)] + pub secondary: Option, + /// Background color + #[serde(default)] + pub background: Option, + /// Foreground/text color + #[serde(default)] + pub foreground: Option, + /// Success/positive color + #[serde(default)] + pub success: Option, + /// Warning color + #[serde(default)] + pub warning: Option, + /// Error/danger color + #[serde(default)] + pub error: Option, + /// Info/informational color + #[serde(default)] + pub info: Option, + /// Border color + #[serde(default)] + pub border: Option, + /// Selection highlight color + #[serde(default)] + pub selection: Option, + /// Muted/dimmed text color + #[serde(default)] + pub muted: Option, + /// Custom colors by name + #[serde(default)] + pub custom: HashMap, +} + +/// Theme override that plugins can apply +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ThemeOverride { + /// Color palette overrides + #[serde(default)] + pub colors: ThemeColors, + /// Component-specific style overrides + #[serde(default)] + pub components: HashMap, +} + +// ============================================================================ +// UI RENDER HOOK +// ============================================================================ + +/// Input for ui.render hook +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiRenderInput { + /// Session ID + pub session_id: String, + /// Component being rendered + pub component: UiComponent, + /// Current theme name + pub theme: String, + /// Current terminal dimensions (width, height) + #[serde(default)] + pub dimensions: (u16, u16), + /// Whether the component has focus + #[serde(default)] + pub has_focus: bool, + /// Current frame number (for animations) + #[serde(default)] + pub frame: u64, +} + +/// Output for ui.render hook +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UiRenderOutput { + /// Custom styles to apply to the component + #[serde(default)] + pub styles: HashMap, + /// Additional content to render after the component + #[serde(default)] + pub extra_content: Option, + /// Widgets to inject into the component + #[serde(default)] + pub widgets: Vec, + /// Style overrides for the component + #[serde(default)] + pub style_override: Option, + /// Theme overrides to apply temporarily + #[serde(default)] + pub theme_override: Option, + /// Hook result (continue, skip, abort) + #[serde(default)] + pub result: HookResult, +} + +impl UiRenderOutput { + /// Create a new empty output + pub fn new() -> Self { + Self::default() + } + + /// Add a widget to inject + pub fn add_widget(&mut self, widget: UiWidget) { + self.widgets.push(widget); + } + + /// Set a style value + pub fn set_style(&mut self, key: impl Into, value: impl Into) { + self.styles.insert(key.into(), value.into()); + } + + /// Set the style override + pub fn with_style(mut self, style: WidgetStyle) -> Self { + self.style_override = Some(style); + self + } + + /// Set extra content + pub fn with_content(mut self, content: impl Into) -> Self { + self.extra_content = Some(content.into()); + self + } +} + +/// Handler for ui.render hook #[async_trait] pub trait UiRenderHook: Send + Sync { + /// Get the priority of this hook fn priority(&self) -> HookPriority { HookPriority::default() } - /// Get components this hook applies to (None = all). + /// Get components this hook applies to (None = all) fn components(&self) -> Option> { None } + /// Execute the hook async fn execute(&self, input: &UiRenderInput, output: &mut UiRenderOutput) -> Result<()>; } + +// ============================================================================ +// WIDGET REGISTRATION HOOK +// ============================================================================ + +/// Input for widget registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WidgetRegisterInput { + /// Plugin ID registering the widget + pub plugin_id: String, + /// Widget type identifier + pub widget_type: String, + /// Region where widget should appear + pub region: UiRegion, + /// Widget constraints + #[serde(default)] + pub constraints: WidgetConstraints, + /// Initial widget data + #[serde(default)] + pub initial_data: Option, +} + +/// Output for widget registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WidgetRegisterOutput { + /// Whether registration succeeded + pub success: bool, + /// Widget ID assigned (for updates/removal) + #[serde(default)] + pub widget_id: Option, + /// Error message if failed + #[serde(default)] + pub error: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl WidgetRegisterOutput { + /// Create a successful registration output + pub fn success(widget_id: impl Into) -> Self { + Self { + success: true, + widget_id: Some(widget_id.into()), + error: None, + result: HookResult::Continue, + } + } + + /// Create an error registration output + pub fn error(message: impl Into) -> Self { + Self { + success: false, + widget_id: None, + error: Some(message.into()), + result: HookResult::Continue, + } + } +} + +impl Default for WidgetRegisterOutput { + fn default() -> Self { + Self { + success: false, + widget_id: None, + error: None, + result: HookResult::Continue, + } + } +} + +/// Handler for widget registration +#[async_trait] +pub trait WidgetRegisterHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &WidgetRegisterInput, output: &mut WidgetRegisterOutput) -> Result<()>; +} + +// ============================================================================ +// KEYBOARD BINDING HOOK +// ============================================================================ + +/// Input for keyboard binding registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyBindingInput { + /// Plugin ID registering the binding + pub plugin_id: String, + /// Key binding definition + pub binding: KeyBinding, +} + +/// Output for keyboard binding registration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyBindingOutput { + /// Registration result + pub result: KeyBindingResult, + /// Hook result + #[serde(default)] + pub hook_result: HookResult, +} + +impl KeyBindingOutput { + /// Create a successful binding output + pub fn success(binding_id: impl Into) -> Self { + Self { + result: KeyBindingResult { + success: true, + error: None, + binding_id: Some(binding_id.into()), + }, + hook_result: HookResult::Continue, + } + } + + /// Create an error binding output + pub fn error(message: impl Into) -> Self { + Self { + result: KeyBindingResult { + success: false, + error: Some(message.into()), + binding_id: None, + }, + hook_result: HookResult::Continue, + } + } +} + +impl Default for KeyBindingOutput { + fn default() -> Self { + Self { + result: KeyBindingResult { + success: false, + error: None, + binding_id: None, + }, + hook_result: HookResult::Continue, + } + } +} + +/// Handler for keyboard binding registration +#[async_trait] +pub trait KeyBindingHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &KeyBindingInput, output: &mut KeyBindingOutput) -> Result<()>; +} + +// ============================================================================ +// THEME OVERRIDE HOOK +// ============================================================================ + +/// Input for theme override +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeOverrideInput { + /// Session ID + pub session_id: String, + /// Current theme name + pub current_theme: String, + /// Current theme colors + pub current_colors: ThemeColors, +} + +/// Output for theme override +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ThemeOverrideOutput { + /// Theme overrides to apply + #[serde(default)] + pub overrides: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl ThemeOverrideOutput { + /// Create a new empty output + pub fn new() -> Self { + Self::default() + } + + /// Set theme overrides + pub fn with_override(mut self, override_: ThemeOverride) -> Self { + self.overrides = Some(override_); + self + } +} + +/// Handler for theme override hook +#[async_trait] +pub trait ThemeOverrideHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &ThemeOverrideInput, output: &mut ThemeOverrideOutput) -> Result<()>; +} + +// ============================================================================ +// LAYOUT CUSTOMIZATION HOOK +// ============================================================================ + +/// Layout direction +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayoutDirection { + /// Horizontal layout + Horizontal, + /// Vertical layout + Vertical, +} + +impl Default for LayoutDirection { + fn default() -> Self { + Self::Vertical + } +} + +/// Panel definition for layout +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayoutPanel { + /// Panel identifier + pub id: String, + /// Panel region + pub region: UiRegion, + /// Size constraint + #[serde(default)] + pub size: WidgetSize, + /// Whether panel is visible + #[serde(default = "default_true")] + pub visible: bool, + /// Whether panel is collapsible + #[serde(default)] + pub collapsible: bool, + /// Whether panel is currently collapsed + #[serde(default)] + pub collapsed: bool, + /// Panel title + #[serde(default)] + pub title: Option, +} + +fn default_true() -> bool { + true +} + +/// Layout configuration from plugin +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LayoutConfig { + /// Main layout direction + #[serde(default)] + pub direction: LayoutDirection, + /// Panels to add/modify + #[serde(default)] + pub panels: Vec, + /// Panels to hide + #[serde(default)] + pub hidden_panels: Vec, +} + +/// Input for layout customization hook +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayoutCustomizeInput { + /// Session ID + pub session_id: String, + /// Current terminal dimensions + pub dimensions: (u16, u16), + /// Current layout config + pub current_layout: LayoutConfig, +} + +/// Output for layout customization hook +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LayoutCustomizeOutput { + /// Layout modifications + #[serde(default)] + pub layout_changes: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl LayoutCustomizeOutput { + /// Create a new empty output + pub fn new() -> Self { + Self::default() + } + + /// Set layout changes + pub fn with_layout(mut self, layout: LayoutConfig) -> Self { + self.layout_changes = Some(layout); + self + } +} + +/// Handler for layout customization hook +#[async_trait] +pub trait LayoutCustomizeHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &LayoutCustomizeInput, output: &mut LayoutCustomizeOutput) -> Result<()>; +} + +// ============================================================================ +// MODAL INJECTION HOOK +// ============================================================================ + +/// Modal priority/layer +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModalLayer { + /// Background layer (behind other modals) + Background = 0, + /// Normal layer + Normal = 1, + /// High priority layer + High = 2, + /// Urgent/critical layer (topmost) + Urgent = 3, +} + +impl Default for ModalLayer { + fn default() -> Self { + Self::Normal + } +} + +/// Modal definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModalDefinition { + /// Modal identifier + pub id: String, + /// Modal title + pub title: String, + /// Modal content widget + pub content: UiWidget, + /// Modal style + #[serde(default)] + pub style: WidgetStyle, + /// Modal layer + #[serde(default)] + pub layer: ModalLayer, + /// Whether modal is dismissible (Escape key) + #[serde(default = "default_true")] + pub dismissible: bool, + /// Width constraint + #[serde(default)] + pub width: WidgetSize, + /// Height constraint + #[serde(default)] + pub height: WidgetSize, + /// Action buttons + #[serde(default)] + pub buttons: Vec, +} + +/// Input for modal injection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModalInjectInput { + /// Plugin ID requesting modal + pub plugin_id: String, + /// Modal definition + pub modal: ModalDefinition, +} + +/// Output for modal injection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModalInjectOutput { + /// Whether modal was shown + pub shown: bool, + /// Modal ID for later dismissal + #[serde(default)] + pub modal_id: Option, + /// Error if failed + #[serde(default)] + pub error: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl ModalInjectOutput { + /// Create a successful modal output + pub fn success(modal_id: impl Into) -> Self { + Self { + shown: true, + modal_id: Some(modal_id.into()), + error: None, + result: HookResult::Continue, + } + } + + /// Create an error modal output + pub fn error(message: impl Into) -> Self { + Self { + shown: false, + modal_id: None, + error: Some(message.into()), + result: HookResult::Continue, + } + } +} + +impl Default for ModalInjectOutput { + fn default() -> Self { + Self { + shown: false, + modal_id: None, + error: None, + result: HookResult::Continue, + } + } +} + +/// Handler for modal injection +#[async_trait] +pub trait ModalInjectHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &ModalInjectInput, output: &mut ModalInjectOutput) -> Result<()>; +} + +// ============================================================================ +// TOAST/NOTIFICATION HOOK +// ============================================================================ + +/// Toast notification level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToastLevel { + /// Informational toast + Info, + /// Success toast + Success, + /// Warning toast + Warning, + /// Error toast + Error, +} + +impl Default for ToastLevel { + fn default() -> Self { + Self::Info + } +} + +/// Toast notification definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToastDefinition { + /// Toast message + pub message: String, + /// Toast level + #[serde(default)] + pub level: ToastLevel, + /// Duration in milliseconds (0 = persistent) + #[serde(default = "default_toast_duration")] + pub duration_ms: u64, + /// Optional title + #[serde(default)] + pub title: Option, + /// Optional action button (label, action_id) + #[serde(default)] + pub action: Option<(String, String)>, +} + +fn default_toast_duration() -> u64 { + 3000 +} + +/// Input for toast notification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToastShowInput { + /// Plugin ID showing toast + pub plugin_id: String, + /// Toast definition + pub toast: ToastDefinition, +} + +/// Output for toast notification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToastShowOutput { + /// Whether toast was shown + pub shown: bool, + /// Toast ID for later dismissal + #[serde(default)] + pub toast_id: Option, + /// Hook result + #[serde(default)] + pub result: HookResult, +} + +impl ToastShowOutput { + /// Create a successful toast output + pub fn success(toast_id: impl Into) -> Self { + Self { + shown: true, + toast_id: Some(toast_id.into()), + result: HookResult::Continue, + } + } +} + +impl Default for ToastShowOutput { + fn default() -> Self { + Self { + shown: false, + toast_id: None, + result: HookResult::Continue, + } + } +} + +/// Handler for toast notifications +#[async_trait] +pub trait ToastShowHook: Send + Sync { + /// Get the priority of this hook + fn priority(&self) -> HookPriority { + HookPriority::default() + } + + /// Execute the hook + async fn execute(&self, input: &ToastShowInput, output: &mut ToastShowOutput) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ui_region_display() { + assert_eq!(UiRegion::Header.to_string(), "header"); + assert_eq!(UiRegion::SidebarLeft.to_string(), "sidebar_left"); + } + + #[test] + fn test_widget_style_default() { + let style = WidgetStyle::default(); + assert_eq!(style.border, BorderStyle::Plain); + assert!(style.background.is_none()); + } + + #[test] + fn test_ui_render_output() { + let mut output = UiRenderOutput::new(); + output.set_style("color", "cyan"); + output.add_widget(UiWidget::Badge { + text: "Test".to_string(), + color: Some(Color::Named("green".to_string())), + bg_color: None, + }); + + assert_eq!(output.styles.get("color"), Some(&"cyan".to_string())); + assert_eq!(output.widgets.len(), 1); + } + + #[test] + fn test_theme_colors() { + let colors = ThemeColors { + primary: Some(Color::Hex("#00CED1".to_string())), + ..Default::default() + }; + assert!(colors.primary.is_some()); + assert!(colors.secondary.is_none()); + } + + #[test] + fn test_key_binding() { + let binding = KeyBinding { + key: "k".to_string(), + modifiers: vec![KeyModifier::Ctrl], + action: "kill_line".to_string(), + description: Some("Kill line".to_string()), + context: None, + }; + assert_eq!(binding.modifiers.len(), 1); + } + + #[test] + fn test_widget_register_output() { + let output = WidgetRegisterOutput::success("widget-123"); + assert!(output.success); + assert_eq!(output.widget_id, Some("widget-123".to_string())); + } + + #[test] + fn test_modal_definition() { + let modal = ModalDefinition { + id: "test-modal".to_string(), + title: "Test".to_string(), + content: UiWidget::Text { + content: "Hello".to_string(), + style: TextStyle::default(), + wrap: false, + }, + style: WidgetStyle::default(), + layer: ModalLayer::Normal, + dismissible: true, + width: WidgetSize::Percent(50), + height: WidgetSize::Auto, + buttons: vec![], + }; + assert!(modal.dismissible); + } + + #[test] + fn test_toast_definition() { + let toast = ToastDefinition { + message: "Test message".to_string(), + level: ToastLevel::Success, + duration_ms: 5000, + title: Some("Success!".to_string()), + action: None, + }; + assert_eq!(toast.level, ToastLevel::Success); + assert_eq!(toast.duration_ms, 5000); + } + + #[test] + fn test_layout_panel() { + let panel = LayoutPanel { + id: "sidebar".to_string(), + region: UiRegion::SidebarLeft, + size: WidgetSize::Percent(20), + visible: true, + collapsible: true, + collapsed: false, + title: Some("Navigation".to_string()), + }; + assert!(panel.visible); + assert!(panel.collapsible); + } +} diff --git a/src/cortex-plugins/src/lib.rs b/src/cortex-plugins/src/lib.rs index eceec703..1d0df799 100644 --- a/src/cortex-plugins/src/lib.rs +++ b/src/cortex-plugins/src/lib.rs @@ -69,7 +69,7 @@ pub use events::{Event, EventBus, EventHandler, EventSubscription}; // Hook system re-exports pub use hooks::{ - // Prompt/AI hooks (NEW) + // Prompt/AI hooks AiResponseAfterHook, AiResponseAfterInput, AiResponseAfterOutput, @@ -83,7 +83,7 @@ pub use hooks::{ ChatMessageHook, ChatMessageInput, ChatMessageOutput, - // Clipboard hooks (NEW) + // Clipboard hooks ClipboardCopyHook, ClipboardCopyInput, ClipboardCopyOutput, @@ -91,14 +91,14 @@ pub use hooks::{ ClipboardPasteInput, ClipboardPasteOutput, ClipboardSource, - // Command hooks (NEW) + // Command hooks CommandExecuteAfterHook, CommandExecuteAfterInput, CommandExecuteAfterOutput, CommandExecuteBeforeHook, CommandExecuteBeforeInput, CommandExecuteBeforeOutput, - // Config hooks (NEW) + // Config hooks ConfigChangeAction, ConfigChangeSource, ConfigChangedHook, @@ -106,13 +106,13 @@ pub use hooks::{ ConfigChangedOutput, ContextDocument, ContextDocumentType, - // Error hooks (NEW) + // Error hooks ErrorHandleHook, ErrorHandleInput, ErrorHandleOutput, ErrorRecovery, ErrorSource, - // File operation hooks (NEW) + // File operation hooks FileOperation, FileOperationAfterHook, FileOperationAfterInput, @@ -121,7 +121,7 @@ pub use hooks::{ FileOperationBeforeInput, FileOperationBeforeOutput, FilePostAction, - // Focus hooks (NEW) + // Focus hooks FocusAction, FocusChangeHook, FocusChangeInput, @@ -131,7 +131,7 @@ pub use hooks::{ HookPriority, HookRegistry, HookResult, - // Input hooks (NEW) + // Input hooks InputAction, InputInterceptHook, InputInterceptInput, @@ -143,13 +143,13 @@ pub use hooks::{ PermissionAskInput, PermissionAskOutput, PermissionDecision, - // Workspace hooks (NEW) + // Workspace hooks ProjectType, PromptInjectHook, PromptInjectInput, PromptInjectOutput, QuickPickItem, - // Session hooks (NEW) + // Session hooks SessionEndAction, SessionEndHook, SessionEndInput, @@ -166,15 +166,104 @@ pub use hooks::{ ToolExecuteBeforeHook, ToolExecuteBeforeInput, ToolExecuteBeforeOutput, - // UI hooks (NEW) + // UI hooks - Basic UiComponent, UiRenderHook, UiRenderInput, UiRenderOutput, UiWidget, + // UI hooks - Advanced + UiRegion, + Color, + BorderStyle, + TextStyle, + WidgetStyle, + WidgetSize, + WidgetConstraints, + KeyModifier, + KeyBinding, + KeyBindingResult, + KeyBindingHook, + KeyBindingInput, + KeyBindingOutput, + ThemeColors, + ThemeOverride, + ThemeOverrideHook, + ThemeOverrideInput, + ThemeOverrideOutput, + WidgetRegisterHook, + WidgetRegisterInput, + WidgetRegisterOutput, + LayoutDirection, + LayoutPanel, + LayoutConfig, + LayoutCustomizeHook, + LayoutCustomizeInput, + LayoutCustomizeOutput, + ModalLayer, + ModalDefinition, + ModalInjectHook, + ModalInjectInput, + ModalInjectOutput, + ToastLevel, + ToastDefinition, + ToastShowHook, + ToastShowInput, + ToastShowOutput, + // TUI event hooks + TuiEvent, + ScrollDirection, + MouseEventType, + MouseButton, + TuiEventFilter, + TuiEventSubscribeHook, + TuiEventSubscribeInput, + TuiEventSubscribeOutput, + TuiEventDispatchHook, + TuiEventDispatchInput, + TuiEventDispatchOutput, + CustomEventEmitHook, + CustomEventEmitInput, + CustomEventEmitOutput, + InterceptMode, + EventInterceptHook, + EventInterceptInput, + EventInterceptOutput, + AnimationFrameHook, + AnimationFrameInput, + AnimationFrameOutput, + // Workspace hooks WorkspaceChangedHook, WorkspaceChangedInput, WorkspaceChangedOutput, + // Completion hooks + CompletionKind, + CompletionItem, + CompletionContext, + CompletionProvider, + CompletionProviderRegisterHook, + CompletionProviderRegisterInput, + CompletionProviderRegisterOutput, + CompletionRequestHook, + CompletionRequestInput, + CompletionRequestOutput, + CompletionResolveHook, + CompletionResolveInput, + CompletionResolveOutput, + ArgumentDefinition, + ArgumentCompletionHook, + ArgumentCompletionInput, + ArgumentCompletionOutput, +}; + +// SDK re-exports +pub use sdk::{ + generate_manifest, generate_rust_code, generate_cargo_toml, + generate_hot_reload_config, generate_typescript_code, generate_test_utils, + generate_advanced_rust_code, HotReloadConfig, PluginDev, + MANIFEST_TEMPLATE, RUST_TEMPLATE, CARGO_TEMPLATE, + TYPESCRIPT_TEMPLATE, TSCONFIG_TEMPLATE, HOT_RELOAD_CONFIG, + TEST_UTILS_TEMPLATE, RUST_ADVANCED_TEMPLATE, }; pub use loader::PluginLoader; diff --git a/src/cortex-plugins/src/manifest.rs b/src/cortex-plugins/src/manifest.rs index 66cad00b..9dc72aed 100644 --- a/src/cortex-plugins/src/manifest.rs +++ b/src/cortex-plugins/src/manifest.rs @@ -386,6 +386,30 @@ pub enum HookType { // ========== UI Hooks ========== /// UI render customization UiRender, + /// Widget registration + WidgetRegister, + /// Keyboard binding registration + KeyBinding, + /// Theme override + ThemeOverride, + /// Layout customization + LayoutCustomize, + /// Modal injection + ModalInject, + /// Toast notification + ToastShow, + + // ========== TUI Event Hooks ========== + /// TUI event subscription + TuiEventSubscribe, + /// TUI event dispatch + TuiEventDispatch, + /// Custom event emission + CustomEventEmit, + /// Event interception + EventIntercept, + /// Animation frame callback + AnimationFrame, // ========== Focus Hooks ========== /// Focus change (gained/lost) @@ -431,6 +455,18 @@ impl std::fmt::Display for HookType { Self::ClipboardPaste => write!(f, "clipboard.paste"), // UI hooks Self::UiRender => write!(f, "ui.render"), + Self::WidgetRegister => write!(f, "ui.widget.register"), + Self::KeyBinding => write!(f, "ui.key.binding"), + Self::ThemeOverride => write!(f, "ui.theme.override"), + Self::LayoutCustomize => write!(f, "ui.layout.customize"), + Self::ModalInject => write!(f, "ui.modal.inject"), + Self::ToastShow => write!(f, "ui.toast.show"), + // TUI event hooks + Self::TuiEventSubscribe => write!(f, "tui.event.subscribe"), + Self::TuiEventDispatch => write!(f, "tui.event.dispatch"), + Self::CustomEventEmit => write!(f, "tui.event.custom"), + Self::EventIntercept => write!(f, "tui.event.intercept"), + Self::AnimationFrame => write!(f, "tui.animation.frame"), // Focus hooks Self::FocusChange => write!(f, "focus.change"), } diff --git a/src/cortex-plugins/src/runtime.rs b/src/cortex-plugins/src/runtime.rs index 426ec844..60aece07 100644 --- a/src/cortex-plugins/src/runtime.rs +++ b/src/cortex-plugins/src/runtime.rs @@ -2,6 +2,12 @@ //! //! This module provides the WebAssembly runtime using wasmtime //! for executing plugin code in a sandboxed environment. +//! +//! # Security +//! +//! The runtime includes resource limits to prevent DoS attacks: +//! - CPU: Fuel-based limiting and epoch interruption +//! - Memory: Maximum 16MB per plugin instance use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -14,17 +20,53 @@ use crate::manifest::PluginManifest; use crate::plugin::{Plugin, PluginInfo, PluginState}; use crate::{PluginError, Result}; +/// Default fuel limit for WASM execution (CPU operations limit). +/// This value allows approximately 10 million operations before exhaustion. +const DEFAULT_FUEL_LIMIT: u64 = 10_000_000; + +/// Maximum memory size for a plugin instance (16MB). +const MAX_MEMORY_SIZE: usize = 16 * 1024 * 1024; + +/// Maximum number of memory pages (256 pages = 16MB, each page is 64KB). +const MAX_MEMORY_PAGES: u64 = 256; + +/// Maximum number of table elements. +const MAX_TABLE_ELEMENTS: u64 = 10_000; + +/// Maximum number of instances per plugin. +const MAX_INSTANCES: u32 = 10; + +/// Maximum number of tables per instance. +const MAX_TABLES: u32 = 10; + +/// Maximum number of memories per instance. +const MAX_MEMORIES: u32 = 1; + /// WASM runtime for executing plugins. pub struct WasmRuntime { engine: Engine, } impl WasmRuntime { - /// Create a new WASM runtime. + /// Create a new WASM runtime with security limits. + /// + /// # Security + /// + /// The runtime is configured with: + /// - Fuel consumption for CPU limiting + /// - Epoch-based interruption for timeout handling pub fn new() -> Result { let mut config = Config::new(); config.async_support(true); + // SECURITY: Enable fuel consumption for CPU limiting + // This prevents infinite loops and excessive CPU usage + config.consume_fuel(true); + + // SECURITY: Enable epoch-based interruption for timeout handling + // This allows external timeout enforcement + config.epoch_interruption(true); + let engine = Engine::new(&config)?; Ok(Self { engine }) @@ -48,11 +90,9 @@ impl WasmRuntime { } } -impl Default for WasmRuntime { - fn default() -> Self { - Self::new().expect("Failed to create WASM runtime") - } -} +// NOTE: Default impl intentionally removed for WasmRuntime. +// SECURITY: WasmRuntime::new() can fail, and using expect() in Default::default() +// would cause a panic. Callers should explicitly handle the Result from new(). /// A WASM-based plugin implementation. pub struct WasmPlugin { @@ -118,13 +158,30 @@ impl WasmPlugin { } /// Call a WASM function with no arguments. + /// + /// # Security + /// + /// The store is configured with resource limits: + /// - Fuel limit: Prevents excessive CPU usage (default: 10M operations) + /// - Memory limit: Maximum 16MB per instance + /// - Table/instance limits for additional sandboxing pub async fn call_function(&self, name: &str) -> Result { let module = self .module .as_ref() .ok_or_else(|| PluginError::execution_error(&self.info.id, "Plugin not loaded"))?; - let mut store = Store::new(self.runtime.engine(), ()); + // SECURITY: Create store with resource limiter + let mut store = Store::new(self.runtime.engine(), PluginStoreLimits::default()); + + // SECURITY: Set fuel limit to prevent infinite loops and excessive CPU usage + store + .set_fuel(DEFAULT_FUEL_LIMIT) + .map_err(|e| PluginError::execution_error(&self.info.id, format!("Failed to set fuel: {}", e)))?; + + // SECURITY: Configure the store's resource limiter + store.limiter(|limits| limits); + let instance = Instance::new(&mut store, module, &[]) .map_err(|e| PluginError::execution_error(&self.info.id, e.to_string()))?; @@ -142,6 +199,88 @@ impl WasmPlugin { } } +/// Store limits for WASM plugin execution. +/// +/// SECURITY: Implements wasmtime's ResourceLimiter trait to enforce +/// memory and resource constraints on plugin execution. +#[derive(Debug, Clone)] +struct PluginStoreLimits { + /// Current memory allocated by this store. + memory_used: usize, +} + +impl Default for PluginStoreLimits { + fn default() -> Self { + Self { memory_used: 0 } + } +} + +impl ResourceLimiter for PluginStoreLimits { + /// Called when memory is being grown. + /// + /// # Security + /// + /// Enforces maximum memory limit of 16MB per plugin instance. + fn memory_growing( + &mut self, + current: usize, + desired: usize, + _maximum: Option, + ) -> anyhow::Result { + // SECURITY: Check if the desired memory exceeds our limit + if desired > MAX_MEMORY_SIZE { + tracing::warn!( + current_bytes = current, + desired_bytes = desired, + max_bytes = MAX_MEMORY_SIZE, + "Plugin memory request denied: exceeds maximum allowed" + ); + return Ok(false); + } + + self.memory_used = desired; + Ok(true) + } + + /// Called when a table is being grown. + /// + /// # Security + /// + /// Enforces maximum table elements limit. + fn table_growing( + &mut self, + _current: usize, + desired: usize, + _maximum: Option, + ) -> anyhow::Result { + // SECURITY: Limit table size to prevent excessive memory usage + if desired as u64 > MAX_TABLE_ELEMENTS { + tracing::warn!( + desired_elements = desired, + max_elements = MAX_TABLE_ELEMENTS, + "Plugin table growth denied: exceeds maximum allowed" + ); + return Ok(false); + } + Ok(true) + } + + /// Returns the maximum number of instances. + fn instances(&self) -> usize { + MAX_INSTANCES as usize + } + + /// Returns the maximum number of tables. + fn tables(&self) -> usize { + MAX_TABLES as usize + } + + /// Returns the maximum number of memories. + fn memories(&self) -> usize { + MAX_MEMORIES as usize + } +} + #[async_trait::async_trait] impl Plugin for WasmPlugin { fn info(&self) -> &PluginInfo { diff --git a/src/cortex-plugins/src/sdk.rs b/src/cortex-plugins/src/sdk.rs index 3b35ac98..e02dad2a 100644 --- a/src/cortex-plugins/src/sdk.rs +++ b/src/cortex-plugins/src/sdk.rs @@ -177,6 +177,14 @@ extern "C" { // ============================================================================ fn log_message(level: i32, msg: &str) { + // SAFETY: FFI call to host-provided `log` function. + // Contract with the host runtime: + // 1. `log` is a valid function pointer provided by the WASM runtime during instantiation + // 2. The host reads the message from WASM linear memory using (ptr, len) immediately + // 3. The host does not retain the pointer past the call boundary + // 4. The host handles all memory management on its side (copies data if needed) + // 5. Invalid level values are handled gracefully by the host (treated as info) + // 6. The pointer is valid for the duration of this call (Rust string guarantee) unsafe { log(level, msg.as_ptr() as i32, msg.len() as i32); } @@ -354,6 +362,782 @@ impl PluginDev { } } +// ============================================================================ +// TypeScript Template +// ============================================================================ + +/// TypeScript template for plugin development (for JavaScript/TypeScript plugins). +pub const TYPESCRIPT_TEMPLATE: &str = r#"/** + * {{plugin_name}} - A Cortex Plugin + * + * This template provides a TypeScript-based plugin structure. + * Compile with: npx tsc && npx wasm-pack build + */ + +// Plugin metadata +export const PLUGIN_ID = "{{plugin_id}}"; +export const PLUGIN_VERSION = "0.1.0"; + +// ============================================================================ +// Plugin Lifecycle +// ============================================================================ + +/** + * Called when the plugin is initialized. + */ +export function init(): number { + console.log(`${PLUGIN_ID} initialized`); + return 0; +} + +/** + * Called when the plugin is shutting down. + */ +export function shutdown(): number { + console.log(`${PLUGIN_ID} shutting down`); + return 0; +} + +// ============================================================================ +// Command Handlers +// ============================================================================ + +/** + * Handler for the /{{command_name}} command. + */ +export function cmd_{{command_name_snake}}(args: string[]): number { + console.log("{{command_name}} command executed with args:", args); + return 0; +} + +// ============================================================================ +// Hook Handlers +// ============================================================================ + +/** + * Called before a tool is executed. + * Return: 0 = continue, 1 = skip, 2 = abort + */ +export function hook_tool_execute_before(input: ToolExecuteBeforeInput): number { + console.log(`Tool ${input.tool} about to execute`); + return 0; +} + +/** + * Called when UI is being rendered. + */ +export function hook_ui_render(input: UiRenderInput): UiRenderOutput { + return { + styles: {}, + widgets: [], + result: "continue" + }; +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +interface ToolExecuteBeforeInput { + tool: string; + session_id: string; + call_id: string; + args: Record; +} + +interface UiRenderInput { + session_id: string; + component: string; + theme: string; + dimensions: [number, number]; +} + +interface UiRenderOutput { + styles: Record; + widgets: Widget[]; + result: "continue" | "skip" | "abort"; +} + +interface Widget { + type: string; + [key: string]: unknown; +} +"#; + +/// tsconfig.json template for TypeScript plugins. +pub const TSCONFIG_TEMPLATE: &str = r#"{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +"#; + +// ============================================================================ +// Hot-Reload Configuration +// ============================================================================ + +/// Hot-reload configuration for plugin development. +pub const HOT_RELOAD_CONFIG: &str = r#"# Hot Reload Configuration +# This file configures hot-reload behavior during plugin development. + +[hot_reload] +# Enable hot-reload (set to false in production) +enabled = true + +# Watch patterns for file changes +watch_patterns = [ + "src/**/*.rs", + "src/**/*.ts", + "plugin.toml" +] + +# Debounce time in milliseconds (prevents rapid reloads) +debounce_ms = 500 + +# Auto-rebuild on change +auto_rebuild = true + +# Preserve plugin state on reload (if supported) +preserve_state = false + +# Log reload events +log_reloads = true + +[build] +# Build command for the plugin +command = "cargo build --target wasm32-wasi --release" + +# Output path for the compiled WASM +output = "target/wasm32-wasi/release/{{plugin_id}}.wasm" + +# Pre-build commands (optional) +pre_build = [] + +# Post-build commands (optional) +post_build = [] +"#; + +// ============================================================================ +// Testing Utilities Template +// ============================================================================ + +/// Testing utilities template for plugin authors. +pub const TEST_UTILS_TEMPLATE: &str = r#"//! Testing utilities for {{plugin_name}} +//! +//! This module provides utilities for testing your plugin. + +#![cfg(test)] + +use std::collections::HashMap; + +/// Mock context for testing plugin functions. +pub struct MockContext { + pub session_id: String, + pub config: HashMap, +} + +impl MockContext { + pub fn new() -> Self { + Self { + session_id: "test-session".to_string(), + config: HashMap::new(), + } + } + + pub fn with_config(mut self, key: &str, value: &str) -> Self { + self.config.insert(key.to_string(), value.to_string()); + self + } +} + +impl Default for MockContext { + fn default() -> Self { + Self::new() + } +} + +/// Mock tool execution input for testing. +pub struct MockToolInput { + pub tool: String, + pub args: serde_json::Value, +} + +impl MockToolInput { + pub fn new(tool: &str) -> Self { + Self { + tool: tool.to_string(), + args: serde_json::json!({}), + } + } + + pub fn with_arg(mut self, key: &str, value: serde_json::Value) -> Self { + if let Some(obj) = self.args.as_object_mut() { + obj.insert(key.to_string(), value); + } + self + } +} + +/// Assert that a hook returned the expected result. +#[macro_export] +macro_rules! assert_hook_result { + ($result:expr, continue) => { + assert_eq!($result, 0, "Expected hook to continue"); + }; + ($result:expr, skip) => { + assert_eq!($result, 1, "Expected hook to skip"); + }; + ($result:expr, abort) => { + assert_eq!($result, 2, "Expected hook to abort"); + }; +} + +/// Test fixture for widget rendering. +pub struct WidgetTestFixture { + pub width: u16, + pub height: u16, + pub theme: String, +} + +impl WidgetTestFixture { + pub fn new(width: u16, height: u16) -> Self { + Self { + width, + height, + theme: "ocean".to_string(), + } + } + + pub fn with_theme(mut self, theme: &str) -> Self { + self.theme = theme.to_string(); + self + } +} + +impl Default for WidgetTestFixture { + fn default() -> Self { + Self::new(120, 40) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_context() { + let ctx = MockContext::new() + .with_config("api_key", "test-key"); + + assert_eq!(ctx.config.get("api_key"), Some(&"test-key".to_string())); + } + + #[test] + fn test_mock_tool_input() { + let input = MockToolInput::new("read") + .with_arg("path", serde_json::json!("/test/file.txt")); + + assert_eq!(input.tool, "read"); + assert_eq!(input.args["path"], "/test/file.txt"); + } +} +"#; + +// ============================================================================ +// Advanced Rust Template +// ============================================================================ + +/// Advanced Rust plugin template with TUI hooks. +pub const RUST_ADVANCED_TEMPLATE: &str = r#"//! {{plugin_name}} - Advanced Cortex Plugin +//! +//! This template demonstrates advanced plugin features including: +//! - TUI customization hooks +//! - Custom widgets +//! - Keyboard bindings +//! - Event handling +//! +//! Build with: cargo build --target wasm32-wasi --release + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use alloc::vec; + +// ============================================================================ +// Host function imports +// ============================================================================ + +#[link(wasm_import_module = "cortex")] +extern "C" { + fn log(level: i32, msg_ptr: i32, msg_len: i32); + fn get_context() -> i64; + fn register_widget(region: i32, widget_type_ptr: i32, widget_type_len: i32) -> i32; + fn register_keybinding(key_ptr: i32, key_len: i32, action_ptr: i32, action_len: i32) -> i32; + fn show_toast(level: i32, msg_ptr: i32, msg_len: i32, duration_ms: i32) -> i32; + fn emit_event(name_ptr: i32, name_len: i32, data_ptr: i32, data_len: i32) -> i32; +} + +// ============================================================================ +// Logging helpers +// ============================================================================ + +fn log_message(level: i32, msg: &str) { + unsafe { + log(level, msg.as_ptr() as i32, msg.len() as i32); + } +} + +fn log_info(msg: &str) { log_message(2, msg); } +fn log_debug(msg: &str) { log_message(1, msg); } +fn log_warn(msg: &str) { log_message(3, msg); } +fn log_error(msg: &str) { log_message(4, msg); } + +// ============================================================================ +// Widget helpers +// ============================================================================ + +/// UI regions for widget placement +#[repr(i32)] +enum UiRegion { + Header = 0, + Footer = 1, + SidebarLeft = 2, + SidebarRight = 3, + StatusBar = 7, +} + +fn register_widget_in_region(region: UiRegion, widget_type: &str) -> bool { + // SAFETY: FFI call to host-provided `register_widget` function. + // Contract with the host runtime: + // 1. `register_widget` is a valid function pointer provided by the WASM runtime + // 2. Arguments are passed by value (region) and by pointer+len (widget_type string) + // 3. The host copies the string data before this call returns + // 4. The host validates the region value and handles invalid values gracefully + // 5. Return value 0 indicates success, non-zero indicates failure + // 6. The widget_type pointer remains valid for the duration of this call + unsafe { + register_widget( + region as i32, + widget_type.as_ptr() as i32, + widget_type.len() as i32, + ) == 0 + } +} + +fn register_key(key: &str, action: &str) -> bool { + // SAFETY: FFI call to host-provided `register_keybinding` function. + // Contract with the host runtime: + // 1. `register_keybinding` is a valid function pointer provided by the WASM runtime + // 2. Both string arguments are passed as (ptr, len) pairs + // 3. The host copies both strings before this call returns + // 4. The host validates the key combination and action name + // 5. Return value 0 indicates success, non-zero indicates failure + // 6. Both pointers remain valid for the duration of this call (Rust string guarantee) + unsafe { + register_keybinding( + key.as_ptr() as i32, + key.len() as i32, + action.as_ptr() as i32, + action.len() as i32, + ) == 0 + } +} + +/// Toast notification levels +#[repr(i32)] +enum ToastLevel { + Info = 0, + Success = 1, + Warning = 2, + Error = 3, +} + +fn show_notification(level: ToastLevel, message: &str, duration_ms: i32) { + // SAFETY: FFI call to host-provided `show_toast` function. + // Contract with the host runtime: + // 1. `show_toast` is a valid function pointer provided by the WASM runtime + // 2. The level is passed by value and validated by the host (invalid = Info) + // 3. The message string is passed as (ptr, len) and copied by the host + // 4. duration_ms is passed by value; invalid values are clamped by host + // 5. The host does not retain the message pointer past this call + // 6. The function has no return value; failures are logged on the host side + unsafe { + show_toast( + level as i32, + message.as_ptr() as i32, + message.len() as i32, + duration_ms, + ); + } +} + +// ============================================================================ +// Plugin lifecycle +// ============================================================================ + +#[no_mangle] +pub extern "C" fn init() -> i32 { + log_info("{{plugin_name}} initializing..."); + + // Register custom widgets + if register_widget_in_region(UiRegion::StatusBar, "{{plugin_id}}_status") { + log_debug("Status widget registered"); + } + + // Register keyboard bindings + if register_key("ctrl+shift+p", "{{plugin_id}}_action") { + log_debug("Keybinding registered: Ctrl+Shift+P"); + } + + log_info("{{plugin_name}} initialized successfully"); + 0 +} + +#[no_mangle] +pub extern "C" fn shutdown() -> i32 { + log_info("{{plugin_name}} shutting down"); + 0 +} + +// ============================================================================ +// Command handlers +// ============================================================================ + +#[no_mangle] +pub extern "C" fn cmd_{{command_name_snake}}() -> i32 { + log_info("{{command_name}} command executed"); + show_notification(ToastLevel::Info, "Command executed!", 2000); + 0 +} + +// ============================================================================ +// Hook handlers +// ============================================================================ + +/// UI render hook - customize component rendering +#[no_mangle] +pub extern "C" fn hook_ui_render() -> i32 { + // Return 0 to continue with normal rendering + // Modifications are passed through the output buffer + 0 +} + +/// Animation frame hook - called every frame for animations +#[no_mangle] +pub extern "C" fn hook_animation_frame(_frame: u64, _delta_us: u64) -> i32 { + // Return 1 to request another frame, 0 to stop + 0 +} + +/// Focus change hook +#[no_mangle] +pub extern "C" fn hook_focus_change(_focused: i32) -> i32 { + 0 +} + +/// TUI event handler +#[no_mangle] +pub extern "C" fn hook_tui_event() -> i32 { + 0 +} + +// ============================================================================ +// Custom action handlers +// ============================================================================ + +#[no_mangle] +pub extern "C" fn action_{{plugin_id_snake}}_action() -> i32 { + log_info("Custom action triggered via keybinding"); + show_notification(ToastLevel::Success, "Action executed!", 1500); + 0 +} + +// ============================================================================ +// Panic handler +// ============================================================================ + +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + // Try to log panic info + if let Some(location) = info.location() { + let file = location.file(); + let line = location.line(); + log_error("PANIC occurred"); + let _ = file; + let _ = line; + } + loop {} +} + +// ============================================================================ +// Global allocator +// ============================================================================ + +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; +"#; + +// ============================================================================ +// Generator Functions +// ============================================================================ + +/// Generate hot-reload configuration from a template. +pub fn generate_hot_reload_config(plugin_id: &str) -> String { + HOT_RELOAD_CONFIG.replace("{{plugin_id}}", plugin_id) +} + +/// Generate TypeScript plugin code from a template. +pub fn generate_typescript_code(plugin_id: &str, plugin_name: &str, command_name: &str) -> String { + let command_name_snake = command_name.replace('-', "_"); + + TYPESCRIPT_TEMPLATE + .replace("{{plugin_id}}", plugin_id) + .replace("{{plugin_name}}", plugin_name) + .replace("{{command_name}}", command_name) + .replace("{{command_name_snake}}", &command_name_snake) +} + +/// Generate test utilities template. +pub fn generate_test_utils(plugin_name: &str) -> String { + TEST_UTILS_TEMPLATE.replace("{{plugin_name}}", plugin_name) +} + +/// Generate advanced Rust code with TUI hooks. +pub fn generate_advanced_rust_code( + plugin_id: &str, + plugin_name: &str, + command_name: &str, +) -> String { + let command_name_snake = command_name.replace('-', "_"); + let plugin_id_snake = plugin_id.replace('-', "_"); + + RUST_ADVANCED_TEMPLATE + .replace("{{plugin_id}}", plugin_id) + .replace("{{plugin_id_snake}}", &plugin_id_snake) + .replace("{{plugin_name}}", plugin_name) + .replace("{{command_name}}", command_name) + .replace("{{command_name_snake}}", &command_name_snake) +} + +// ============================================================================ +// Hot-Reload Configuration Struct +// ============================================================================ + +/// Hot-reload watcher configuration. +#[derive(Debug, Clone)] +pub struct HotReloadConfig { + /// Whether hot-reload is enabled. + pub enabled: bool, + /// File patterns to watch. + pub watch_patterns: Vec, + /// Debounce time in milliseconds. + pub debounce_ms: u64, + /// Auto-rebuild on change. + pub auto_rebuild: bool, + /// Preserve plugin state on reload. + pub preserve_state: bool, +} + +impl Default for HotReloadConfig { + fn default() -> Self { + Self { + enabled: true, + watch_patterns: vec![ + "src/**/*.rs".to_string(), + "src/**/*.ts".to_string(), + "plugin.toml".to_string(), + ], + debounce_ms: 500, + auto_rebuild: true, + preserve_state: false, + } + } +} + +impl HotReloadConfig { + /// Create a new hot-reload configuration. + pub fn new() -> Self { + Self::default() + } + + /// Enable or disable hot-reload. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Set watch patterns. + pub fn with_patterns(mut self, patterns: Vec) -> Self { + self.watch_patterns = patterns; + self + } + + /// Set debounce time. + pub fn with_debounce(mut self, ms: u64) -> Self { + self.debounce_ms = ms; + self + } +} + +// ============================================================================ +// PluginDev Advanced Scaffold +// ============================================================================ + +impl PluginDev { + /// Scaffold a new plugin project with advanced features. + pub fn scaffold_advanced( + output_dir: &std::path::Path, + plugin_id: &str, + plugin_name: &str, + description: &str, + author: &str, + use_typescript: bool, + ) -> std::io::Result<()> { + use std::fs; + + // Create directory structure + let plugin_dir = output_dir.join(plugin_id); + let src_dir = plugin_dir.join("src"); + let tests_dir = plugin_dir.join("tests"); + + fs::create_dir_all(&src_dir)?; + fs::create_dir_all(&tests_dir)?; + + // Generate manifest + let manifest = generate_manifest( + plugin_id, + plugin_name, + description, + author, + "example", + "An example command", + ); + + // Write manifest + fs::write(plugin_dir.join("plugin.toml"), manifest)?; + + // Generate hot-reload config + let hot_reload = generate_hot_reload_config(plugin_id); + fs::write(plugin_dir.join("hot-reload.toml"), hot_reload)?; + + if use_typescript { + // TypeScript project + let ts_code = generate_typescript_code(plugin_id, plugin_name, "example"); + fs::write(src_dir.join("index.ts"), ts_code)?; + fs::write(plugin_dir.join("tsconfig.json"), TSCONFIG_TEMPLATE)?; + + // package.json + let package_json = format!( + r#"{{ + "name": "{}", + "version": "0.1.0", + "description": "{}", + "main": "dist/index.js", + "scripts": {{ + "build": "tsc", + "watch": "tsc --watch" + }}, + "devDependencies": {{ + "typescript": "^5.0.0" + }} +}}"#, + plugin_id, description + ); + fs::write(plugin_dir.join("package.json"), package_json)?; + } else { + // Rust project with advanced template + let rust_code = generate_advanced_rust_code(plugin_id, plugin_name, "example"); + fs::write(src_dir.join("lib.rs"), rust_code)?; + + // Cargo.toml + let cargo_toml = generate_cargo_toml(plugin_id); + fs::write(plugin_dir.join("Cargo.toml"), cargo_toml)?; + + // Test utilities + let test_utils = generate_test_utils(plugin_name); + fs::write(tests_dir.join("utils.rs"), test_utils)?; + } + + // Write README + let readme = format!( + r#"# {} + +{} + +## Features + +- Custom widgets and UI customization +- Keyboard bindings +- Event handling +- Hot-reload support for development + +## Building + +{} + +## Development + +Enable hot-reload during development: + +```bash +cortex plugin dev --watch +``` + +## Testing + +```bash +cargo test +``` + +## Installing + +Copy the compiled WASM and manifest to your Cortex plugins directory: + +```bash +mkdir -p ~/.cortex/plugins/{} +cp target/wasm32-wasi/release/{}.wasm ~/.cortex/plugins/{}/plugin.wasm +cp plugin.toml ~/.cortex/plugins/{}/ +``` +"#, + plugin_name, + description, + if use_typescript { + "```bash\nnpm install\nnpm run build\n```" + } else { + "```bash\ncargo build --target wasm32-wasi --release\n```" + }, + plugin_id, + plugin_id.replace('-', "_"), + plugin_id, + plugin_id, + ); + fs::write(plugin_dir.join("README.md"), readme)?; + + // Write .gitignore + let gitignore = if use_typescript { + "node_modules/\ndist/\n*.wasm\n" + } else { + "target/\n*.wasm\n" + }; + fs::write(plugin_dir.join(".gitignore"), gitignore)?; + + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -390,4 +1174,35 @@ mod tests { assert!(cargo.contains("my-plugin")); assert!(cargo.contains("wasm32-wasi")); } + + #[test] + fn test_generate_hot_reload_config() { + let config = generate_hot_reload_config("my-plugin"); + assert!(config.contains("my-plugin")); + assert!(config.contains("enabled = true")); + } + + #[test] + fn test_generate_typescript_code() { + let code = generate_typescript_code("my-plugin", "My Plugin", "my-command"); + assert!(code.contains("My Plugin")); + assert!(code.contains("my-command")); + assert!(code.contains("cmd_my_command")); + } + + #[test] + fn test_generate_advanced_rust_code() { + let code = generate_advanced_rust_code("my-plugin", "My Plugin", "example"); + assert!(code.contains("My Plugin")); + assert!(code.contains("register_widget")); + assert!(code.contains("register_keybinding")); + } + + #[test] + fn test_hot_reload_config() { + let config = HotReloadConfig::new().with_enabled(true).with_debounce(300); + + assert!(config.enabled); + assert_eq!(config.debounce_ms, 300); + } }