Skip to main content

mermaid_runtime/
plugin.rs

1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, 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. A runaway hook must
13/// never hang the caller (the TUI event loop, the daemon, or the CLI) — this
14/// bound is what makes that guarantee. A killed hook counts as "no opinion"
15/// (infrastructure fails open; see [`HookDecision`]).
16const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
17
18/// Cap on captured hook stdout/stderr, per stream per hook. Reading continues
19/// to EOF past the cap so a chatty hook never blocks on a full pipe; only the
20/// first `HOOK_OUTPUT_CAP` bytes are kept.
21const HOOK_OUTPUT_CAP: usize = 64 * 1024;
22
23/// Exit code a hook uses to deny the action without printing JSON — its
24/// captured stderr becomes the denial reason (Claude Code parity).
25const HOOK_DENY_EXIT_CODE: i32 = 2;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PluginManifest {
29    pub name: String,
30    #[serde(default)]
31    pub version: Option<String>,
32    #[serde(default)]
33    pub description: Option<String>,
34    #[serde(default)]
35    pub skills: Vec<String>,
36    #[serde(default)]
37    pub agents: Vec<String>,
38    #[serde(default)]
39    pub hooks: Vec<String>,
40    #[serde(default)]
41    pub mcp: Vec<String>,
42    /// Capabilities the plugin *declares* it uses (e.g. "network", "filesystem").
43    /// ADVISORY ONLY — surfaced at install/enable time for informed consent, not
44    /// enforced. A plugin hook is a native child process running with the user's
45    /// privileges; the runtime cannot confine it to this list without OS-level
46    /// sandboxing, so the field documents intent rather than granting a sandbox.
47    /// The real boundary is the explicit `mermaid plugin enable` decision.
48    #[serde(default)]
49    pub capabilities: Vec<String>,
50    #[serde(default)]
51    pub prompts: Vec<String>,
52    #[serde(default)]
53    pub bin: Vec<String>,
54}
55
56/// A summary of what a plugin declares it will do, shown before install/enable.
57/// These are advisory disclosures for informed consent, not an enforced sandbox
58/// (see [`PluginManifest::capabilities`]).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PluginCapabilityPreview {
61    pub name: String,
62    pub declared_capabilities: Vec<String>,
63    pub capabilities_toml: Option<toml::Value>,
64    pub hooks: Vec<String>,
65    pub mcp: Vec<String>,
66    pub bin: Vec<String>,
67}
68
69/// Check a manifest before anything it declares is installed or run.
70///
71/// # Errors
72///
73/// An empty `name`, and any entry under `skills`, `agents`, `hooks`, `mcp`,
74/// `prompts`, or `bin` that is absolute or escapes `root` — a plugin may only
75/// point at files inside its own directory. Declared `capabilities` are not
76/// checked: they are advisory disclosures, not something this can enforce.
77pub fn validate_plugin_manifest(manifest: &PluginManifest, root: &Path) -> Result<()> {
78    anyhow::ensure!(!manifest.name.trim().is_empty(), "plugin name is required");
79    ensure_relative_paths("skills", &manifest.skills, root)?;
80    ensure_relative_paths("agents", &manifest.agents, root)?;
81    ensure_relative_paths("hooks", &manifest.hooks, root)?;
82    ensure_relative_paths("mcp", &manifest.mcp, root)?;
83    ensure_relative_paths("prompts", &manifest.prompts, root)?;
84    ensure_relative_paths("bin", &manifest.bin, root)?;
85    Ok(())
86}
87
88/// Install the plugin at `path`, disabled.
89///
90/// # Errors
91///
92/// A missing or unparseable manifest, anything
93/// [`validate_plugin_manifest`] rejects, opening the runtime store, the
94/// install row itself (a name already installed), and rewriting the lockfile.
95/// A lockfile failure leaves the row in place, so the plugin is installed but
96/// the lockfile is stale — nothing runs regardless, since install never
97/// enables.
98pub fn install_plugin_from_path(path: &Path) -> Result<PluginInstallRecord> {
99    let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
100    validate_plugin_manifest(&manifest, &root)?;
101    let manifest_json = serde_json::to_string_pretty(&manifest)?;
102    let store = RuntimeStore::open_default()?;
103    let record = store.plugins().install(NewPluginInstall {
104        id: Some(manifest.name.clone()),
105        name: manifest.name,
106        source: root.display().to_string(),
107        version: manifest.version,
108        // Installed plugins are DISABLED by default. A hook runs native code
109        // with the user's privileges, so activation is a separate, explicit
110        // decision (`mermaid plugin enable <id>`) — never a side effect of
111        // install. This is the meaningful boundary in a hook system.
112        enabled: false,
113        manifest_json,
114    })?;
115    write_plugin_lockfile()?;
116    Ok(record)
117}
118
119/// Read what the plugin at `path` declares, for the pre-install disclosure.
120///
121/// # Errors
122///
123/// A missing or unparseable manifest, anything [`validate_plugin_manifest`]
124/// rejects, and an unreadable or invalid `capabilities.toml`. An absent
125/// `capabilities.toml` is not an error — that field is simply `None`. Nothing
126/// is installed or executed here.
127pub fn plugin_capability_preview(path: &Path) -> Result<PluginCapabilityPreview> {
128    let (_manifest_path, root, manifest) = load_plugin_manifest(path)?;
129    validate_plugin_manifest(&manifest, &root)?;
130    let capabilities_path = root.join("capabilities.toml");
131    let capabilities_toml = if capabilities_path.exists() {
132        let raw = std::fs::read_to_string(&capabilities_path)
133            .with_context(|| format!("failed to read {}", capabilities_path.display()))?;
134        Some(toml::from_str(&raw)?)
135    } else {
136        None
137    };
138    Ok(PluginCapabilityPreview {
139        name: manifest.name,
140        declared_capabilities: manifest.capabilities,
141        capabilities_toml,
142        hooks: manifest.hooks,
143        mcp: manifest.mcp,
144        bin: manifest.bin,
145    })
146}
147
148/// Rewrite `plugins.lock.json` from the installed set. Returns its path.
149///
150/// # Errors
151///
152/// Opening the runtime store, listing the plugins, resolving the data dir,
153/// creating its parent, and the atomic write. The write is atomic, so a
154/// failure leaves the previous lockfile intact rather than a truncated one.
155pub fn write_plugin_lockfile() -> Result<PathBuf> {
156    let store = RuntimeStore::open_default()?;
157    let plugins = store.plugins().list()?;
158    let path = data_dir()?.join("plugins.lock.json");
159    if let Some(parent) = path.parent() {
160        std::fs::create_dir_all(parent)?;
161    }
162    // Atomic write so a crash can't leave a truncated lockfile.
163    crate::write_atomic(&path, &serde_json::to_vec_pretty(&plugins)?)?;
164    Ok(path)
165}
166
167/// One enabled hook's parsed response to a hook event. Most hooks print
168/// nothing and exit 0 — that's an [`HookDecision::Allow`] with no extras, so
169/// pre-contract hooks keep working unchanged.
170#[derive(Debug, Clone, Default, PartialEq)]
171pub struct HookResponse {
172    /// Plugin that owns the responding hook.
173    pub plugin: String,
174    /// Hook path (as declared in the manifest), for logs.
175    pub hook: String,
176    /// The allow-or-deny verdict.
177    pub decision: HookDecision,
178    /// Replacement tool-arguments object (consulted on `before_tool_use`).
179    pub updated_input: Option<serde_json::Value>,
180    /// Context string to surface to the model on the next request.
181    pub additional_context: Option<String>,
182}
183
184/// Allow-or-deny verdict parsed from a hook's stdout / exit status.
185///
186/// Failure semantics are asymmetric by design: INTENT fails closed (an
187/// explicit deny — JSON or exit code 2 — always denies), while INFRASTRUCTURE
188/// fails open (a parse error, timeout, or spawn failure counts as `Allow`
189/// with a warning). Hooks are user-installed, disabled-by-default code layered
190/// on top of the policy gate — a buggy hook must not lock the user out of
191/// every tool call.
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
193pub enum HookDecision {
194    /// No opinion, explicit allow, or an infrastructure failure.
195    #[default]
196    Allow,
197    /// Block the action.
198    Deny {
199        /// Human-readable reason surfaced in the tool outcome.
200        reason: String,
201    },
202}
203
204/// Combined verdict across all responding hooks for one event.
205#[derive(Debug, Clone, Default, PartialEq)]
206pub struct HookGate {
207    /// First denial in plugin order: `(plugin name, reason)`.
208    pub deny: Option<(String, String)>,
209    /// Replacement tool arguments — the LAST rewrite wins (a collision is
210    /// logged).
211    pub updated_input: Option<serde_json::Value>,
212    /// All `additionalContext` strings, in plugin order.
213    pub context: Vec<String>,
214}
215
216/// Aggregate hook responses: first deny wins, last `updated_input` wins,
217/// contexts concatenate in order.
218pub fn aggregate_hook_responses(responses: Vec<HookResponse>) -> HookGate {
219    let mut gate = HookGate::default();
220    for response in responses {
221        if gate.deny.is_none()
222            && let HookDecision::Deny { reason } = &response.decision
223        {
224            gate.deny = Some((response.plugin.clone(), reason.clone()));
225        }
226        if let Some(input) = response.updated_input {
227            if gate.updated_input.is_some() {
228                tracing::warn!(
229                    plugin = %response.plugin,
230                    "multiple hooks rewrote the tool input; the last rewrite wins"
231                );
232            }
233            gate.updated_input = Some(input);
234        }
235        if let Some(context) = response.additional_context {
236            gate.context.push(context);
237        }
238    }
239    gate
240}
241
242/// Claude Code-compatible wire shapes a hook may print on stdout.
243#[derive(Debug, Deserialize)]
244struct HookWire {
245    #[serde(rename = "hookSpecificOutput")]
246    hook_specific_output: Option<HookSpecificWire>,
247    /// Legacy shape: `{"decision": "block", "reason": "..."}`.
248    decision: Option<String>,
249    reason: Option<String>,
250    #[serde(rename = "systemMessage")]
251    system_message: Option<String>,
252}
253
254#[derive(Debug, Deserialize)]
255struct HookSpecificWire {
256    #[serde(rename = "permissionDecision")]
257    permission_decision: Option<String>,
258    #[serde(rename = "permissionDecisionReason")]
259    permission_decision_reason: Option<String>,
260    /// Mermaid extension: full replacement tool-arguments object.
261    #[serde(rename = "updatedInput")]
262    updated_input: Option<serde_json::Value>,
263    /// Mermaid extension: string surfaced to the model on the next request.
264    #[serde(rename = "additionalContext")]
265    additional_context: Option<String>,
266}
267
268/// Run every enabled plugin's hooks for `event`, returning their parsed
269/// responses (empty when no plugin responds — most events, most hooks).
270/// Callers that gate on the result aggregate via [`aggregate_hook_responses`];
271/// fire-and-forget callers keep ignoring the return value.
272///
273/// # Errors
274///
275/// Only the setup: opening the runtime store, serializing `payload`, and
276/// listing the plugins. Nothing a single plugin does can fail the call — an
277/// unparseable manifest, a source directory that has been deleted, and a hook
278/// that exits nonzero are each logged and skipped. An `Ok` therefore means the
279/// hooks were dispatched, not that they all succeeded.
280pub fn run_plugin_hooks(event: &str, payload: &serde_json::Value) -> Result<Vec<HookResponse>> {
281    let store = RuntimeStore::open_default()?;
282    let payload_bytes = std::sync::Arc::new(serde_json::to_string(payload)?.into_bytes());
283    let mut responses = Vec::new();
284    for plugin in store.plugins().list()? {
285        // The enabled flag is the trust boundary: a plugin runs native hook
286        // code only after an explicit `plugin enable`. Declared capabilities are
287        // advisory and intentionally not consulted here — they cannot constrain
288        // a native child process.
289        if !plugin.enabled {
290            continue;
291        }
292        let Ok(manifest) = serde_json::from_str::<PluginManifest>(&plugin.manifest_json) else {
293            tracing::warn!(plugin = %plugin.name, "skipping plugin with unparseable manifest");
294            continue;
295        };
296        // Canonicalize the root so a symlink inside it can't be used to escape.
297        let Ok(root) = std::fs::canonicalize(&plugin.source) else {
298            tracing::warn!(plugin = %plugin.name, "plugin source missing; skipping hooks");
299            continue;
300        };
301        responses.extend(run_hooks_for_plugin(
302            &root,
303            &manifest.hooks,
304            &plugin.name,
305            event,
306            &payload_bytes,
307        ));
308    }
309    Ok(responses)
310}
311
312/// Run one plugin's declared hooks and parse each response. Extracted from
313/// [`run_plugin_hooks`] so the spawn/capture/parse path is unit-testable
314/// against a temp directory without a `RuntimeStore`.
315fn run_hooks_for_plugin(
316    root: &Path,
317    hooks: &[String],
318    plugin_name: &str,
319    event: &str,
320    payload_bytes: &std::sync::Arc<Vec<u8>>,
321) -> Vec<HookResponse> {
322    let mut responses = Vec::new();
323    for hook in hooks {
324        // Resolve the hook through symlinks and verify containment on the
325        // CANONICAL path (the old lexical `starts_with` could be escaped
326        // by a symlink inside the root pointing outside it).
327        let Ok(canonical_hook) = std::fs::canonicalize(root.join(hook)) else {
328            continue; // missing hook: nothing to run
329        };
330        if !canonical_hook.starts_with(root) {
331            tracing::warn!(plugin = %plugin_name, hook = %hook, "plugin hook escapes root; skipping");
332            continue;
333        }
334        // Execute with a SCRUBBED environment (clear + minimal allowlist)
335        // so provider API keys and MERMAID_DAEMON_TOKEN never leak into
336        // plugin-provided code. stdout/stderr are captured (capped) — a hook
337        // may answer with a decision JSON on stdout, or deny via exit code 2
338        // with the reason on stderr.
339        let spawn = Command::new(&canonical_hook)
340            .env_clear()
341            .env("PATH", std::env::var_os("PATH").unwrap_or_default())
342            .env("HOME", std::env::var_os("HOME").unwrap_or_default())
343            .env("MERMAID_HOOK_EVENT", event)
344            .env("MERMAID_PLUGIN_NAME", plugin_name)
345            .stdin(Stdio::piped())
346            .stdout(Stdio::piped())
347            .stderr(Stdio::piped())
348            .spawn();
349        let mut child = match spawn {
350            Ok(child) => child,
351            Err(err) => {
352                tracing::warn!(plugin = %plugin_name, error = %err, "failed to spawn plugin hook");
353                continue; // isolate: one bad hook must not abort the rest
354            },
355        };
356        // Write the payload on a detached thread, then let stdin drop so the
357        // hook sees EOF. Two failure modes are bounded here: a hook that
358        // reads stdin-to-EOF (the drop unblocks it), AND a hook that never
359        // reads stdin while the payload exceeds the pipe buffer (~64 KiB;
360        // checkpoint payloads embed the full file list) — a synchronous
361        // `write_all` would block forever there, and the timeout below only
362        // bounds the WAIT. The thread unblocks when the child exits or is
363        // killed on timeout (closing the pipe), so we never join it.
364        if let Some(mut stdin) = child.stdin.take() {
365            let payload = std::sync::Arc::clone(payload_bytes);
366            std::thread::spawn(move || {
367                let _ = stdin.write_all(&payload);
368            });
369        }
370        // Capped reader threads drain each stream to EOF (so a chatty hook
371        // never blocks on a full pipe) keeping only the first cap bytes.
372        // They terminate when the child exits or is killed (pipes close), so
373        // the joins after the bounded wait cannot hang.
374        let stdout_reader = child.stdout.take().map(spawn_capped_reader);
375        let stderr_reader = child.stderr.take().map(spawn_capped_reader);
376        let status = wait_hook_bounded(&mut child, plugin_name, hook, HOOK_TIMEOUT);
377        let stdout = join_reader(stdout_reader);
378        let stderr = join_reader(stderr_reader);
379        responses.push(parse_hook_output(
380            plugin_name,
381            hook,
382            &stdout,
383            &stderr,
384            status,
385        ));
386    }
387    responses
388}
389
390/// Read a hook output stream to EOF on a thread, keeping the first
391/// [`HOOK_OUTPUT_CAP`] bytes.
392fn spawn_capped_reader<R: Read + Send + 'static>(
393    mut stream: R,
394) -> std::thread::JoinHandle<Vec<u8>> {
395    std::thread::spawn(move || {
396        let mut kept = Vec::new();
397        let mut chunk = [0u8; 4096];
398        loop {
399            match stream.read(&mut chunk) {
400                Ok(0) | Err(_) => break,
401                Ok(n) => {
402                    if kept.len() < HOOK_OUTPUT_CAP {
403                        let take = n.min(HOOK_OUTPUT_CAP - kept.len());
404                        kept.extend_from_slice(&chunk[..take]);
405                    }
406                    // Past the cap: keep draining to EOF without storing.
407                },
408            }
409        }
410        kept
411    })
412}
413
414/// Join a capped reader, tolerating a panicked thread as empty output.
415fn join_reader(handle: Option<std::thread::JoinHandle<Vec<u8>>>) -> Vec<u8> {
416    handle.and_then(|h| h.join().ok()).unwrap_or_default()
417}
418
419/// Parse one hook's captured output + exit status into a [`HookResponse`].
420/// Pure — unit-tested against every accepted wire shape.
421fn parse_hook_output(
422    plugin: &str,
423    hook: &str,
424    stdout: &[u8],
425    stderr: &[u8],
426    status: Option<ExitStatus>,
427) -> HookResponse {
428    let mut response = HookResponse {
429        plugin: plugin.to_string(),
430        hook: hook.to_string(),
431        ..HookResponse::default()
432    };
433    // Timeout / kill / wait failure: infrastructure fails open.
434    let Some(status) = status else {
435        return response;
436    };
437    // Exit code 2 = deny, stderr is the reason (Claude Code parity). Any
438    // other nonzero exit is a non-blocking failure: warn + allow.
439    match status.code() {
440        Some(HOOK_DENY_EXIT_CODE) => {
441            let reason = String::from_utf8_lossy(stderr).trim().to_string();
442            response.decision = HookDecision::Deny {
443                reason: if reason.is_empty() {
444                    format!("hook exited {HOOK_DENY_EXIT_CODE}")
445                } else {
446                    reason
447                },
448            };
449            return response;
450        },
451        Some(0) => {},
452        _ => {
453            tracing::warn!(plugin = %plugin, hook = %hook, %status, "plugin hook failed");
454            return response;
455        },
456    }
457    let text = String::from_utf8_lossy(stdout);
458    let text = text.trim();
459    if text.is_empty() {
460        return response; // silent hook: no opinion
461    }
462    let Ok(wire) = serde_json::from_str::<HookWire>(text) else {
463        // Garbage stdout is an infrastructure failure: warn + allow.
464        tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook printed unparseable output; ignoring");
465        return response;
466    };
467    let mut deny_reason: Option<String> = None;
468    if let Some(specific) = wire.hook_specific_output {
469        match specific.permission_decision.as_deref() {
470            Some("deny") => {
471                deny_reason = Some(
472                    specific
473                        .permission_decision_reason
474                        .or(wire.system_message.clone())
475                        .unwrap_or_else(|| "denied by hook".to_string()),
476                );
477            },
478            // "ask" is parsed but mapped to deny: mermaid has no hook-driven
479            // confirmation modal, and allowing on an explicit "ask" would be
480            // the unsafe reading of the hook's intent.
481            Some("ask") => {
482                deny_reason = Some(format!(
483                    "{} (hook requested user confirmation, which mermaid does not support; treating as deny)",
484                    specific
485                        .permission_decision_reason
486                        .unwrap_or_else(|| "hook requested confirmation".to_string())
487                ));
488            },
489            _ => {},
490        }
491        response.updated_input = specific.updated_input;
492        response.additional_context = specific.additional_context;
493    }
494    // Legacy shape: {"decision": "block", "reason": "..."}.
495    if deny_reason.is_none() && wire.decision.as_deref() == Some("block") {
496        deny_reason = Some(
497            wire.reason
498                .or(wire.system_message)
499                .unwrap_or_else(|| "blocked by hook".to_string()),
500        );
501    }
502    if let Some(reason) = deny_reason {
503        response.decision = HookDecision::Deny { reason };
504    }
505    response
506}
507
508/// Wait for a plugin hook to exit, killing it if it overruns `timeout`.
509/// Returns the exit status, or `None` on timeout/kill/wait-failure (which the
510/// parser treats as "no opinion" — infrastructure fails open). Synchronous —
511/// the runtime crate has no async runtime; callers that must not block an
512/// executor (the `effect/` loop) wrap `run_plugin_hooks` in `spawn_blocking`.
513fn wait_hook_bounded(
514    child: &mut std::process::Child,
515    plugin: &str,
516    hook: &str,
517    timeout: Duration,
518) -> Option<ExitStatus> {
519    let deadline = Instant::now() + timeout;
520    loop {
521        match child.try_wait() {
522            Ok(Some(status)) => {
523                return Some(status);
524            },
525            Ok(None) => {
526                if Instant::now() >= deadline {
527                    let _ = child.kill();
528                    let _ = child.wait();
529                    tracing::warn!(plugin = %plugin, hook = %hook, "plugin hook timed out; killed");
530                    return None;
531                }
532                std::thread::sleep(Duration::from_millis(20));
533            },
534            Err(err) => {
535                tracing::warn!(plugin = %plugin, error = %err, "plugin hook wait failed");
536                return None;
537            },
538        }
539    }
540}
541
542fn load_plugin_manifest(path: &Path) -> Result<(PathBuf, PathBuf, PluginManifest)> {
543    let resolved = resolve_plugin_source(path)?;
544    let manifest_path = if resolved.is_dir() {
545        resolved.join("plugin.toml")
546    } else {
547        resolved
548    };
549    let root = manifest_path
550        .parent()
551        .context("plugin manifest must have a parent directory")?
552        .to_path_buf();
553    let raw = std::fs::read_to_string(&manifest_path)
554        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
555    let manifest: PluginManifest = toml::from_str(&raw)
556        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
557    Ok((manifest_path, root, manifest))
558}
559
560fn resolve_plugin_source(path: &Path) -> Result<PathBuf> {
561    if path.exists() {
562        return Ok(path.to_path_buf());
563    }
564    let source = path.to_string_lossy();
565    // Only an EXPLICIT git URL is treated as a remote source. The old bare
566    // `owner/repo` → github.com expansion turned any short string into a
567    // network fetch of attacker-named code; require the full URL instead.
568    let is_git_url = source.starts_with("https://")
569        || source.starts_with("git@")
570        || source.starts_with("ssh://")
571        || source.ends_with(".git");
572    if !is_git_url {
573        return Ok(path.to_path_buf());
574    }
575
576    // Fetching remote plugin code is a privileged operation — gate it behind
577    // an explicit opt-in so it can't be triggered silently (e.g. via the
578    // daemon's local fallback). Operators who want it set the env var.
579    anyhow::ensure!(
580        std::env::var("MERMAID_ALLOW_PLUGIN_FETCH").is_ok_and(|v| v == "1" || v == "true"),
581        "refusing to fetch remote plugin source {source:?}: set MERMAID_ALLOW_PLUGIN_FETCH=1 to allow, \
582         or clone it yourself and install from the local path",
583    );
584
585    let git_source = source.to_string();
586    let dest = data_dir()?
587        .join("plugins")
588        .join("sources")
589        .join(crate::hex_lower(&Sha256::digest(git_source.as_bytes())));
590    // `crate::git` is what keeps materializing the source from running
591    // attacker code: no repo-provided hooks, no `ext::` transports, no
592    // credential prompt to hang a background install on.
593    if dest.exists() {
594        // A refresh that fails leaves the already-cloned source in place,
595        // which is still installable — don't fail the install over it.
596        let _ = crate::git::git(&dest).args(["pull", "--ff-only"]).run();
597    } else {
598        if let Some(parent) = dest.parent() {
599            std::fs::create_dir_all(parent)?;
600        }
601        crate::git::GitCommand::new()
602            .args(["clone", "--depth", "1"])
603            .arg(&git_source)
604            .arg(&dest)
605            .run()
606            .with_context(|| format!("failed to clone plugin source {git_source}"))?;
607    }
608    Ok(dest)
609}
610
611fn ensure_relative_paths(kind: &str, paths: &[String], root: &Path) -> Result<()> {
612    for path in paths {
613        let rel = Path::new(path);
614        anyhow::ensure!(
615            !rel.is_absolute() && !path.contains(".."),
616            "{kind} path must stay inside plugin root: {path}"
617        );
618        let full = root.join(rel);
619        anyhow::ensure!(
620            full.exists(),
621            "{kind} path does not exist under plugin root: {path}"
622        );
623    }
624    Ok(())
625}
626
627#[cfg(test)]
628mod tests {
629    use super::parse_hook_output;
630    use crate::*;
631
632    #[test]
633    fn manifest_rejects_parent_escape() {
634        let root = std::env::temp_dir();
635        let manifest = PluginManifest {
636            name: "bad".to_string(),
637            version: None,
638            description: None,
639            skills: vec!["../x".to_string()],
640            agents: vec![],
641            hooks: vec![],
642            mcp: vec![],
643            capabilities: vec![],
644            prompts: vec![],
645            bin: vec![],
646        };
647        assert!(validate_plugin_manifest(&manifest, &root).is_err());
648    }
649
650    #[test]
651    fn manifest_round_trips_capabilities_field() {
652        let toml_src = r#"
653            name = "demo"
654            capabilities = ["network", "filesystem"]
655        "#;
656        let manifest: PluginManifest = toml::from_str(toml_src).expect("parse manifest");
657        assert_eq!(manifest.capabilities, vec!["network", "filesystem"]);
658        // Serializes back under the new key, and the old key is gone.
659        let json = serde_json::to_string(&manifest).expect("serialize");
660        assert!(json.contains("\"capabilities\""));
661        assert!(!json.contains("\"permissions\""));
662    }
663
664    fn wire(stdout: &str) -> HookResponse {
665        // Exit-0 with the given stdout, no stderr.
666        parse_hook_output("p", "h", stdout.as_bytes(), b"", Some(exit_status(0)))
667    }
668
669    /// Build a real `ExitStatus` with the given code (portable enough: run a
670    /// shell that exits with it).
671    fn exit_status(code: i32) -> std::process::ExitStatus {
672        #[cfg(unix)]
673        {
674            std::process::Command::new("sh")
675                .arg("-c")
676                .arg(format!("exit {code}"))
677                .status()
678                .expect("sh exit")
679        }
680        #[cfg(windows)]
681        {
682            std::process::Command::new("cmd")
683                .args(["/C", &format!("exit {code}")])
684                .status()
685                .expect("cmd exit")
686        }
687    }
688
689    #[test]
690    fn parse_permission_decision_shapes() {
691        // deny with reason
692        let r = wire(
693            r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no writes on friday"}}"#,
694        );
695        assert_eq!(
696            r.decision,
697            HookDecision::Deny {
698                reason: "no writes on friday".to_string()
699            }
700        );
701        // allow (explicit) and extensions ride along
702        let r = wire(
703            r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"ls -la"},"additionalContext":"prefer -la"}}"#,
704        );
705        assert_eq!(r.decision, HookDecision::Allow);
706        assert_eq!(r.updated_input.unwrap()["command"], "ls -la");
707        assert_eq!(r.additional_context.as_deref(), Some("prefer -la"));
708        // "ask" maps to deny with an explanation
709        let r = wire(
710            r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs review"}}"#,
711        );
712        match r.decision {
713            HookDecision::Deny { reason } => {
714                assert!(reason.contains("needs review"));
715                assert!(reason.contains("treating as deny"));
716            },
717            other => panic!("ask must deny, got {other:?}"),
718        }
719    }
720
721    #[test]
722    fn parse_legacy_block_shape_and_silent_and_garbage() {
723        let r = wire(r#"{"decision":"block","reason":"legacy nope"}"#);
724        assert_eq!(
725            r.decision,
726            HookDecision::Deny {
727                reason: "legacy nope".to_string()
728            }
729        );
730        // Empty stdout = no opinion; garbage = infrastructure fail-open.
731        assert_eq!(wire("").decision, HookDecision::Allow);
732        assert_eq!(wire("not json at all").decision, HookDecision::Allow);
733    }
734
735    #[test]
736    fn parse_exit_codes() {
737        // Exit 2 denies with stderr as the reason.
738        let r = parse_hook_output("p", "h", b"", b"policy violation\n", Some(exit_status(2)));
739        assert_eq!(
740            r.decision,
741            HookDecision::Deny {
742                reason: "policy violation".to_string()
743            }
744        );
745        // Exit 2 with empty stderr still denies (fallback reason).
746        let r = parse_hook_output("p", "h", b"", b"", Some(exit_status(2)));
747        assert!(matches!(r.decision, HookDecision::Deny { .. }));
748        // Other nonzero exits are non-blocking failures: allow.
749        let r = parse_hook_output("p", "h", b"", b"boom", Some(exit_status(1)));
750        assert_eq!(r.decision, HookDecision::Allow);
751        // Timeout/kill (no status) is infrastructure: allow.
752        let r = parse_hook_output("p", "h", b"", b"", None);
753        assert_eq!(r.decision, HookDecision::Allow);
754    }
755
756    #[test]
757    fn aggregate_first_deny_last_rewrite_ordered_context() {
758        let responses = vec![
759            HookResponse {
760                plugin: "a".into(),
761                additional_context: Some("ctx-a".into()),
762                updated_input: Some(serde_json::json!({"v": 1})),
763                ..HookResponse::default()
764            },
765            HookResponse {
766                plugin: "b".into(),
767                decision: HookDecision::Deny {
768                    reason: "first deny".into(),
769                },
770                ..HookResponse::default()
771            },
772            HookResponse {
773                plugin: "c".into(),
774                decision: HookDecision::Deny {
775                    reason: "second deny".into(),
776                },
777                updated_input: Some(serde_json::json!({"v": 2})),
778                additional_context: Some("ctx-c".into()),
779                ..HookResponse::default()
780            },
781        ];
782        let gate = aggregate_hook_responses(responses);
783        assert_eq!(gate.deny, Some(("b".to_string(), "first deny".to_string())));
784        assert_eq!(gate.updated_input.unwrap()["v"], 2);
785        assert_eq!(gate.context, vec!["ctx-a".to_string(), "ctx-c".to_string()]);
786    }
787
788    #[cfg(unix)]
789    #[test]
790    fn fixture_scripts_deny_via_json_and_exit2_and_timeout_allows() {
791        use std::os::unix::fs::PermissionsExt;
792
793        use super::run_hooks_for_plugin;
794        let dir = std::env::temp_dir().join(format!(
795            "mermaid_hook_fixtures_{}_{}",
796            std::process::id(),
797            std::time::SystemTime::now()
798                .duration_since(std::time::UNIX_EPOCH)
799                .unwrap()
800                .as_nanos()
801        ));
802        std::fs::create_dir_all(&dir).unwrap();
803        let write_script = |name: &str, body: &str| {
804            let path = dir.join(name);
805            std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
806            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
807            name.to_string()
808        };
809        let hooks = vec![
810            write_script(
811                "deny_json.sh",
812                r#"echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"json says no"}}'"#,
813            ),
814            write_script("deny_exit2.sh", "echo 'stderr says no' >&2; exit 2"),
815            write_script("silent_ok.sh", "exit 0"),
816        ];
817        let payload = std::sync::Arc::new(b"{}".to_vec());
818        let root = std::fs::canonicalize(&dir).unwrap();
819        let responses = run_hooks_for_plugin(&root, &hooks, "fixture", "before_tool_use", &payload);
820        assert_eq!(responses.len(), 3);
821        assert_eq!(
822            responses[0].decision,
823            HookDecision::Deny {
824                reason: "json says no".to_string()
825            }
826        );
827        assert_eq!(
828            responses[1].decision,
829            HookDecision::Deny {
830                reason: "stderr says no".to_string()
831            }
832        );
833        assert_eq!(responses[2].decision, HookDecision::Allow);
834        let _ = std::fs::remove_dir_all(&dir);
835    }
836
837    #[test]
838    fn hook_overrunning_timeout_is_killed() {
839        use std::time::{Duration, Instant};
840        // A hook that sleeps far past the timeout must be killed, and
841        // wait_hook_bounded must return promptly (no permanent hang).
842        #[cfg(unix)]
843        let mut child = std::process::Command::new("sh")
844            .arg("-c")
845            .arg("sleep 10")
846            .spawn()
847            .expect("spawn sleep");
848        #[cfg(windows)]
849        let mut child = std::process::Command::new("cmd")
850            .args(["/C", "ping -n 11 127.0.0.1 >NUL"])
851            .spawn()
852            .expect("spawn ping");
853        let start = Instant::now();
854        super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_millis(150));
855        assert!(
856            start.elapsed() < Duration::from_secs(3),
857            "should return promptly after killing the overrunning hook"
858        );
859    }
860
861    #[test]
862    fn hook_that_exits_quickly_returns_without_kill() {
863        use std::time::{Duration, Instant};
864        #[cfg(unix)]
865        let mut child = std::process::Command::new("true")
866            .spawn()
867            .expect("spawn true");
868        #[cfg(windows)]
869        let mut child = std::process::Command::new("cmd")
870            .args(["/C", "exit 0"])
871            .spawn()
872            .expect("spawn exit");
873        let start = Instant::now();
874        super::wait_hook_bounded(&mut child, "test", "hook", Duration::from_secs(30));
875        assert!(start.elapsed() < Duration::from_secs(5));
876    }
877}