1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::{NewPluginInstall, PluginInstallRecord, RuntimeStore, data_dir};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct PluginManifest {
13 pub name: String,
14 #[serde(default)]
15 pub version: Option<String>,
16 #[serde(default)]
17 pub description: Option<String>,
18 #[serde(default)]
19 pub skills: Vec<String>,
20 #[serde(default)]
21 pub agents: Vec<String>,
22 #[serde(default)]
23 pub hooks: Vec<String>,
24 #[serde(default)]
25 pub mcp: Vec<String>,
26 #[serde(default)]
27 pub permissions: Vec<String>,
28 #[serde(default)]
29 pub prompts: Vec<String>,
30 #[serde(default)]
31 pub bin: Vec<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PluginPermissionPreview {
36 pub name: String,
37 pub declared_permissions: Vec<String>,
38 pub permissions_toml: Option<toml::Value>,
39 pub hooks: Vec<String>,
40 pub mcp: Vec<String>,
41 pub bin: Vec<String>,
42}
43
44pub fn validate_plugin_manifest(manifest: &PluginManifest, root: &Path) -> Result<()> {
45 anyhow::ensure!(!manifest.name.trim().is_empty(), "plugin name is required");
46 ensure_relative_paths("skills", &manifest.skills, root)?;
47 ensure_relative_paths("agents", &manifest.agents, root)?;
48 ensure_relative_paths("hooks", &manifest.hooks, root)?;
49 ensure_relative_paths("mcp", &manifest.mcp, root)?;
50 ensure_relative_paths("prompts", &manifest.prompts, root)?;
51 ensure_relative_paths("bin", &manifest.bin, root)?;
52 Ok(())
53}
54
55pub fn install_plugin_from_path(path: &Path) -> Result<PluginInstallRecord> {
56 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
57 validate_plugin_manifest(&manifest, &root)?;
58 let manifest_json = serde_json::to_string_pretty(&manifest)?;
59 let store = RuntimeStore::open_default()?;
60 let record = store.plugins().install(NewPluginInstall {
61 id: Some(manifest.name.clone()),
62 name: manifest.name,
63 source: root.display().to_string(),
64 version: manifest.version,
65 enabled: true,
66 manifest_json,
67 })?;
68 write_plugin_lockfile()?;
69 Ok(record)
70}
71
72pub fn plugin_permission_preview(path: &Path) -> Result<PluginPermissionPreview> {
73 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
74 validate_plugin_manifest(&manifest, &root)?;
75 let permissions_path = root.join("permissions.toml");
76 let permissions_toml = if permissions_path.exists() {
77 let raw = std::fs::read_to_string(&permissions_path)
78 .with_context(|| format!("failed to read {}", permissions_path.display()))?;
79 Some(toml::from_str(&raw)?)
80 } else {
81 None
82 };
83 Ok(PluginPermissionPreview {
84 name: manifest.name,
85 declared_permissions: manifest.permissions,
86 permissions_toml,
87 hooks: manifest.hooks,
88 mcp: manifest.mcp,
89 bin: manifest.bin,
90 })
91}
92
93pub fn write_plugin_lockfile() -> Result<PathBuf> {
94 let store = RuntimeStore::open_default()?;
95 let plugins = store.plugins().list()?;
96 let path = data_dir()?.join("plugins.lock.json");
97 if let Some(parent) = path.parent() {
98 std::fs::create_dir_all(parent)?;
99 }
100 std::fs::write(&path, serde_json::to_vec_pretty(&plugins)?)?;
101 Ok(path)
102}
103
104pub fn run_plugin_hooks(event: &str, payload: &serde_json::Value) -> Result<()> {
105 let store = RuntimeStore::open_default()?;
106 let payload_bytes = serde_json::to_string(payload)?.into_bytes();
107 for plugin in store.plugins().list()? {
108 if !plugin.enabled {
109 continue;
110 }
111 let Ok(manifest) = serde_json::from_str::<PluginManifest>(&plugin.manifest_json) else {
112 tracing::warn!(plugin = %plugin.name, "skipping plugin with unparseable manifest");
113 continue;
114 };
115 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
117 tracing::warn!(plugin = %plugin.name, "plugin source missing; skipping hooks");
118 continue;
119 };
120 for hook in manifest.hooks {
121 let Ok(canonical_hook) = std::fs::canonicalize(root.join(&hook)) else {
125 continue; };
127 if !canonical_hook.starts_with(&root) {
128 tracing::warn!(plugin = %plugin.name, hook = %hook, "plugin hook escapes root; skipping");
129 continue;
130 }
131 let spawn = Command::new(&canonical_hook)
135 .env_clear()
136 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
137 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
138 .env("MERMAID_HOOK_EVENT", event)
139 .env("MERMAID_PLUGIN_NAME", &plugin.name)
140 .stdin(Stdio::piped())
141 .stdout(Stdio::null())
142 .stderr(Stdio::null())
143 .spawn();
144 let mut child = match spawn {
145 Ok(child) => child,
146 Err(err) => {
147 tracing::warn!(plugin = %plugin.name, error = %err, "failed to spawn plugin hook");
148 continue; },
150 };
151 if let Some(stdin) = child.stdin.as_mut() {
152 let _ = stdin.write_all(&payload_bytes);
153 }
154 match child.wait() {
155 Ok(status) if !status.success() => {
156 tracing::warn!(plugin = %plugin.name, hook = %hook, %status, "plugin hook failed");
157 },
158 Err(err) => {
159 tracing::warn!(plugin = %plugin.name, error = %err, "plugin hook wait failed");
160 },
161 _ => {},
162 }
163 }
164 }
165 Ok(())
166}
167
168fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
169 let resolved = resolve_plugin_source(path)?;
170 let manifest_path = if resolved.is_dir() {
171 resolved.join("plugin.toml")
172 } else {
173 resolved
174 };
175 let root = manifest_path
176 .parent()
177 .context("plugin manifest must have a parent directory")?
178 .to_path_buf();
179 let raw = std::fs::read_to_string(&manifest_path)
180 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
181 let manifest: PluginManifest = toml::from_str(&raw)
182 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
183 Ok((manifest_path, root, manifest))
184}
185
186fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
187 if path.exists() {
188 return Ok(path.to_path_buf());
189 }
190 let source = path.to_string_lossy();
191 let is_git_url = source.starts_with("https://")
195 || source.starts_with("git@")
196 || source.starts_with("ssh://")
197 || source.ends_with(".git");
198 if !is_git_url {
199 return Ok(path.to_path_buf());
200 }
201
202 anyhow::ensure!(
206 std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
207 "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
208 or clone it yourself and install from the local path",
209 );
210
211 let git_source = source.to_string();
212 let dest = data_dir()?
213 .join("plugins")
214 .join("sources")
215 .join(hex_lower(&Sha256::digest(git_source.as_bytes())));
216 const HARDENING: [&str; 4] = [
220 "-c",
221 "core.hooksPath=/dev/null",
222 "-c",
223 "protocol.ext.allow=never",
224 ];
225 if dest.exists() {
226 let _ = Command::new("git")
227 .env("GIT_TERMINAL_PROMPT", "0")
228 .args(HARDENING)
229 .arg("-C")
230 .arg(&dest)
231 .args(["pull", "--ff-only"])
232 .status();
233 } else {
234 if let Some(parent) = dest.parent() {
235 std::fs::create_dir_all(parent)?;
236 }
237 let status = Command::new("git")
238 .env("GIT_TERMINAL_PROMPT", "0")
239 .args(HARDENING)
240 .args(["clone", "--depth", "1"])
241 .arg(&git_source)
242 .arg(&dest)
243 .status()
244 .with_context(|| format!("failed to clone plugin source {}", git_source))?;
245 anyhow::ensure!(status.success(), "git clone failed for {}", git_source);
246 }
247 Ok(dest)
248}
249
250fn hex_lower(bytes: &[u8]) -> String {
251 const HEX: &[u8; 16] = b"0123456789abcdef";
252 let mut out = String::with_capacity(bytes.len() * 2);
253 for byte in bytes {
254 out.push(HEX[(byte >> 4) as usize] as char);
255 out.push(HEX[(byte & 0x0f) as usize] as char);
256 }
257 out
258}
259
260fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
261 for path in paths {
262 let rel = Path::new(path);
263 anyhow::ensure!(
264 !rel.is_absolute() && !path.contains(".."),
265 "{} path must stay inside plugin root: {}",
266 kind,
267 path
268 );
269 let full = root.join(rel);
270 anyhow::ensure!(
271 full.exists(),
272 "{} path does not exist under plugin root: {}",
273 kind,
274 path
275 );
276 }
277 Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282 use crate::*;
283
284 #[test]
285 fn manifest_rejects_parent_escape() {
286 let root = std::env::temp_dir();
287 let manifest = PluginManifest {
288 name: "bad".to_string(),
289 version: None,
290 description: None,
291 skills: vec!["../x".to_string()],
292 agents: vec![],
293 hooks: vec![],
294 mcp: vec![],
295 permissions: vec![],
296 prompts: vec![],
297 bin: vec![],
298 };
299 assert!(validate_plugin_manifest(&manifest, &root).is_err());
300 }
301}