Skip to main content

sparrow/capabilities/
plugin.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct PluginManifest {
6    pub name: String,
7    #[serde(default)]
8    pub version: String,
9    #[serde(default)]
10    pub description: String,
11    #[serde(default)]
12    pub commands: Vec<PluginCommand>,
13    #[serde(default)]
14    pub agents: Vec<String>,
15    #[serde(default)]
16    pub skills: Vec<PluginSkill>,
17    #[serde(default)]
18    pub hooks: Vec<PluginHook>,
19    #[serde(default)]
20    pub mcp_servers: Vec<crate::capabilities::mcp::McpServer>,
21    #[serde(default)]
22    pub themes: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct PluginCommand {
27    pub name: String,
28    #[serde(default)]
29    pub description: String,
30    #[serde(default)]
31    pub body: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct PluginSkill {
36    pub name: String,
37    #[serde(default)]
38    pub path: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct PluginHook {
43    pub name: String,
44    #[serde(default)]
45    pub kind: String,
46    #[serde(default)]
47    pub command: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Plugin {
52    pub manifest: PluginManifest,
53    pub root: PathBuf,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct PluginAudit {
58    pub allowed: bool,
59    pub warnings: Vec<String>,
60}
61
62pub struct PluginScanner {
63    allowlist: Vec<String>,
64}
65
66impl PluginScanner {
67    pub fn new(allowlist: Vec<String>) -> Self {
68        Self { allowlist }
69    }
70
71    pub fn scan(&self, plugin: &Plugin) -> PluginAudit {
72        let mut warnings = Vec::new();
73        if plugin.manifest.name.trim().is_empty() {
74            warnings.push("plugin name is empty".into());
75        }
76        if !self.allowlist.is_empty() && !self.allowlist.contains(&plugin.manifest.name) {
77            warnings.push(format!(
78                "plugin '{}' is not in the allowlist",
79                plugin.manifest.name
80            ));
81        }
82        for hook in &plugin.manifest.hooks {
83            if hook.kind.eq_ignore_ascii_case("command") && command_looks_dangerous(&hook.command) {
84                warnings.push(format!("dangerous hook '{}' is blocked", hook.name));
85            }
86        }
87        PluginAudit {
88            allowed: warnings.is_empty(),
89            warnings,
90        }
91    }
92}
93
94pub struct PluginRegistry {
95    plugins_dir: PathBuf,
96    allowlist: Vec<String>,
97}
98
99impl PluginRegistry {
100    pub fn new(plugins_dir: PathBuf) -> Self {
101        std::fs::create_dir_all(&plugins_dir).ok();
102        Self {
103            plugins_dir,
104            allowlist: Vec::new(),
105        }
106    }
107
108    pub fn with_allowlist(mut self, allowlist: Vec<String>) -> Self {
109        self.allowlist = allowlist;
110        self
111    }
112
113    pub fn scan(&self) -> Vec<Plugin> {
114        let Ok(entries) = std::fs::read_dir(&self.plugins_dir) else {
115            return Vec::new();
116        };
117        entries
118            .flatten()
119            .filter_map(|entry| load_plugin(&entry.path()).ok())
120            .collect()
121    }
122
123    pub fn audit(&self, plugin: &Plugin) -> PluginAudit {
124        PluginScanner::new(self.allowlist.clone()).scan(plugin)
125    }
126
127    pub fn install_local(&self, source: &Path) -> anyhow::Result<Plugin> {
128        let plugin = load_plugin(source)?;
129        let audit = self.audit(&plugin);
130        if !audit.allowed {
131            anyhow::bail!("plugin blocked: {}", audit.warnings.join("; "));
132        }
133        let dest = self.plugins_dir.join(&plugin.manifest.name);
134        if dest.exists() {
135            std::fs::remove_dir_all(&dest)?;
136        }
137        copy_dir_all(source, &dest)?;
138        load_plugin(&dest)
139    }
140
141    pub fn install_github(&self, repo: &str) -> anyhow::Result<Plugin> {
142        let tmp = std::env::temp_dir().join(format!("sparrow-plugin-{}", uuid::Uuid::new_v4()));
143        let status = std::process::Command::new("git")
144            .args([
145                "clone",
146                "--depth",
147                "1",
148                repo,
149                tmp.to_string_lossy().as_ref(),
150            ])
151            .status()?;
152        if !status.success() {
153            anyhow::bail!("git clone failed for plugin repo {}", repo);
154        }
155        let plugin = self.install_local(&tmp);
156        let _ = std::fs::remove_dir_all(&tmp);
157        plugin
158    }
159}
160
161pub fn load_plugin(root: &Path) -> anyhow::Result<Plugin> {
162    let toml_path = root.join(".sparrow-plugin").join("plugin.toml");
163    let json_path = root.join(".sparrow-plugin").join("plugin.json");
164    let manifest = if toml_path.exists() {
165        toml::from_str::<PluginManifest>(&std::fs::read_to_string(toml_path)?)?
166    } else if json_path.exists() {
167        serde_json::from_str::<PluginManifest>(&std::fs::read_to_string(json_path)?)?
168    } else {
169        anyhow::bail!("plugin manifest not found in {}", root.display());
170    };
171    Ok(Plugin {
172        manifest,
173        root: root.to_path_buf(),
174    })
175}
176
177pub fn namespace(plugin: &str, item: &str) -> String {
178    format!("{}:{}", plugin.trim(), item.trim())
179}
180
181fn command_looks_dangerous(command: &str) -> bool {
182    let lower = command.to_ascii_lowercase();
183    [
184        "rm -rf",
185        "remove-item",
186        "format ",
187        "del /s",
188        "shutdown",
189        "curl ",
190        "invoke-webrequest",
191        "powershell -enc",
192    ]
193    .iter()
194    .any(|needle| lower.contains(needle))
195}
196
197fn copy_dir_all(src: &Path, dst: &Path) -> anyhow::Result<()> {
198    std::fs::create_dir_all(dst)?;
199    for entry in std::fs::read_dir(src)? {
200        let entry = entry?;
201        let ty = entry.file_type()?;
202        let dest = dst.join(entry.file_name());
203        if ty.is_dir() {
204            copy_dir_all(&entry.path(), &dest)?;
205        } else {
206            std::fs::copy(entry.path(), dest)?;
207        }
208    }
209    Ok(())
210}