Skip to main content

mermaid_runtime/
plugin.rs

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        // Canonicalize the root so a symlink inside it can't be used to escape.
116        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            // Resolve the hook through symlinks and verify containment on the
122            // CANONICAL path (the old lexical `starts_with` could be escaped
123            // by a symlink inside the root pointing outside it).
124            let Ok(canonical_hook) = std::fs::canonicalize(root.join(&hook)) else {
125                continue; // missing hook: nothing to run
126            };
127            if !canonical_hook.starts_with(&root) {
128                tracing::warn!(plugin = %plugin.name, hook = %hook, "plugin hook escapes root; skipping");
129                continue;
130            }
131            // Execute with a SCRUBBED environment (clear + minimal allowlist)
132            // so provider API keys and MERMAID_DAEMON_TOKEN never leak into
133            // plugin-provided code.
134            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; // isolate: one bad hook must not abort the rest
149                },
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    // Only an EXPLICIT git URL is treated as a remote source. The old bare
192    // `owner/repo` → github.com expansion turned any short string into a
193    // network fetch of attacker-named code; require the full URL instead.
194    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    // Fetching remote plugin code is a privileged operation — gate it behind
203    // an explicit opt-in so it can't be triggered silently (e.g. via the
204    // daemon's local fallback). Operators who want it set the env var.
205    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    // Harden git: no credential prompts, no repo-provided hooks, no external
217    // transports (`ext::` RCE), so materializing the source can't itself run
218    // attacker code.
219    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}