1use 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
38pub 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 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 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(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#[derive(Debug, Clone)]
707struct CommandRunOutput {
708 output: String,
709 exit_code: Option<i32>,
710 stdout_lines: usize,
711 stderr_lines: usize,
712}
713
714const 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
734fn 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
756async 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
844fn 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 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}