Skip to main content

mermaid_cli/providers/tool/
exec.rs

1//! `execute_command` tool.
2//!
3//! The `ExecContext::token` races the subprocess wait in a `select!`.
4//! When the user Ctrl+C's:
5//!
6//!   1. Reducer emits `Cmd::CancelScope(turn)`.
7//!   2. Effect runner cancels the turn's scope token.
8//!   3. This tool's select! branch fires, the `Command` is dropped,
9//!      `kill_on_drop(true)` reaps the child, and `ToolOutcome::
10//!      Cancelled` flows back to the reducer.
11//!
12//! End-to-end latency: microseconds plus whatever it takes `SIGKILL`
13//! to arrive. No polling loop to "forget" to include.
14//!
15//! The dangerous-command blocklist is defense-in-depth, not a
16//! security boundary: the real boundary is the user's decision to
17//! run Mermaid with shell access. But the known destructive shapes
18//! (`rm -rf /`, fork bombs, dd to device, etc.) are cheap to catch
19//! upfront.
20
21use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::time::{Duration, Instant};
24
25use async_trait::async_trait;
26use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
27use tokio::process::Command;
28
29use crate::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
30use crate::domain::{
31    ManagedProcess, ManagedProcessStatus, ToolDefinition, ToolMetadata, ToolOutcome,
32    ToolRunMetadata,
33};
34
35use super::super::ctx::{ExecContext, ProgressEvent};
36use super::ToolExecutor;
37
38/// `execute_command` — spawn a shell, run a command, capture output.
39///
40/// Honors three escape hatches:
41/// - `ExecContext::token` (the main event): cancellation from the
42///   reducer aborts the child. This is *the* Ctrl+C fix.
43/// - `timeout` argument: model-specified per-call cap (capped at
44///   `COMMAND_MAX_TIMEOUT_SECS`). Default `COMMAND_TIMEOUT_SECS`.
45/// - Dangerous-command blocklist: refuses obvious destructive
46///   patterns before spawning.
47pub struct ExecuteCommandTool;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum CommandMode {
51    Wait,
52    Background,
53}
54
55impl CommandMode {
56    fn parse(args: &serde_json::Value) -> Result<Self, String> {
57        match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
58            "wait" | "foreground" => Ok(Self::Wait),
59            "background" => Ok(Self::Background),
60            other => Err(format!(
61                "execute_command: mode must be 'wait' or 'background', got '{}'",
62                other
63            )),
64        }
65    }
66}
67
68#[async_trait]
69impl ToolExecutor for ExecuteCommandTool {
70    fn name(&self) -> &'static str {
71        "execute_command"
72    }
73
74    fn schema(&self) -> ToolDefinition {
75        ToolDefinition {
76            name: "execute_command".to_string(),
77            description:
78                "Run a shell command. Use mode='wait' for finite commands, or mode='background' for dev servers and GUI/daemon-style commands that should keep running after the tool returns. Ctrl+C during foreground execution aborts the child immediately."
79                    .to_string(),
80            input_schema: serde_json::json!({
81                "type": "object",
82                "properties": {
83                    "command": { "type": "string", "description": "Shell command to run." },
84                    "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
85                    "mode": {
86                        "type": "string",
87                        "enum": ["wait", "background"],
88                        "default": "wait",
89                        "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
90                    },
91                    "timeout": {
92                        "type": "integer",
93                        "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
94                    },
95                    "startup_timeout_secs": {
96                        "type": "integer",
97                        "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
98                    },
99                    "ready_pattern": {
100                        "type": "string",
101                        "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
102                    },
103                    "open_url": {
104                        "type": "string",
105                        "description": "Background mode: URL to open with the default browser after startup."
106                    }
107                },
108                "required": ["command"]
109            }),
110        }
111    }
112
113    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
114        let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
115            return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
116        };
117
118        if contains_dangerous_command(command) {
119            return ToolOutcome::error(format!("Dangerous command blocked: {}", command), 0.0);
120        }
121
122        let mut policy_request = crate::runtime::ActionRequest::new(
123            "execute_command",
124            crate::runtime::ToolCategory::Shell,
125            command.to_string(),
126        );
127        policy_request.command = Some(command.to_string());
128        let pending_action = serde_json::json!({
129            "tool": "execute_command",
130            "args": args.clone(),
131            "workdir": ctx.workdir.display().to_string(),
132            "turn_id": ctx.turn.0,
133            "call_id": ctx.call_id.0,
134            "task_id": ctx.task_id.clone(),
135        });
136        // Central safety gate. An Ask decision is handled inside the gate
137        // (checkpoint + approval row + blocking outcome). Allow returns the
138        // classified risk so we can take the pre-existing Allow-path
139        // checkpoint below.
140        match super::policy_gate::gate(&ctx, policy_request, &[], pending_action.clone(), true)
141            .await
142        {
143            super::policy_gate::Gate::Block(outcome) => return outcome,
144            super::policy_gate::Gate::Proceed { risk } => {
145                if ctx.config.safety.checkpoint_on_mutation
146                    && risk != crate::runtime::RiskClass::ReadOnly
147                {
148                    let _ = crate::runtime::create_checkpoint_for_task(
149                        &ctx.workdir,
150                        &[],
151                        Some(pending_action.clone()),
152                        ctx.task_id.clone(),
153                    );
154                }
155            },
156        }
157
158        let mode = match CommandMode::parse(&args) {
159            Ok(mode) => mode,
160            Err(error) => return ToolOutcome::error(error, 0.0),
161        };
162        let working_dir = args
163            .get("working_dir")
164            .and_then(|v| v.as_str())
165            .map(|s| s.to_string());
166        let shell_payload = serde_json::json!({
167            "task_id": ctx.task_id.clone(),
168            "turn_id": ctx.turn.0,
169            "call_id": ctx.call_id.0,
170            "command": command,
171            "working_dir": working_dir.clone().unwrap_or_else(|| ctx.workdir.display().to_string()),
172        });
173        let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
174        if mode == CommandMode::Background {
175            let startup_timeout_secs = args
176                .get("startup_timeout_secs")
177                .or_else(|| args.get("startup_timeout"))
178                .and_then(|v| v.as_u64())
179                .unwrap_or(5)
180                .clamp(1, 30);
181            let ready_pattern = args
182                .get("ready_pattern")
183                .and_then(|v| v.as_str())
184                .map(str::to_string);
185            let open_url = args
186                .get("open_url")
187                .and_then(|v| v.as_str())
188                .filter(|v| !v.trim().is_empty())
189                .map(str::to_string);
190            let outcome = run_background_command(
191                command,
192                working_dir.as_deref(),
193                startup_timeout_secs,
194                ready_pattern.as_deref(),
195                open_url.as_deref(),
196                ctx,
197            )
198            .await;
199            let _ = crate::runtime::run_plugin_hooks(
200                "after_shell",
201                &serde_json::json!({
202                    "command": command,
203                    "status": format!("{:?}", outcome.status),
204                    "summary": &outcome.summary,
205                }),
206            );
207            return outcome;
208        }
209
210        let timeout_secs = args
211            .get("timeout")
212            .and_then(|v| v.as_u64())
213            .unwrap_or(COMMAND_TIMEOUT_SECS)
214            .min(COMMAND_MAX_TIMEOUT_SECS);
215
216        let command = command.to_string();
217        let start = Instant::now();
218        let progress = ctx.progress.clone();
219
220        // Spawn + wait. The select! below races three outcomes:
221        // subprocess exit, timeout, cancel.
222        let mut cmd = Command::new(if cfg!(target_os = "windows") {
223            "cmd"
224        } else {
225            "sh"
226        });
227        cmd.arg(if cfg!(target_os = "windows") { "/C" } else { "-c" })
228            .arg(&command)
229            .stdin(Stdio::null())
230            .stdout(Stdio::piped())
231            .stderr(Stdio::piped())
232            // `kill_on_drop` on the `Command` — when this
233            // Command is dropped (by select! falling through the
234            // cancel branch), tokio reaps the child. No orphans.
235            .kill_on_drop(true);
236
237        if let Some(dir) = working_dir.as_ref() {
238            cmd.current_dir(dir);
239        } else {
240            cmd.current_dir(&ctx.workdir);
241        }
242        scrub_secret_env(&mut cmd);
243
244        let run_fut = run_command(cmd, progress, ctx.background.clone());
245        let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
246
247        let outcome = tokio::select! {
248            biased;
249            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
250            _ = timeout_fut => {
251                let message = format!(
252                    "Command timed out after {} seconds and was killed. \
253                     For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
254                    timeout_secs
255                );
256                let duration_secs = start.elapsed().as_secs_f64();
257                ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
258                    CommandMetadataInput {
259                        command: command.clone(),
260                        working_dir: working_dir.clone(),
261                        exit_code: None,
262                        timed_out: true,
263                        background: false,
264                        stdout_lines: 0,
265                        stderr_lines: 0,
266                        detected_urls: Vec::new(),
267                        pid: None,
268                        log_path: None,
269                        byte_count: None,
270                    },
271                ))
272            },
273            result = run_fut => match result {
274                Ok(CommandRunResult::Completed(run)) => {
275                    let duration_secs = start.elapsed().as_secs_f64();
276                    let output_len = run.output.len();
277                    ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
278                        .with_metadata(command_metadata(
279                            CommandMetadataInput {
280                                command: command.clone(),
281                                working_dir: working_dir.clone(),
282                                exit_code: run.exit_code,
283                                timed_out: false,
284                                background: false,
285                                stdout_lines: run.stdout_lines,
286                                stderr_lines: run.stderr_lines,
287                                detected_urls: all_urls(&run.output),
288                                pid: None,
289                                log_path: None,
290                                byte_count: Some(output_len),
291                            },
292                        ))
293                },
294                Ok(CommandRunResult::Detached { pid, log_path }) => {
295                    // Ctrl+B moved this command to the background.
296                    let duration_secs = start.elapsed().as_secs_f64();
297                    let log_path_str = log_path.display().to_string();
298                    let output = format!(
299                        "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
300                    );
301                    let process = ManagedProcess {
302                        id: format!("bg-{pid}"),
303                        pid,
304                        command: command.to_string(),
305                        cwd: Some(
306                            working_dir
307                                .clone()
308                                .unwrap_or_else(|| ctx.workdir.display().to_string()),
309                        ),
310                        log_path: log_path_str.clone(),
311                        detected_url: None,
312                        status: ManagedProcessStatus::Running,
313                    };
314                    let mut metadata = command_metadata(CommandMetadataInput {
315                        command: command.to_string(),
316                        working_dir: working_dir.clone(),
317                        exit_code: None,
318                        timed_out: false,
319                        background: true,
320                        stdout_lines: 0,
321                        stderr_lines: 0,
322                        detected_urls: Vec::new(),
323                        pid: Some(pid),
324                        log_path: Some(log_path_str),
325                        byte_count: Some(output.len()),
326                    });
327                    metadata.process = Some(process);
328                    ToolOutcome::success(output, "moved to background", duration_secs)
329                        .with_metadata(metadata)
330                },
331                Err(e) => {
332                    let duration_secs = start.elapsed().as_secs_f64();
333                    ToolOutcome::error(format!("Command failed: {}", e), duration_secs)
334                        .with_metadata(command_metadata(
335                            CommandMetadataInput {
336                                command: command.clone(),
337                                working_dir: working_dir.clone(),
338                                exit_code: None,
339                                timed_out: false,
340                                background: false,
341                                stdout_lines: 0,
342                                stderr_lines: 0,
343                                detected_urls: Vec::new(),
344                                pid: None,
345                                log_path: None,
346                                byte_count: None,
347                            },
348                        ))
349                },
350            },
351        };
352        let _ = crate::runtime::run_plugin_hooks(
353            "after_shell",
354            &serde_json::json!({
355                "command": command,
356                "status": format!("{:?}", outcome.status),
357                "summary": &outcome.summary,
358            }),
359        );
360        outcome
361    }
362}
363
364#[derive(Debug)]
365struct BackgroundStartup {
366    ready_message: String,
367    log_excerpt: String,
368    detected_url: Option<String>,
369}
370
371async fn run_background_command(
372    command: &str,
373    working_dir: Option<&str>,
374    startup_timeout_secs: u64,
375    ready_pattern: Option<&str>,
376    open_url: Option<&str>,
377    ctx: ExecContext,
378) -> ToolOutcome {
379    let start = Instant::now();
380
381    {
382        let workdir = working_dir
383            .map(PathBuf::from)
384            .unwrap_or_else(|| ctx.workdir.clone());
385        let log_path = background_log_path();
386        let pid = match launch_background_process(command, &workdir, &log_path).await {
387            Ok(pid) => pid,
388            Err(error) => {
389                return ToolOutcome::error(error, start.elapsed().as_secs_f64());
390            },
391        };
392
393        let startup = match wait_for_background_startup(
394            pid,
395            &log_path,
396            startup_timeout_secs,
397            ready_pattern,
398            &ctx,
399        )
400        .await
401        {
402            Ok(startup) => startup,
403            Err(BackgroundWaitError::Cancelled) => {
404                let _ = kill_background_process(pid).await;
405                return ToolOutcome::cancelled();
406            },
407            Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
408                return ToolOutcome::error(
409                    format!(
410                        "Background command exited during startup. Log: {}\n\n{}",
411                        log_path.display(),
412                        log_excerpt
413                    ),
414                    start.elapsed().as_secs_f64(),
415                );
416            },
417        };
418
419        let opened = if let Some(url) = open_url {
420            Some((url.to_string(), open_browser_url(url).await))
421        } else {
422            None
423        };
424
425        let mut output = format!(
426            "Background command started.\nPID: {}\nLog: {}\n{}\n",
427            pid,
428            log_path.display(),
429            startup.ready_message
430        );
431        if let Some(url) = startup.detected_url.as_ref() {
432            output.push_str(&format!("Detected URL: {}\n", url));
433        }
434        if let Some((url, result)) = opened {
435            match result {
436                Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
437                Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
438            }
439        }
440        if !startup.log_excerpt.trim().is_empty() {
441            output.push_str("\n--- startup output ---\n");
442            output.push_str(&startup.log_excerpt);
443        }
444
445        let duration_secs = start.elapsed().as_secs_f64();
446        let log_path_str = log_path.display().to_string();
447        let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
448        let process = ManagedProcess {
449            id: format!("bg-{}", pid),
450            pid,
451            command: command.to_string(),
452            cwd: Some(workdir.display().to_string()),
453            log_path: log_path_str.clone(),
454            detected_url: startup.detected_url.clone(),
455            status: ManagedProcessStatus::Running,
456        };
457        let byte_count = output.len();
458        let mut metadata = command_metadata(CommandMetadataInput {
459            command: command.to_string(),
460            working_dir: working_dir.map(str::to_string),
461            exit_code: None,
462            timed_out: false,
463            background: true,
464            stdout_lines: startup.log_excerpt.lines().count(),
465            stderr_lines: 0,
466            detected_urls,
467            pid: Some(pid),
468            log_path: Some(log_path_str),
469            byte_count: Some(byte_count),
470        });
471        metadata.process = Some(process);
472        ToolOutcome::success(output, "background process started", duration_secs)
473            .with_metadata(metadata)
474    }
475}
476
477#[cfg(not(target_os = "windows"))]
478async fn launch_background_process(
479    command: &str,
480    workdir: &Path,
481    log_path: &Path,
482) -> Result<u32, String> {
483    let mut launcher = Command::new("sh");
484    launcher
485        .arg("-c")
486        .arg(
487            r#"log=$MERMAID_BG_LOG
488cmd=$MERMAID_BG_COMMAND
489: > "$log" || exit 125
490nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
491printf '%s\n' "$!""#,
492        )
493        .env("MERMAID_BG_LOG", log_path)
494        .env("MERMAID_BG_COMMAND", command)
495        .current_dir(workdir)
496        .stdin(Stdio::null())
497        .stdout(Stdio::piped())
498        .stderr(Stdio::piped());
499    scrub_secret_env(&mut launcher);
500
501    let output = launcher
502        .output()
503        .await
504        .map_err(|e| format!("failed to launch background command: {}", e))?;
505    if !output.status.success() {
506        return Err(format!(
507            "background launcher failed: {}",
508            String::from_utf8_lossy(&output.stderr)
509        ));
510    }
511    let stdout = String::from_utf8_lossy(&output.stdout);
512    stdout.trim().parse::<u32>().map_err(|e| {
513        format!(
514            "background launcher did not return a pid: {} ({})",
515            stdout, e
516        )
517    })
518}
519
520/// Windows: spawn the command detached (no console, own process group) with
521/// output redirected to the log file, and return its PID. tokio's `Child`
522/// defaults to `kill_on_drop(false)`, so dropping the handle leaves the
523/// process running — the OS owns its lifetime from here.
524#[cfg(target_os = "windows")]
525async fn launch_background_process(
526    command: &str,
527    workdir: &Path,
528    log_path: &Path,
529) -> Result<u32, String> {
530    // DETACHED_PROCESS: no inherited console. CREATE_NEW_PROCESS_GROUP: not
531    // killed when the parent gets Ctrl+C. Together: `cmd /C <command>` keeps
532    // running after the tool returns.
533    const DETACHED_PROCESS: u32 = 0x0000_0008;
534    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
535    let log = std::fs::File::create(log_path).map_err(|e| {
536        format!(
537            "failed to create background log {}: {e}",
538            log_path.display()
539        )
540    })?;
541    let log_err = log
542        .try_clone()
543        .map_err(|e| format!("failed to clone background log handle: {e}"))?;
544    let mut launcher = Command::new("cmd");
545    launcher
546        .arg("/C")
547        .arg(command)
548        .current_dir(workdir)
549        .stdin(Stdio::null())
550        .stdout(Stdio::from(log))
551        .stderr(Stdio::from(log_err))
552        .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
553    scrub_secret_env(&mut launcher);
554    let child = launcher
555        .spawn()
556        .map_err(|e| format!("failed to launch background command: {e}"))?;
557    child
558        .id()
559        .ok_or_else(|| "background command produced no pid".to_string())
560}
561
562#[derive(Debug)]
563enum BackgroundWaitError {
564    Cancelled,
565    ExitedEarly(String),
566}
567
568async fn wait_for_background_startup(
569    pid: u32,
570    log_path: &Path,
571    startup_timeout_secs: u64,
572    ready_pattern: Option<&str>,
573    ctx: &ExecContext,
574) -> Result<BackgroundStartup, BackgroundWaitError> {
575    let start = Instant::now();
576    let startup_timeout = Duration::from_secs(startup_timeout_secs);
577
578    loop {
579        if ctx.token.is_cancelled() {
580            return Err(BackgroundWaitError::Cancelled);
581        }
582
583        let last_log = read_log_lossy(log_path).await;
584        let detected_url = first_url(&last_log);
585
586        if !process_running(pid).await {
587            return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
588        }
589
590        if let Some(pattern) = ready_pattern {
591            if last_log.contains(pattern) {
592                return Ok(BackgroundStartup {
593                    ready_message: format!("Ready: matched pattern {:?}", pattern),
594                    log_excerpt: tail_lines(&last_log, 40),
595                    detected_url,
596                });
597            }
598        } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
599            return Ok(BackgroundStartup {
600                ready_message:
601                    "Ready: no ready_pattern provided; process is running after startup check"
602                        .to_string(),
603                log_excerpt: tail_lines(&last_log, 40),
604                detected_url,
605            });
606        }
607
608        if start.elapsed() >= startup_timeout {
609            let ready_message = if let Some(pattern) = ready_pattern {
610                format!(
611                    "Ready: pattern {:?} was not seen within {}s; process is still running",
612                    pattern, startup_timeout_secs
613                )
614            } else {
615                format!(
616                    "Ready: startup check reached {}s; process is still running",
617                    startup_timeout_secs
618                )
619            };
620            return Ok(BackgroundStartup {
621                ready_message,
622                log_excerpt: tail_lines(&last_log, 40),
623                detected_url,
624            });
625        }
626
627        tokio::select! {
628            _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
629            _ = tokio::time::sleep(Duration::from_millis(200)) => {},
630        }
631    }
632}
633
634async fn read_log_lossy(path: &Path) -> String {
635    tokio::fs::read_to_string(path).await.unwrap_or_default()
636}
637
638#[cfg(not(target_os = "windows"))]
639async fn process_running(pid: u32) -> bool {
640    Command::new("kill")
641        .arg("-0")
642        .arg(pid.to_string())
643        .stdin(Stdio::null())
644        .stdout(Stdio::null())
645        .stderr(Stdio::null())
646        .status()
647        .await
648        .map(|status| status.success())
649        .unwrap_or(false)
650}
651
652/// Windows: `tasklist` filtered by PID prints the process row only when it
653/// exists (otherwise an "INFO: No tasks…" line that doesn't contain the PID).
654#[cfg(target_os = "windows")]
655async fn process_running(pid: u32) -> bool {
656    Command::new("tasklist")
657        .args(["/FI", &format!("PID eq {pid}"), "/NH"])
658        .stdin(Stdio::null())
659        .stdout(Stdio::piped())
660        .stderr(Stdio::null())
661        .output()
662        .await
663        .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
664        .unwrap_or(false)
665}
666
667#[cfg(not(target_os = "windows"))]
668async fn kill_background_process(pid: u32) -> std::io::Result<()> {
669    let _ = Command::new("kill")
670        .arg(pid.to_string())
671        .stdin(Stdio::null())
672        .stdout(Stdio::null())
673        .stderr(Stdio::null())
674        .status()
675        .await?;
676    Ok(())
677}
678
679/// Windows: `taskkill /T` kills the whole tree (a `cmd /C` wrapper spawns the
680/// real child), `/F` forces it.
681#[cfg(target_os = "windows")]
682async fn kill_background_process(pid: u32) -> std::io::Result<()> {
683    let _ = Command::new("taskkill")
684        .args(["/PID", &pid.to_string(), "/T", "/F"])
685        .stdin(Stdio::null())
686        .stdout(Stdio::null())
687        .stderr(Stdio::null())
688        .status()
689        .await?;
690    Ok(())
691}
692
693fn background_log_path() -> PathBuf {
694    let nanos = std::time::SystemTime::now()
695        .duration_since(std::time::UNIX_EPOCH)
696        .map(|d| d.as_nanos())
697        .unwrap_or_default();
698    std::env::temp_dir().join(format!("mermaid-bg-{}-{}.log", std::process::id(), nanos))
699}
700
701struct CommandMetadataInput {
702    command: String,
703    working_dir: Option<String>,
704    exit_code: Option<i32>,
705    timed_out: bool,
706    background: bool,
707    stdout_lines: usize,
708    stderr_lines: usize,
709    detected_urls: Vec<String>,
710    pid: Option<u32>,
711    log_path: Option<String>,
712    byte_count: Option<usize>,
713}
714
715fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
716    ToolRunMetadata {
717        detail: ToolMetadata::ExecuteCommand {
718            command: input.command,
719            working_dir: input.working_dir,
720            exit_code: input.exit_code,
721            timed_out: input.timed_out,
722            background: input.background,
723            stdout_lines: input.stdout_lines,
724            stderr_lines: input.stderr_lines,
725            detected_urls: input.detected_urls,
726            pid: input.pid,
727            log_path: input.log_path,
728        },
729        line_count: Some(input.stdout_lines + input.stderr_lines),
730        byte_count: input.byte_count,
731        ..ToolRunMetadata::default()
732    }
733}
734
735fn tail_lines(text: &str, max_lines: usize) -> String {
736    let lines: Vec<&str> = text.lines().collect();
737    let start = lines.len().saturating_sub(max_lines);
738    lines[start..].join("\n")
739}
740
741fn first_url(text: &str) -> Option<String> {
742    text.split_whitespace()
743        .find(|part| part.starts_with("http://") || part.starts_with("https://"))
744        .map(|url| {
745            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
746                .to_string()
747        })
748}
749
750fn all_urls(text: &str) -> Vec<String> {
751    text.split_whitespace()
752        .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
753        .map(|url| {
754            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
755                .to_string()
756        })
757        .collect()
758}
759
760async fn open_browser_url(url: &str) -> Result<(), String> {
761    #[cfg(target_os = "macos")]
762    let mut command = {
763        let mut cmd = Command::new("open");
764        cmd.arg(url);
765        cmd
766    };
767
768    #[cfg(target_os = "linux")]
769    let mut command = {
770        let mut cmd = Command::new("xdg-open");
771        cmd.arg(url);
772        cmd
773    };
774
775    #[cfg(target_os = "windows")]
776    let mut command = {
777        let mut cmd = Command::new("cmd");
778        cmd.args(["/C", "start", "", url]);
779        cmd
780    };
781
782    command
783        .stdin(Stdio::null())
784        .stdout(Stdio::null())
785        .stderr(Stdio::null())
786        .kill_on_drop(false)
787        .spawn()
788        .map(|_| ())
789        .map_err(|e| e.to_string())
790}
791
792/// Drive the child process, pumping stdout+stderr concurrently so
793/// the kernel pipe buffer never wedges the child. Emits
794/// `ProgressEvent::Output` chunks on `ExecContext::progress` for
795/// any future consumer that wants to show live subprocess output.
796#[derive(Debug, Clone)]
797struct CommandRunOutput {
798    output: String,
799    exit_code: Option<i32>,
800    stdout_lines: usize,
801    stderr_lines: usize,
802}
803
804/// Result of driving a foreground command: it either ran to completion, or the
805/// user pressed Ctrl+B and it was detached to keep running in the background.
806enum CommandRunResult {
807    Completed(CommandRunOutput),
808    Detached { pid: u32, log_path: PathBuf },
809}
810
811/// Names that must never be inherited by a spawned command. Provider API
812/// keys + the daemon token live in the parent's environment; a model-driven
813/// shell command could otherwise read them via `env`/`printenv` and
814/// exfiltrate them. We strip these by exact name in addition to the
815/// pattern match in [`scrub_secret_env`].
816const SECRET_ENV_VARS: &[&str] = &[
817    "ANTHROPIC_API_KEY",
818    "OPENAI_API_KEY",
819    "GEMINI_API_KEY",
820    "GOOGLE_API_KEY",
821    "OLLAMA_API_KEY",
822    "GROQ_API_KEY",
823    "MISTRAL_API_KEY",
824    "DEEPSEEK_API_KEY",
825    "OPENROUTER_API_KEY",
826    "XAI_API_KEY",
827    "TOGETHER_API_KEY",
828    "MERMAID_DAEMON_TOKEN",
829];
830
831/// Remove secret-bearing environment variables from a child command. Uses a
832/// denylist (known provider keys + name patterns) rather than an allowlist so
833/// ordinary build/run commands keep `PATH`, `CARGO_HOME`, language toolchain
834/// vars, `XAUTHORITY`, etc. and still work.
835fn scrub_secret_env(cmd: &mut Command) {
836    for (name, _) in std::env::vars() {
837        let upper = name.to_ascii_uppercase();
838        let looks_secret = SECRET_ENV_VARS.contains(&upper.as_str())
839            || upper.contains("API_KEY")
840            || upper.contains("APIKEY")
841            || upper.contains("ACCESS_KEY")
842            || upper.contains("SECRET")
843            || upper.contains("PASSWORD")
844            || upper.contains("PASSWD")
845            || upper.contains("CREDENTIAL")
846            || upper.contains("TOKEN");
847        if looks_secret {
848            cmd.env_remove(&name);
849        }
850    }
851}
852
853/// Drain a child stream, capping the captured bytes at `cap` so a chatty or
854/// newline-less command can't exhaust memory. Bytes are accumulated raw and
855/// decoded once at the end (lossy) so a multibyte char split across reads is
856/// not corrupted by the cap. Returns `(text, truncated)`.
857async fn read_capped<R: AsyncRead + Unpin>(
858    mut reader: R,
859    cap: usize,
860    progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
861    log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
862) -> (String, bool) {
863    let mut buf = [0u8; 8192];
864    let mut bytes: Vec<u8> = Vec::new();
865    let mut truncated = false;
866    loop {
867        match reader.read(&mut buf).await {
868            Ok(0) => break,
869            Ok(n) => {
870                // Tee raw bytes to the shared log file so a backgrounded
871                // (Ctrl+B) process stays tail-able via /logs.
872                if let Some(file) = &log {
873                    let mut f = file.lock().await;
874                    let _ = f.write_all(&buf[..n]).await;
875                    let _ = f.flush().await;
876                }
877                if let Some(tx) = &progress {
878                    let chunk = String::from_utf8_lossy(&buf[..n]);
879                    for line in chunk.split('\n') {
880                        if !line.is_empty() {
881                            let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
882                        }
883                    }
884                }
885                if bytes.len() < cap {
886                    let take = (cap - bytes.len()).min(n);
887                    bytes.extend_from_slice(&buf[..take]);
888                    if take < n {
889                        truncated = true;
890                    }
891                } else {
892                    truncated = true;
893                }
894            },
895            Err(_) => break,
896        }
897    }
898    let mut out = String::from_utf8_lossy(&bytes).into_owned();
899    if truncated {
900        out.push_str(&format!("\n…[output truncated at {} bytes]…", cap));
901    }
902    (out, truncated)
903}
904
905async fn run_command(
906    mut cmd: Command,
907    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
908    background: tokio_util::sync::CancellationToken,
909) -> std::io::Result<CommandRunResult> {
910    let mut child = cmd.spawn()?;
911    let pid = child.id();
912
913    let stdout = child
914        .stdout
915        .take()
916        .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
917    let stderr = child
918        .stderr
919        .take()
920        .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
921
922    // Tee combined output to a log file so that, if the user backgrounds the
923    // command (Ctrl+B), it stays tail-able via /logs. Removed on normal exit.
924    let log_path = background_log_path();
925    let log = tokio::fs::File::create(&log_path)
926        .await
927        .ok()
928        .map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
929
930    let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
931    let stdout_task = tokio::spawn(read_capped(
932        stdout,
933        cap,
934        Some(progress.clone()),
935        log.clone(),
936    ));
937    let stderr_task = tokio::spawn(read_capped(stderr, cap, None, log.clone()));
938
939    // A driver task owns the child + drain tasks and runs to completion no
940    // matter what. On normal exit it ships the result back. If we detach, we
941    // just stop listening — the driver (and its child) keep running, the log
942    // keeps filling — until the child exits or Mermaid quits.
943    let (done_tx, done_rx) = tokio::sync::oneshot::channel();
944    let driver = tokio::spawn(async move {
945        let (output, _) = stdout_task.await.unwrap_or_default();
946        let (errors, _) = stderr_task.await.unwrap_or_default();
947        let status = child.wait().await;
948        let _ = done_tx.send((output, errors, status));
949    });
950
951    tokio::select! {
952        biased;
953        _ = background.cancelled() => {
954            // Ctrl+B: detach. Dropping `driver`'s JoinHandle does NOT abort the
955            // task — it runs on, keeping the child alive and the log filling.
956            drop(driver);
957            Ok(CommandRunResult::Detached { pid: pid.unwrap_or(0), log_path })
958        }
959        res = done_rx => {
960            // Normal completion — drop the tee log.
961            drop(log);
962            let _ = tokio::fs::remove_file(&log_path).await;
963            let (output, errors, status) = res
964                .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
965            let status = status?;
966            let stdout_lines = output.lines().count();
967            let stderr_lines = errors.lines().count();
968            let mut full_output = output;
969            if !errors.is_empty() {
970                full_output.push_str("\n--- stderr ---\n");
971                full_output.push_str(&errors);
972            }
973            if !status.success() {
974                full_output.push_str(&format!(
975                    "\n--- Command exited with status: {} ---",
976                    status.code().unwrap_or(-1)
977                ));
978            }
979            Ok(CommandRunResult::Completed(CommandRunOutput {
980                output: full_output,
981                exit_code: status.code(),
982                stdout_lines,
983                stderr_lines,
984            }))
985        }
986    }
987}
988
989/// Defense-in-depth check for obviously destructive commands. Same
990/// Applies the same patterns historically shipped with Mermaid
991/// — documented there as a blocklist, NOT a security boundary. The
992/// real boundary is the user's decision to grant shell access to the
993/// AI.
994///
995/// Known residual gaps (documented for honesty, not as bugs):
996/// - Encoded payloads (`echo ... | base64 -d | sh`).
997/// - `eval` / `exec` chains where literal `rm` never appears.
998/// - Script languages (`python -c ...`, `node -e ...`).
999/// - Nested expansions beyond `$(...)` and backticks.
1000fn contains_dangerous_command(command: &str) -> bool {
1001    let dangerous_patterns = [
1002        "rm -rf /",
1003        "rm -rf /*",
1004        "dd if=/dev/zero of=/",
1005        "dd if=/dev/random of=/",
1006        "dd if=/dev/urandom of=/",
1007        "mkfs.",
1008        "format c:",
1009        "> /dev/sda",
1010        "chmod -R 777 /",
1011        "chmod -R 000 /",
1012        ":(){ :|:& };:",
1013        ":(){ :|:&};:",
1014        "curl | bash",
1015        "curl | sh",
1016        "wget | bash",
1017        "wget | sh",
1018        "nc -l",
1019        "ncat -l",
1020        "socat tcp-listen:",
1021    ];
1022
1023    let lower = command.to_lowercase();
1024    for pattern in &dangerous_patterns {
1025        if lower.contains(pattern) {
1026            return true;
1027        }
1028    }
1029
1030    let system_dir_patterns: [(&str, bool); 10] = [
1031        ("/etc", false),
1032        ("/usr", false),
1033        ("/boot", false),
1034        ("/proc", false),
1035        ("/sys", false),
1036        ("/dev/", true),
1037        ("/home", false),
1038        ("C:\\Windows", false),
1039        ("C:\\Program Files", false),
1040        ("C:\\Users", false),
1041    ];
1042
1043    let has_rm = lower.starts_with("rm ")
1044        || lower.contains(" rm ")
1045        || lower.contains(";rm ")
1046        || lower.contains("&rm ")
1047        || lower.contains("|rm ")
1048        || lower.contains("$(rm ")
1049        || lower.contains("`rm ");
1050    let has_del = lower.starts_with("del ")
1051        || lower.contains(" del ")
1052        || lower.contains(";del ")
1053        || lower.contains("&del ")
1054        || lower.contains("$(del ")
1055        || lower.contains("`del ");
1056
1057    if has_rm || has_del {
1058        for (dir, require_trailing) in &system_dir_patterns {
1059            if *require_trailing {
1060                if command.contains(dir)
1061                    && !command.contains(&format!("{}null", dir))
1062                    && !command.contains(&format!("{}zero", dir))
1063                {
1064                    return true;
1065                }
1066            } else if command.contains(dir) {
1067                return true;
1068            }
1069        }
1070        if command.contains(" ~/")
1071            || command.ends_with(" ~")
1072            || command.contains(" ~ ")
1073            || command.contains("$HOME")
1074        {
1075            return true;
1076        }
1077    }
1078
1079    false
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::domain::{ToolCallId, TurnId};
1086    use crate::providers::ctx::test_exec_context;
1087    use std::path::PathBuf;
1088
1089    #[tokio::test]
1090    async fn safe_command_runs_and_captures_output() {
1091        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1092        let outcome = ExecuteCommandTool
1093            .execute(serde_json::json!({"command": "echo hello world"}), ctx)
1094            .await;
1095        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1096        assert!(outcome.output().contains("hello world"));
1097    }
1098
1099    #[tokio::test]
1100    async fn dangerous_command_blocked() {
1101        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1102        let outcome = ExecuteCommandTool
1103            .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1104            .await;
1105        let error = outcome.error_message().expect("expected error");
1106        assert!(error.contains("Dangerous"));
1107    }
1108
1109    #[tokio::test]
1110    async fn cancellation_aborts_long_running_command() {
1111        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1112        let token = ctx.token.clone();
1113        let handle = tokio::spawn(async move {
1114            ExecuteCommandTool
1115                .execute(serde_json::json!({"command": "sleep 10"}), ctx)
1116                .await
1117        });
1118        // Give the child a beat to spawn, then cancel.
1119        tokio::time::sleep(Duration::from_millis(30)).await;
1120        token.cancel();
1121        let start = Instant::now();
1122        // The 5s outer timeout is the real "didn't hang" guard — a propagation
1123        // regression would block until the 10s sleep, past 5s.
1124        let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
1125            .await
1126            .expect("didn't hang")
1127            .expect("join");
1128        let elapsed = start.elapsed();
1129        assert!(outcome.was_cancelled());
1130        // "Aborts promptly", not a hard sub-200ms SLA — a tight bound measured
1131        // CI scheduling / process-teardown jitter and flaked on loaded windows
1132        // runners. 2s keeps a wide margin while still catching a real hang.
1133        assert!(
1134            elapsed < Duration::from_secs(2),
1135            "cancellation took {:?} — far slower than expected (regression?)",
1136            elapsed
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn timeout_honored() {
1142        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1143        let outcome = ExecuteCommandTool
1144            .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1145            .await;
1146        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1147        let output = outcome.as_tool_message_content();
1148        assert!(output.contains("timed out"));
1149        assert!(output.contains("was killed"));
1150        assert!(output.contains("mode=\"background\""));
1151    }
1152
1153    #[cfg(not(target_os = "windows"))]
1154    #[tokio::test]
1155    async fn background_mode_returns_pid_log_and_detected_url() {
1156        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1157        let outcome = ExecuteCommandTool
1158            .execute(
1159                serde_json::json!({
1160                    "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1161                    "mode": "background",
1162                    "startup_timeout_secs": 2,
1163                    "ready_pattern": "ready"
1164                }),
1165                ctx,
1166            )
1167            .await;
1168
1169        assert!(
1170            outcome.is_success(),
1171            "expected background success: {:?}",
1172            outcome
1173        );
1174        let output = outcome.output().to_string();
1175        assert!(output.contains("Background command started"));
1176        assert!(output.contains("PID:"));
1177        assert!(output.contains("Log:"));
1178        assert!(output.contains("Ready: matched pattern"));
1179        assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1180
1181        if let Some(pid) = parse_pid(&output) {
1182            let _ = Command::new("kill").arg(pid.to_string()).status().await;
1183        }
1184    }
1185
1186    #[cfg(target_os = "windows")]
1187    #[tokio::test]
1188    async fn background_mode_returns_pid_and_log_on_windows() {
1189        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1190        let outcome = ExecuteCommandTool
1191            .execute(
1192                serde_json::json!({
1193                    "command": "echo ready & ping -n 30 127.0.0.1",
1194                    "mode": "background",
1195                    "startup_timeout_secs": 3,
1196                    "ready_pattern": "ready"
1197                }),
1198                ctx,
1199            )
1200            .await;
1201
1202        assert!(
1203            outcome.is_success(),
1204            "expected background success on Windows: {:?}",
1205            outcome
1206        );
1207        let output = outcome.output().to_string();
1208        assert!(output.contains("Background command started"));
1209        assert!(output.contains("PID:"));
1210        assert!(output.contains("Ready: matched pattern"));
1211        // The ManagedProcess must be attached so /processes lists it.
1212        assert!(
1213            outcome.metadata.process.is_some(),
1214            "background outcome must carry a ManagedProcess"
1215        );
1216
1217        // Clean up the detached process (and its child ping) via the tree kill.
1218        if let Some(pid) = parse_pid(&output) {
1219            let _ = kill_background_process(pid).await;
1220        }
1221    }
1222
1223    #[tokio::test]
1224    async fn ctrl_b_backgrounds_a_running_foreground_command() {
1225        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1226        let background = ctx.background.clone();
1227        // A command that keeps running so it's still live when we background it.
1228        let command = if cfg!(target_os = "windows") {
1229            "ping -n 30 127.0.0.1"
1230        } else {
1231            "sleep 30"
1232        };
1233
1234        // Press "Ctrl+B" shortly after the command starts.
1235        let canceller = tokio::spawn(async move {
1236            tokio::time::sleep(Duration::from_millis(300)).await;
1237            background.cancel();
1238        });
1239        let outcome = ExecuteCommandTool
1240            .execute(
1241                serde_json::json!({ "command": command, "timeout": 60 }),
1242                ctx,
1243            )
1244            .await;
1245        let _ = canceller.await;
1246
1247        assert!(
1248            outcome.is_success(),
1249            "backgrounding should yield success: {:?}",
1250            outcome
1251        );
1252        let output = outcome.output().to_string();
1253        assert!(output.contains("Moved to background"), "got: {output}");
1254        // It must register as a managed process so /processes lists it.
1255        let process = outcome.metadata.process.clone();
1256        assert!(
1257            process.is_some(),
1258            "background outcome must carry a ManagedProcess"
1259        );
1260
1261        // Clean up the still-running detached process (tree kill).
1262        if let Some(p) = process {
1263            let _ = kill_background_process(p.pid).await;
1264        }
1265    }
1266
1267    fn parse_pid(output: &str) -> Option<u32> {
1268        output
1269            .lines()
1270            .find_map(|line| line.strip_prefix("PID: "))
1271            .and_then(|pid| pid.trim().parse().ok())
1272    }
1273
1274    #[test]
1275    fn dangerous_detection_covers_known_shapes() {
1276        assert!(contains_dangerous_command("rm -rf /"));
1277        assert!(contains_dangerous_command(":(){ :|:& };:"));
1278        assert!(contains_dangerous_command("ncat -l 8080"));
1279        assert!(!contains_dangerous_command("ls -la"));
1280        assert!(!contains_dangerous_command("cargo build"));
1281        assert!(!contains_dangerous_command(
1282            r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1283        ));
1284    }
1285}