1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::{NewPluginInstall, PluginInstallRecord, RuntimeStore, data_dir};
11
12const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PluginManifest {
19 pub name: String,
20 #[serde(default)]
21 pub version: Option<String>,
22 #[serde(default)]
23 pub description: Option<String>,
24 #[serde(default)]
25 pub skills: Vec<String>,
26 #[serde(default)]
27 pub agents: Vec<String>,
28 #[serde(default)]
29 pub hooks: Vec<String>,
30 #[serde(default)]
31 pub mcp: Vec<String>,
32 #[serde(default)]
39 pub capabilities: Vec<String>,
40 #[serde(default)]
41 pub prompts: Vec<String>,
42 #[serde(default)]
43 pub bin: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PluginCapabilityPreview {
51 pub name: String,
52 pub declared_capabilities: Vec<String>,
53 pub capabilities_toml: Option<toml::Value>,
54 pub hooks: Vec<String>,
55 pub mcp: Vec<String>,
56 pub bin: Vec<String>,
57}
58
59pub fn validate_plugin_manifest(manifest: &PluginManifest, root: &Path) -> Result<()> {
60 anyhow::ensure!(!manifest.name.trim().is_empty(), "plugin name is required");
61 ensure_relative_paths("skills", &manifest.skills, root)?;
62 ensure_relative_paths("agents", &manifest.agents, root)?;
63 ensure_relative_paths("hooks", &manifest.hooks, root)?;
64 ensure_relative_paths("mcp", &manifest.mcp, root)?;
65 ensure_relative_paths("prompts", &manifest.prompts, root)?;
66 ensure_relative_paths("bin", &manifest.bin, root)?;
67 Ok(())
68}
69
70pub fn install_plugin_from_path(path: &Path) -> Result<PluginInstallRecord> {
71 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
72 validate_plugin_manifest(&manifest, &root)?;
73 let manifest_json = serde_json::to_string_pretty(&manifest)?;
74 let store = RuntimeStore::open_default()?;
75 let record = store.plugins().install(NewPluginInstall {
76 id: Some(manifest.name.clone()),
77 name: manifest.name,
78 source: root.display().to_string(),
79 version: manifest.version,
80 enabled: false,
85 manifest_json,
86 })?;
87 write_plugin_lockfile()?;
88 Ok(record)
89}
90
91pub fn plugin_capability_preview(path: &Path) -> Result<PluginCapabilityPreview> {
92 let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
93 validate_plugin_manifest(&manifest, &root)?;
94 let capabilities_path = root.join("capabilities.toml");
95 let capabilities_toml = if capabilities_path.exists() {
96 let raw = std::fs::read_to_string(&capabilities_path)
97 .with_context(|| format!("failed to read {}", capabilities_path.display()))?;
98 Some(toml::from_str(&raw)?)
99 } else {
100 None
101 };
102 Ok(PluginCapabilityPreview {
103 name: manifest.name,
104 declared_capabilities: manifest.capabilities,
105 capabilities_toml,
106 hooks: manifest.hooks,
107 mcp: manifest.mcp,
108 bin: manifest.bin,
109 })
110}
111
112pub fn write_plugin_lockfile() -> Result<PathBuf> {
113 let store = RuntimeStore::open_default()?;
114 let plugins = store.plugins().list()?;
115 let path = data_dir()?.join("plugins.lock.json");
116 if let Some(parent) = path.parent() {
117 std::fs::create_dir_all(parent)?;
118 }
119 crate::write_atomic(&path, &serde_json::to_vec_pretty(&plugins)?)?;
121 Ok(path)
122}
123
124pub fn run_plugin_hooks(event: &str, payload: &serde_json::Value) -> Result<()> {
125 let store = RuntimeStore::open_default()?;
126 let payload_bytes = serde_json::to_string(payload)?.into_bytes();
127 for plugin in store.plugins().list()? {
128 if !plugin.enabled {
133 continue;
134 }
135 let Ok(manifest) = serde_json::from_str::<PluginManifest>(&plugin.manifest_json) else {
136 tracing::warn!(plugin = %plugin.name, "skipping plugin with unparseable manifest");
137 continue;
138 };
139 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
141 tracing::warn!(plugin = %plugin.name, "plugin source missing; skipping hooks");
142 continue;
143 };
144 for hook in manifest.hooks {
145 let Ok(canonical_hook) = std::fs::canonicalize(root.join(&hook)) else {
149 continue; };
151 if !canonical_hook.starts_with(&root) {
152 tracing::warn!(plugin = %plugin.name, hook = %hook, "plugin hook escapes root; skipping");
153 continue;
154 }
155 let spawn = Command::new(&canonical_hook)
159 .env_clear()
160 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
161 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
162 .env("MERMAID_HOOK_EVENT", event)
163 .env("MERMAID_PLUGIN_NAME", &plugin.name)
164 .stdin(Stdio::piped())
165 .stdout(Stdio::null())
166 .stderr(Stdio::null())
167 .spawn();
168 let mut child = match spawn {
169 Ok(child) => child,
170 Err(err) => {
171 tracing::warn!(plugin = %plugin.name, error = %err, "failed to spawn plugin hook");
172 continue; },
174 };
175 if let Some(mut stdin) = child.stdin.take() {
178 let _ = stdin.write_all(&payload_bytes);
179 }
180 wait_hook_bounded(&mut child, &plugin.name, &hook, HOOK_TIMEOUT);
181 }
182 }
183 Ok(())
184}
185
186fn wait_hook_bounded(child: &mut std::process::Child, plugin: &str, hook: &str, timeout: Duration) {
191 let deadline = Instant::now() + timeout;
192 loop {
193 match child.try_wait() {
194 Ok(Some(status)) => {
195 if !status.success() {
196 tracing::warn!(plugin = %plugin, hook = %hook, %status, "plugin hook failed");
197 }
198 return;
199 },
200 Ok(None) => {
201 if Instant::now() >= deadline {
202 let _ = child.kill();
203 let _ = child.wait();
204 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook timed out; killed");
205 return;
206 }
207 std::thread::sleep(Duration::from_millis(20));
208 },
209 Err(err) => {
210 tracing::warn!(plugin = %plugin, error = %err, "plugin hook wait failed");
211 return;
212 },
213 }
214 }
215}
216
217fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
218 let resolved = resolve_plugin_source(path)?;
219 let manifest_path = if resolved.is_dir() {
220 resolved.join("plugin.toml")
221 } else {
222 resolved
223 };
224 let root = manifest_path
225 .parent()
226 .context("plugin manifest must have a parent directory")?
227 .to_path_buf();
228 let raw = std::fs::read_to_string(&manifest_path)
229 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
230 let manifest: PluginManifest = toml::from_str(&raw)
231 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
232 Ok((manifest_path, root, manifest))
233}
234
235fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
236 if path.exists() {
237 return Ok(path.to_path_buf());
238 }
239 let source = path.to_string_lossy();
240 let is_git_url = source.starts_with("https://")
244 || source.starts_with("git@")
245 || source.starts_with("ssh://")
246 || source.ends_with(".git");
247 if !is_git_url {
248 return Ok(path.to_path_buf());
249 }
250
251 anyhow::ensure!(
255 std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
256 "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
257 or clone it yourself and install from the local path",
258 );
259
260 let git_source = source.to_string();
261 let dest = data_dir()?
262 .join("plugins")
263 .join("sources")
264 .join(hex_lower(&Sha256::digest(git_source.as_bytes())));
265 const HARDENING: [&str; 4] = [
269 "-c",
270 "core.hooksPath=/dev/null",
271 "-c",
272 "protocol.ext.allow=never",
273 ];
274 if dest.exists() {
275 let _ = Command::new("git")
276 .env("GIT_TERMINAL_PROMPT", "0")
277 .args(HARDENING)
278 .arg("-C")
279 .arg(&dest)
280 .args(["pull", "--ff-only"])
281 .status();
282 } else {
283 if let Some(parent) = dest.parent() {
284 std::fs::create_dir_all(parent)?;
285 }
286 let status = Command::new("git")
287 .env("GIT_TERMINAL_PROMPT", "0")
288 .args(HARDENING)
289 .args(["clone", "--depth", "1"])
290 .arg(&git_source)
291 .arg(&dest)
292 .status()
293 .with_context(|| format!("failed to clone plugin source {}", git_source))?;
294 anyhow::ensure!(status.success(), "git clone failed for {}", git_source);
295 }
296 Ok(dest)
297}
298
299fn hex_lower(bytes: &[u8]) -> String {
300 const HEX: &[u8; 16] = b"0123456789abcdef";
301 let mut out = String::with_capacity(bytes.len() * 2);
302 for byte in bytes {
303 out.push(HEX[(byte >> 4) as usize] as char);
304 out.push(HEX[(byte & 0x0f) as usize] as char);
305 }
306 out
307}
308
309fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
310 for path in paths {
311 let rel = Path::new(path);
312 anyhow::ensure!(
313 !rel.is_absolute() && !path.contains(".."),
314 "{} path must stay inside plugin root: {}",
315 kind,
316 path
317 );
318 let full = root.join(rel);
319 anyhow::ensure!(
320 full.exists(),
321 "{} path does not exist under plugin root: {}",
322 kind,
323 path
324 );
325 }
326 Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331 use crate::*;
332
333 #[test]
334 fn manifest_rejects_parent_escape() {
335 let root = std::env::temp_dir();
336 let manifest = PluginManifest {
337 name: "bad".to_string(),
338 version: None,
339 description: None,
340 skills: vec!["../x".to_string()],
341 agents: vec![],
342 hooks: vec![],
343 mcp: vec![],
344 capabilities: vec![],
345 prompts: vec![],
346 bin: vec![],
347 };
348 assert!(validate_plugin_manifest(&manifest, &root).is_err());
349 }
350
351 #[test]
352 fn manifest_round_trips_capabilities_field() {
353 let toml_src = r#"
354 name = "demo"
355 capabilities = ["network", "filesystem"]
356 "#;
357 let manifest: PluginManifest = toml::from_str(toml_src).expect("parse manifest");
358 assert_eq!(manifest.capabilities, vec!["network", "filesystem"]);
359 let json = serde_json::to_string(&manifest).expect("serialize");
361 assert!(json.contains("\"capabilities\""));
362 assert!(!json.contains("\"permissions\""));
363 }
364
365 #[test]
366 fn hook_overrunning_timeout_is_killed() {
367 use std::time::{Duration, Instant};
368 #[cfg(unix)]
371 let mut child = std::process::Command::new("sh")
372 .arg("-c")
373 .arg("sleep 10")
374 .spawn()
375 .expect("spawn sleep");
376 #[cfg(windows)]
377 let mut child = std::process::Command::new("cmd")
378 .args(["/C", "ping -n 11 127.0.0.1 >NUL"])
379 .spawn()
380 .expect("spawn ping");
381 let start = Instant::now();
382 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_millis(150));
383 assert!(
384 start.elapsed() < Duration::from_secs(3),
385 "should return promptly after killing the overrunning hook"
386 );
387 }
388
389 #[test]
390 fn hook_that_exits_quickly_returns_without_kill() {
391 use std::time::{Duration, Instant};
392 #[cfg(unix)]
393 let mut child = std::process::Command::new("true")
394 .spawn()
395 .expect("spawn true");
396 #[cfg(windows)]
397 let mut child = std::process::Command::new("cmd")
398 .args(["/C", "exit 0"])
399 .spawn()
400 .expect("spawn exit");
401 let start = Instant::now();
402 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_secs(30));
403 assert!(start.elapsed() < Duration::from_secs(5));
404 }
405}