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