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
16crate::id_newtype::define_id_newtype! {
17 pub struct PluginId
19}
20
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct PluginManifest {
25 #[serde(default)]
27 pub id: PluginId,
28
29 pub name: String,
31
32 #[serde(default)]
34 pub version: String,
35
36 #[serde(default)]
38 pub description: String,
39
40 #[serde(default)]
42 pub entrypoint: PathBuf,
43
44 #[serde(default)]
46 pub capabilities: Vec<String>,
47
48 #[serde(default)]
50 pub trust_level: Option<PluginTrustLevel>,
51
52 #[serde(default)]
54 pub metadata: serde_json::Value,
55}
56
57impl PluginManifest {
58 fn normalized(mut self, default_trust: PluginTrustLevel) -> Self {
59 if self.id.is_empty() {
60 self.id = std::mem::take(&mut self.name).into();
61 }
62
63 if self.trust_level.is_none() {
64 self.trust_level = Some(default_trust);
65 }
66
67 self
68 }
69}
70
71#[derive(Debug, Clone)]
72pub struct PluginHandle {
73 pub manifest: PluginManifest,
74 pub manifest_path: PathBuf,
75 pub loaded_at: SystemTime,
76}
77
78#[async_trait]
79pub trait PluginInstaller: Send + Sync {
80 async fn materialize(&self, manifest: &PluginManifest) -> Result<ToolRegistration>;
82}
83
84#[derive(Debug)]
86pub struct PluginRuntime {
87 workspace_root: PathBuf,
88 config: PluginRuntimeConfig,
89 plugins: RwLock<HashMap<PluginId, PluginHandle>>,
90}
91
92impl PluginRuntime {
93 pub fn new(workspace_root: PathBuf, config: PluginRuntimeConfig) -> Self {
94 Self {
95 workspace_root,
96 config,
97 plugins: RwLock::new(HashMap::new()),
98 }
99 }
100
101 pub fn config(&self) -> &PluginRuntimeConfig {
102 &self.config
103 }
104
105 pub async fn load_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginManifest> {
106 let path = manifest_path.as_ref();
107 let data = read_file_with_context(path, "plugin manifest")
108 .await
109 .with_context(|| format!("{ERR_READ_FILE}: {}", path.display()))?;
110
111 let manifest: PluginManifest =
112 toml::from_str(&data).with_context(|| format!("{ERR_DESERIALIZE}: {}", path.display()))?;
113
114 Ok(manifest.normalized(self.config.default_trust))
115 }
116
117 pub async fn register_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginHandle> {
118 let path = manifest_path.as_ref();
119 ensure!(self.config.enabled, "plugin runtime disabled by configuration");
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!(self.config.enabled, "plugin runtime disabled by configuration");
147 self.validate_trust(manifest)?;
148
149 let registration = installer.materialize(manifest).await?;
150 registry.register_tool(registration).await?;
151 Ok(())
152 }
153
154 pub async fn list_registered(&self) -> Vec<PluginHandle> {
155 let plugins = self.plugins.read().await;
156 plugins.values().cloned().collect()
157 }
158
159 pub async fn unload_plugin(&self, plugin_id: &str) -> Result<()> {
160 let mut plugins = self.plugins.write().await;
161 if plugins.remove(plugin_id).is_some() {
162 Ok(())
163 } else {
164 bail!("plugin {plugin_id} not found in runtime")
165 }
166 }
167
168 fn validate_trust(&self, manifest: &PluginManifest) -> Result<()> {
169 if self.config.deny.iter().any(|blocked| blocked.as_str() == manifest.id.as_str()) {
170 bail!("plugin {} is blocked by deny list", manifest.id);
171 }
172
173 if !self.config.allow.is_empty()
174 && !self.config.allow.iter().any(|allowed| allowed.as_str() == manifest.id.as_str())
175 {
176 bail!("plugin {} not present in allow list", manifest.id);
177 }
178
179 let trust = manifest.trust_level.unwrap_or(self.config.default_trust);
180 ensure!(
181 matches!(trust, PluginTrustLevel::Sandbox | PluginTrustLevel::Trusted | PluginTrustLevel::Untrusted),
182 "invalid trust level for plugin {}",
183 manifest.id
184 );
185 Ok(())
186 }
187
188 pub fn workspace_root(&self) -> &Path {
189 &self.workspace_root
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn manifest_toml(id: &str) -> String {
198 format!(
199 r#"
200name = "{id}"
201version = "0.1.0"
202description = "test plugin"
203entrypoint = "bin/plugin"
204"#
205 )
206 }
207
208 #[tokio::test]
209 async fn deny_list_blocks_manifest() {
210 let tmp_dir = std::env::temp_dir();
211 let manifest_path = tmp_dir.join("vtcode_plugin_deny.toml");
212 tokio::fs::write(&manifest_path, manifest_toml("blocked"))
213 .await
214 .expect("write manifest");
215
216 let runtime = PluginRuntime::new(
217 tmp_dir.clone(),
218 PluginRuntimeConfig {
219 deny: vec!["blocked".into()],
220 ..PluginRuntimeConfig::default()
221 },
222 );
223
224 let err = runtime.register_manifest(&manifest_path).await.unwrap_err();
225 assert!(err.to_string().contains("blocked"), "expected deny list rejection");
226 }
227
228 #[tokio::test]
229 async fn allow_list_enforced() {
230 let tmp_dir = std::env::temp_dir();
231 let manifest_path = tmp_dir.join("vtcode_plugin_allow.toml");
232 tokio::fs::write(&manifest_path, manifest_toml("allowed"))
233 .await
234 .expect("write manifest");
235
236 let runtime = PluginRuntime::new(
237 tmp_dir.clone(),
238 PluginRuntimeConfig {
239 allow: vec!["allowed".into()],
240 ..PluginRuntimeConfig::default()
241 },
242 );
243
244 let handle = runtime
245 .register_manifest(&manifest_path)
246 .await
247 .expect("allowed manifest to register");
248
249 assert_eq!(handle.manifest.id, "allowed");
250 }
251}