vtcode_core/tools/plugins/
mod.rs1use hashbrown::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use anyhow::{Context, Result, bail, ensure};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use tokio::sync::RwLock;
9
10use crate::config::{PluginRuntimeConfig, PluginTrustLevel};
11use crate::tools::ToolRegistry;
12use crate::tools::registry::ToolRegistration;
13use crate::utils::error_messages::{ERR_DESERIALIZE, ERR_READ_FILE};
14use crate::utils::file_utils::read_file_with_context;
15
16pub type PluginId = String;
17
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct PluginManifest {
22 #[serde(default)]
24 pub id: PluginId,
25
26 pub name: String,
28
29 #[serde(default)]
31 pub version: String,
32
33 #[serde(default)]
35 pub description: String,
36
37 #[serde(default)]
39 pub entrypoint: PathBuf,
40
41 #[serde(default)]
43 pub capabilities: Vec<String>,
44
45 #[serde(default)]
47 pub trust_level: Option<PluginTrustLevel>,
48
49 #[serde(default)]
51 pub metadata: serde_json::Value,
52}
53
54impl PluginManifest {
55 fn normalized(mut self, default_trust: PluginTrustLevel) -> Self {
56 if self.id.is_empty() {
57 self.id = std::mem::take(&mut self.name);
58 }
59
60 if self.trust_level.is_none() {
61 self.trust_level = Some(default_trust);
62 }
63
64 self
65 }
66}
67
68#[derive(Debug, Clone)]
69pub struct PluginHandle {
70 pub manifest: PluginManifest,
71 pub manifest_path: PathBuf,
72 pub loaded_at: SystemTime,
73}
74
75#[async_trait]
76pub trait PluginInstaller: Send + Sync {
77 async fn materialize(&self, manifest: &PluginManifest) -> Result<ToolRegistration>;
79}
80
81#[derive(Debug)]
83pub struct PluginRuntime {
84 workspace_root: PathBuf,
85 config: PluginRuntimeConfig,
86 plugins: RwLock<HashMap<PluginId, PluginHandle>>,
87}
88
89impl PluginRuntime {
90 pub fn new(workspace_root: PathBuf, config: PluginRuntimeConfig) -> Self {
91 Self {
92 workspace_root,
93 config,
94 plugins: RwLock::new(HashMap::new()),
95 }
96 }
97
98 pub fn config(&self) -> &PluginRuntimeConfig {
99 &self.config
100 }
101
102 pub async fn load_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginManifest> {
103 let path = manifest_path.as_ref();
104 let data = read_file_with_context(path, "plugin manifest")
105 .await
106 .with_context(|| format!("{ERR_READ_FILE}: {}", path.display()))?;
107
108 let manifest: PluginManifest = toml::from_str(&data)
109 .with_context(|| format!("{ERR_DESERIALIZE}: {}", path.display()))?;
110
111 Ok(manifest.normalized(self.config.default_trust))
112 }
113
114 pub async fn register_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginHandle> {
115 let path = manifest_path.as_ref();
116 ensure!(
117 self.config.enabled,
118 "plugin runtime disabled by configuration"
119 );
120
121 let manifest = self.load_manifest(path).await?;
122 self.validate_trust(&manifest)?;
123
124 let handle = PluginHandle {
125 manifest: manifest.clone(),
126 manifest_path: path.to_path_buf(),
127 loaded_at: SystemTime::now(),
128 };
129
130 let mut plugins = self.plugins.write().await;
131 plugins.insert(manifest.id.clone(), handle.clone());
132 Ok(handle)
133 }
134
135 pub async fn hot_swap(&self, manifest_path: impl AsRef<Path>) -> Result<PluginHandle> {
136 let handle = self.register_manifest(manifest_path).await?;
137 Ok(handle)
138 }
139
140 pub async fn attach_to_registry(
141 &self,
142 registry: &ToolRegistry,
143 manifest: &PluginManifest,
144 installer: &dyn PluginInstaller,
145 ) -> Result<()> {
146 ensure!(
147 self.config.enabled,
148 "plugin runtime disabled by configuration"
149 );
150 self.validate_trust(manifest)?;
151
152 let registration = installer.materialize(manifest).await?;
153 registry.register_tool(registration).await?;
154 Ok(())
155 }
156
157 pub async fn list_registered(&self) -> Vec<PluginHandle> {
158 let plugins = self.plugins.read().await;
159 plugins.values().cloned().collect()
160 }
161
162 pub async fn unload_plugin(&self, plugin_id: &str) -> Result<()> {
163 let mut plugins = self.plugins.write().await;
164 if plugins.remove(plugin_id).is_some() {
165 Ok(())
166 } else {
167 bail!("plugin {} not found in runtime", plugin_id)
168 }
169 }
170
171 fn validate_trust(&self, manifest: &PluginManifest) -> Result<()> {
172 if self
173 .config
174 .deny
175 .iter()
176 .any(|blocked| blocked == &manifest.id)
177 {
178 bail!("plugin {} is blocked by deny list", manifest.id);
179 }
180
181 if !self.config.allow.is_empty()
182 && !self
183 .config
184 .allow
185 .iter()
186 .any(|allowed| allowed == &manifest.id)
187 {
188 bail!("plugin {} not present in allow list", manifest.id);
189 }
190
191 let trust = manifest.trust_level.unwrap_or(self.config.default_trust);
192 ensure!(
193 matches!(
194 trust,
195 PluginTrustLevel::Sandbox | PluginTrustLevel::Trusted | PluginTrustLevel::Untrusted
196 ),
197 "invalid trust level for plugin {}",
198 manifest.id
199 );
200 Ok(())
201 }
202
203 pub fn workspace_root(&self) -> &Path {
204 &self.workspace_root
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn manifest_toml(id: &str) -> String {
213 format!(
214 r#"
215name = "{id}"
216version = "0.1.0"
217description = "test plugin"
218entrypoint = "bin/plugin"
219"#
220 )
221 }
222
223 #[tokio::test]
224 async fn deny_list_blocks_manifest() {
225 let tmp_dir = std::env::temp_dir();
226 let manifest_path = tmp_dir.join("vtcode_plugin_deny.toml");
227 tokio::fs::write(&manifest_path, manifest_toml("blocked"))
228 .await
229 .expect("write manifest");
230
231 let runtime = PluginRuntime::new(
232 tmp_dir.clone(),
233 PluginRuntimeConfig {
234 deny: vec!["blocked".into()],
235 ..PluginRuntimeConfig::default()
236 },
237 );
238
239 let err = runtime.register_manifest(&manifest_path).await.unwrap_err();
240 assert!(
241 err.to_string().contains("blocked"),
242 "expected deny list rejection"
243 );
244 }
245
246 #[tokio::test]
247 async fn allow_list_enforced() {
248 let tmp_dir = std::env::temp_dir();
249 let manifest_path = tmp_dir.join("vtcode_plugin_allow.toml");
250 tokio::fs::write(&manifest_path, manifest_toml("allowed"))
251 .await
252 .expect("write manifest");
253
254 let runtime = PluginRuntime::new(
255 tmp_dir.clone(),
256 PluginRuntimeConfig {
257 allow: vec!["allowed".into()],
258 ..PluginRuntimeConfig::default()
259 },
260 );
261
262 let handle = runtime
263 .register_manifest(&manifest_path)
264 .await
265 .expect("allowed manifest to register");
266
267 assert_eq!(handle.manifest.id, "allowed");
268 }
269}