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