Skip to main content

vtcode_core/plugins/
validation.rs

1//! Plugin validation and debugging tools for VT Code
2//!
3//! Provides utilities for validating plugin manifests, structures, and debugging
4//! plugin functionality.
5
6use std::path::Path;
7
8use anyhow::Result;
9
10use crate::command_safety::command_might_be_dangerous;
11use crate::plugins::{PluginError, PluginManifest, PluginResult};
12use crate::utils::file_utils::read_file_with_context;
13
14/// Plugin validator
15pub struct PluginValidator;
16
17impl PluginValidator {
18    /// Validate a plugin manifest
19    pub fn validate_manifest(manifest: &PluginManifest) -> PluginResult<()> {
20        // Validate required fields
21        if manifest.name.is_empty() {
22            return Err(PluginError::ManifestValidationError("Plugin name is required".to_string()));
23        }
24
25        // Validate name format (kebab-case)
26        if !Self::is_valid_plugin_name(&manifest.name) {
27            return Err(PluginError::ManifestValidationError(
28                "Plugin name must be in kebab-case (lowercase with hyphens)".to_string(),
29            ));
30        }
31
32        // Validate version format if present
33        if let Some(version) = &manifest.version
34            && !Self::is_valid_version(version)
35        {
36            return Err(PluginError::ManifestValidationError(
37                "Plugin version must follow semantic versioning (e.g., 1.0.0)".to_string(),
38            ));
39        }
40
41        // Validate author if present
42        if let Some(author) = &manifest.author
43            && author.name.is_empty()
44        {
45            return Err(PluginError::ManifestValidationError(
46                "Plugin author name is required when author is specified".to_string(),
47            ));
48        }
49
50        Ok(())
51    }
52
53    /// Validate a plugin directory structure
54    pub async fn validate_plugin_structure(plugin_path: &Path) -> PluginResult<()> {
55        // Call the function from PluginTemplate
56        crate::plugins::PluginTemplate::validate_plugin_structure(plugin_path).await
57    }
58
59    /// Check if a plugin name is valid (kebab-case)
60    fn is_valid_plugin_name(name: &str) -> bool {
61        // Check if name contains only lowercase letters, numbers, and hyphens
62        name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
63            && !name.starts_with('-')
64            && !name.ends_with('-')
65            && !name.is_empty()
66    }
67
68    /// Check if a version string is valid semantic version
69    fn is_valid_version(version: &str) -> bool {
70        // Basic semantic version check (X.Y.Z format)
71        let parts: Vec<&str> = version.split('.').collect();
72        if parts.len() < 3 {
73            return false;
74        }
75
76        // Check that each part is numeric
77        parts.iter().all(|part| {
78            // Handle pre-release versions (e.g., 1.0.0-beta.1)
79            let clean_part = part.split('-').next().unwrap_or(part);
80            clean_part.chars().all(|c| c.is_ascii_digit())
81        })
82    }
83
84    /// Validate plugin security (check for dangerous patterns)
85    pub fn validate_plugin_security(manifest: &PluginManifest) -> PluginResult<()> {
86        // Check for potentially dangerous configurations
87        if let Some(mcp_servers) = &manifest.mcp_servers {
88            // If it's an inline configuration, validate the commands
89            match mcp_servers {
90                crate::plugins::manifest::McpServerConfig::Inline(servers) => {
91                    for (name, server) in servers {
92                        if Self::is_dangerous_command(&server.command) {
93                            return Err(PluginError::LoadingError(format!(
94                                "MCP server '{}' uses potentially dangerous command: {}",
95                                name, server.command
96                            )));
97                        }
98                    }
99                }
100                crate::plugins::manifest::McpServerConfig::Path(_) => {
101                    // For path-based configs, we can't validate until loaded
102                }
103            }
104        }
105
106        Ok(())
107    }
108
109    /// Check if a command is potentially dangerous
110    fn is_dangerous_command(command: &str) -> bool {
111        command_might_be_dangerous(&[command.to_string()])
112    }
113}
114
115/// Plugin debugging utilities
116pub struct PluginDebugger;
117
118impl PluginDebugger {
119    /// Print detailed information about a plugin manifest
120    pub fn debug_manifest(manifest: &PluginManifest) -> String {
121        let mut output = String::new();
122
123        output.push_str(&format!("Plugin: {}\n", manifest.name));
124        output.push_str(&format!("  Version: {}\n", manifest.version.as_deref().unwrap_or("not specified")));
125        output.push_str(&format!("  Description: {}\n", manifest.description.as_deref().unwrap_or("not specified")));
126
127        if let Some(author) = &manifest.author {
128            output.push_str(&format!("  Author: {}\n", author.name));
129            if let Some(email) = &author.email {
130                output.push_str(&format!("    Email: {email}\n"));
131            }
132            if let Some(url) = &author.url {
133                output.push_str(&format!("    URL: {url}\n"));
134            }
135        }
136
137        if let Some(homepage) = &manifest.homepage {
138            output.push_str(&format!("  Homepage: {homepage}\n"));
139        }
140
141        if let Some(repository) = &manifest.repository {
142            output.push_str(&format!("  Repository: {repository}\n"));
143        }
144
145        if let Some(license) = &manifest.license {
146            output.push_str(&format!("  License: {license}\n"));
147        }
148
149        if let Some(keywords) = &manifest.keywords {
150            output.push_str(&format!("  Keywords: {}\n", keywords.join(", ")));
151        }
152
153        // Component counts
154        let commands_count = manifest.commands.as_ref().map_or(0, |c| c.len());
155        let agents_count = manifest.agents.as_ref().map_or(0, |a| a.len());
156        let skills_count = manifest.skills.as_ref().map_or(0, |s| s.len());
157
158        output.push_str("  Components:\n");
159        output.push_str(&format!("    Commands: {commands_count}\n"));
160        output.push_str(&format!("    Agents: {agents_count}\n"));
161        output.push_str(&format!("    Skills: {skills_count}\n"));
162        output.push_str(&format!("    Hooks: {}\n", if manifest.hooks.is_some() { "yes" } else { "no" }));
163        output.push_str(&format!("    MCP Servers: {}\n", if manifest.mcp_servers.is_some() { "yes" } else { "no" }));
164        output.push_str(&format!("    LSP Servers: {}\n", if manifest.lsp_servers.is_some() { "yes" } else { "no" }));
165
166        output
167    }
168
169    /// Validate and debug a plugin manifest with detailed output
170    pub fn validate_and_debug_manifest(manifest: &PluginManifest) -> Result<String> {
171        let mut issues = Vec::new();
172
173        // Run validation checks
174        if let Err(e) = PluginValidator::validate_manifest(manifest) {
175            issues.push(format!("Validation error: {e}"));
176        }
177
178        if let Err(e) = PluginValidator::validate_plugin_security(manifest) {
179            issues.push(format!("Security warning: {e}"));
180        }
181
182        // Create debug output
183        let mut output = Self::debug_manifest(manifest);
184
185        if !issues.is_empty() {
186            output.push_str("\nIssues found:\n");
187            for issue in issues {
188                output.push_str(&format!("  - {issue}\n"));
189            }
190        } else {
191            output.push_str("\nNo issues found.\n");
192        }
193
194        Ok(output)
195    }
196}
197
198/// Plugin validation CLI command handler
199pub async fn handle_plugin_validate(path: &Path) -> Result<()> {
200    // Check if path exists
201    if !tokio::fs::try_exists(path).await.unwrap_or(false) {
202        anyhow::bail!("Plugin path does not exist: {}", path.display());
203    }
204
205    // Try to load the manifest
206    let manifest_path = path.join(".vtcode-plugin/plugin.json");
207    if !tokio::fs::try_exists(&manifest_path).await.unwrap_or(false) {
208        anyhow::bail!("Plugin manifest not found at: {}", manifest_path.display());
209    }
210
211    let manifest_content = read_file_with_context(&manifest_path, "plugin manifest").await?;
212    let manifest: PluginManifest = serde_json::from_str(&manifest_content)?;
213
214    // Validate the manifest
215    let validation_output = PluginDebugger::validate_and_debug_manifest(&manifest)?;
216    tracing::info!(output = %validation_output, "plugin validation result");
217
218    Ok(())
219}