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::{AsyncBufReadExt, BufReader};
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 desktop 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 mode = match CommandMode::parse(&args) {
123            Ok(mode) => mode,
124            Err(error) => return ToolOutcome::error(error, 0.0),
125        };
126        let working_dir = args
127            .get("working_dir")
128            .and_then(|v| v.as_str())
129            .map(|s| s.to_string());
130        if mode == CommandMode::Background {
131            let startup_timeout_secs = args
132                .get("startup_timeout_secs")
133                .or_else(|| args.get("startup_timeout"))
134                .and_then(|v| v.as_u64())
135                .unwrap_or(5)
136                .clamp(1, 30);
137            let ready_pattern = args
138                .get("ready_pattern")
139                .and_then(|v| v.as_str())
140                .map(str::to_string);
141            let open_url = args
142                .get("open_url")
143                .and_then(|v| v.as_str())
144                .filter(|v| !v.trim().is_empty())
145                .map(str::to_string);
146            return run_background_command(
147                command,
148                working_dir.as_deref(),
149                startup_timeout_secs,
150                ready_pattern.as_deref(),
151                open_url.as_deref(),
152                ctx,
153            )
154            .await;
155        }
156
157        let timeout_secs = args
158            .get("timeout")
159            .and_then(|v| v.as_u64())
160            .unwrap_or(COMMAND_TIMEOUT_SECS)
161            .min(COMMAND_MAX_TIMEOUT_SECS);
162
163        let command = command.to_string();
164        let start = Instant::now();
165        let progress = ctx.progress.clone();
166
167        // Spawn + wait. The select! below races three outcomes:
168        // subprocess exit, timeout, cancel.
169        let mut cmd = Command::new(if cfg!(target_os = "windows") {
170            "cmd"
171        } else {
172            "sh"
173        });
174        cmd.arg(if cfg!(target_os = "windows") { "/C" } else { "-c" })
175            .arg(&command)
176            .stdin(Stdio::null())
177            .stdout(Stdio::piped())
178            .stderr(Stdio::piped())
179            // `kill_on_drop` on the `Command` — when this
180            // Command is dropped (by select! falling through the
181            // cancel branch), tokio reaps the child. No orphans.
182            .kill_on_drop(true);
183
184        if let Some(dir) = working_dir.as_ref() {
185            cmd.current_dir(dir);
186        } else {
187            cmd.current_dir(&ctx.workdir);
188        }
189
190        let run_fut = run_command(cmd, progress);
191        let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
192
193        tokio::select! {
194            biased;
195            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
196            _ = timeout_fut => {
197                let message = format!(
198                    "Command timed out after {} seconds and was killed. \
199                     For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
200                    timeout_secs
201                );
202                let duration_secs = start.elapsed().as_secs_f64();
203                ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
204                    CommandMetadataInput {
205                        command: command.clone(),
206                        working_dir: working_dir.clone(),
207                        exit_code: None,
208                        timed_out: true,
209                        background: false,
210                        stdout_lines: 0,
211                        stderr_lines: 0,
212                        detected_urls: Vec::new(),
213                        pid: None,
214                        log_path: None,
215                        byte_count: None,
216                    },
217                ))
218            },
219            result = run_fut => match result {
220                Ok(run) => {
221                    let duration_secs = start.elapsed().as_secs_f64();
222                    let output_len = run.output.len();
223                    ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
224                        .with_metadata(command_metadata(
225                            CommandMetadataInput {
226                                command: command.clone(),
227                                working_dir: working_dir.clone(),
228                                exit_code: run.exit_code,
229                                timed_out: false,
230                                background: false,
231                                stdout_lines: run.stdout_lines,
232                                stderr_lines: run.stderr_lines,
233                                detected_urls: all_urls(&run.output),
234                                pid: None,
235                                log_path: None,
236                                byte_count: Some(output_len),
237                            },
238                        ))
239                },
240                Err(e) => {
241                    let duration_secs = start.elapsed().as_secs_f64();
242                    ToolOutcome::error(format!("Command failed: {}", e), duration_secs)
243                        .with_metadata(command_metadata(
244                            CommandMetadataInput {
245                                command: command.clone(),
246                                working_dir: working_dir.clone(),
247                                exit_code: None,
248                                timed_out: false,
249                                background: false,
250                                stdout_lines: 0,
251                                stderr_lines: 0,
252                                detected_urls: Vec::new(),
253                                pid: None,
254                                log_path: None,
255                                byte_count: None,
256                            },
257                        ))
258                },
259            },
260        }
261    }
262}
263
264#[derive(Debug)]
265struct BackgroundStartup {
266    ready_message: String,
267    log_excerpt: String,
268    detected_url: Option<String>,
269}
270
271async fn run_background_command(
272    command: &str,
273    working_dir: Option<&str>,
274    startup_timeout_secs: u64,
275    ready_pattern: Option<&str>,
276    open_url: Option<&str>,
277    ctx: ExecContext,
278) -> ToolOutcome {
279    let start = Instant::now();
280
281    #[cfg(target_os = "windows")]
282    {
283        let _ = (
284            command,
285            working_dir,
286            startup_timeout_secs,
287            ready_pattern,
288            open_url,
289            ctx,
290        );
291        return ToolOutcome::error(
292            "execute_command background mode is not supported on Windows yet",
293            start.elapsed().as_secs_f64(),
294        );
295    }
296
297    #[cfg(not(target_os = "windows"))]
298    {
299        let workdir = working_dir
300            .map(PathBuf::from)
301            .unwrap_or_else(|| ctx.workdir.clone());
302        let log_path = background_log_path();
303        let pid = match launch_background_process(command, &workdir, &log_path).await {
304            Ok(pid) => pid,
305            Err(error) => {
306                return ToolOutcome::error(error, start.elapsed().as_secs_f64());
307            },
308        };
309
310        let startup = match wait_for_background_startup(
311            pid,
312            &log_path,
313            startup_timeout_secs,
314            ready_pattern,
315            &ctx,
316        )
317        .await
318        {
319            Ok(startup) => startup,
320            Err(BackgroundWaitError::Cancelled) => {
321                let _ = kill_background_process(pid).await;
322                return ToolOutcome::cancelled();
323            },
324            Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
325                return ToolOutcome::error(
326                    format!(
327                        "Background command exited during startup. Log: {}\n\n{}",
328                        log_path.display(),
329                        log_excerpt
330                    ),
331                    start.elapsed().as_secs_f64(),
332                );
333            },
334        };
335
336        let opened = if let Some(url) = open_url {
337            Some((url.to_string(), open_browser_url(url).await))
338        } else {
339            None
340        };
341
342        let mut output = format!(
343            "Background command started.\nPID: {}\nLog: {}\n{}\n",
344            pid,
345            log_path.display(),
346            startup.ready_message
347        );
348        if let Some(url) = startup.detected_url.as_ref() {
349            output.push_str(&format!("Detected URL: {}\n", url));
350        }
351        if let Some((url, result)) = opened {
352            match result {
353                Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
354                Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
355            }
356        }
357        if !startup.log_excerpt.trim().is_empty() {
358            output.push_str("\n--- startup output ---\n");
359            output.push_str(&startup.log_excerpt);
360        }
361
362        let duration_secs = start.elapsed().as_secs_f64();
363        let log_path_str = log_path.display().to_string();
364        let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
365        let process = ManagedProcess {
366            id: format!("bg-{}", pid),
367            pid,
368            command: command.to_string(),
369            cwd: Some(workdir.display().to_string()),
370            log_path: log_path_str.clone(),
371            detected_url: startup.detected_url.clone(),
372            status: ManagedProcessStatus::Running,
373        };
374        let byte_count = output.len();
375        let mut metadata = command_metadata(CommandMetadataInput {
376            command: command.to_string(),
377            working_dir: working_dir.map(str::to_string),
378            exit_code: None,
379            timed_out: false,
380            background: true,
381            stdout_lines: startup.log_excerpt.lines().count(),
382            stderr_lines: 0,
383            detected_urls,
384            pid: Some(pid),
385            log_path: Some(log_path_str),
386            byte_count: Some(byte_count),
387        });
388        metadata.process = Some(process);
389        ToolOutcome::success(output, "background process started", duration_secs)
390            .with_metadata(metadata)
391    }
392}
393
394#[cfg(not(target_os = "windows"))]
395async fn launch_background_process(
396    command: &str,
397    workdir: &Path,
398    log_path: &Path,
399) -> Result<u32, String> {
400    let mut launcher = Command::new("sh");
401    launcher
402        .arg("-c")
403        .arg(
404            r#"log=$MERMAID_BG_LOG
405cmd=$MERMAID_BG_COMMAND
406: > "$log" || exit 125
407nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
408printf '%s\n' "$!""#,
409        )
410        .env("MERMAID_BG_LOG", log_path)
411        .env("MERMAID_BG_COMMAND", command)
412        .current_dir(workdir)
413        .stdin(Stdio::null())
414        .stdout(Stdio::piped())
415        .stderr(Stdio::piped());
416
417    let output = launcher
418        .output()
419        .await
420        .map_err(|e| format!("failed to launch background command: {}", e))?;
421    if !output.status.success() {
422        return Err(format!(
423            "background launcher failed: {}",
424            String::from_utf8_lossy(&output.stderr)
425        ));
426    }
427    let stdout = String::from_utf8_lossy(&output.stdout);
428    stdout.trim().parse::<u32>().map_err(|e| {
429        format!(
430            "background launcher did not return a pid: {} ({})",
431            stdout, e
432        )
433    })
434}
435
436#[cfg(not(target_os = "windows"))]
437#[derive(Debug)]
438enum BackgroundWaitError {
439    Cancelled,
440    ExitedEarly(String),
441}
442
443#[cfg(not(target_os = "windows"))]
444async fn wait_for_background_startup(
445    pid: u32,
446    log_path: &Path,
447    startup_timeout_secs: u64,
448    ready_pattern: Option<&str>,
449    ctx: &ExecContext,
450) -> Result<BackgroundStartup, BackgroundWaitError> {
451    let start = Instant::now();
452    let startup_timeout = Duration::from_secs(startup_timeout_secs);
453
454    loop {
455        if ctx.token.is_cancelled() {
456            return Err(BackgroundWaitError::Cancelled);
457        }
458
459        let last_log = read_log_lossy(log_path).await;
460        let detected_url = first_url(&last_log);
461
462        if !process_running(pid).await {
463            return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
464        }
465
466        if let Some(pattern) = ready_pattern {
467            if last_log.contains(pattern) {
468                return Ok(BackgroundStartup {
469                    ready_message: format!("Ready: matched pattern {:?}", pattern),
470                    log_excerpt: tail_lines(&last_log, 40),
471                    detected_url,
472                });
473            }
474        } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
475            return Ok(BackgroundStartup {
476                ready_message:
477                    "Ready: no ready_pattern provided; process is running after startup check"
478                        .to_string(),
479                log_excerpt: tail_lines(&last_log, 40),
480                detected_url,
481            });
482        }
483
484        if start.elapsed() >= startup_timeout {
485            let ready_message = if let Some(pattern) = ready_pattern {
486                format!(
487                    "Ready: pattern {:?} was not seen within {}s; process is still running",
488                    pattern, startup_timeout_secs
489                )
490            } else {
491                format!(
492                    "Ready: startup check reached {}s; process is still running",
493                    startup_timeout_secs
494                )
495            };
496            return Ok(BackgroundStartup {
497                ready_message,
498                log_excerpt: tail_lines(&last_log, 40),
499                detected_url,
500            });
501        }
502
503        tokio::select! {
504            _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
505            _ = tokio::time::sleep(Duration::from_millis(200)) => {},
506        }
507    }
508}
509
510#[cfg(not(target_os = "windows"))]
511async fn read_log_lossy(path: &Path) -> String {
512    tokio::fs::read_to_string(path).await.unwrap_or_default()
513}
514
515#[cfg(not(target_os = "windows"))]
516async fn process_running(pid: u32) -> bool {
517    Command::new("kill")
518        .arg("-0")
519        .arg(pid.to_string())
520        .stdin(Stdio::null())
521        .stdout(Stdio::null())
522        .stderr(Stdio::null())
523        .status()
524        .await
525        .map(|status| status.success())
526        .unwrap_or(false)
527}
528
529#[cfg(not(target_os = "windows"))]
530async fn kill_background_process(pid: u32) -> std::io::Result<()> {
531    let _ = Command::new("kill")
532        .arg(pid.to_string())
533        .stdin(Stdio::null())
534        .stdout(Stdio::null())
535        .stderr(Stdio::null())
536        .status()
537        .await?;
538    Ok(())
539}
540
541fn background_log_path() -> PathBuf {
542    let nanos = std::time::SystemTime::now()
543        .duration_since(std::time::UNIX_EPOCH)
544        .map(|d| d.as_nanos())
545        .unwrap_or_default();
546    std::env::temp_dir().join(format!("mermaid-bg-{}-{}.log", std::process::id(), nanos))
547}
548
549struct CommandMetadataInput {
550    command: String,
551    working_dir: Option<String>,
552    exit_code: Option<i32>,
553    timed_out: bool,
554    background: bool,
555    stdout_lines: usize,
556    stderr_lines: usize,
557    detected_urls: Vec<String>,
558    pid: Option<u32>,
559    log_path: Option<String>,
560    byte_count: Option<usize>,
561}
562
563fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
564    ToolRunMetadata {
565        detail: ToolMetadata::ExecuteCommand {
566            command: input.command,
567            working_dir: input.working_dir,
568            exit_code: input.exit_code,
569            timed_out: input.timed_out,
570            background: input.background,
571            stdout_lines: input.stdout_lines,
572            stderr_lines: input.stderr_lines,
573            detected_urls: input.detected_urls,
574            pid: input.pid,
575            log_path: input.log_path,
576        },
577        line_count: Some(input.stdout_lines + input.stderr_lines),
578        byte_count: input.byte_count,
579        ..ToolRunMetadata::default()
580    }
581}
582
583fn tail_lines(text: &str, max_lines: usize) -> String {
584    let lines: Vec<&str> = text.lines().collect();
585    let start = lines.len().saturating_sub(max_lines);
586    lines[start..].join("\n")
587}
588
589fn first_url(text: &str) -> Option<String> {
590    text.split_whitespace()
591        .find(|part| part.starts_with("http://") || part.starts_with("https://"))
592        .map(|url| {
593            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
594                .to_string()
595        })
596}
597
598fn all_urls(text: &str) -> Vec<String> {
599    text.split_whitespace()
600        .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
601        .map(|url| {
602            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
603                .to_string()
604        })
605        .collect()
606}
607
608async fn open_browser_url(url: &str) -> Result<(), String> {
609    #[cfg(target_os = "macos")]
610    let mut command = {
611        let mut cmd = Command::new("open");
612        cmd.arg(url);
613        cmd
614    };
615
616    #[cfg(target_os = "linux")]
617    let mut command = {
618        let mut cmd = Command::new("xdg-open");
619        cmd.arg(url);
620        cmd
621    };
622
623    #[cfg(target_os = "windows")]
624    let mut command = {
625        let mut cmd = Command::new("cmd");
626        cmd.args(["/C", "start", "", url]);
627        cmd
628    };
629
630    command
631        .stdin(Stdio::null())
632        .stdout(Stdio::null())
633        .stderr(Stdio::null())
634        .kill_on_drop(false)
635        .spawn()
636        .map(|_| ())
637        .map_err(|e| e.to_string())
638}
639
640/// Drive the child process, pumping stdout+stderr concurrently so
641/// the kernel pipe buffer never wedges the child. Emits
642/// `ProgressEvent::Output` chunks on `ExecContext::progress` for
643/// any future consumer that wants to show live subprocess output.
644#[derive(Debug, Clone)]
645struct CommandRunOutput {
646    output: String,
647    exit_code: Option<i32>,
648    stdout_lines: usize,
649    stderr_lines: usize,
650}
651
652async fn run_command(
653    mut cmd: Command,
654    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
655) -> std::io::Result<CommandRunOutput> {
656    let mut child = cmd.spawn()?;
657
658    let stdout = child
659        .stdout
660        .take()
661        .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
662    let stderr = child
663        .stderr
664        .take()
665        .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
666
667    let progress_clone = progress.clone();
668    let stdout_task = tokio::spawn(async move {
669        let mut reader = BufReader::new(stdout).lines();
670        let mut output = String::new();
671        while let Ok(Some(line)) = reader.next_line().await {
672            let _ = progress_clone
673                .send(ProgressEvent::Output(line.clone()))
674                .await;
675            output.push_str(&line);
676            output.push('\n');
677        }
678        output
679    });
680
681    let stderr_task = tokio::spawn(async move {
682        let mut reader = BufReader::new(stderr).lines();
683        let mut errors = String::new();
684        while let Ok(Some(line)) = reader.next_line().await {
685            errors.push_str(&line);
686            errors.push('\n');
687        }
688        errors
689    });
690
691    let output = stdout_task.await.unwrap_or_default();
692    let errors = stderr_task.await.unwrap_or_default();
693    let status = child.wait().await?;
694    let stdout_lines = output.lines().count();
695    let stderr_lines = errors.lines().count();
696
697    let mut full_output = output;
698    if !errors.is_empty() {
699        full_output.push_str("\n--- stderr ---\n");
700        full_output.push_str(&errors);
701    }
702    if !status.success() {
703        full_output.push_str(&format!(
704            "\n--- Command exited with status: {} ---",
705            status.code().unwrap_or(-1)
706        ));
707    }
708    Ok(CommandRunOutput {
709        output: full_output,
710        exit_code: status.code(),
711        stdout_lines,
712        stderr_lines,
713    })
714}
715
716/// Defense-in-depth check for obviously destructive commands. Same
717/// Applies the same patterns historically shipped with Mermaid
718/// — documented there as a blocklist, NOT a security boundary. The
719/// real boundary is the user's decision to grant shell access to the
720/// AI.
721///
722/// Known residual gaps (documented for honesty, not as bugs):
723/// - Encoded payloads (`echo ... | base64 -d | sh`).
724/// - `eval` / `exec` chains where literal `rm` never appears.
725/// - Script languages (`python -c ...`, `node -e ...`).
726/// - Nested expansions beyond `$(...)` and backticks.
727fn contains_dangerous_command(command: &str) -> bool {
728    let dangerous_patterns = [
729        "rm -rf /",
730        "rm -rf /*",
731        "dd if=/dev/zero of=/",
732        "dd if=/dev/random of=/",
733        "dd if=/dev/urandom of=/",
734        "mkfs.",
735        "format c:",
736        "> /dev/sda",
737        "chmod -R 777 /",
738        "chmod -R 000 /",
739        ":(){ :|:& };:",
740        ":(){ :|:&};:",
741        "curl | bash",
742        "curl | sh",
743        "wget | bash",
744        "wget | sh",
745        "nc -l",
746        "ncat -l",
747        "socat tcp-listen:",
748    ];
749
750    let lower = command.to_lowercase();
751    for pattern in &dangerous_patterns {
752        if lower.contains(pattern) {
753            return true;
754        }
755    }
756
757    let system_dir_patterns: [(&str, bool); 10] = [
758        ("/etc", false),
759        ("/usr", false),
760        ("/boot", false),
761        ("/proc", false),
762        ("/sys", false),
763        ("/dev/", true),
764        ("/home", false),
765        ("C:\\Windows", false),
766        ("C:\\Program Files", false),
767        ("C:\\Users", false),
768    ];
769
770    let has_rm = lower.starts_with("rm ")
771        || lower.contains(" rm ")
772        || lower.contains(";rm ")
773        || lower.contains("&rm ")
774        || lower.contains("|rm ")
775        || lower.contains("$(rm ")
776        || lower.contains("`rm ");
777    let has_del = lower.starts_with("del ")
778        || lower.contains(" del ")
779        || lower.contains(";del ")
780        || lower.contains("&del ")
781        || lower.contains("$(del ")
782        || lower.contains("`del ");
783
784    if has_rm || has_del {
785        for (dir, require_trailing) in &system_dir_patterns {
786            if *require_trailing {
787                if command.contains(dir)
788                    && !command.contains(&format!("{}null", dir))
789                    && !command.contains(&format!("{}zero", dir))
790                {
791                    return true;
792                }
793            } else if command.contains(dir) {
794                return true;
795            }
796        }
797        if command.contains(" ~/")
798            || command.ends_with(" ~")
799            || command.contains(" ~ ")
800            || command.contains("$HOME")
801        {
802            return true;
803        }
804    }
805
806    false
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::domain::{ToolCallId, TurnId};
813    use crate::providers::ctx::test_exec_context;
814    use std::path::PathBuf;
815
816    #[tokio::test]
817    async fn safe_command_runs_and_captures_output() {
818        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
819        let outcome = ExecuteCommandTool
820            .execute(serde_json::json!({"command": "echo hello world"}), ctx)
821            .await;
822        assert!(outcome.is_success(), "expected success: {:?}", outcome);
823        assert!(outcome.output().contains("hello world"));
824    }
825
826    #[tokio::test]
827    async fn dangerous_command_blocked() {
828        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
829        let outcome = ExecuteCommandTool
830            .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
831            .await;
832        let error = outcome.error_message().expect("expected error");
833        assert!(error.contains("Dangerous"));
834    }
835
836    #[tokio::test]
837    async fn cancellation_aborts_long_running_command() {
838        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
839        let token = ctx.token.clone();
840        let handle = tokio::spawn(async move {
841            ExecuteCommandTool
842                .execute(serde_json::json!({"command": "sleep 10"}), ctx)
843                .await
844        });
845        // Give the child a beat to spawn, then cancel.
846        tokio::time::sleep(Duration::from_millis(30)).await;
847        token.cancel();
848        let start = Instant::now();
849        let outcome = tokio::time::timeout(Duration::from_millis(500), handle)
850            .await
851            .expect("didn't hang")
852            .expect("join");
853        let elapsed = start.elapsed();
854        assert!(outcome.was_cancelled());
855        assert!(
856            elapsed < Duration::from_millis(200),
857            "cancellation took {:?}",
858            elapsed
859        );
860    }
861
862    #[tokio::test]
863    async fn timeout_honored() {
864        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
865        let outcome = ExecuteCommandTool
866            .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
867            .await;
868        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
869        let output = outcome.as_tool_message_content();
870        assert!(output.contains("timed out"));
871        assert!(output.contains("was killed"));
872        assert!(output.contains("mode=\"background\""));
873    }
874
875    #[cfg(not(target_os = "windows"))]
876    #[tokio::test]
877    async fn background_mode_returns_pid_log_and_detected_url() {
878        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
879        let outcome = ExecuteCommandTool
880            .execute(
881                serde_json::json!({
882                    "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
883                    "mode": "background",
884                    "startup_timeout_secs": 2,
885                    "ready_pattern": "ready"
886                }),
887                ctx,
888            )
889            .await;
890
891        assert!(
892            outcome.is_success(),
893            "expected background success: {:?}",
894            outcome
895        );
896        let output = outcome.output().to_string();
897        assert!(output.contains("Background command started"));
898        assert!(output.contains("PID:"));
899        assert!(output.contains("Log:"));
900        assert!(output.contains("Ready: matched pattern"));
901        assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
902
903        if let Some(pid) = parse_pid(&output) {
904            let _ = Command::new("kill").arg(pid.to_string()).status().await;
905        }
906    }
907
908    fn parse_pid(output: &str) -> Option<u32> {
909        output
910            .lines()
911            .find_map(|line| line.strip_prefix("PID: "))
912            .and_then(|pid| pid.trim().parse().ok())
913    }
914
915    #[test]
916    fn dangerous_detection_covers_known_shapes() {
917        assert!(contains_dangerous_command("rm -rf /"));
918        assert!(contains_dangerous_command(":(){ :|:& };:"));
919        assert!(contains_dangerous_command("ncat -l 8080"));
920        assert!(!contains_dangerous_command("ls -la"));
921        assert!(!contains_dangerous_command("cargo build"));
922        assert!(!contains_dangerous_command(
923            r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
924        ));
925    }
926}