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};
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);
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(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                Err(e) => {
295                    let duration_secs = start.elapsed().as_secs_f64();
296                    ToolOutcome::error(format!("Command failed: {}", e), duration_secs)
297                        .with_metadata(command_metadata(
298                            CommandMetadataInput {
299                                command: command.clone(),
300                                working_dir: working_dir.clone(),
301                                exit_code: None,
302                                timed_out: false,
303                                background: false,
304                                stdout_lines: 0,
305                                stderr_lines: 0,
306                                detected_urls: Vec::new(),
307                                pid: None,
308                                log_path: None,
309                                byte_count: None,
310                            },
311                        ))
312                },
313            },
314        };
315        let _ = crate::runtime::run_plugin_hooks(
316            "after_shell",
317            &serde_json::json!({
318                "command": command,
319                "status": format!("{:?}", outcome.status),
320                "summary": &outcome.summary,
321            }),
322        );
323        outcome
324    }
325}
326
327#[derive(Debug)]
328struct BackgroundStartup {
329    ready_message: String,
330    log_excerpt: String,
331    detected_url: Option<String>,
332}
333
334async fn run_background_command(
335    command: &str,
336    working_dir: Option<&str>,
337    startup_timeout_secs: u64,
338    ready_pattern: Option<&str>,
339    open_url: Option<&str>,
340    ctx: ExecContext,
341) -> ToolOutcome {
342    let start = Instant::now();
343
344    #[cfg(target_os = "windows")]
345    {
346        let _ = (
347            command,
348            working_dir,
349            startup_timeout_secs,
350            ready_pattern,
351            open_url,
352            ctx,
353        );
354        return ToolOutcome::error(
355            "execute_command background mode is not supported on Windows yet",
356            start.elapsed().as_secs_f64(),
357        );
358    }
359
360    #[cfg(not(target_os = "windows"))]
361    {
362        let workdir = working_dir
363            .map(PathBuf::from)
364            .unwrap_or_else(|| ctx.workdir.clone());
365        let log_path = background_log_path();
366        let pid = match launch_background_process(command, &workdir, &log_path).await {
367            Ok(pid) => pid,
368            Err(error) => {
369                return ToolOutcome::error(error, start.elapsed().as_secs_f64());
370            },
371        };
372
373        let startup = match wait_for_background_startup(
374            pid,
375            &log_path,
376            startup_timeout_secs,
377            ready_pattern,
378            &ctx,
379        )
380        .await
381        {
382            Ok(startup) => startup,
383            Err(BackgroundWaitError::Cancelled) => {
384                let _ = kill_background_process(pid).await;
385                return ToolOutcome::cancelled();
386            },
387            Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
388                return ToolOutcome::error(
389                    format!(
390                        "Background command exited during startup. Log: {}\n\n{}",
391                        log_path.display(),
392                        log_excerpt
393                    ),
394                    start.elapsed().as_secs_f64(),
395                );
396            },
397        };
398
399        let opened = if let Some(url) = open_url {
400            Some((url.to_string(), open_browser_url(url).await))
401        } else {
402            None
403        };
404
405        let mut output = format!(
406            "Background command started.\nPID: {}\nLog: {}\n{}\n",
407            pid,
408            log_path.display(),
409            startup.ready_message
410        );
411        if let Some(url) = startup.detected_url.as_ref() {
412            output.push_str(&format!("Detected URL: {}\n", url));
413        }
414        if let Some((url, result)) = opened {
415            match result {
416                Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
417                Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
418            }
419        }
420        if !startup.log_excerpt.trim().is_empty() {
421            output.push_str("\n--- startup output ---\n");
422            output.push_str(&startup.log_excerpt);
423        }
424
425        let duration_secs = start.elapsed().as_secs_f64();
426        let log_path_str = log_path.display().to_string();
427        let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
428        let process = ManagedProcess {
429            id: format!("bg-{}", pid),
430            pid,
431            command: command.to_string(),
432            cwd: Some(workdir.display().to_string()),
433            log_path: log_path_str.clone(),
434            detected_url: startup.detected_url.clone(),
435            status: ManagedProcessStatus::Running,
436        };
437        let byte_count = output.len();
438        let mut metadata = command_metadata(CommandMetadataInput {
439            command: command.to_string(),
440            working_dir: working_dir.map(str::to_string),
441            exit_code: None,
442            timed_out: false,
443            background: true,
444            stdout_lines: startup.log_excerpt.lines().count(),
445            stderr_lines: 0,
446            detected_urls,
447            pid: Some(pid),
448            log_path: Some(log_path_str),
449            byte_count: Some(byte_count),
450        });
451        metadata.process = Some(process);
452        ToolOutcome::success(output, "background process started", duration_secs)
453            .with_metadata(metadata)
454    }
455}
456
457#[cfg(not(target_os = "windows"))]
458async fn launch_background_process(
459    command: &str,
460    workdir: &Path,
461    log_path: &Path,
462) -> Result<u32, String> {
463    let mut launcher = Command::new("sh");
464    launcher
465        .arg("-c")
466        .arg(
467            r#"log=$MERMAID_BG_LOG
468cmd=$MERMAID_BG_COMMAND
469: > "$log" || exit 125
470nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
471printf '%s\n' "$!""#,
472        )
473        .env("MERMAID_BG_LOG", log_path)
474        .env("MERMAID_BG_COMMAND", command)
475        .current_dir(workdir)
476        .stdin(Stdio::null())
477        .stdout(Stdio::piped())
478        .stderr(Stdio::piped());
479    scrub_secret_env(&mut launcher);
480
481    let output = launcher
482        .output()
483        .await
484        .map_err(|e| format!("failed to launch background command: {}", e))?;
485    if !output.status.success() {
486        return Err(format!(
487            "background launcher failed: {}",
488            String::from_utf8_lossy(&output.stderr)
489        ));
490    }
491    let stdout = String::from_utf8_lossy(&output.stdout);
492    stdout.trim().parse::<u32>().map_err(|e| {
493        format!(
494            "background launcher did not return a pid: {} ({})",
495            stdout, e
496        )
497    })
498}
499
500#[cfg(not(target_os = "windows"))]
501#[derive(Debug)]
502enum BackgroundWaitError {
503    Cancelled,
504    ExitedEarly(String),
505}
506
507#[cfg(not(target_os = "windows"))]
508async fn wait_for_background_startup(
509    pid: u32,
510    log_path: &Path,
511    startup_timeout_secs: u64,
512    ready_pattern: Option<&str>,
513    ctx: &ExecContext,
514) -> Result<BackgroundStartup, BackgroundWaitError> {
515    let start = Instant::now();
516    let startup_timeout = Duration::from_secs(startup_timeout_secs);
517
518    loop {
519        if ctx.token.is_cancelled() {
520            return Err(BackgroundWaitError::Cancelled);
521        }
522
523        let last_log = read_log_lossy(log_path).await;
524        let detected_url = first_url(&last_log);
525
526        if !process_running(pid).await {
527            return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
528        }
529
530        if let Some(pattern) = ready_pattern {
531            if last_log.contains(pattern) {
532                return Ok(BackgroundStartup {
533                    ready_message: format!("Ready: matched pattern {:?}", pattern),
534                    log_excerpt: tail_lines(&last_log, 40),
535                    detected_url,
536                });
537            }
538        } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
539            return Ok(BackgroundStartup {
540                ready_message:
541                    "Ready: no ready_pattern provided; process is running after startup check"
542                        .to_string(),
543                log_excerpt: tail_lines(&last_log, 40),
544                detected_url,
545            });
546        }
547
548        if start.elapsed() >= startup_timeout {
549            let ready_message = if let Some(pattern) = ready_pattern {
550                format!(
551                    "Ready: pattern {:?} was not seen within {}s; process is still running",
552                    pattern, startup_timeout_secs
553                )
554            } else {
555                format!(
556                    "Ready: startup check reached {}s; process is still running",
557                    startup_timeout_secs
558                )
559            };
560            return Ok(BackgroundStartup {
561                ready_message,
562                log_excerpt: tail_lines(&last_log, 40),
563                detected_url,
564            });
565        }
566
567        tokio::select! {
568            _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
569            _ = tokio::time::sleep(Duration::from_millis(200)) => {},
570        }
571    }
572}
573
574#[cfg(not(target_os = "windows"))]
575async fn read_log_lossy(path: &Path) -> String {
576    tokio::fs::read_to_string(path).await.unwrap_or_default()
577}
578
579#[cfg(not(target_os = "windows"))]
580async fn process_running(pid: u32) -> bool {
581    Command::new("kill")
582        .arg("-0")
583        .arg(pid.to_string())
584        .stdin(Stdio::null())
585        .stdout(Stdio::null())
586        .stderr(Stdio::null())
587        .status()
588        .await
589        .map(|status| status.success())
590        .unwrap_or(false)
591}
592
593#[cfg(not(target_os = "windows"))]
594async fn kill_background_process(pid: u32) -> std::io::Result<()> {
595    let _ = Command::new("kill")
596        .arg(pid.to_string())
597        .stdin(Stdio::null())
598        .stdout(Stdio::null())
599        .stderr(Stdio::null())
600        .status()
601        .await?;
602    Ok(())
603}
604
605fn background_log_path() -> PathBuf {
606    let nanos = std::time::SystemTime::now()
607        .duration_since(std::time::UNIX_EPOCH)
608        .map(|d| d.as_nanos())
609        .unwrap_or_default();
610    std::env::temp_dir().join(format!("mermaid-bg-{}-{}.log", std::process::id(), nanos))
611}
612
613struct CommandMetadataInput {
614    command: String,
615    working_dir: Option<String>,
616    exit_code: Option<i32>,
617    timed_out: bool,
618    background: bool,
619    stdout_lines: usize,
620    stderr_lines: usize,
621    detected_urls: Vec<String>,
622    pid: Option<u32>,
623    log_path: Option<String>,
624    byte_count: Option<usize>,
625}
626
627fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
628    ToolRunMetadata {
629        detail: ToolMetadata::ExecuteCommand {
630            command: input.command,
631            working_dir: input.working_dir,
632            exit_code: input.exit_code,
633            timed_out: input.timed_out,
634            background: input.background,
635            stdout_lines: input.stdout_lines,
636            stderr_lines: input.stderr_lines,
637            detected_urls: input.detected_urls,
638            pid: input.pid,
639            log_path: input.log_path,
640        },
641        line_count: Some(input.stdout_lines + input.stderr_lines),
642        byte_count: input.byte_count,
643        ..ToolRunMetadata::default()
644    }
645}
646
647fn tail_lines(text: &str, max_lines: usize) -> String {
648    let lines: Vec<&str> = text.lines().collect();
649    let start = lines.len().saturating_sub(max_lines);
650    lines[start..].join("\n")
651}
652
653fn first_url(text: &str) -> Option<String> {
654    text.split_whitespace()
655        .find(|part| part.starts_with("http://") || part.starts_with("https://"))
656        .map(|url| {
657            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
658                .to_string()
659        })
660}
661
662fn all_urls(text: &str) -> Vec<String> {
663    text.split_whitespace()
664        .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
665        .map(|url| {
666            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
667                .to_string()
668        })
669        .collect()
670}
671
672async fn open_browser_url(url: &str) -> Result<(), String> {
673    #[cfg(target_os = "macos")]
674    let mut command = {
675        let mut cmd = Command::new("open");
676        cmd.arg(url);
677        cmd
678    };
679
680    #[cfg(target_os = "linux")]
681    let mut command = {
682        let mut cmd = Command::new("xdg-open");
683        cmd.arg(url);
684        cmd
685    };
686
687    #[cfg(target_os = "windows")]
688    let mut command = {
689        let mut cmd = Command::new("cmd");
690        cmd.args(["/C", "start", "", url]);
691        cmd
692    };
693
694    command
695        .stdin(Stdio::null())
696        .stdout(Stdio::null())
697        .stderr(Stdio::null())
698        .kill_on_drop(false)
699        .spawn()
700        .map(|_| ())
701        .map_err(|e| e.to_string())
702}
703
704/// Drive the child process, pumping stdout+stderr concurrently so
705/// the kernel pipe buffer never wedges the child. Emits
706/// `ProgressEvent::Output` chunks on `ExecContext::progress` for
707/// any future consumer that wants to show live subprocess output.
708#[derive(Debug, Clone)]
709struct CommandRunOutput {
710    output: String,
711    exit_code: Option<i32>,
712    stdout_lines: usize,
713    stderr_lines: usize,
714}
715
716/// Names that must never be inherited by a spawned command. Provider API
717/// keys + the daemon token live in the parent's environment; a model-driven
718/// shell command could otherwise read them via `env`/`printenv` and
719/// exfiltrate them. We strip these by exact name in addition to the
720/// pattern match in [`scrub_secret_env`].
721const SECRET_ENV_VARS: &[&str] = &[
722    "ANTHROPIC_API_KEY",
723    "OPENAI_API_KEY",
724    "GEMINI_API_KEY",
725    "GOOGLE_API_KEY",
726    "OLLAMA_API_KEY",
727    "GROQ_API_KEY",
728    "MISTRAL_API_KEY",
729    "DEEPSEEK_API_KEY",
730    "OPENROUTER_API_KEY",
731    "XAI_API_KEY",
732    "TOGETHER_API_KEY",
733    "MERMAID_DAEMON_TOKEN",
734];
735
736/// Remove secret-bearing environment variables from a child command. Uses a
737/// denylist (known provider keys + name patterns) rather than an allowlist so
738/// ordinary build/run commands keep `PATH`, `CARGO_HOME`, language toolchain
739/// vars, `XAUTHORITY`, etc. and still work.
740fn scrub_secret_env(cmd: &mut Command) {
741    for (name, _) in std::env::vars() {
742        let upper = name.to_ascii_uppercase();
743        let looks_secret = SECRET_ENV_VARS.contains(&upper.as_str())
744            || upper.contains("API_KEY")
745            || upper.contains("APIKEY")
746            || upper.contains("ACCESS_KEY")
747            || upper.contains("SECRET")
748            || upper.contains("PASSWORD")
749            || upper.contains("PASSWD")
750            || upper.contains("CREDENTIAL")
751            || upper.contains("TOKEN");
752        if looks_secret {
753            cmd.env_remove(&name);
754        }
755    }
756}
757
758/// Drain a child stream, capping the captured bytes at `cap` so a chatty or
759/// newline-less command can't exhaust memory. Bytes are accumulated raw and
760/// decoded once at the end (lossy) so a multibyte char split across reads is
761/// not corrupted by the cap. Returns `(text, truncated)`.
762async fn read_capped<R: AsyncRead + Unpin>(
763    mut reader: R,
764    cap: usize,
765    progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
766) -> (String, bool) {
767    let mut buf = [0u8; 8192];
768    let mut bytes: Vec<u8> = Vec::new();
769    let mut truncated = false;
770    loop {
771        match reader.read(&mut buf).await {
772            Ok(0) => break,
773            Ok(n) => {
774                if let Some(tx) = &progress {
775                    let chunk = String::from_utf8_lossy(&buf[..n]);
776                    for line in chunk.split('\n') {
777                        if !line.is_empty() {
778                            let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
779                        }
780                    }
781                }
782                if bytes.len() < cap {
783                    let take = (cap - bytes.len()).min(n);
784                    bytes.extend_from_slice(&buf[..take]);
785                    if take < n {
786                        truncated = true;
787                    }
788                } else {
789                    truncated = true;
790                }
791            },
792            Err(_) => break,
793        }
794    }
795    let mut out = String::from_utf8_lossy(&bytes).into_owned();
796    if truncated {
797        out.push_str(&format!("\n…[output truncated at {} bytes]…", cap));
798    }
799    (out, truncated)
800}
801
802async fn run_command(
803    mut cmd: Command,
804    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
805) -> std::io::Result<CommandRunOutput> {
806    let mut child = cmd.spawn()?;
807
808    let stdout = child
809        .stdout
810        .take()
811        .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
812    let stderr = child
813        .stderr
814        .take()
815        .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
816
817    let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
818    let stdout_task = tokio::spawn(read_capped(stdout, cap, Some(progress.clone())));
819    let stderr_task = tokio::spawn(read_capped(stderr, cap, None));
820
821    let (output, _) = stdout_task.await.unwrap_or_default();
822    let (errors, _) = stderr_task.await.unwrap_or_default();
823    let status = child.wait().await?;
824    let stdout_lines = output.lines().count();
825    let stderr_lines = errors.lines().count();
826
827    let mut full_output = output;
828    if !errors.is_empty() {
829        full_output.push_str("\n--- stderr ---\n");
830        full_output.push_str(&errors);
831    }
832    if !status.success() {
833        full_output.push_str(&format!(
834            "\n--- Command exited with status: {} ---",
835            status.code().unwrap_or(-1)
836        ));
837    }
838    Ok(CommandRunOutput {
839        output: full_output,
840        exit_code: status.code(),
841        stdout_lines,
842        stderr_lines,
843    })
844}
845
846/// Defense-in-depth check for obviously destructive commands. Same
847/// Applies the same patterns historically shipped with Mermaid
848/// — documented there as a blocklist, NOT a security boundary. The
849/// real boundary is the user's decision to grant shell access to the
850/// AI.
851///
852/// Known residual gaps (documented for honesty, not as bugs):
853/// - Encoded payloads (`echo ... | base64 -d | sh`).
854/// - `eval` / `exec` chains where literal `rm` never appears.
855/// - Script languages (`python -c ...`, `node -e ...`).
856/// - Nested expansions beyond `$(...)` and backticks.
857fn contains_dangerous_command(command: &str) -> bool {
858    let dangerous_patterns = [
859        "rm -rf /",
860        "rm -rf /*",
861        "dd if=/dev/zero of=/",
862        "dd if=/dev/random of=/",
863        "dd if=/dev/urandom of=/",
864        "mkfs.",
865        "format c:",
866        "> /dev/sda",
867        "chmod -R 777 /",
868        "chmod -R 000 /",
869        ":(){ :|:& };:",
870        ":(){ :|:&};:",
871        "curl | bash",
872        "curl | sh",
873        "wget | bash",
874        "wget | sh",
875        "nc -l",
876        "ncat -l",
877        "socat tcp-listen:",
878    ];
879
880    let lower = command.to_lowercase();
881    for pattern in &dangerous_patterns {
882        if lower.contains(pattern) {
883            return true;
884        }
885    }
886
887    let system_dir_patterns: [(&str, bool); 10] = [
888        ("/etc", false),
889        ("/usr", false),
890        ("/boot", false),
891        ("/proc", false),
892        ("/sys", false),
893        ("/dev/", true),
894        ("/home", false),
895        ("C:\\Windows", false),
896        ("C:\\Program Files", false),
897        ("C:\\Users", false),
898    ];
899
900    let has_rm = lower.starts_with("rm ")
901        || lower.contains(" rm ")
902        || lower.contains(";rm ")
903        || lower.contains("&rm ")
904        || lower.contains("|rm ")
905        || lower.contains("$(rm ")
906        || lower.contains("`rm ");
907    let has_del = lower.starts_with("del ")
908        || lower.contains(" del ")
909        || lower.contains(";del ")
910        || lower.contains("&del ")
911        || lower.contains("$(del ")
912        || lower.contains("`del ");
913
914    if has_rm || has_del {
915        for (dir, require_trailing) in &system_dir_patterns {
916            if *require_trailing {
917                if command.contains(dir)
918                    && !command.contains(&format!("{}null", dir))
919                    && !command.contains(&format!("{}zero", dir))
920                {
921                    return true;
922                }
923            } else if command.contains(dir) {
924                return true;
925            }
926        }
927        if command.contains(" ~/")
928            || command.ends_with(" ~")
929            || command.contains(" ~ ")
930            || command.contains("$HOME")
931        {
932            return true;
933        }
934    }
935
936    false
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::domain::{ToolCallId, TurnId};
943    use crate::providers::ctx::test_exec_context;
944    use std::path::PathBuf;
945
946    #[tokio::test]
947    async fn safe_command_runs_and_captures_output() {
948        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
949        let outcome = ExecuteCommandTool
950            .execute(serde_json::json!({"command": "echo hello world"}), ctx)
951            .await;
952        assert!(outcome.is_success(), "expected success: {:?}", outcome);
953        assert!(outcome.output().contains("hello world"));
954    }
955
956    #[tokio::test]
957    async fn dangerous_command_blocked() {
958        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
959        let outcome = ExecuteCommandTool
960            .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
961            .await;
962        let error = outcome.error_message().expect("expected error");
963        assert!(error.contains("Dangerous"));
964    }
965
966    #[tokio::test]
967    async fn cancellation_aborts_long_running_command() {
968        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
969        let token = ctx.token.clone();
970        let handle = tokio::spawn(async move {
971            ExecuteCommandTool
972                .execute(serde_json::json!({"command": "sleep 10"}), ctx)
973                .await
974        });
975        // Give the child a beat to spawn, then cancel.
976        tokio::time::sleep(Duration::from_millis(30)).await;
977        token.cancel();
978        let start = Instant::now();
979        let outcome = tokio::time::timeout(Duration::from_millis(500), handle)
980            .await
981            .expect("didn't hang")
982            .expect("join");
983        let elapsed = start.elapsed();
984        assert!(outcome.was_cancelled());
985        assert!(
986            elapsed < Duration::from_millis(200),
987            "cancellation took {:?}",
988            elapsed
989        );
990    }
991
992    #[tokio::test]
993    async fn timeout_honored() {
994        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
995        let outcome = ExecuteCommandTool
996            .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
997            .await;
998        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
999        let output = outcome.as_tool_message_content();
1000        assert!(output.contains("timed out"));
1001        assert!(output.contains("was killed"));
1002        assert!(output.contains("mode=\"background\""));
1003    }
1004
1005    #[cfg(not(target_os = "windows"))]
1006    #[tokio::test]
1007    async fn background_mode_returns_pid_log_and_detected_url() {
1008        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1009        let outcome = ExecuteCommandTool
1010            .execute(
1011                serde_json::json!({
1012                    "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1013                    "mode": "background",
1014                    "startup_timeout_secs": 2,
1015                    "ready_pattern": "ready"
1016                }),
1017                ctx,
1018            )
1019            .await;
1020
1021        assert!(
1022            outcome.is_success(),
1023            "expected background success: {:?}",
1024            outcome
1025        );
1026        let output = outcome.output().to_string();
1027        assert!(output.contains("Background command started"));
1028        assert!(output.contains("PID:"));
1029        assert!(output.contains("Log:"));
1030        assert!(output.contains("Ready: matched pattern"));
1031        assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1032
1033        if let Some(pid) = parse_pid(&output) {
1034            let _ = Command::new("kill").arg(pid.to_string()).status().await;
1035        }
1036    }
1037
1038    fn parse_pid(output: &str) -> Option<u32> {
1039        output
1040            .lines()
1041            .find_map(|line| line.strip_prefix("PID: "))
1042            .and_then(|pid| pid.trim().parse().ok())
1043    }
1044
1045    #[test]
1046    fn dangerous_detection_covers_known_shapes() {
1047        assert!(contains_dangerous_command("rm -rf /"));
1048        assert!(contains_dangerous_command(":(){ :|:& };:"));
1049        assert!(contains_dangerous_command("ncat -l 8080"));
1050        assert!(!contains_dangerous_command("ls -la"));
1051        assert!(!contains_dangerous_command("cargo build"));
1052        assert!(!contains_dangerous_command(
1053            r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1054        ));
1055    }
1056}