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 = std::sync::Arc::new(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() {
184 let payload = std::sync::Arc::clone(&payload_bytes);
185 std::thread::spawn(move || {
186 let _ = stdin.write_all(&payload);
187 });
188 }
189 wait_hook_bounded(&mut child, &plugin.name, &hook, HOOK_TIMEOUT);
190 }
191 }
192 Ok(())
193}
194
195fn wait_hook_bounded(child: &mut std::process::Child, plugin: &str, hook: &str, timeout: Duration) {
200 let deadline = Instant::now() + timeout;
201 loop {
202 match child.try_wait() {
203 Ok(Some(status)) => {
204 if !status.success() {
205 tracing::warn!(plugin = %plugin, hook = %hook, %status, "plugin hook failed");
206 }
207 return;
208 },
209 Ok(None) => {
210 if Instant::now() >= deadline {
211 let _ = child.kill();
212 let _ = child.wait();
213 tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook timed out; killed");
214 return;
215 }
216 std::thread::sleep(Duration::from_millis(20));
217 },
218 Err(err) => {
219 tracing::warn!(plugin = %plugin, error = %err, "plugin hook wait failed");
220 return;
221 },
222 }
223 }
224}
225
226fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
227 let resolved = resolve_plugin_source(path)?;
228 let manifest_path = if resolved.is_dir() {
229 resolved.join("plugin.toml")
230 } else {
231 resolved
232 };
233 let root = manifest_path
234 .parent()
235 .context("plugin manifest must have a parent directory")?
236 .to_path_buf();
237 let raw = std::fs::read_to_string(&manifest_path)
238 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
239 let manifest: PluginManifest = toml::from_str(&raw)
240 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
241 Ok((manifest_path, root, manifest))
242}
243
244fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
245 if path.exists() {
246 return Ok(path.to_path_buf());
247 }
248 let source = path.to_string_lossy();
249 let is_git_url = source.starts_with("https://")
253 || source.starts_with("git@")
254 || source.starts_with("ssh://")
255 || source.ends_with(".git");
256 if !is_git_url {
257 return Ok(path.to_path_buf());
258 }
259
260 anyhow::ensure!(
264 std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
265 "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
266 or clone it yourself and install from the local path",
267 );
268
269 let git_source = source.to_string();
270 let dest = data_dir()?
271 .join("plugins")
272 .join("sources")
273 .join(crate::hex_lower(&Sha256::digest(git_source.as_bytes())));
274 const HARDENING: [&str; 4] = [
278 "-c",
279 "core.hooksPath=/dev/null",
280 "-c",
281 "protocol.ext.allow=never",
282 ];
283 if dest.exists() {
284 let _ = Command::new("git")
285 .env("GIT_TERMINAL_PROMPT", "0")
286 .args(HARDENING)
287 .arg("-C")
288 .arg(&dest)
289 .args(["pull", "--ff-only"])
290 .status();
291 } else {
292 if let Some(parent) = dest.parent() {
293 std::fs::create_dir_all(parent)?;
294 }
295 let status = Command::new("git")
296 .env("GIT_TERMINAL_PROMPT", "0")
297 .args(HARDENING)
298 .args(["clone", "--depth", "1"])
299 .arg(&git_source)
300 .arg(&dest)
301 .status()
302 .with_context(|| format!("failed to clone plugin source {}", git_source))?;
303 anyhow::ensure!(status.success(), "git clone failed for {}", git_source);
304 }
305 Ok(dest)
306}
307
308fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
309 for path in paths {
310 let rel = Path::new(path);
311 anyhow::ensure!(
312 !rel.is_absolute() && !path.contains(".."),
313 "{} path must stay inside plugin root: {}",
314 kind,
315 path
316 );
317 let full = root.join(rel);
318 anyhow::ensure!(
319 full.exists(),
320 "{} path does not exist under plugin root: {}",
321 kind,
322 path
323 );
324 }
325 Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330 use crate::*;
331
332 #[test]
333 fn manifest_rejects_parent_escape() {
334 let root = std::env::temp_dir();
335 let manifest = PluginManifest {
336 name: "bad".to_string(),
337 version: None,
338 description: None,
339 skills: vec!["../x".to_string()],
340 agents: vec![],
341 hooks: vec![],
342 mcp: vec![],
343 capabilities: vec![],
344 prompts: vec![],
345 bin: vec![],
346 };
347 assert!(validate_plugin_manifest(&manifest, &root).is_err());
348 }
349
350 #[test]
351 fn manifest_round_trips_capabilities_field() {
352 let toml_src = r#"
353 name = "demo"
354 capabilities = ["network", "filesystem"]
355 "#;
356 let manifest: PluginManifest = toml::from_str(toml_src).expect("parse manifest");
357 assert_eq!(manifest.capabilities, vec!["network", "filesystem"]);
358 let json = serde_json::to_string(&manifest).expect("serialize");
360 assert!(json.contains("\"capabilities\""));
361 assert!(!json.contains("\"permissions\""));
362 }
363
364 #[test]
365 fn hook_overrunning_timeout_is_killed() {
366 use std::time::{Duration, Instant};
367 #[cfg(unix)]
370 let mut child = std::process::Command::new("sh")
371 .arg("-c")
372 .arg("sleep 10")
373 .spawn()
374 .expect("spawn sleep");
375 #[cfg(windows)]
376 let mut child = std::process::Command::new("cmd")
377 .args(["/C", "ping -n 11 127.0.0.1 >NUL"])
378 .spawn()
379 .expect("spawn ping");
380 let start = Instant::now();
381 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_millis(150));
382 assert!(
383 start.elapsed() < Duration::from_secs(3),
384 "should return promptly after killing the overrunning hook"
385 );
386 }
387
388 #[test]
389 fn hook_that_exits_quickly_returns_without_kill() {
390 use std::time::{Duration, Instant};
391 #[cfg(unix)]
392 let mut child = std::process::Command::new("true")
393 .spawn()
394 .expect("spawn true");
395 #[cfg(windows)]
396 let mut child = std::process::Command::new("cmd")
397 .args(["/C", "exit 0"])
398 .spawn()
399 .expect("spawn exit");
400 let start = Instant::now();
401 super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_secs(30));
402 assert!(start.elapsed() < Duration::from_secs(5));
403 }
404}