vtcode_core/plugins/
validation.rs1use 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
14pub struct PluginValidator;
16
17impl PluginValidator {
18 pub fn validate_manifest(manifest: &PluginManifest) -> PluginResult<()> {
20 if manifest.name.is_empty() {
22 return Err(PluginError::ManifestValidationError("Plugin name is required".to_string()));
23 }
24
25 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 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 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 pub async fn validate_plugin_structure(plugin_path: &Path) -> PluginResult<()> {
55 crate::plugins::PluginTemplate::validate_plugin_structure(plugin_path).await
57 }
58
59 fn is_valid_plugin_name(name: &str) -> bool {
61 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 fn is_valid_version(version: &str) -> bool {
70 let parts: Vec<&str> = version.split('.').collect();
72 if parts.len() < 3 {
73 return false;
74 }
75
76 parts.iter().all(|part| {
78 let clean_part = part.split('-').next().unwrap_or(part);
80 clean_part.chars().all(|c| c.is_ascii_digit())
81 })
82 }
83
84 pub fn validate_plugin_security(manifest: &PluginManifest) -> PluginResult<()> {
86 if let Some(mcp_servers) = &manifest.mcp_servers {
88 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 }
103 }
104 }
105
106 Ok(())
107 }
108
109 fn is_dangerous_command(command: &str) -> bool {
111 command_might_be_dangerous(&[command.to_string()])
112 }
113}
114
115pub struct PluginDebugger;
117
118impl PluginDebugger {
119 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 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 pub fn validate_and_debug_manifest(manifest: &PluginManifest) -> Result<String> {
171 let mut issues = Vec::new();
172
173 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 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
198pub async fn handle_plugin_validate(path: &Path) -> Result<()> {
200 if !tokio::fs::try_exists(path).await.unwrap_or(false) {
202 anyhow::bail!("Plugin path does not exist: {}", path.display());
203 }
204
205 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 let validation_output = PluginDebugger::validate_and_debug_manifest(&manifest)?;
216 tracing::info!(output = %validation_output, "plugin validation result");
217
218 Ok(())
219}