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}