Skip to main content

vtcode_core/tools/plugins/
mod.rs

1use 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    /// Identifier for a tool plugin.
18    pub struct PluginId
19}
20
21/// Declarative metadata for a plugin manifest.
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct PluginManifest {
25    /// Unique identifier. Falls back to the `name` when omitted.
26    #[serde(default)]
27    pub id: PluginId,
28
29    /// Human-readable plugin name.
30    pub name: String,
31
32    /// Semantic version string for compatibility checks.
33    #[serde(default)]
34    pub version: String,
35
36    /// Short description of the plugin.
37    #[serde(default)]
38    pub description: String,
39
40    /// Entrypoint script or binary to launch.
41    #[serde(default)]
42    pub entrypoint: PathBuf,
43
44    /// Capability descriptors advertised by the plugin.
45    #[serde(default)]
46    pub capabilities: Vec<String>,
47
48    /// Optional explicit trust level.
49    #[serde(default)]
50    pub trust_level: Option<PluginTrustLevel>,
51
52    /// Arbitrary metadata for adapters (environment, labels, etc.).
53    #[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    /// Convert a manifest into a runtime tool registration.
81    async fn materialize(&self, manifest: &PluginManifest) -> Result<ToolRegistration>;
82}
83
84/// Runtime for loading, tracking, and hot-swapping plugin manifests.
85#[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}