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