Skip to main content

rustant_plugins/
security.rs

1//! Plugin security validation.
2//!
3//! Validates plugin metadata and capabilities for security risks.
4
5use crate::PluginMetadata;
6use serde::{Deserialize, Serialize};
7
8/// Capabilities a plugin can request.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub enum PluginCapability {
11    /// Register new tools.
12    ToolRegistration,
13    /// Register hooks.
14    HookRegistration,
15    /// Access filesystem.
16    FileSystemAccess,
17    /// Access network.
18    NetworkAccess,
19    /// Execute shell commands.
20    ShellExecution,
21    /// Access credentials/secrets.
22    SecretAccess,
23}
24
25/// Result of security validation.
26#[derive(Debug)]
27pub struct SecurityValidationResult {
28    pub is_valid: bool,
29    pub warnings: Vec<String>,
30    pub errors: Vec<String>,
31}
32
33/// Validates plugin metadata and capabilities.
34pub struct PluginSecurityValidator {
35    /// Blocked plugin names.
36    blocked_names: Vec<String>,
37    /// Maximum allowed capabilities (if set).
38    max_capabilities: Option<usize>,
39}
40
41impl PluginSecurityValidator {
42    /// Create a new validator with default settings.
43    pub fn new() -> Self {
44        Self {
45            blocked_names: Vec::new(),
46            max_capabilities: None,
47        }
48    }
49
50    /// Block a specific plugin name.
51    pub fn block_name(&mut self, name: impl Into<String>) {
52        self.blocked_names.push(name.into());
53    }
54
55    /// Set maximum number of capabilities allowed.
56    pub fn set_max_capabilities(&mut self, max: usize) {
57        self.max_capabilities = Some(max);
58    }
59
60    /// Validate plugin metadata.
61    pub fn validate(&self, metadata: &PluginMetadata) -> SecurityValidationResult {
62        let mut errors = Vec::new();
63        let mut warnings = Vec::new();
64
65        // Check blocked names
66        if self.blocked_names.contains(&metadata.name) {
67            errors.push(format!("Plugin '{}' is blocked", metadata.name));
68        }
69
70        // Check name is not empty
71        if metadata.name.is_empty() {
72            errors.push("Plugin name cannot be empty".into());
73        }
74
75        // Check version is not empty
76        if metadata.version.is_empty() {
77            errors.push("Plugin version cannot be empty".into());
78        }
79
80        // Check capability count
81        if let Some(max) = self.max_capabilities
82            && metadata.capabilities.len() > max
83        {
84            errors.push(format!(
85                "Plugin requests {} capabilities (max: {})",
86                metadata.capabilities.len(),
87                max
88            ));
89        }
90
91        // Warn about dangerous capabilities
92        for cap in &metadata.capabilities {
93            match cap {
94                PluginCapability::ShellExecution => {
95                    warnings.push("Plugin requests shell execution capability".into());
96                }
97                PluginCapability::SecretAccess => {
98                    warnings.push("Plugin requests secret/credential access".into());
99                }
100                PluginCapability::FileSystemAccess => {
101                    warnings.push("Plugin requests filesystem access".into());
102                }
103                PluginCapability::NetworkAccess => {
104                    warnings.push("Plugin requests network access".into());
105                }
106                _ => {}
107            }
108        }
109
110        // Version compatibility check
111        if let Some(ref min_version) = metadata.min_core_version
112            && !is_version_compatible(min_version, env!("CARGO_PKG_VERSION"))
113        {
114            errors.push(format!(
115                "Plugin requires core version >= {} (current: {})",
116                min_version,
117                env!("CARGO_PKG_VERSION")
118            ));
119        }
120
121        let is_valid = errors.is_empty();
122        SecurityValidationResult {
123            is_valid,
124            warnings,
125            errors,
126        }
127    }
128}
129
130impl Default for PluginSecurityValidator {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136/// Simple semver-compatible version comparison.
137/// Returns true if current >= required.
138fn is_version_compatible(required: &str, current: &str) -> bool {
139    let req_parts: Vec<u32> = required.split('.').filter_map(|p| p.parse().ok()).collect();
140    let cur_parts: Vec<u32> = current.split('.').filter_map(|p| p.parse().ok()).collect();
141
142    for i in 0..3 {
143        let req = req_parts.get(i).copied().unwrap_or(0);
144        let cur = cur_parts.get(i).copied().unwrap_or(0);
145        if cur > req {
146            return true;
147        }
148        if cur < req {
149            return false;
150        }
151    }
152    true // Equal versions
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn make_metadata(name: &str, caps: Vec<PluginCapability>) -> PluginMetadata {
160        PluginMetadata {
161            name: name.into(),
162            version: "1.0.0".into(),
163            description: "Test".into(),
164            author: None,
165            min_core_version: None,
166            capabilities: caps,
167        }
168    }
169
170    #[test]
171    fn test_validate_clean_plugin() {
172        let validator = PluginSecurityValidator::new();
173        let meta = make_metadata("safe-plugin", vec![PluginCapability::ToolRegistration]);
174        let result = validator.validate(&meta);
175        assert!(result.is_valid);
176        assert!(result.errors.is_empty());
177    }
178
179    #[test]
180    fn test_validate_blocked_name() {
181        let mut validator = PluginSecurityValidator::new();
182        validator.block_name("evil-plugin");
183        let meta = make_metadata("evil-plugin", vec![]);
184        let result = validator.validate(&meta);
185        assert!(!result.is_valid);
186    }
187
188    #[test]
189    fn test_validate_empty_name() {
190        let validator = PluginSecurityValidator::new();
191        let meta = make_metadata("", vec![]);
192        let result = validator.validate(&meta);
193        assert!(!result.is_valid);
194    }
195
196    #[test]
197    fn test_validate_dangerous_capabilities_warn() {
198        let validator = PluginSecurityValidator::new();
199        let meta = make_metadata(
200            "risky",
201            vec![
202                PluginCapability::ShellExecution,
203                PluginCapability::SecretAccess,
204            ],
205        );
206        let result = validator.validate(&meta);
207        assert!(result.is_valid); // Warnings don't fail validation
208        assert_eq!(result.warnings.len(), 2);
209    }
210
211    #[test]
212    fn test_validate_max_capabilities() {
213        let mut validator = PluginSecurityValidator::new();
214        validator.set_max_capabilities(1);
215        let meta = make_metadata(
216            "greedy",
217            vec![
218                PluginCapability::ToolRegistration,
219                PluginCapability::HookRegistration,
220                PluginCapability::NetworkAccess,
221            ],
222        );
223        let result = validator.validate(&meta);
224        assert!(!result.is_valid);
225    }
226
227    #[test]
228    fn test_version_compatible() {
229        assert!(is_version_compatible("0.1.0", "0.1.0"));
230        assert!(is_version_compatible("0.1.0", "0.2.0"));
231        assert!(is_version_compatible("0.1.0", "1.0.0"));
232        assert!(!is_version_compatible("1.0.0", "0.9.0"));
233        assert!(!is_version_compatible("0.2.0", "0.1.9"));
234    }
235
236    #[test]
237    fn test_version_incompatible_core() {
238        let validator = PluginSecurityValidator::new();
239        let mut meta = make_metadata("new-plugin", vec![]);
240        meta.min_core_version = Some("999.0.0".into());
241        let result = validator.validate(&meta);
242        assert!(!result.is_valid);
243    }
244
245    #[test]
246    fn test_capability_serialization() {
247        let cap = PluginCapability::ShellExecution;
248        let json = serde_json::to_string(&cap).unwrap();
249        let restored: PluginCapability = serde_json::from_str(&json).unwrap();
250        assert_eq!(restored, PluginCapability::ShellExecution);
251    }
252}