Skip to main content

mermaid_runtime/
plugin.rs

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
12/// A plugin hook that runs longer than this is killed. Hooks are fire-and-forget
13/// observers, so a runaway one must never hang the caller (the TUI event loop,
14/// the daemon, or the CLI) — this bound is what makes that guarantee.
15const 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    /// Capabilities the plugin *declares* it uses (e.g. "network", "filesystem").
33    /// ADVISORY ONLY — surfaced at install/enable time for informed consent, not
34    /// enforced. A plugin hook is a native child process running with the user's
35    /// privileges; the runtime cannot confine it to this list without OS-level
36    /// sandboxing, so the field documents intent rather than granting a sandbox.
37    /// The real boundary is the explicit `mermaid plugin enable` decision.
38    #[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/// A summary of what a plugin declares it will do, shown before install/enable.
47/// These are advisory disclosures for informed consent, not an enforced sandbox
48/// (see [`PluginManifest::capabilities`]).
49#[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        // Installed plugins are DISABLED by default. A hook runs native code
81        // with the user's privileges, so activation is a separate, explicit
82        // decision (`mermaid plugin enable <id>`) — never a side effect of
83        // install. This is the meaningful boundary in a hook system.
84        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    // Atomic write so a crash can't leave a truncated lockfile.
120    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        // The enabled flag is the trust boundary: a plugin runs native hook
129        // code only after an explicit `plugin enable`. Declared capabilities are
130        // advisory and intentionally not consulted here — they cannot constrain
131        // a native child process.
132        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        // Canonicalize the root so a symlink inside it can't be used to escape.
140        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            // Resolve the hook through symlinks and verify containment on the
146            // CANONICAL path (the old lexical `starts_with` could be escaped
147            // by a symlink inside the root pointing outside it).
148            let Ok(canonical_hook) = std::fs::canonicalize(root.join(&hook)) else {
149                continue; // missing hook: nothing to run
150            };
151            if !canonical_hook.starts_with(&root) {
152                tracing::warn!(plugin = %plugin.name, hook = %hook, "plugin hook escapes root; skipping");
153                continue;
154            }
155            // Execute with a SCRUBBED environment (clear + minimal allowlist)
156            // so provider API keys and MERMAID_DAEMON_TOKEN never leak into
157            // plugin-provided code.
158            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; // isolate: one bad hook must not abort the rest
173                },
174            };
175            // Write the payload, then DROP stdin so the hook sees EOF. Without
176            // this, a hook that reads stdin-to-EOF blocks the wait forever.
177            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
186/// Wait for a plugin hook to exit, killing it if it overruns `timeout`.
187/// Synchronous — the runtime crate has no async runtime; callers that must not
188/// block an executor (the `effect/` loop) wrap `run_plugin_hooks` in
189/// `spawn_blocking`.
190fn 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    // Only an EXPLICIT git URL is treated as a remote source. The old bare
241    // `owner/repo` → github.com expansion turned any short string into a
242    // network fetch of attacker-named code; require the full URL instead.
243    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    // Fetching remote plugin code is a privileged operation — gate it behind
252    // an explicit opt-in so it can't be triggered silently (e.g. via the
253    // daemon's local fallback). Operators who want it set the env var.
254    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    // Harden git: no credential prompts, no repo-provided hooks, no external
266    // transports (`ext::` RCE), so materializing the source can't itself run
267    // attacker code.
268    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        // Serializes back under the new key, and the old key is gone.
360        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        // A hook that sleeps far past the timeout must be killed, and
369        // wait_hook_bounded must return promptly (no permanent hang).
370        #[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}