Skip to main content

mermaid_cli/providers/tool/
exec.rs

1//! `execute_command` tool.
2//!
3//! The `ExecContext::token` races the subprocess wait in a `select!`.
4//! When the user Ctrl+C's:
5//!
6//!   1. Reducer emits `Cmd::CancelScope(turn)`.
7//!   2. Effect runner cancels the turn's scope token.
8//!   3. `run_command`'s cancel branch fires, `terminate_tree` SIGKILLs
9//!      the child's whole process group, the driver is aborted, and
10//!      `ToolOutcome::Cancelled` flows back to the reducer. (The child
11//!      is deliberately NOT `kill_on_drop`, so a Ctrl+B-detached
12//!      command survives a clean shutdown — see the spawn site.)
13//!
14//! End-to-end latency: microseconds plus whatever it takes `SIGKILL`
15//! to arrive. No polling loop to "forget" to include.
16//!
17//! The dangerous-command blocklist is defense-in-depth, not a
18//! security boundary: the real boundary is the user's decision to
19//! run Mermaid with shell access. But the known destructive shapes
20//! (`rm -rf /`, fork bombs, dd to device, etc.) are cheap to catch
21//! upfront.
22
23use std::path::{Path, PathBuf};
24use std::process::Stdio;
25use std::time::{Duration, Instant};
26
27use async_trait::async_trait;
28use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
29use tokio::process::Command;
30
31use crate::app::{FilesystemPolicy, NetworkPolicy};
32use crate::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
33use crate::domain::{
34    ManagedProcess, ManagedProcessStatus, ToolDefinition, ToolMetadata, ToolOutcome,
35    ToolRunMetadata,
36};
37
38use super::super::ctx::{ExecContext, ProgressEvent};
39use super::ToolExecutor;
40
41/// `execute_command` — spawn a shell, run a command, capture output.
42///
43/// Honors three escape hatches:
44/// - `ExecContext::token` (the main event): cancellation from the
45///   reducer aborts the child. This is *the* Ctrl+C fix.
46/// - `timeout` argument: model-specified per-call cap (capped at
47///   `COMMAND_MAX_TIMEOUT_SECS`). Default `COMMAND_TIMEOUT_SECS`.
48/// - Dangerous-command blocklist: refuses obvious destructive
49///   patterns before spawning.
50pub struct ExecuteCommandTool;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum CommandMode {
54    Wait,
55    Background,
56}
57
58impl CommandMode {
59    fn parse(args: &serde_json::Value) -> Result<Self, String> {
60        match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
61            "wait" | "foreground" => Ok(Self::Wait),
62            "background" => Ok(Self::Background),
63            other => Err(format!(
64                "execute_command: mode must be 'wait' or 'background', got '{}'",
65                other
66            )),
67        }
68    }
69}
70
71#[async_trait]
72impl ToolExecutor for ExecuteCommandTool {
73    fn name(&self) -> &'static str {
74        "execute_command"
75    }
76
77    fn schema(&self) -> ToolDefinition {
78        ToolDefinition {
79            name: "execute_command".to_string(),
80            description:
81                "Run a shell command. Use mode='wait' for finite commands, or mode='background' for dev servers and GUI/daemon-style commands that should keep running after the tool returns. Ctrl+C during foreground execution aborts the child immediately. The session scratchpad directory (for throwaway files) is exported to the child as MERMAID_SCRATCHPAD."
82                    .to_string(),
83            input_schema: serde_json::json!({
84                "type": "object",
85                "properties": {
86                    "command": { "type": "string", "description": "Shell command to run." },
87                    "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
88                    "mode": {
89                        "type": "string",
90                        "enum": ["wait", "background"],
91                        "default": "wait",
92                        "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
93                    },
94                    "timeout": {
95                        "type": "integer",
96                        "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
97                    },
98                    "startup_timeout_secs": {
99                        "type": "integer",
100                        "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
101                    },
102                    "ready_pattern": {
103                        "type": "string",
104                        "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
105                    },
106                    "open_url": {
107                        "type": "string",
108                        "description": "Background mode: URL to open with the default browser after startup."
109                    }
110                },
111                "required": ["command"]
112            }),
113        }
114    }
115
116    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
117        let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
118            return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
119        };
120
121        if contains_dangerous_command(command) {
122            return ToolOutcome::error(format!("Dangerous command blocked: {}", command), 0.0);
123        }
124
125        // Resolve the effective working directory and decide containment. A
126        // cwd inside the session scratchpad stays a plain Shell request; any
127        // other out-of-project cwd is allowed but escalated to
128        // ExternalDirectory so the gate won't auto-allow even a read-only
129        // command run outside the project — closing the working_dir
130        // containment bypass.
131        let (effective_workdir, within_project) = match args
132            .get("working_dir")
133            .and_then(|v| v.as_str())
134        {
135            Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
136                Ok(resolved) => resolved,
137                Err(e) => {
138                    return ToolOutcome::error(format!("execute_command working_dir: {e}"), 0.0);
139                },
140            },
141            None => (ctx.workdir.clone(), true),
142        };
143        let containment = classify_cwd(
144            within_project,
145            &effective_workdir,
146            ctx.scratchpad.as_deref(),
147        );
148
149        let category = match containment {
150            CwdContainment::Project | CwdContainment::Scratchpad => {
151                crate::runtime::ToolCategory::Shell
152            },
153            CwdContainment::External => crate::runtime::ToolCategory::ExternalDirectory,
154        };
155        // Scratch containment must be PROVEN, fail closed: the cwd sits in
156        // the scratchpad AND every token of the command lexically stays there.
157        let scratch_contained = containment == CwdContainment::Scratchpad
158            && ctx
159                .scratchpad
160                .as_deref()
161                .is_some_and(|scratch| command_provably_in_scratch(command, scratch));
162        let mut policy_request =
163            crate::runtime::ActionRequest::new("execute_command", category, command.to_string());
164        policy_request.command = Some(command.to_string());
165        if containment == CwdContainment::External {
166            policy_request.path = Some(effective_workdir.display().to_string());
167        }
168        let pending_action = serde_json::json!({
169            "tool": "execute_command",
170            "args": args.clone(),
171            "workdir": effective_workdir.display().to_string(),
172            "turn_id": ctx.turn.0,
173            "call_id": ctx.call_id.0,
174            "task_id": ctx.task_id.clone(),
175        });
176        // Central safety gate. An Ask decision is handled inside the gate
177        // (checkpoint + approval row + blocking outcome). Allow returns the
178        // classified risk so we can take the pre-existing Allow-path
179        // checkpoint below.
180        match super::policy_gate::gate(
181            &ctx,
182            policy_request,
183            &[],
184            pending_action.clone(),
185            true,
186            scratch_contained,
187        )
188        .await
189        {
190            super::policy_gate::Gate::Block(outcome) => return outcome,
191            super::policy_gate::Gate::Proceed { risk } => {
192                // A proven scratch-contained command can't touch the project,
193                // so there is nothing worth snapshotting.
194                if !scratch_contained
195                    && ctx.config.safety.checkpoint_on_mutation
196                    && risk != crate::runtime::RiskClass::ReadOnly
197                {
198                    let _ = crate::runtime::create_checkpoint_for_task(
199                        &ctx.workdir,
200                        &[],
201                        Some(pending_action.clone()),
202                        ctx.checkpoint_origin(),
203                    );
204                }
205            },
206        }
207
208        let mode = match CommandMode::parse(&args) {
209            Ok(mode) => mode,
210            Err(error) => return ToolOutcome::error(error, 0.0),
211        };
212        let shell_payload = serde_json::json!({
213            "task_id": ctx.task_id.clone(),
214            "turn_id": ctx.turn.0,
215            "call_id": ctx.call_id.0,
216            "command": command,
217            "working_dir": effective_workdir.display().to_string(),
218        });
219        let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
220        if mode == CommandMode::Background {
221            let startup_timeout_secs = args
222                .get("startup_timeout_secs")
223                .or_else(|| args.get("startup_timeout"))
224                .and_then(|v| v.as_u64())
225                .unwrap_or(5)
226                .clamp(1, 30);
227            let ready_pattern = args
228                .get("ready_pattern")
229                .and_then(|v| v.as_str())
230                .map(str::to_string);
231            let open_url = args
232                .get("open_url")
233                .and_then(|v| v.as_str())
234                .filter(|v| !v.trim().is_empty())
235                .map(str::to_string);
236            let outcome = run_background_command(
237                command,
238                &effective_workdir,
239                startup_timeout_secs,
240                ready_pattern.as_deref(),
241                open_url.as_deref(),
242                ctx,
243            )
244            .await;
245            let _ = crate::runtime::run_plugin_hooks(
246                "after_shell",
247                &serde_json::json!({
248                    "command": command,
249                    "status": format!("{:?}", outcome.status),
250                    "summary": &outcome.summary,
251                }),
252            );
253            return outcome;
254        }
255
256        let timeout_secs = args
257            .get("timeout")
258            .and_then(|v| v.as_u64())
259            .unwrap_or(COMMAND_TIMEOUT_SECS)
260            .min(COMMAND_MAX_TIMEOUT_SECS);
261
262        let command = command.to_string();
263        let start = Instant::now();
264        let progress = ctx.progress.clone();
265
266        // Spawn + wait. `run_command`'s select races four outcomes: subprocess
267        // exit, timeout, Esc-cancel, and Ctrl+B detach — the timeout and cancel
268        // arms both tree-kill before returning.
269        //
270        // When network access is denied (`safety.network = "deny"` /
271        // `--no-network`) and/or writes are confined (`safety.filesystem =
272        // "project"` / `--confine-fs`), the shell is wrapped in the
273        // `__sandbox-exec` launcher, which enforces the policy via the
274        // platform backend (Linux: seccomp network kill-switch + Landlock
275        // write rules; macOS: Seatbelt via sandbox-exec) before running it —
276        // so a denied network attempt or out-of-bounds write fails with a
277        // signature the completion arm below maps to a clear denial. Platforms
278        // WITH a backend (linux/macos) always wrap when a policy is requested —
279        // if the probe says the backend is broken, the launcher fails closed
280        // (exit 126) rather than running unconfined. Only platforms with no
281        // backend at all (Windows until the AppContainer port) downgrade to an
282        // unconfined run, with a once-per-process warning.
283        let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
284        let net_requested = matches!(ctx.config.safety.network, NetworkPolicy::Deny);
285        let fs_requested = matches!(ctx.config.safety.filesystem, FilesystemPolicy::Project);
286        let (net_available, fs_available) = sandbox_probes();
287        let sandbox_network = net_requested && (sandbox_expected || net_available);
288        let sandbox_fs = fs_requested && (sandbox_expected || fs_available);
289        if (net_requested && !net_available) || (fs_requested && !fs_available) {
290            static DEGRADED_WARN: std::sync::Once = std::sync::Once::new();
291            DEGRADED_WARN.call_once(|| {
292                if sandbox_expected {
293                    tracing::warn!(
294                        "sandbox policy requested but the OS sandbox backend probe failed; \
295                         sandboxed commands will refuse to run (fail-closed)"
296                    );
297                } else {
298                    tracing::warn!(
299                        "sandbox policy requested but no OS sandbox backend exists on this \
300                         platform; commands run unconfined"
301                    );
302                }
303            });
304        }
305        // Write allowlist: the project root (so a build in a subdir can still
306        // write repo-root artifacts), the effective workdir (out-of-project
307        // commands, separately gated by policy), the system temp dir, and —
308        // unix only — /dev (shell redirects like `>/dev/null` are writes).
309        let confine_writes: Option<Vec<PathBuf>> = sandbox_fs.then(|| {
310            let mut dirs = vec![
311                ctx.workdir.clone(),
312                effective_workdir.clone(),
313                std::env::temp_dir(),
314            ];
315            if cfg!(unix) {
316                dirs.push(PathBuf::from("/dev"));
317            }
318            dirs.dedup();
319            dirs
320        });
321        // Default: run on a pseudo-terminal — openpty on Unix, ConPTY on
322        // Windows — so the child sees a real console (progress bars,
323        // isatty-gated tools); on Unix `/dev/tty` additionally resolves to
324        // the CAPTURED pty. `[exec] pty = false` or any pre-spawn PTY
325        // failure falls back to the pipe path below, which stays fully
326        // intact.
327        if ctx.config.exec.pty_enabled() {
328            let invocation = shell_invocation(&command, sandbox_network, confine_writes.as_deref());
329            match run_command_pty(
330                &invocation,
331                &effective_workdir,
332                ctx.scratchpad.as_deref(),
333                progress.clone(),
334                ctx.token.clone(),
335                ctx.background.clone(),
336                Duration::from_secs(timeout_secs),
337            )
338            .await
339            {
340                Ok(run) => {
341                    let outcome = finish_foreground_command(
342                        Ok(run),
343                        &command,
344                        &effective_workdir,
345                        start,
346                        timeout_secs,
347                        sandbox_network,
348                        sandbox_fs,
349                    );
350                    let _ = crate::runtime::run_plugin_hooks(
351                        "after_shell",
352                        &serde_json::json!({
353                            "command": command,
354                            "status": format!("{:?}", outcome.status),
355                            "summary": &outcome.summary,
356                        }),
357                    );
358                    return outcome;
359                },
360                // Every fallible step in run_command_pty precedes the spawn,
361                // so falling back here can never run the command twice.
362                Err(err) => {
363                    tracing::warn!(error = %err, "PTY exec unavailable; falling back to pipes");
364                },
365            }
366        }
367
368        let mut cmd = build_sandboxed_shell(&command, sandbox_network, confine_writes.as_deref());
369        cmd.stdin(Stdio::null())
370            .stdout(Stdio::piped())
371            .stderr(Stdio::piped())
372            // NOT kill-on-drop: the cancel and timeout arms of `run_command`
373            // explicitly `terminate_tree` the whole process group (the direct
374            // shell is its group leader, so any forked grandchild dies too), so
375            // no drop-time backstop is needed on those paths. Crucially, leaving
376            // the child un-armed lets a Ctrl+B-detached command survive a clean
377            // Mermaid shutdown: the orphaned driver task that still owns this
378            // `Child` is aborted at runtime teardown, and a `kill_on_drop(true)`
379            // child would then be SIGKILLed despite `mode=background` semantics
380            // — inconsistent with a truly backgrounded process (#F16).
381            .kill_on_drop(false);
382
383        // Unix: lead a new SESSION, not just a new process group. `setsid()`
384        // still makes the child a group leader (sid == pgid == pid), so the
385        // cancel/timeout group-kill in `terminate_tree` is unchanged — but a
386        // new session has no controlling terminal, so a child that tries to
387        // open `/dev/tty` (a `sudo` password prompt, an ssh passphrase read)
388        // fails instantly instead of painting its prompt over the TUI and
389        // hanging until timeout. `setsid` is async-signal-safe, so a pre_exec
390        // closure is fine here (unlike the seccomp/Landlock setup, which needs
391        // the `__sandbox-exec` re-exec — see `app::sandbox_exec`). Must NOT be
392        // combined with `process_group(0)`: setpgid runs before pre_exec, and
393        // `setsid` fails with EPERM for an existing group leader.
394        // (Windows kills the tree by pid via `taskkill /T`, no group needed.)
395        #[cfg(unix)]
396        unsafe {
397            cmd.pre_exec(|| {
398                rustix::process::setsid()?;
399                Ok(())
400            });
401        }
402
403        cmd.current_dir(&effective_workdir);
404        scrub_secret_env(&mut cmd);
405        harden_noninteractive_env(&mut cmd);
406        export_scratchpad_env(&mut cmd, ctx.scratchpad.as_deref());
407
408        // The timeout now lives INSIDE `run_command`'s select (alongside the
409        // Esc-cancel and Ctrl+B arms), so a timed-out command is tree-killed and
410        // its driver aborted before we return — the old outer `select!` dropped
411        // the future and leaked the process tree.
412        let outcome = finish_foreground_command(
413            run_command(
414                cmd,
415                progress,
416                ctx.token.clone(),
417                ctx.background.clone(),
418                Duration::from_secs(timeout_secs),
419            )
420            .await,
421            &command,
422            &effective_workdir,
423            start,
424            timeout_secs,
425            sandbox_network,
426            sandbox_fs,
427        );
428        let _ = crate::runtime::run_plugin_hooks(
429            "after_shell",
430            &serde_json::json!({
431                "command": command,
432                "status": format!("{:?}", outcome.status),
433                "summary": &outcome.summary,
434            }),
435        );
436        outcome
437    }
438}
439
440/// Map a completed foreground run (either spawn path) onto the tool outcome:
441/// sandbox-denial detection, detach registration, timeout/cancel/error
442/// shaping, and command metadata. Shared by the pipe and PTY paths so their
443/// user-visible semantics cannot drift.
444#[allow(clippy::too_many_lines)]
445fn finish_foreground_command(
446    result: std::io::Result<CommandRunResult>,
447    command: &str,
448    effective_workdir: &Path,
449    start: Instant,
450    timeout_secs: u64,
451    sandbox_network: bool,
452    sandbox_fs: bool,
453) -> ToolOutcome {
454    let command = command.to_string();
455    match result {
456        Ok(CommandRunResult::Completed(run)) => {
457            let duration_secs = start.elapsed().as_secs_f64();
458            let output_len = run.output.len();
459            let mut metadata = command_metadata(CommandMetadataInput {
460                command: command.clone(),
461                working_dir: Some(effective_workdir.display().to_string()),
462                exit_code: run.exit_code,
463                timed_out: false,
464                background: false,
465                stdout_lines: run.stdout_lines,
466                stderr_lines: run.stderr_lines,
467                detected_urls: all_urls(&run.output),
468                pid: None,
469                log_path: None,
470                byte_count: Some(output_len),
471            });
472            if let Some(kind) = detect_denial(&run, sandbox_network, sandbox_fs) {
473                // The sandbox stopped (or very likely stopped) this command.
474                // Surface a clear, actionable error instead of a confusing
475                // "killed" / opaque permission failure.
476                if let ToolMetadata::ExecuteCommand {
477                    denied_by_sandbox, ..
478                } = &mut metadata.detail
479                {
480                    *denied_by_sandbox = true;
481                }
482                let message = match kind {
483                    // The Linux SIGSYS signature is precise — the message
484                    // stands alone. Every other signature is a hedged text
485                    // match, so the original output stays attached.
486                    DenialKind::Network if cfg!(target_os = "linux") => {
487                        NETWORK_DENIED_MESSAGE.to_string()
488                    },
489                    DenialKind::Network => format!(
490                        "{HEDGED_NETWORK_DENIED_MESSAGE}\n\n--- original output ---\n{}",
491                        run.output
492                    ),
493                    DenialKind::Filesystem => format!(
494                        "{FS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
495                        run.output
496                    ),
497                    DenialKind::Ambiguous => format!(
498                        "{AMBIGUOUS_DENIED_MESSAGE}\n\n--- original output ---\n{}",
499                        run.output
500                    ),
501                };
502                ToolOutcome::error(message, duration_secs).with_metadata(metadata)
503            } else {
504                ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
505                    .with_metadata(metadata)
506            }
507        },
508        Ok(CommandRunResult::Detached { pid, log_path }) => {
509            // Ctrl+B moved this command to the background.
510            let duration_secs = start.elapsed().as_secs_f64();
511            let log_path_str = log_path.display().to_string();
512            let output = format!(
513                "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
514            );
515            let process = ManagedProcess {
516                id: format!("bg-{pid}"),
517                pid,
518                command: command.to_string(),
519                cwd: Some(effective_workdir.display().to_string()),
520                log_path: log_path_str.clone(),
521                detected_url: None,
522                status: ManagedProcessStatus::Running,
523            };
524            let mut metadata = command_metadata(CommandMetadataInput {
525                command: command.to_string(),
526                working_dir: Some(effective_workdir.display().to_string()),
527                exit_code: None,
528                timed_out: false,
529                background: true,
530                stdout_lines: 0,
531                stderr_lines: 0,
532                detected_urls: Vec::new(),
533                pid: Some(pid),
534                log_path: Some(log_path_str),
535                byte_count: Some(output.len()),
536            });
537            metadata.process = Some(process);
538            ToolOutcome::success(output, "moved to background", duration_secs)
539                .with_metadata(metadata)
540        },
541        Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
542        Ok(CommandRunResult::TimedOut) => {
543            let message = format!(
544                "Command timed out after {} seconds and was killed. \
545                     For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
546                timeout_secs
547            );
548            let duration_secs = start.elapsed().as_secs_f64();
549            ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
550                CommandMetadataInput {
551                    command: command.clone(),
552                    working_dir: Some(effective_workdir.display().to_string()),
553                    exit_code: None,
554                    timed_out: true,
555                    background: false,
556                    stdout_lines: 0,
557                    stderr_lines: 0,
558                    detected_urls: Vec::new(),
559                    pid: None,
560                    log_path: None,
561                    byte_count: None,
562                },
563            ))
564        },
565        Err(e) => {
566            let duration_secs = start.elapsed().as_secs_f64();
567            ToolOutcome::error(format!("Command failed: {}", e), duration_secs).with_metadata(
568                command_metadata(CommandMetadataInput {
569                    command: command.clone(),
570                    working_dir: Some(effective_workdir.display().to_string()),
571                    exit_code: None,
572                    timed_out: false,
573                    background: false,
574                    stdout_lines: 0,
575                    stderr_lines: 0,
576                    detected_urls: Vec::new(),
577                    pid: None,
578                    log_path: None,
579                    byte_count: None,
580                }),
581            )
582        },
583    }
584}
585
586#[derive(Debug)]
587struct BackgroundStartup {
588    ready_message: String,
589    log_excerpt: String,
590    detected_url: Option<String>,
591}
592
593async fn run_background_command(
594    command: &str,
595    workdir: &Path,
596    startup_timeout_secs: u64,
597    ready_pattern: Option<&str>,
598    open_url: Option<&str>,
599    ctx: ExecContext,
600) -> ToolOutcome {
601    let start = Instant::now();
602
603    {
604        let log_path = background_log_path();
605        let pid =
606            match launch_background_process(command, workdir, &log_path, ctx.scratchpad.as_deref())
607                .await
608            {
609                Ok(pid) => pid,
610                Err(error) => {
611                    return ToolOutcome::error(error, start.elapsed().as_secs_f64());
612                },
613            };
614
615        let startup = match wait_for_background_startup(
616            pid,
617            &log_path,
618            startup_timeout_secs,
619            ready_pattern,
620            &ctx,
621        )
622        .await
623        {
624            Ok(startup) => startup,
625            Err(BackgroundWaitError::Cancelled) => {
626                crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
627                return ToolOutcome::cancelled();
628            },
629            Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
630                return ToolOutcome::error(
631                    format!(
632                        "Background command exited during startup. Log: {}\n\n{}",
633                        log_path.display(),
634                        log_excerpt
635                    ),
636                    start.elapsed().as_secs_f64(),
637                );
638            },
639        };
640
641        let opened = if let Some(url) = open_url {
642            Some((url.to_string(), open_browser_url(url).await))
643        } else {
644            None
645        };
646
647        let mut output = format!(
648            "Background command started.\nPID: {}\nLog: {}\n{}\n",
649            pid,
650            log_path.display(),
651            startup.ready_message
652        );
653        if let Some(url) = startup.detected_url.as_ref() {
654            output.push_str(&format!("Detected URL: {}\n", url));
655        }
656        if let Some((url, result)) = opened {
657            match result {
658                Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
659                Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
660            }
661        }
662        if !startup.log_excerpt.trim().is_empty() {
663            output.push_str("\n--- startup output ---\n");
664            output.push_str(&startup.log_excerpt);
665        }
666
667        let duration_secs = start.elapsed().as_secs_f64();
668        let log_path_str = log_path.display().to_string();
669        let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
670        let process = ManagedProcess {
671            id: format!("bg-{}", pid),
672            pid,
673            command: command.to_string(),
674            cwd: Some(workdir.display().to_string()),
675            log_path: log_path_str.clone(),
676            detected_url: startup.detected_url.clone(),
677            status: ManagedProcessStatus::Running,
678        };
679        let byte_count = output.len();
680        let mut metadata = command_metadata(CommandMetadataInput {
681            command: command.to_string(),
682            working_dir: Some(workdir.display().to_string()),
683            exit_code: None,
684            timed_out: false,
685            background: true,
686            stdout_lines: startup.log_excerpt.lines().count(),
687            stderr_lines: 0,
688            detected_urls,
689            pid: Some(pid),
690            log_path: Some(log_path_str),
691            byte_count: Some(byte_count),
692        });
693        metadata.process = Some(process);
694        ToolOutcome::success(output, "background process started", duration_secs)
695            .with_metadata(metadata)
696    }
697}
698
699#[cfg(not(target_os = "windows"))]
700async fn launch_background_process(
701    command: &str,
702    workdir: &Path,
703    log_path: &Path,
704    scratchpad: Option<&Path>,
705) -> Result<u32, String> {
706    // Pre-create the log owner-only with O_EXCL BEFORE the launcher runs, so a
707    // symlink pre-planted at the predictable path can't redirect the script's
708    // `: > "$log"` / output redirects to a victim file (#F15), and the captured
709    // output stays owner-readable on top of the 0700 private dir (#F14). The
710    // launcher then truncates this regular file in place, preserving its perms.
711    create_log_file_blocking(log_path).map_err(|e| {
712        format!(
713            "failed to create background log {}: {e}",
714            log_path.display()
715        )
716    })?;
717    let mut launcher = Command::new("sh");
718    launcher
719        .arg("-c")
720        .arg(
721            // `setsid` (when present) makes the backgrounded command a new
722            // session/process-group leader, so its pid (`$!`) IS its group id and
723            // `terminate_tree` can later group-kill the whole subtree rather than
724            // orphaning grandchildren. Falls back to `nohup` on hosts without
725            // setsid (e.g. stock macOS), where the bare-pid kill still applies.
726            r#"log=$MERMAID_BG_LOG
727cmd=$MERMAID_BG_COMMAND
728: > "$log" || exit 125
729if command -v setsid >/dev/null 2>&1; then
730  setsid sh -c "$cmd" > "$log" 2>&1 < /dev/null &
731else
732  nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
733fi
734printf '%s\n' "$!""#,
735        )
736        .env("MERMAID_BG_LOG", log_path)
737        .env("MERMAID_BG_COMMAND", command)
738        .current_dir(workdir)
739        .stdin(Stdio::null())
740        .stdout(Stdio::piped())
741        .stderr(Stdio::piped());
742    scrub_secret_env(&mut launcher);
743    harden_noninteractive_env(&mut launcher);
744    export_scratchpad_env(&mut launcher, scratchpad);
745
746    let output = launcher
747        .output()
748        .await
749        .map_err(|e| format!("failed to launch background command: {}", e))?;
750    if !output.status.success() {
751        return Err(format!(
752            "background launcher failed: {}",
753            String::from_utf8_lossy(&output.stderr)
754        ));
755    }
756    let stdout = String::from_utf8_lossy(&output.stdout);
757    stdout.trim().parse::<u32>().map_err(|e| {
758        format!(
759            "background launcher did not return a pid: {} ({})",
760            stdout, e
761        )
762    })
763}
764
765/// Windows: spawn the command detached (no console, own process group) with
766/// output redirected to the log file, and return its PID. tokio's `Child`
767/// defaults to `kill_on_drop(false)`, so dropping the handle leaves the
768/// process running — the OS owns its lifetime from here.
769#[cfg(target_os = "windows")]
770async fn launch_background_process(
771    command: &str,
772    workdir: &Path,
773    log_path: &Path,
774    scratchpad: Option<&Path>,
775) -> Result<u32, String> {
776    use crate::utils::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS};
777    let log = std::fs::File::create(log_path).map_err(|e| {
778        format!(
779            "failed to create background log {}: {e}",
780            log_path.display()
781        )
782    })?;
783    let log_err = log
784        .try_clone()
785        .map_err(|e| format!("failed to clone background log handle: {e}"))?;
786    let mut launcher = Command::new("cmd");
787    launcher
788        .arg("/C")
789        .arg(command)
790        .current_dir(workdir)
791        .stdin(Stdio::null())
792        .stdout(Stdio::from(log))
793        .stderr(Stdio::from(log_err))
794        .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
795    scrub_secret_env(&mut launcher);
796    harden_noninteractive_env(&mut launcher);
797    export_scratchpad_env(&mut launcher, scratchpad);
798    let child = launcher
799        .spawn()
800        .map_err(|e| format!("failed to launch background command: {e}"))?;
801    child
802        .id()
803        .ok_or_else(|| "background command produced no pid".to_string())
804}
805
806#[derive(Debug)]
807enum BackgroundWaitError {
808    Cancelled,
809    ExitedEarly(String),
810}
811
812async fn wait_for_background_startup(
813    pid: u32,
814    log_path: &Path,
815    startup_timeout_secs: u64,
816    ready_pattern: Option<&str>,
817    ctx: &ExecContext,
818) -> Result<BackgroundStartup, BackgroundWaitError> {
819    let start = Instant::now();
820    let startup_timeout = Duration::from_secs(startup_timeout_secs);
821
822    loop {
823        if ctx.token.is_cancelled() {
824            return Err(BackgroundWaitError::Cancelled);
825        }
826
827        let last_log = read_log_lossy(log_path).await;
828        let detected_url = first_url(&last_log);
829
830        if !process_running(pid).await {
831            return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
832        }
833
834        if let Some(pattern) = ready_pattern {
835            if last_log.contains(pattern) {
836                return Ok(BackgroundStartup {
837                    ready_message: format!("Ready: matched pattern {:?}", pattern),
838                    log_excerpt: tail_lines(&last_log, 40),
839                    detected_url,
840                });
841            }
842        } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
843            return Ok(BackgroundStartup {
844                ready_message:
845                    "Ready: no ready_pattern provided; process is running after startup check"
846                        .to_string(),
847                log_excerpt: tail_lines(&last_log, 40),
848                detected_url,
849            });
850        }
851
852        if start.elapsed() >= startup_timeout {
853            let ready_message = if let Some(pattern) = ready_pattern {
854                format!(
855                    "Ready: pattern {:?} was not seen within {}s; process is still running",
856                    pattern, startup_timeout_secs
857                )
858            } else {
859                format!(
860                    "Ready: startup check reached {}s; process is still running",
861                    startup_timeout_secs
862                )
863            };
864            return Ok(BackgroundStartup {
865                ready_message,
866                log_excerpt: tail_lines(&last_log, 40),
867                detected_url,
868            });
869        }
870
871        tokio::select! {
872            _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
873            _ = tokio::time::sleep(Duration::from_millis(200)) => {},
874        }
875    }
876}
877
878async fn read_log_lossy(path: &Path) -> String {
879    tokio::fs::read_to_string(path).await.unwrap_or_default()
880}
881
882#[cfg(not(target_os = "windows"))]
883async fn process_running(pid: u32) -> bool {
884    Command::new("kill")
885        .arg("-0")
886        .arg(pid.to_string())
887        .stdin(Stdio::null())
888        .stdout(Stdio::null())
889        .stderr(Stdio::null())
890        .status()
891        .await
892        .map(|status| status.success())
893        .unwrap_or(false)
894}
895
896/// Windows: `tasklist` filtered by PID prints the process row only when it
897/// exists (otherwise an "INFO: No tasks…" line that doesn't contain the PID).
898#[cfg(target_os = "windows")]
899async fn process_running(pid: u32) -> bool {
900    Command::new("tasklist")
901        .args(["/FI", &format!("PID eq {pid}"), "/NH"])
902        .stdin(Stdio::null())
903        .stdout(Stdio::piped())
904        .stderr(Stdio::null())
905        .output()
906        .await
907        .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
908        .unwrap_or(false)
909}
910
911// Process-tree termination lives in `crate::utils::terminate_tree` — the single
912// primitive shared by the Esc-cancel path, the foreground timeout, the
913// Ctrl+B-detached cleanup, and the daemon's `/stop`/`/restart`. It kills the
914// process group (catching grandchildren), not just the direct pid.
915
916/// Build a unique, hard-to-predict path for a command's tee log inside the
917/// per-user `0700` private temp dir (#F14). Command stdout/stderr is tee'd here
918/// and can contain secrets (`cat .env`, `gh auth token`), so it must NOT land in
919/// the world-readable shared system temp dir. Falls back to the system temp dir
920/// only if the private dir can't be created — the owner-only + `O_EXCL` create
921/// at the use-site (`create_log_file_blocking`) still applies there.
922fn background_log_path() -> PathBuf {
923    let nanos = std::time::SystemTime::now()
924        .duration_since(std::time::UNIX_EPOCH)
925        .map(|d| d.as_nanos())
926        .unwrap_or_default();
927    let name = format!("mermaid-bg-{}-{}.log", std::process::id(), nanos);
928    match crate::utils::private_temp_dir() {
929        Ok(dir) => dir.join(name),
930        Err(_) => std::env::temp_dir().join(name),
931    }
932}
933
934/// Create (exclusively) the tee log at `path`. On Unix the file is owner-only
935/// (`0600`) and opened `O_CREAT | O_EXCL` (via `create_new`): per POSIX that
936/// refuses to open — and refuses to follow — a symlink someone pre-planted at
937/// the predictable name, so the log write can't be redirected to a victim file
938/// (#F15). The `0600` mode keeps the captured stdout/stderr owner-readable on
939/// top of the `0700` private dir (#F14).
940#[cfg(unix)]
941fn create_log_file_blocking(path: &Path) -> std::io::Result<std::fs::File> {
942    use std::os::unix::fs::OpenOptionsExt;
943    std::fs::OpenOptions::new()
944        .write(true)
945        .create_new(true)
946        .mode(0o600)
947        .open(path)
948}
949
950/// Create the foreground tee log, returning a `tokio` handle. Unix uses the
951/// hardened owner-only + `O_EXCL` create above; other platforms fall back to a
952/// plain create (the log already lives in the private dir). Best-effort: `None`
953/// means "no tee log", which only costs `/logs` tail-ability, not correctness.
954fn create_tee_log_blocking(path: &Path) -> Option<tokio::fs::File> {
955    #[cfg(unix)]
956    let std_file = create_log_file_blocking(path).ok();
957    #[cfg(not(unix))]
958    let std_file = std::fs::File::create(path).ok();
959    std_file.map(tokio::fs::File::from_std)
960}
961
962struct CommandMetadataInput {
963    command: String,
964    working_dir: Option<String>,
965    exit_code: Option<i32>,
966    timed_out: bool,
967    background: bool,
968    stdout_lines: usize,
969    stderr_lines: usize,
970    detected_urls: Vec<String>,
971    pid: Option<u32>,
972    log_path: Option<String>,
973    byte_count: Option<usize>,
974}
975
976fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
977    ToolRunMetadata {
978        detail: ToolMetadata::ExecuteCommand {
979            command: input.command,
980            working_dir: input.working_dir,
981            exit_code: input.exit_code,
982            timed_out: input.timed_out,
983            background: input.background,
984            stdout_lines: input.stdout_lines,
985            stderr_lines: input.stderr_lines,
986            detected_urls: input.detected_urls,
987            pid: input.pid,
988            log_path: input.log_path,
989            // Set by the completion arm when a sandbox denial is detected; the
990            // metadata builder itself never sees the terminating signal.
991            denied_by_sandbox: false,
992        },
993        line_count: Some(input.stdout_lines + input.stderr_lines),
994        byte_count: input.byte_count,
995        ..ToolRunMetadata::default()
996    }
997}
998
999/// The cached OS-sandbox availability probes (network kill-switch, filesystem
1000/// write-confinement). Probed once per process — platform capability cannot
1001/// change mid-run, and the Linux probe assembles a BPF program each call.
1002fn sandbox_probes() -> (bool, bool) {
1003    static PROBES: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
1004    *PROBES.get_or_init(|| {
1005        (
1006            crate::runtime::network_killswitch_available(),
1007            crate::runtime::fs_confinement_available(),
1008        )
1009    })
1010}
1011
1012/// SIGSYS on Linux (x86_64/aarch64) — the signal the seccomp kill-switch raises.
1013const SANDBOX_KILL_SIGNAL: i32 = 31;
1014
1015/// Which sandbox dimension a completed command's failure matches. `Ambiguous`
1016/// exists for macOS with both policies active: Seatbelt denies network AND
1017/// filesystem access with the same `EPERM` text and no signal, so the two
1018/// cannot be told apart.
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum DenialKind {
1021    Network,
1022    Filesystem,
1023    Ambiguous,
1024}
1025
1026/// Map a completed run onto a sandbox-denial kind, gated on which policies
1027/// were actually active for this spawn (so an ordinary permission failure or
1028/// `exit 159` is never mislabeled when the sandbox was off).
1029///
1030/// Signatures per platform:
1031/// - Linux network: precise — the shell died with SIGSYS or reaped a
1032///   SIGSYS-killed child (`128 + SIGSYS`). Nothing else produces it.
1033/// - Linux filesystem: hedged — Landlock denials are ordinary `EACCES` text.
1034/// - macOS (Seatbelt): both dimensions are hedged `EPERM` "Operation not
1035///   permitted" text with no signal; with both policies active the match is
1036///   [`DenialKind::Ambiguous`].
1037fn detect_denial(
1038    run: &CommandRunOutput,
1039    sandbox_network: bool,
1040    sandbox_fs: bool,
1041) -> Option<DenialKind> {
1042    if cfg!(target_os = "linux") {
1043        if sandbox_network && is_sigsys_denial(run) {
1044            return Some(DenialKind::Network);
1045        }
1046        if sandbox_fs && is_permission_denial(run) {
1047            return Some(DenialKind::Filesystem);
1048        }
1049        return None;
1050    }
1051    if !is_permission_denial(run) {
1052        return None;
1053    }
1054    match (sandbox_network, sandbox_fs) {
1055        (true, true) => Some(DenialKind::Ambiguous),
1056        (true, false) => Some(DenialKind::Network),
1057        (false, true) => Some(DenialKind::Filesystem),
1058        (false, false) => None,
1059    }
1060}
1061
1062/// Message shown when the Linux network kill-switch blocks a command (the
1063/// precise SIGSYS signature). States the cause and the three ways to allow it.
1064/// No emojis.
1065const NETWORK_DENIED_MESSAGE: &str = "Blocked by the network sandbox: this command tried to open an internet socket, which is denied because network access is off (safety.network = \"deny\" / --no-network). Re-run without --no-network, approve the command, or use full-access mode to allow network access.";
1066
1067/// Hedged network-denial message for platforms without a precise signal
1068/// (macOS Seatbelt denies with plain EPERM). No emojis.
1069const HEDGED_NETWORK_DENIED_MESSAGE: &str = "Command failed with a permission error while the network sandbox was active (safety.network = \"deny\" / --no-network); a network access was likely denied. Re-run without --no-network, approve the command, or use full-access mode to allow network access.";
1070
1071/// Message shown when a command's failure matches the filesystem-sandbox denial
1072/// signature. Hedged ("likely") because write denials surface as ordinary
1073/// permission errors (Linux Landlock EACCES, macOS Seatbelt EPERM), unlike the
1074/// unambiguous SIGSYS of the Linux network kill-switch. No emojis.
1075const FS_DENIED_MESSAGE: &str = "Command failed with a permission error while the filesystem sandbox was active (safety.filesystem = \"project\" / --confine-fs); a write outside the project directory, the system temp directory, or /dev was likely denied. Write inside the project, or re-run without --confine-fs to allow it.";
1076
1077/// Combined hedged message for [`DenialKind::Ambiguous`] (macOS, both
1078/// policies active — the EPERM signature cannot say which one fired). No
1079/// emojis.
1080const AMBIGUOUS_DENIED_MESSAGE: &str = "Command failed with a permission error while the network and filesystem sandboxes were active (--no-network / --confine-fs); a network access or a write outside the allowed directories was likely denied. Write inside the project, or re-run without the sandbox flags to allow it.";
1081
1082/// Whether a completed command was terminated by the Linux seccomp
1083/// kill-switch: the shell itself died with SIGSYS, or (more often) it reaped a
1084/// SIGSYS-killed child and exited `128 + SIGSYS`.
1085fn is_sigsys_denial(run: &CommandRunOutput) -> bool {
1086    run.signal == Some(SANDBOX_KILL_SIGNAL) || run.exit_code == Some(128 + SANDBOX_KILL_SIGNAL)
1087}
1088
1089/// Whether a completed command's failure looks like a sandbox permission
1090/// denial: non-zero exit plus the shell/tool permission-error text. A
1091/// signature match, not a proof — [`detect_denial`] gates on "the sandbox was
1092/// active for this spawn", and the surfaced messages hedge accordingly.
1093fn is_permission_denial(run: &CommandRunOutput) -> bool {
1094    let failed = matches!(run.exit_code, Some(code) if code != 0);
1095    failed
1096        && (run.output.contains("Permission denied")
1097            || run.output.contains("Operation not permitted"))
1098}
1099
1100/// Build the shell `Command` for a model command, optionally wrapped in the
1101/// `__sandbox-exec` launcher for the network kill-switch and/or filesystem
1102/// write-confinement (platform backend chosen by the launcher). The caller
1103/// sets stdio, process group, cwd, and env scrubbing on the returned command.
1104/// The resolved program + argv for a foreground command — one description
1105/// consumed by BOTH spawn paths (tokio pipes and the Unix PTY), so the PTY
1106/// child execs the exact same `__sandbox-exec` launcher (seccomp/Landlock
1107/// unchanged) as the pipe child.
1108struct ShellInvocation {
1109    program: PathBuf,
1110    args: Vec<std::ffi::OsString>,
1111}
1112
1113fn shell_invocation(
1114    command: &str,
1115    sandbox_network: bool,
1116    confine_writes: Option<&[PathBuf]>,
1117) -> ShellInvocation {
1118    if sandbox_network || confine_writes.is_some() {
1119        // `mermaid __sandbox-exec [--no-network] [--confine-writes <dir>]… --
1120        // sh -c <command>`: the launcher installs the requested confinement on
1121        // itself, then execs the shell.
1122        let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("mermaid"));
1123        let mut args: Vec<std::ffi::OsString> = vec!["__sandbox-exec".into()];
1124        if sandbox_network {
1125            args.push("--no-network".into());
1126        }
1127        for dir in confine_writes.unwrap_or_default() {
1128            args.push("--confine-writes".into());
1129            args.push(dir.into());
1130        }
1131        args.extend(["--".into(), "sh".into(), "-c".into(), command.into()]);
1132        ShellInvocation { program: exe, args }
1133    } else {
1134        ShellInvocation {
1135            program: PathBuf::from(if cfg!(target_os = "windows") {
1136                "cmd"
1137            } else {
1138                "sh"
1139            }),
1140            args: vec![
1141                (if cfg!(target_os = "windows") {
1142                    "/C"
1143                } else {
1144                    "-c"
1145                })
1146                .into(),
1147                command.into(),
1148            ],
1149        }
1150    }
1151}
1152
1153fn build_sandboxed_shell(
1154    command: &str,
1155    sandbox_network: bool,
1156    confine_writes: Option<&[PathBuf]>,
1157) -> Command {
1158    let invocation = shell_invocation(command, sandbox_network, confine_writes);
1159    let mut cmd = Command::new(&invocation.program);
1160    cmd.args(&invocation.args);
1161    cmd
1162}
1163
1164/// Where the effective working directory landed: inside the project, inside
1165/// the session scratchpad, or outside both (escalated to ExternalDirectory).
1166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1167enum CwdContainment {
1168    Project,
1169    Scratchpad,
1170    External,
1171}
1172
1173/// Classify the (already-canonicalized) effective workdir. The scratchpad
1174/// check canonicalizes the scratch root itself; if that fails (dir missing,
1175/// permissions) the cwd fails closed to `External` — never to a downgrade.
1176fn classify_cwd(
1177    within_project: bool,
1178    effective_workdir: &Path,
1179    scratchpad: Option<&Path>,
1180) -> CwdContainment {
1181    if within_project {
1182        return CwdContainment::Project;
1183    }
1184    match scratchpad.and_then(|s| std::fs::canonicalize(s).ok()) {
1185        Some(scratch) if effective_workdir.starts_with(&scratch) => CwdContainment::Scratchpad,
1186        _ => CwdContainment::External,
1187    }
1188}
1189
1190/// Fail-closed lexical prover: true only when the command, run with its cwd
1191/// inside the scratchpad, provably cannot touch anything outside it. Any
1192/// construct we cannot reason about — shell metacharacters, substitutions,
1193/// expansions, `..`, absolute or embedded paths pointing elsewhere, even a
1194/// parse failure — fails the proof and the command keeps its normal gating.
1195/// Over-rejecting is fine here (the command merely prompts as usual);
1196/// under-rejecting would silently skip an approval.
1197fn command_provably_in_scratch(command: &str, scratch: &Path) -> bool {
1198    // Metacharacters make the command opaque to token-level reasoning:
1199    // separators/pipes can chain arbitrary commands, redirection retargets
1200    // writes, `$`/backtick substitute or expand unseen text, `~`/globs
1201    // re-expand at run time, and grouping braces/parens introduce subshells.
1202    // Checked on the RAW string so even quoted occurrences fail closed.
1203    const OPAQUE: &[char] = &[
1204        ';', '|', '&', '<', '>', '$', '`', '~', '*', '?', '[', ']', '(', ')', '{', '}', '!', '\n',
1205        '\r',
1206    ];
1207    if command.contains(OPAQUE) {
1208        return false;
1209    }
1210    let Ok(tokens) = shell_words::split(command) else {
1211        return false;
1212    };
1213    if tokens.is_empty() {
1214        return false;
1215    }
1216    tokens.iter().all(|t| token_provably_in_scratch(t, scratch))
1217}
1218
1219/// One token of a scratch-candidate command. Rules, all fail-closed:
1220/// - `..` anywhere: rejected (can climb out of the scratch cwd).
1221/// - `:/` anywhere: rejected (URL / remote-host / list-of-paths shapes).
1222/// - Drive-designator shape (`C:x`, `c:\x`): rejected on every platform —
1223///   on Windows it targets a drive root or a per-drive cwd, never scratch.
1224/// - No path separator: fine — a bare word, flag, or PATH-resolved argv0.
1225/// - Rooted: must sit lexically inside the scratchpad. `has_root`, not
1226///   `is_absolute` — on Windows `/etc/passwd` is rooted but not "absolute"
1227///   (no drive prefix), yet still escapes the scratch cwd via the drive
1228///   root, so every rooted token gets the containment check.
1229/// - Relative with a separator: accepted only as a PLAIN path (no leading
1230///   `-`, no `=`) so flag-embedded paths (`-t/etc`, `--output=/etc/x`,
1231///   `VAR=/etc`) can't smuggle a target past the rooted check.
1232fn token_provably_in_scratch(token: &str, scratch: &Path) -> bool {
1233    if token.contains("..") || token.contains(":/") {
1234        return false;
1235    }
1236    let bytes = token.as_bytes();
1237    if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
1238        return false;
1239    }
1240    if !token.contains(['/', '\\']) {
1241        return true;
1242    }
1243    if Path::new(token).has_root() {
1244        return Path::new(token).starts_with(scratch);
1245    }
1246    !token.starts_with('-') && !token.contains('=')
1247}
1248
1249/// Advertised to spawned commands so scripts have a ready-made place for
1250/// throwaway files that never dirties the project tree.
1251const SCRATCHPAD_ENV_VAR: &str = "MERMAID_SCRATCHPAD";
1252
1253/// Export the session scratchpad to a child command (pipe + background spawn
1254/// paths; the PTY path sets the same variable on its `CommandBuilder`). No-op
1255/// when the session has no scratchpad materialized.
1256fn export_scratchpad_env(cmd: &mut Command, scratchpad: Option<&Path>) {
1257    if let Some(dir) = scratchpad {
1258        cmd.env(SCRATCHPAD_ENV_VAR, dir);
1259    }
1260}
1261
1262fn tail_lines(text: &str, max_lines: usize) -> String {
1263    let lines: Vec<&str> = text.lines().collect();
1264    let start = lines.len().saturating_sub(max_lines);
1265    lines[start..].join("\n")
1266}
1267
1268fn first_url(text: &str) -> Option<String> {
1269    text.split_whitespace()
1270        .find(|part| part.starts_with("http://") || part.starts_with("https://"))
1271        .map(|url| {
1272            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1273                .to_string()
1274        })
1275}
1276
1277fn all_urls(text: &str) -> Vec<String> {
1278    text.split_whitespace()
1279        .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
1280        .map(|url| {
1281            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
1282                .to_string()
1283        })
1284        .collect()
1285}
1286
1287async fn open_browser_url(url: &str) -> Result<(), String> {
1288    // Only ever hand a plain http(s) URL to the OS launcher — reject
1289    // `file:`/`javascript:`/`data:`/etc. supplied by the model. On Windows this
1290    // is also what lets us drop the `cmd` shell below safely.
1291    super::web::require_http_scheme(url)?;
1292
1293    #[cfg(target_os = "macos")]
1294    let mut command = {
1295        let mut cmd = Command::new("open");
1296        cmd.arg(url);
1297        cmd
1298    };
1299
1300    #[cfg(target_os = "linux")]
1301    let mut command = {
1302        let mut cmd = Command::new("xdg-open");
1303        cmd.arg(url);
1304        cmd
1305    };
1306
1307    #[cfg(target_os = "windows")]
1308    let mut command = {
1309        // Launch via `rundll32` (a real executable) rather than `cmd /C start`,
1310        // so the URL is passed as a single argv and never re-parsed by a shell —
1311        // `& | > ^ "` in a model-supplied URL can't break out into arbitrary
1312        // commands the way they can inside `cmd`.
1313        let mut cmd = Command::new("rundll32");
1314        cmd.args(["url.dll,FileProtocolHandler", url]);
1315        cmd
1316    };
1317
1318    command
1319        .stdin(Stdio::null())
1320        .stdout(Stdio::null())
1321        .stderr(Stdio::null())
1322        .kill_on_drop(false)
1323        .spawn()
1324        .map(|_| ())
1325        .map_err(|e| e.to_string())
1326}
1327
1328/// Drive the child process, pumping stdout+stderr concurrently so
1329/// the kernel pipe buffer never wedges the child. Emits
1330/// `ProgressEvent::Output` chunks on `ExecContext::progress` for
1331/// any future consumer that wants to show live subprocess output.
1332#[derive(Debug, Clone)]
1333struct CommandRunOutput {
1334    output: String,
1335    exit_code: Option<i32>,
1336    /// Terminating signal (Unix), when the process was killed by one — e.g.
1337    /// SIGSYS from the seccomp network kill-switch. `None` on a normal exit or
1338    /// on non-Unix.
1339    signal: Option<i32>,
1340    stdout_lines: usize,
1341    stderr_lines: usize,
1342}
1343
1344/// Result of driving a foreground command: ran to completion, was detached
1345/// (Ctrl+B), was cancelled (the turn token fired), or hit its timeout. The
1346/// cancelled and timed-out arms both tree-kill the process group and abort the
1347/// driver before returning, so neither can leak the child.
1348enum CommandRunResult {
1349    Completed(CommandRunOutput),
1350    Detached { pid: u32, log_path: PathBuf },
1351    Cancelled,
1352    TimedOut,
1353}
1354
1355/// Names that must never be inherited by a spawned command. Provider API
1356/// keys + the daemon token live in the parent's environment; a model-driven
1357/// shell command could otherwise read them via `env`/`printenv` and
1358/// exfiltrate them. We strip these by exact name in addition to the
1359/// pattern match in [`scrub_secret_env`].
1360const SECRET_ENV_VARS: &[&str] = &[
1361    "ANTHROPIC_API_KEY",
1362    "OPENAI_API_KEY",
1363    "GEMINI_API_KEY",
1364    "GOOGLE_API_KEY",
1365    "OLLAMA_API_KEY",
1366    "GROQ_API_KEY",
1367    "MISTRAL_API_KEY",
1368    "DEEPSEEK_API_KEY",
1369    "OPENROUTER_API_KEY",
1370    "XAI_API_KEY",
1371    "TOGETHER_API_KEY",
1372    "MERMAID_DAEMON_TOKEN",
1373];
1374
1375/// Tell child processes they have no human to talk to. A spawned command runs
1376/// session-detached with stdin on `/dev/null`, so any interactive credential
1377/// prompt can only fail or hang — git is the one common tool that would
1378/// otherwise sit on a prompt until the command timeout. Set unconditionally:
1379/// any other value guarantees a hang in this environment. (Same value the
1380/// plugin git hooks already use — see `mermaid-runtime`'s plugin module.)
1381fn harden_noninteractive_env(cmd: &mut Command) {
1382    cmd.env("GIT_TERMINAL_PROMPT", "0");
1383}
1384
1385/// Remove secret-bearing environment variables from a child command. Uses a
1386/// denylist (known provider keys + name patterns) rather than an allowlist so
1387/// ordinary build/run commands keep `PATH`, `CARGO_HOME`, language toolchain
1388/// vars, `XAUTHORITY`, etc. and still work.
1389fn scrub_secret_env(cmd: &mut Command) {
1390    for name in secret_env_names() {
1391        cmd.env_remove(&name);
1392    }
1393}
1394
1395/// The concrete secret-bearing names present in THIS process's environment —
1396/// shared by the pipe path (`scrub_secret_env`) and the PTY path
1397/// (`CommandBuilder::env_remove`), so the two spawn paths can't drift.
1398fn secret_env_names() -> Vec<String> {
1399    std::env::vars()
1400        .map(|(name, _)| name)
1401        .filter(|name| is_secret_env_name(name))
1402        .collect()
1403}
1404
1405/// True if an env var name looks like it carries a secret/credential and must
1406/// not leak into a model-run child process. Denylist (not allowlist) so
1407/// ordinary build/run vars (`PATH`, toolchain, `XAUTHORITY`, …) survive.
1408fn is_secret_env_name(name: &str) -> bool {
1409    let upper = name.to_ascii_uppercase();
1410    SECRET_ENV_VARS.contains(&upper.as_str())
1411        || upper.contains("API_KEY")
1412        || upper.contains("APIKEY")
1413        || upper.contains("ACCESS_KEY")
1414        || upper.contains("PRIVATE_KEY")
1415        || upper.contains("SECRET")
1416        || upper.contains("PASSWORD")
1417        || upper.contains("PASSWD")
1418        || upper.contains("CREDENTIAL")
1419        || upper.contains("TOKEN")
1420        || upper.contains("WEBHOOK")
1421        || upper.contains("DATABASE_URL")
1422        || upper.ends_with("_DSN")
1423        || upper.contains("CONNECTION_STRING")
1424        || upper == "KUBECONFIG"
1425        || upper == "SSH_AUTH_SOCK"
1426}
1427
1428/// Drain a child stream, capping the captured bytes at `cap` so a chatty or
1429/// newline-less command can't exhaust memory. Bytes are accumulated raw and
1430/// decoded once at the end (lossy) so a multibyte char split across reads is
1431/// not corrupted by the cap. Returns `(text, truncated)`.
1432/// On-disk cap for the per-stream tee log (#126). The in-memory buffer is
1433/// capped at `MAX_TOOL_OUTPUT_BYTES`; the log may grow larger (it stays
1434/// tail-able for a backgrounded process) but must not be unbounded — a command
1435/// spewing gigabytes would otherwise fill the temp dir.
1436const TEE_LOG_CAP_BYTES: usize = 64 * 1024 * 1024;
1437
1438/// Bounded head+tail capture core, shared by the pipe reader (`read_capped`)
1439/// and the PTY drain. Keeps the HEAD (up to cap/2) and a bounded TAIL ring:
1440/// command output puts the actual error / exit summary at the END, which
1441/// head-only truncation used to discard. head_cap + tail_cap == cap, so any
1442/// total <= cap reconstructs exactly (no marker); only a genuine overflow
1443/// drops the middle.
1444struct CappedCapture {
1445    head_cap: usize,
1446    tail_cap: usize,
1447    head: Vec<u8>,
1448    tail: std::collections::VecDeque<u8>,
1449    total: usize,
1450}
1451
1452impl CappedCapture {
1453    fn new(cap: usize) -> Self {
1454        let head_cap = cap / 2;
1455        Self {
1456            head_cap,
1457            tail_cap: cap - head_cap,
1458            head: Vec::new(),
1459            tail: std::collections::VecDeque::new(),
1460            total: 0,
1461        }
1462    }
1463
1464    fn push(&mut self, mut chunk: &[u8]) {
1465        self.total += chunk.len();
1466        // Fill the head first; everything past head_cap flows into the
1467        // bounded tail ring so the last tail_cap bytes always survive.
1468        if self.head.len() < self.head_cap {
1469            let take = (self.head_cap - self.head.len()).min(chunk.len());
1470            self.head.extend_from_slice(&chunk[..take]);
1471            chunk = &chunk[take..];
1472        }
1473        if !chunk.is_empty() {
1474            self.tail.extend(chunk.iter().copied());
1475            while self.tail.len() > self.tail_cap {
1476                self.tail.pop_front();
1477            }
1478        }
1479    }
1480
1481    /// `(text, truncated)` — bytes decoded lossily once at the end so a
1482    /// multibyte char split across reads is not corrupted by the cap.
1483    fn finish(self) -> (String, bool) {
1484        let truncated = self.total > self.head_cap + self.tail_cap;
1485        let tail_bytes: Vec<u8> = self.tail.into_iter().collect();
1486        let mut out = String::from_utf8_lossy(&self.head).into_owned();
1487        if truncated {
1488            let dropped = self.total - self.head.len() - tail_bytes.len();
1489            out.push_str(&format!("\n…[output truncated, {dropped} bytes elided]…\n"));
1490        }
1491        out.push_str(&String::from_utf8_lossy(&tail_bytes));
1492        (out, truncated)
1493    }
1494}
1495
1496async fn read_capped<R: AsyncRead + Unpin>(
1497    mut reader: R,
1498    cap: usize,
1499    log_cap: usize,
1500    progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
1501    log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1502) -> (String, bool) {
1503    let mut buf = [0u8; 8192];
1504    let mut capture = CappedCapture::new(cap);
1505    let mut logged: usize = 0;
1506    let mut log_capped = false;
1507    loop {
1508        match reader.read(&mut buf).await {
1509            Ok(0) => break,
1510            Ok(n) => {
1511                // Tee raw bytes to the shared log file so a backgrounded
1512                // (Ctrl+B) process stays tail-able via /logs — bounded at
1513                // `TEE_LOG_CAP_BYTES` so a runaway command can't fill the disk
1514                // (#126). Once capped we write a one-time marker and stop.
1515                if let Some(file) = &log
1516                    && !log_capped
1517                {
1518                    let mut f = file.lock().await;
1519                    if logged + n <= log_cap {
1520                        let _ = f.write_all(&buf[..n]).await;
1521                        logged += n;
1522                    } else {
1523                        let remaining = log_cap - logged;
1524                        let _ = f.write_all(&buf[..remaining]).await;
1525                        let _ = f.write_all(b"\n...[log truncated]...\n").await;
1526                        log_capped = true;
1527                    }
1528                    let _ = f.flush().await;
1529                }
1530                if let Some(tx) = &progress {
1531                    let chunk = String::from_utf8_lossy(&buf[..n]);
1532                    for line in chunk.split('\n') {
1533                        if !line.is_empty() {
1534                            let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
1535                        }
1536                    }
1537                }
1538                capture.push(&buf[..n]);
1539            },
1540            Err(_) => break,
1541        }
1542    }
1543    capture.finish()
1544}
1545
1546/// Strip terminal escape sequences and normalize PTY line discipline for
1547/// model-facing text: CSI (`ESC[…final`), OSC (`ESC]…BEL|ESC\\`), string
1548/// sequences (DCS/SOS/PM/APC — `ESC P/X/^/_ … ST`, payload included), and
1549/// other two-byte ESC sequences are dropped; a bare BEL is dropped; a
1550/// backspace erases the previous character (ConPTY repaints emit both);
1551/// `\r\n` (ONLCR — every PTY line) normalizes to `\n`; a lone `\r`
1552/// (progress-bar rewrite) becomes `\n` so rewrites read as lines, bounded
1553/// upstream by the output cap.
1554fn strip_ansi(input: &str) -> String {
1555    let mut out = String::with_capacity(input.len());
1556    let mut chars = input.chars().peekable();
1557    while let Some(c) = chars.next() {
1558        match c {
1559            '\u{1b}' => match chars.next() {
1560                // CSI: parameters/intermediates until a final byte 0x40..=0x7E.
1561                Some('[') => {
1562                    for f in chars.by_ref() {
1563                        if ('\u{40}'..='\u{7e}').contains(&f) {
1564                            break;
1565                        }
1566                    }
1567                },
1568                // OSC: terminated by BEL or ST (ESC \).
1569                Some(']') => {
1570                    let mut prev_esc = false;
1571                    for f in chars.by_ref() {
1572                        if f == '\u{7}' || (prev_esc && f == '\\') {
1573                            break;
1574                        }
1575                        prev_esc = f == '\u{1b}';
1576                    }
1577                },
1578                // DCS/SOS/PM/APC string sequences: the whole PAYLOAD is
1579                // device data, not text, so it must be consumed through the
1580                // ST terminator (ESC \) — dropping only the introducer
1581                // would leak the payload into the capture.
1582                Some('P' | 'X' | '^' | '_') => {
1583                    let mut prev_esc = false;
1584                    for f in chars.by_ref() {
1585                        if prev_esc && f == '\\' {
1586                            break;
1587                        }
1588                        prev_esc = f == '\u{1b}';
1589                    }
1590                },
1591                // Other two-byte escapes (charset selection, keypad modes…):
1592                // the consumed char IS the sequence.
1593                Some(_) | None => {},
1594            },
1595            // Bare BEL rings the bell; it is never text.
1596            '\u{7}' => {},
1597            // Backspace: the terminal would erase the previous cell, so pop
1598            // the previous character — but never across a line break.
1599            '\u{8}' => {
1600                if out.ends_with(|p: char| p != '\n') {
1601                    out.pop();
1602                }
1603            },
1604            '\r' => {
1605                if chars.peek() == Some(&'\n') {
1606                    chars.next();
1607                }
1608                out.push('\n');
1609            },
1610            _ => out.push(c),
1611        }
1612    }
1613    out
1614}
1615
1616async fn run_command(
1617    mut cmd: Command,
1618    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1619    token: tokio_util::sync::CancellationToken,
1620    background: tokio_util::sync::CancellationToken,
1621    timeout: Duration,
1622) -> std::io::Result<CommandRunResult> {
1623    let mut child = cmd.spawn()?;
1624    let pid = child.id();
1625
1626    let stdout = child
1627        .stdout
1628        .take()
1629        .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
1630    let stderr = child
1631        .stderr
1632        .take()
1633        .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
1634
1635    // Tee combined output to a log file so that, if the user backgrounds the
1636    // command (Ctrl+B), it stays tail-able via /logs. Removed on normal exit.
1637    // Lives in the 0700 private temp dir, created owner-only + O_EXCL (#F14/#F15).
1638    let log_path = background_log_path();
1639    let log =
1640        create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1641
1642    let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
1643    let stdout_task = tokio::spawn(read_capped(
1644        stdout,
1645        cap,
1646        TEE_LOG_CAP_BYTES,
1647        Some(progress.clone()),
1648        log.clone(),
1649    ));
1650    let stderr_task = tokio::spawn(read_capped(
1651        stderr,
1652        cap,
1653        TEE_LOG_CAP_BYTES,
1654        None,
1655        log.clone(),
1656    ));
1657
1658    // A driver task owns the child + drain tasks and runs to completion no
1659    // matter what. On normal exit it ships the result back. If we detach, we
1660    // just stop listening — the driver (and its child) keep running, the log
1661    // keeps filling — until the child exits or Mermaid quits.
1662    let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1663    let driver = tokio::spawn(async move {
1664        let (output, _) = stdout_task.await.unwrap_or_default();
1665        let (errors, _) = stderr_task.await.unwrap_or_default();
1666        let status = child.wait().await;
1667        let _ = done_tx.send((output, errors, status));
1668    });
1669
1670    let timeout_fut = tokio::time::sleep(timeout);
1671
1672    tokio::select! {
1673        biased;
1674        _ = background.cancelled() => {
1675            match pid {
1676                // Ctrl+B: detach. Dropping `driver`'s JoinHandle does NOT abort
1677                // the task — it runs on, keeping the child alive and the log
1678                // filling.
1679                Some(pid) => {
1680                    drop(driver);
1681                    Ok(CommandRunResult::Detached { pid, log_path })
1682                }
1683                // No OS pid means the child was already polled to completion —
1684                // there is nothing left to background. Report cancellation
1685                // rather than minting a phantom `bg-0` process that a later
1686                // `/stop` could mis-signal.
1687                None => {
1688                    driver.abort();
1689                    let _ = tokio::fs::remove_file(&log_path).await;
1690                    Ok(CommandRunResult::Cancelled)
1691                }
1692            }
1693        }
1694        _ = token.cancelled() => {
1695            // Turn cancelled (Esc): the detached `driver` would otherwise keep
1696            // the child (and any grandchild it forked) alive until it exited on
1697            // its own. Kill the whole tree/group, abort the driver, drop the log.
1698            if let Some(p) = pid {
1699                crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1700            }
1701            // This is the one deliberate `JoinHandle::abort` in the codebase.
1702            // `driver` is a raw (non-scoped) `tokio::spawn` because it must be
1703            // able to outlive the turn on Ctrl+B detach; on Esc-cancel we've
1704            // just force-killed its whole process tree, so its `await`s would
1705            // unblock at EOF momentarily anyway — the abort just makes teardown
1706            // immediate before we drop the tee log. See the doc note in
1707            // `src/domain/reducer.rs` and `docs/architecture.md`.
1708            driver.abort();
1709            let _ = tokio::fs::remove_file(&log_path).await;
1710            Ok(CommandRunResult::Cancelled)
1711        }
1712        res = done_rx => {
1713            // Normal completion — drop the tee log.
1714            drop(log);
1715            let _ = tokio::fs::remove_file(&log_path).await;
1716            let (output, errors, status) = res
1717                .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
1718            let status = status?;
1719            let stdout_lines = output.lines().count();
1720            let stderr_lines = errors.lines().count();
1721            let mut full_output = output;
1722            if !errors.is_empty() {
1723                full_output.push_str("\n--- stderr ---\n");
1724                full_output.push_str(&errors);
1725            }
1726            if !status.success() {
1727                full_output.push_str(&format!(
1728                    "\n--- Command exited with status: {} ---",
1729                    status.code().unwrap_or(-1)
1730                ));
1731            }
1732            // Preserve the terminating signal so the caller can distinguish a
1733            // seccomp SIGSYS denial from an ordinary failure (mirrors
1734            // `mcp/transport.rs`). `None` on non-Unix / normal exit.
1735            #[cfg(unix)]
1736            let signal = {
1737                use std::os::unix::process::ExitStatusExt;
1738                status.signal()
1739            };
1740            #[cfg(not(unix))]
1741            let signal = None;
1742            Ok(CommandRunResult::Completed(CommandRunOutput {
1743                output: full_output,
1744                exit_code: status.code(),
1745                signal,
1746                stdout_lines,
1747                stderr_lines,
1748            }))
1749        }
1750        _ = timeout_fut => {
1751            // Foreground timeout: same teardown as Esc. The old outer-`select!`
1752            // form dropped the `run_command` future on timeout, which only
1753            // DETACHED the spawned `driver` that owns the Child — so the whole
1754            // tree leaked despite the "was killed" message. Tree-kill the group,
1755            // abort the driver, drop the tee log, then report TimedOut.
1756            if let Some(p) = pid {
1757                crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1758            }
1759            driver.abort();
1760            let _ = tokio::fs::remove_file(&log_path).await;
1761            Ok(CommandRunResult::TimedOut)
1762        }
1763    }
1764}
1765
1766/// PTY drain state: tees raw bytes to the log, emits sanitized complete
1767/// lines as progress, and feeds the bounded capture. One merged stream —
1768/// a PTY has no stdout/stderr split (`stderr_lines` reports 0).
1769struct PtyDrain {
1770    capture: CappedCapture,
1771    log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
1772    logged: usize,
1773    log_capped: bool,
1774    line_buf: String,
1775    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1776}
1777
1778impl PtyDrain {
1779    async fn push(&mut self, chunk: &[u8]) {
1780        // Tee RAW bytes (ANSI kept — tailing a backgrounded log renders
1781        // correctly); same bound as the pipe path (#126).
1782        if let Some(file) = &self.log
1783            && !self.log_capped
1784        {
1785            let mut f = file.lock().await;
1786            if self.logged + chunk.len() <= TEE_LOG_CAP_BYTES {
1787                let _ = f.write_all(chunk).await;
1788                self.logged += chunk.len();
1789            } else {
1790                let remaining = TEE_LOG_CAP_BYTES - self.logged;
1791                let _ = f.write_all(&chunk[..remaining]).await;
1792                let _ = f.write_all(b"\n...[log truncated]...\n").await;
1793                self.log_capped = true;
1794            }
1795            let _ = f.flush().await;
1796        }
1797        // Progress: sanitize, then emit complete lines only (an escape split
1798        // across chunks is cosmetic here; the final output sanitizes whole).
1799        self.line_buf
1800            .push_str(&strip_ansi(&String::from_utf8_lossy(chunk)));
1801        while let Some(i) = self.line_buf.find('\n') {
1802            let line: String = self.line_buf.drain(..=i).collect();
1803            let line = line.trim_end();
1804            if !line.is_empty() {
1805                let _ = self
1806                    .progress
1807                    .send(ProgressEvent::Output(line.to_string()))
1808                    .await;
1809            }
1810        }
1811        // Cap applies to RAW bytes pre-strip (bounded memory).
1812        self.capture.push(chunk);
1813    }
1814}
1815
1816/// Foreground command on a pseudo-terminal (openpty on Unix, ConPTY on
1817/// Windows): `tty`/`isatty` report a terminal, spinner-heavy tools behave,
1818/// and on Unix `/dev/tty` resolves to THIS captured pty instead of
1819/// scribbling over the TUI. Mirrors `run_command`'s select shape (detach /
1820/// cancel / done / timeout) and reuses the same sandbox launcher, env
1821/// scrubbing, tee log, and capture core.
1822///
1823/// Load-bearing differences from the pipe path:
1824/// - NO `setsid` pre_exec: on Unix portable-pty already setsids and sets
1825///   the controlling tty — the child is session+group leader, so
1826///   `terminate_tree`'s group-kill semantics are byte-identical. On
1827///   Windows `terminate_tree` kills the tree by pid (`taskkill /T`), so no
1828///   group setup is needed on either spawn path.
1829/// - stdin is the pty slave (not /dev/null): a child that READS stdin now
1830///   hangs to timeout instead of instant EOF — mitigated by
1831///   GIT_TERMINAL_PROMPT=0 (still set) and the command timeout.
1832/// - fixed 24x80 size: nothing resizes it (plumbing the live TUI size is
1833///   not worth a resize protocol for batch commands).
1834///
1835/// Every fallible step happens BEFORE the child spawns, so an `Err` return
1836/// can safely fall back to the pipe path without re-running side effects —
1837/// openpty, clone_reader, and (Windows) the CPR priming write are the only
1838/// `?` points ahead of `spawn_command`.
1839async fn run_command_pty(
1840    invocation: &ShellInvocation,
1841    workdir: &Path,
1842    scratchpad: Option<&Path>,
1843    progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1844    token: tokio_util::sync::CancellationToken,
1845    background: tokio_util::sync::CancellationToken,
1846    timeout: Duration,
1847) -> std::io::Result<CommandRunResult> {
1848    use portable_pty::{CommandBuilder, PtySize, native_pty_system};
1849
1850    let pty = native_pty_system();
1851    let pair = pty
1852        .openpty(PtySize {
1853            rows: 24,
1854            cols: 80,
1855            pixel_width: 0,
1856            pixel_height: 0,
1857        })
1858        .map_err(std::io::Error::other)?;
1859    // Clone the reader BEFORE spawning: after this point nothing may fail
1860    // fallibly (a post-spawn fallback would re-run the command).
1861    let mut reader = pair
1862        .master
1863        .try_clone_reader()
1864        .map_err(std::io::Error::other)?;
1865
1866    // portable-pty opens the ConPTY with PSEUDOCONSOLE_INHERIT_CURSOR, so
1867    // conhost emits a cursor-position query (ESC[6n) and stalls ALL output
1868    // until it reads a reply. Prime it once with "cursor at 1;1": conhost
1869    // consumes the reply itself, so the child never sees these bytes. The
1870    // writer must then live exactly as long as the master (an early close
1871    // can detach the pseudoconsole), so it moves into the waiter below and
1872    // drops alongside the master. Both steps sit BEFORE the spawn, so a
1873    // failure here still falls back to pipes without double-running.
1874    #[cfg(windows)]
1875    let writer = {
1876        use std::io::Write as _;
1877        let mut writer = pair.master.take_writer().map_err(std::io::Error::other)?;
1878        writer.write_all(b"\x1b[1;1R")?;
1879        writer
1880    };
1881
1882    let mut builder = CommandBuilder::new(&invocation.program);
1883    builder.args(&invocation.args);
1884    builder.cwd(workdir);
1885    for name in secret_env_names() {
1886        builder.env_remove(name);
1887    }
1888    // Still load-bearing on a PTY: git COULD prompt here and nothing feeds
1889    // the master, so it must fail fast instead of sitting on the prompt.
1890    builder.env("GIT_TERMINAL_PROMPT", "0");
1891    builder.env("TERM", "xterm-256color");
1892    // Same export the pipe/background paths apply via `export_scratchpad_env`
1893    // — keep the spawn paths from drifting.
1894    if let Some(dir) = scratchpad {
1895        builder.env(SCRATCHPAD_ENV_VAR, dir);
1896    }
1897
1898    let mut child = pair
1899        .slave
1900        .spawn_command(builder)
1901        .map_err(std::io::Error::other)?;
1902    // Drop the slave so the master reads EOF when the child exits.
1903    drop(pair.slave);
1904    let pid = child.process_id();
1905    let master = pair.master;
1906
1907    let log_path = background_log_path();
1908    let log =
1909        create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1910
1911    // Reader thread: blocking pty reads into a bounded channel.
1912    let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
1913    let reader_thread = tokio::task::spawn_blocking(move || {
1914        let mut buf = [0u8; 8192];
1915        loop {
1916            match reader.read(&mut buf) {
1917                Ok(0) | Err(_) => break,
1918                Ok(n) => {
1919                    if chunk_tx.blocking_send(buf[..n].to_vec()).is_err() {
1920                        break;
1921                    }
1922                },
1923            }
1924        }
1925    });
1926
1927    let drain = tokio::spawn(async move {
1928        let mut drain = PtyDrain {
1929            capture: CappedCapture::new(crate::constants::MAX_TOOL_OUTPUT_BYTES),
1930            log,
1931            logged: 0,
1932            log_capped: false,
1933            line_buf: String::new(),
1934            progress,
1935        };
1936        while let Some(chunk) = chunk_rx.recv().await {
1937            drain.push(&chunk).await;
1938        }
1939        drain.capture.finish()
1940    });
1941
1942    // Waiter owns the child AND the master: the master must outlive the
1943    // child (dropping it early can SIGHUP the session on Unix / detach the
1944    // ConPTY on Windows), and dropping it right after `wait` returns
1945    // unblocks the reader thread — EOF/EIO on Unix; on Windows the master
1946    // and (already-dropped) slave share the pseudoconsole, so the last drop
1947    // runs ClosePseudoConsole, conhost exits, and the reader's duplicated
1948    // handle EOFs — the drain always finishes. (A reader wedged by a hung
1949    // conhost would leak bounded-by-process; the timeout arm below is an
1950    // independent backstop, so no read timeout on the drain.)
1951    let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1952    let driver = tokio::spawn(async move {
1953        let status = tokio::task::spawn_blocking(move || {
1954            let status = child.wait();
1955            // The CPR priming writer must drop WITH the master, never
1956            // before it (early close = detach risk).
1957            #[cfg(windows)]
1958            drop(writer);
1959            drop(master);
1960            status
1961        })
1962        .await;
1963        let (output, truncated) = drain.await.unwrap_or_default();
1964        let _ = reader_thread.await;
1965        let _ = done_tx.send((output, truncated, status));
1966    });
1967
1968    let timeout_fut = tokio::time::sleep(timeout);
1969
1970    tokio::select! {
1971        biased;
1972        _ = background.cancelled() => {
1973            match pid {
1974                // Ctrl+B detach: stop listening; the blocking wait/read
1975                // threads keep running, the log keeps filling, and the child
1976                // survives Mermaid's exit (nothing is kill-on-drop here).
1977                Some(pid) => {
1978                    drop(driver);
1979                    Ok(CommandRunResult::Detached { pid, log_path })
1980                },
1981                None => {
1982                    driver.abort();
1983                    let _ = tokio::fs::remove_file(&log_path).await;
1984                    Ok(CommandRunResult::Cancelled)
1985                },
1986            }
1987        }
1988        _ = token.cancelled() => {
1989            // Unix: the child is the session/group leader (portable-pty
1990            // setsids), so the group-kill takes the whole tree, exactly like
1991            // the pipe path; the reader then unblocks at EOF/EIO. Windows:
1992            // `terminate_tree` kills the tree by pid (`taskkill /T`); the
1993            // waiter's `wait` then returns and drops the master, which
1994            // closes the pseudoconsole and EOFs the reader. Neither arm
1995            // reads the exit status, so killed-child exit-code quirks on
1996            // Windows never surface here.
1997            if let Some(p) = pid {
1998                crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1999            }
2000            driver.abort();
2001            let _ = tokio::fs::remove_file(&log_path).await;
2002            Ok(CommandRunResult::Cancelled)
2003        }
2004        res = done_rx => {
2005            let _ = tokio::fs::remove_file(&log_path).await;
2006            let (raw, _truncated, status) = res
2007                .map_err(|_| std::io::Error::other("pty driver dropped before completing"))?;
2008            let status = status
2009                .map_err(|e| std::io::Error::other(format!("pty waiter panicked: {e}")))?
2010                .map_err(std::io::Error::other)?;
2011            // Sanitize the WHOLE capture once (escape sequences can span
2012            // chunk boundaries; per-chunk stripping is progress-only).
2013            let mut output = strip_ansi(&raw);
2014            // portable-pty reports a terminating signal by NAME; SIGSYS is
2015            // the one downstream consumer (the seccomp denial mapping) —
2016            // `128 + SIGSYS` shell-reaped exits flow through exit_code as-is.
2017            // On Windows `signal()` is always None, so the exit-code arm is
2018            // taken unconditionally (the seccomp sandbox is Linux-only
2019            // anyway) — no cfg needed on these arms.
2020            let (exit_code, signal) = match status.signal() {
2021                Some(name) if name.eq_ignore_ascii_case("bad system call") => {
2022                    (None, Some(SANDBOX_KILL_SIGNAL))
2023                },
2024                Some(_) => (None, None),
2025                None => (Some(status.exit_code() as i32), None),
2026            };
2027            if !status.success() {
2028                output.push_str(&format!(
2029                    "\n--- Command exited with status: {} ---",
2030                    exit_code.unwrap_or(-1)
2031                ));
2032            }
2033            let stdout_lines = output.lines().count();
2034            Ok(CommandRunResult::Completed(CommandRunOutput {
2035                output,
2036                exit_code,
2037                signal,
2038                // One merged stream on a PTY — there is no stderr split.
2039                stdout_lines,
2040                stderr_lines: 0,
2041            }))
2042        }
2043        _ = timeout_fut => {
2044            if let Some(p) = pid {
2045                crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
2046            }
2047            driver.abort();
2048            let _ = tokio::fs::remove_file(&log_path).await;
2049            Ok(CommandRunResult::TimedOut)
2050        }
2051    }
2052}
2053
2054/// Defense-in-depth pre-check for obviously destructive commands, run before
2055/// the policy engine. Delegates to `crate::runtime::is_destructive_command`,
2056/// which segments the command the way `sh -c` would and classifies each head on
2057/// the TOKENIZED form — so spacing, case, quoting, flag bundling, and chaining
2058/// can't trivially evade it (the substring blocklist this replaced could be
2059/// dodged by `RM -RF /`, `rm  -rf  /`, or `echo x; rm -rf /` — #114). NOT a
2060/// security boundary: the real boundary is deny-by-default + the policy engine,
2061/// whose hard-deny this mirrors.
2062fn contains_dangerous_command(command: &str) -> bool {
2063    crate::runtime::is_destructive_command(command)
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068    use super::*;
2069    use crate::domain::{ToolCallId, TurnId};
2070    use crate::providers::ctx::test_exec_context;
2071    use std::path::PathBuf;
2072
2073    #[test]
2074    fn network_denial_detects_sigsys_and_reaped_child_exit() {
2075        let out = |exit: Option<i32>, signal: Option<i32>| CommandRunOutput {
2076            output: String::new(),
2077            exit_code: exit,
2078            signal,
2079            stdout_lines: 0,
2080            stderr_lines: 0,
2081        };
2082        // The shell itself was SIGSYS-killed.
2083        assert!(is_sigsys_denial(&out(None, Some(31))));
2084        // The shell reaped a SIGSYS-killed child and exited 128 + 31.
2085        assert!(is_sigsys_denial(&out(Some(159), None)));
2086        // Ordinary failures / success / a different signal are not denials.
2087        assert!(!is_sigsys_denial(&out(Some(1), None)));
2088        assert!(!is_sigsys_denial(&out(Some(0), None)));
2089        assert!(!is_sigsys_denial(&out(None, Some(11)))); // SIGSEGV, not SIGSYS
2090    }
2091
2092    #[test]
2093    fn detect_denial_gates_on_active_policies() {
2094        let out = |exit: Option<i32>, signal: Option<i32>, output: &str| CommandRunOutput {
2095            output: output.to_string(),
2096            exit_code: exit,
2097            signal,
2098            stdout_lines: 0,
2099            stderr_lines: 0,
2100        };
2101        // Sandbox off for this spawn: nothing is ever labeled a denial, no
2102        // matter how denial-shaped the failure looks.
2103        assert_eq!(
2104            detect_denial(&out(Some(159), None, "Permission denied"), false, false),
2105            None
2106        );
2107        assert_eq!(detect_denial(&out(None, Some(31), ""), false, false), None);
2108        // A clean success is never a denial even with both policies active.
2109        assert_eq!(detect_denial(&out(Some(0), None, ""), true, true), None);
2110        #[cfg(target_os = "linux")]
2111        {
2112            // Precise SIGSYS signature maps to Network; permission text with
2113            // only the FS sandbox active maps to Filesystem.
2114            assert_eq!(
2115                detect_denial(&out(None, Some(31), ""), true, true),
2116                Some(DenialKind::Network)
2117            );
2118            assert_eq!(
2119                detect_denial(&out(Some(1), None, "Permission denied"), false, true),
2120                Some(DenialKind::Filesystem)
2121            );
2122            // Linux network denials are SIGSYS-only: permission text alone
2123            // does not implicate the network sandbox.
2124            assert_eq!(
2125                detect_denial(&out(Some(1), None, "Permission denied"), true, false),
2126                None
2127            );
2128        }
2129        #[cfg(target_os = "macos")]
2130        {
2131            // Seatbelt: hedged EPERM text; both-active is ambiguous.
2132            let eperm = out(Some(1), None, "curl: Operation not permitted");
2133            assert_eq!(
2134                detect_denial(&eperm, true, false),
2135                Some(DenialKind::Network)
2136            );
2137            assert_eq!(
2138                detect_denial(&eperm, false, true),
2139                Some(DenialKind::Filesystem)
2140            );
2141            assert_eq!(
2142                detect_denial(&eperm, true, true),
2143                Some(DenialKind::Ambiguous)
2144            );
2145        }
2146    }
2147
2148    #[test]
2149    fn fs_denial_requires_failure_and_permission_signature() {
2150        let out = |exit: Option<i32>, output: &str| CommandRunOutput {
2151            output: output.to_string(),
2152            exit_code: exit,
2153            signal: None,
2154            stdout_lines: 0,
2155            stderr_lines: 0,
2156        };
2157        // Non-zero exit + the permission-error text ⇒ denial signature.
2158        assert!(is_permission_denial(&out(
2159            Some(1),
2160            "sh: line 1: /etc/nope: Permission denied"
2161        )));
2162        assert!(is_permission_denial(&out(
2163            Some(2),
2164            "touch: Operation not permitted"
2165        )));
2166        // A successful command mentioning the phrase is not a denial…
2167        assert!(!is_permission_denial(&out(
2168            Some(0),
2169            "grep found: Permission denied"
2170        )));
2171        // …nor is an ordinary failure without it, or a signal death.
2172        assert!(!is_permission_denial(&out(Some(1), "some other failure")));
2173        assert!(!is_permission_denial(&out(None, "Permission denied")));
2174    }
2175
2176    #[test]
2177    fn sandboxed_shell_wraps_only_when_requested() {
2178        let plain = build_sandboxed_shell("echo hi", false, None);
2179        let plain_prog = plain.as_std().get_program().to_string_lossy().into_owned();
2180        assert!(
2181            plain_prog.ends_with("sh") || plain_prog.ends_with("cmd"),
2182            "plain shell program: {plain_prog}"
2183        );
2184
2185        let wrapped = build_sandboxed_shell("echo hi", true, None);
2186        let args: Vec<String> = wrapped
2187            .as_std()
2188            .get_args()
2189            .map(|a| a.to_string_lossy().into_owned())
2190            .collect();
2191        assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2192        assert!(args.contains(&"--no-network".to_string()));
2193        assert!(!args.contains(&"--confine-writes".to_string()));
2194        assert!(args.contains(&"sh".to_string()));
2195    }
2196
2197    #[test]
2198    fn sandboxed_shell_passes_confine_writes_dirs() {
2199        let dirs = vec![PathBuf::from("/proj"), PathBuf::from("/dev")];
2200        let wrapped = build_sandboxed_shell("echo hi", false, Some(&dirs));
2201        let args: Vec<String> = wrapped
2202            .as_std()
2203            .get_args()
2204            .map(|a| a.to_string_lossy().into_owned())
2205            .collect();
2206        assert_eq!(args.first().map(String::as_str), Some("__sandbox-exec"));
2207        assert!(!args.contains(&"--no-network".to_string()));
2208        // Each dir rides its own `--confine-writes`.
2209        assert_eq!(
2210            args.iter().filter(|a| *a == "--confine-writes").count(),
2211            2,
2212            "args: {args:?}"
2213        );
2214        assert!(args.contains(&"/proj".to_string()));
2215        assert!(args.contains(&"/dev".to_string()));
2216    }
2217
2218    #[tokio::test]
2219    async fn tee_log_is_capped() {
2220        // #126: the on-disk tee log must be bounded so a command spewing
2221        // gigabytes can't fill the temp dir, even though the in-memory buffer is
2222        // already capped.
2223        let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
2224        let _ = std::fs::create_dir_all(&dir);
2225        let path = dir.join("log.txt");
2226        let file = tokio::fs::File::create(&path).await.unwrap();
2227        let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
2228        // 4000 bytes of output, on-disk log capped at 16.
2229        let data = vec![b'x'; 4000];
2230        let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
2231        let written = std::fs::read(&path).unwrap();
2232        assert!(
2233            written.len() < 200,
2234            "log must be capped near 16 bytes + marker, got {}",
2235            written.len()
2236        );
2237        assert!(String::from_utf8_lossy(&written).contains("log truncated"));
2238        let _ = std::fs::remove_dir_all(&dir);
2239    }
2240
2241    #[cfg(unix)]
2242    #[test]
2243    fn tee_log_created_owner_only_and_refuses_existing() {
2244        // #F14/#F15: the tee log (which can capture secret-bearing stdout) must
2245        // be owner-only, and the O_EXCL create must refuse a pre-existing path —
2246        // the same guard that refuses to follow a symlink planted at the
2247        // predictable name.
2248        use std::os::unix::fs::PermissionsExt;
2249        let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
2250        let _ = std::fs::create_dir_all(&dir);
2251        let path = dir.join("bg.log");
2252        let _ = std::fs::remove_file(&path);
2253
2254        let file = create_log_file_blocking(&path).expect("first create succeeds");
2255        drop(file);
2256        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2257        assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
2258
2259        // O_EXCL: a second create at the same path (e.g. an attacker-planted
2260        // symlink/file) is refused rather than followed/truncated.
2261        assert!(
2262            create_log_file_blocking(&path).is_err(),
2263            "O_EXCL must refuse an existing path"
2264        );
2265        let _ = std::fs::remove_dir_all(&dir);
2266    }
2267
2268    #[test]
2269    fn secret_env_name_denylist_covers_common_carriers() {
2270        // #4: secrets the old denylist missed.
2271        for name in [
2272            "ANTHROPIC_API_KEY",
2273            "AWS_SECRET_ACCESS_KEY",
2274            "GITHUB_TOKEN",
2275            "MY_SERVICE_PRIVATE_KEY",
2276            "DATABASE_URL",
2277            "SENTRY_DSN",
2278            "SLACK_WEBHOOK_URL",
2279            "KUBECONFIG",
2280            "SSH_AUTH_SOCK",
2281            "DB_PASSWORD",
2282            "PG_CONNECTION_STRING",
2283        ] {
2284            assert!(is_secret_env_name(name), "{name} should be scrubbed");
2285        }
2286        // Ordinary build/run vars must survive.
2287        for name in [
2288            "PATH",
2289            "HOME",
2290            "CARGO_HOME",
2291            "LANG",
2292            "XAUTHORITY",
2293            "RUSTUP_HOME",
2294        ] {
2295            assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
2296        }
2297    }
2298
2299    #[tokio::test]
2300    async fn out_of_project_working_dir_is_escalated_and_blocked() {
2301        // #1: a read-only command auto-runs in-project, but the same command
2302        // with an out-of-project working_dir is escalated to ExternalDirectory
2303        // and denied (here, by ReadOnly mode — proving it's no longer treated
2304        // as an auto-allowable in-project read).
2305        let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
2306        let _ = std::fs::remove_dir_all(&project);
2307        std::fs::create_dir_all(&project).unwrap();
2308        let outside = project.parent().unwrap().to_path_buf();
2309
2310        let mk_ctx = || {
2311            let (tx, rx) = tokio::sync::mpsc::channel(64);
2312            let mut config = crate::app::Config::default();
2313            config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
2314            let ctx = crate::providers::ctx::ExecContext::new(
2315                tokio_util::sync::CancellationToken::new(),
2316                tx,
2317                ToolCallId(1),
2318                TurnId(1),
2319                project.clone(),
2320                std::sync::Arc::new(config),
2321                String::new(),
2322                None,
2323                None,
2324                None,
2325                crate::runtime::SafetyMode::ReadOnly,
2326                None,
2327                None,
2328                None,
2329                None,
2330                None,
2331            );
2332            (ctx, rx)
2333        };
2334
2335        let (ctx, _rx) = mk_ctx();
2336        let outcome = ExecuteCommandTool
2337            .execute(serde_json::json!({"command": "echo hi"}), ctx)
2338            .await;
2339        assert!(
2340            outcome.is_success(),
2341            "in-project read-only echo should run: {outcome:?}",
2342        );
2343
2344        let (ctx, _rx) = mk_ctx();
2345        let outcome = ExecuteCommandTool
2346            .execute(
2347                serde_json::json!({
2348                    "command": "echo hi",
2349                    "working_dir": outside.display().to_string(),
2350                }),
2351                ctx,
2352            )
2353            .await;
2354        assert_eq!(
2355            outcome.status,
2356            crate::domain::ToolStatus::Error,
2357            "out-of-project working_dir must be escalated + blocked: {outcome:?}",
2358        );
2359
2360        let _ = std::fs::remove_dir_all(&project);
2361    }
2362
2363    #[tokio::test]
2364    async fn safe_command_runs_and_captures_output() {
2365        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2366        let outcome = ExecuteCommandTool
2367            .execute(serde_json::json!({"command": "echo hello world"}), ctx)
2368            .await;
2369        assert!(outcome.is_success(), "expected success: {:?}", outcome);
2370        assert!(outcome.output().contains("hello world"));
2371    }
2372
2373    /// The foreground child must be a session leader (sid == its own pid).
2374    /// This is the non-vacuous half of the /dev/tty fix: a new session has no
2375    /// controlling terminal, so `sudo`-style prompts fail instead of writing
2376    /// over the TUI. Linux-only: probes /proc (field 6 of stat is the sid).
2377    #[cfg(target_os = "linux")]
2378    #[tokio::test]
2379    async fn foreground_child_runs_in_new_session() {
2380        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2381        let outcome = ExecuteCommandTool
2382            .execute(
2383                serde_json::json!({
2384                    "command": r#"test "$(awk '{print $6}' /proc/$$/stat)" = "$$" && echo NEW_SESSION_OK || echo "NOT_A_SESSION_LEADER sid=$(awk '{print $6}' /proc/$$/stat) pid=$$""#,
2385                }),
2386                ctx,
2387            )
2388            .await;
2389        assert!(outcome.is_success(), "expected success: {outcome:?}");
2390        assert!(
2391            outcome.output().contains("NEW_SESSION_OK"),
2392            "child shell is not a session leader: {}",
2393            outcome.output()
2394        );
2395    }
2396
2397    /// The sudo-incident invariant, PTY era: `/dev/tty` must resolve to the
2398    /// CAPTURED pty, never the user's terminal — a prompt writes into the
2399    /// tool output instead of over the TUI. (The pipe path keeps the old
2400    /// stricter guarantee — see the pipes-mode test below.)
2401    #[cfg(unix)]
2402    #[tokio::test]
2403    async fn pty_child_dev_tty_is_the_captured_pty() {
2404        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2405        let outcome = ExecuteCommandTool
2406            .execute(
2407                serde_json::json!({
2408                    "command": "if echo CAPTURED_BY_PTY > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2409                }),
2410                ctx,
2411            )
2412            .await;
2413        assert!(outcome.is_success(), "expected success: {outcome:?}");
2414        assert!(
2415            outcome.output().contains("TTY_OPEN_OK"),
2416            "PTY child should see a controlling terminal: {}",
2417            outcome.output()
2418        );
2419        assert!(
2420            outcome.output().contains("CAPTURED_BY_PTY"),
2421            "/dev/tty writes must land in the CAPTURE, not the user's terminal: {}",
2422            outcome.output()
2423        );
2424    }
2425
2426    /// Direct regression for the sudo incident on the PIPE path
2427    /// (`[exec] pty = false`): a child that opens `/dev/tty` must fail. Only
2428    /// meaningful where the test process itself has a controlling terminal —
2429    /// CI runners have none (the open fails for everyone there), so skip
2430    /// explicitly rather than pass vacuously.
2431    #[cfg(unix)]
2432    #[tokio::test]
2433    async fn foreground_child_cannot_open_dev_tty() {
2434        if std::fs::File::open("/dev/tty").is_err() {
2435            eprintln!("skipped: no controlling terminal in test environment");
2436            return;
2437        }
2438        let (ctx, _rx) = pipes_ctx();
2439        let outcome = ExecuteCommandTool
2440            .execute(
2441                serde_json::json!({
2442                    "command": "if echo x > /dev/tty 2>/dev/null; then echo TTY_OPEN_OK; else echo TTY_OPEN_DENIED; fi",
2443                }),
2444                ctx,
2445            )
2446            .await;
2447        assert!(
2448            outcome.output().contains("TTY_OPEN_DENIED"),
2449            "session-detached child could still open /dev/tty: {}",
2450            outcome.output()
2451        );
2452    }
2453
2454    /// Pipe-mode context: `[exec] pty = false` pins the pipe spawn path.
2455    fn pipes_ctx() -> (
2456        crate::providers::ctx::ExecContext,
2457        tokio::sync::mpsc::Receiver<crate::providers::ctx::ProgressEvent>,
2458    ) {
2459        let mut config = crate::app::Config::default();
2460        config.safety.mode = crate::runtime::SafetyMode::FullAccess;
2461        config.exec.pty = Some(false);
2462        crate::providers::ctx::test_exec_context_with_config(
2463            TurnId(1),
2464            ToolCallId(1),
2465            std::env::temp_dir(),
2466            config,
2467        )
2468    }
2469
2470    #[cfg(unix)]
2471    #[tokio::test]
2472    async fn pty_child_sees_a_terminal_and_pipes_child_does_not() {
2473        // PTY (default): isatty(stdout) is true and `tty` names a pts.
2474        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2475        let outcome = ExecuteCommandTool
2476            .execute(
2477                serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; fi; tty"}),
2478                ctx,
2479            )
2480            .await;
2481        assert!(outcome.is_success(), "{outcome:?}");
2482        assert!(outcome.output().contains("IS_TTY"), "{}", outcome.output());
2483        assert!(
2484            outcome.output().contains("/dev/pts/") || outcome.output().contains("/dev/tty"),
2485            "tty should name the pts: {}",
2486            outcome.output()
2487        );
2488        // Pipes (`pty = false`): not a terminal.
2489        let (ctx, _rx) = pipes_ctx();
2490        let outcome = ExecuteCommandTool
2491            .execute(
2492                serde_json::json!({"command": "if [ -t 1 ]; then echo IS_TTY; else echo NOT_TTY; fi"}),
2493                ctx,
2494            )
2495            .await;
2496        assert!(outcome.output().contains("NOT_TTY"), "{}", outcome.output());
2497    }
2498
2499    #[cfg(unix)]
2500    #[tokio::test]
2501    async fn pty_output_is_ansi_clean_and_crlf_normalized() {
2502        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2503        // A color-emitting printf: the capture must carry the words, none of
2504        // the escape bytes, and PTY ONLCR \r\n must read back as plain \n.
2505        let outcome = ExecuteCommandTool
2506            .execute(
2507                serde_json::json!({
2508                    "command": r"printf '\033[31mRED\033[0m\nline2\n'",
2509                }),
2510                ctx,
2511            )
2512            .await;
2513        assert!(outcome.is_success(), "{outcome:?}");
2514        let out = outcome.output();
2515        assert!(out.contains("RED\nline2"), "clean joined lines: {out:?}");
2516        assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2517        assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2518    }
2519
2520    /// Windows twin of the unix isatty split: under ConPTY the child gets a
2521    /// real console (`IsOutputRedirected` is False); under `pty = false`
2522    /// pipes it sees redirected handles (True).
2523    #[cfg(windows)]
2524    #[tokio::test]
2525    async fn pty_child_sees_a_console_and_pipes_child_does_not() {
2526        let probe = "powershell -NoProfile -Command [Console]::IsOutputRedirected";
2527        // ConPTY (default): stdout is a console.
2528        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2529        let outcome = ExecuteCommandTool
2530            .execute(serde_json::json!({ "command": probe }), ctx)
2531            .await;
2532        assert!(outcome.is_success(), "{outcome:?}");
2533        assert!(
2534            outcome.output().contains("False"),
2535            "ConPTY child must see a console: {}",
2536            outcome.output()
2537        );
2538        // Pipes (`pty = false`): stdout is redirected.
2539        let (ctx, _rx) = pipes_ctx();
2540        let outcome = ExecuteCommandTool
2541            .execute(serde_json::json!({ "command": probe }), ctx)
2542            .await;
2543        assert!(outcome.is_success(), "{outcome:?}");
2544        assert!(
2545            outcome.output().contains("True"),
2546            "pipe child must see redirected stdout: {}",
2547            outcome.output()
2548        );
2549    }
2550
2551    /// Windows twin of the unix ANSI/CRLF test: ConPTY output reaches the
2552    /// model with escapes stripped and CRLF normalized. Line matching is
2553    /// whitespace-tolerant because ConPTY repaints pad lines to the
2554    /// pseudoconsole width.
2555    #[cfg(windows)]
2556    #[tokio::test]
2557    async fn pty_output_is_ansi_clean_and_crlf_normalized_windows() {
2558        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2559        let outcome = ExecuteCommandTool
2560            .execute(
2561                serde_json::json!({ "command": "echo RED& echo line2" }),
2562                ctx,
2563            )
2564            .await;
2565        assert!(outcome.is_success(), "{outcome:?}");
2566        let out = outcome.output();
2567        assert!(!out.contains('\u{1b}'), "no escape bytes: {out:?}");
2568        assert!(!out.contains('\r'), "no carriage returns: {out:?}");
2569        let lines: Vec<&str> = out.lines().map(str::trim).collect();
2570        assert!(lines.contains(&"RED"), "RED line present: {out:?}");
2571        assert!(lines.contains(&"line2"), "line2 line present: {out:?}");
2572    }
2573
2574    #[test]
2575    fn strip_ansi_drops_escapes_and_normalizes_line_endings() {
2576        // CSI color + cursor movement, OSC title (BEL and ST terminated),
2577        // two-byte ESC, CRLF and lone CR.
2578        assert_eq!(strip_ansi("\u{1b}[31mRED\u{1b}[0m"), "RED");
2579        assert_eq!(strip_ansi("\u{1b}[2K\u{1b}[1Gline"), "line");
2580        assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
2581        assert_eq!(strip_ansi("\u{1b}]8;;url\u{1b}\\link"), "link");
2582        assert_eq!(strip_ansi("\u{1b}=keypad"), "keypad");
2583        assert_eq!(strip_ansi("a\r\nb"), "a\nb");
2584        assert_eq!(strip_ansi("50%\r100%\r\n"), "50%\n100%\n");
2585        // String sequences (DCS/SOS/PM/APC): the payload is consumed
2586        // through the ST terminator, not leaked into the text.
2587        assert_eq!(strip_ansi("\u{1b}P1$r0m\u{1b}\\text"), "text");
2588        assert_eq!(strip_ansi("\u{1b}_payload\u{1b}\\ok"), "ok");
2589        assert_eq!(strip_ansi("\u{1b}Xsos\u{1b}\\a\u{1b}^pm\u{1b}\\b"), "ab");
2590        // Backspace erases the previous character; bare BEL disappears.
2591        assert_eq!(strip_ansi("ab\u{8}c"), "ac");
2592        assert_eq!(strip_ansi("x\u{7}y"), "xy");
2593        // Backspace never eats a line break (or pops from empty output).
2594        assert_eq!(strip_ansi("a\n\u{8}b"), "a\nb");
2595        assert_eq!(strip_ansi("\u{8}b"), "b");
2596        // Plain text passes through untouched.
2597        assert_eq!(strip_ansi("plain text"), "plain text");
2598        // Truncated escape at end of input must not panic.
2599        assert_eq!(strip_ansi("x\u{1b}"), "x");
2600        assert_eq!(strip_ansi("x\u{1b}[31"), "x");
2601        // Truncated string sequence at end of input must not panic either.
2602        assert_eq!(strip_ansi("x\u{1b}Pdangling"), "x");
2603    }
2604
2605    #[test]
2606    fn capped_capture_keeps_head_and_tail() {
2607        // Under the cap: byte-exact round trip.
2608        let mut c = CappedCapture::new(64);
2609        c.push(b"hello ");
2610        c.push(b"world");
2611        let (out, truncated) = c.finish();
2612        assert_eq!(out, "hello world");
2613        assert!(!truncated);
2614        // Over the cap: head survives, tail survives, middle elided.
2615        let mut c = CappedCapture::new(20);
2616        c.push(b"AAAAAAAAAA");
2617        c.push(&[b'x'; 100]);
2618        c.push(b"BBBBBBBBBB");
2619        let (out, truncated) = c.finish();
2620        assert!(truncated);
2621        assert!(out.starts_with("AAAAAAAAAA"), "head kept: {out:?}");
2622        assert!(out.ends_with("BBBBBBBBBB"), "tail kept: {out:?}");
2623        assert!(out.contains("truncated"), "marker present: {out:?}");
2624    }
2625
2626    #[test]
2627    fn secret_env_names_reports_planted_secret() {
2628        // Uses the process env (read-only) — plant via temp_env.
2629        temp_env::with_var("MERMAID_TEST_PLANTED_API_KEY", Some("v"), || {
2630            let names = secret_env_names();
2631            assert!(
2632                names.iter().any(|n| n == "MERMAID_TEST_PLANTED_API_KEY"),
2633                "planted secret name must be scrubbed: {names:?}"
2634            );
2635            assert!(!names.iter().any(|n| n == "PATH"));
2636        });
2637    }
2638
2639    #[test]
2640    fn harden_env_sets_git_terminal_prompt() {
2641        let mut cmd = Command::new("sh");
2642        harden_noninteractive_env(&mut cmd);
2643        let set = cmd
2644            .as_std()
2645            .get_envs()
2646            .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some_and(|v| v == "0"));
2647        assert!(set, "GIT_TERMINAL_PROMPT=0 must be injected");
2648    }
2649
2650    #[tokio::test]
2651    async fn dangerous_command_blocked() {
2652        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2653        let outcome = ExecuteCommandTool
2654            .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
2655            .await;
2656        let error = outcome.error_message().expect("expected error");
2657        assert!(error.contains("Dangerous"));
2658    }
2659
2660    #[tokio::test]
2661    async fn cancellation_aborts_long_running_command() {
2662        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2663        let token = ctx.token.clone();
2664        let handle = tokio::spawn(async move {
2665            ExecuteCommandTool
2666                .execute(serde_json::json!({"command": "sleep 10"}), ctx)
2667                .await
2668        });
2669        // Give the child a beat to spawn, then cancel.
2670        tokio::time::sleep(Duration::from_millis(30)).await;
2671        token.cancel();
2672        let start = Instant::now();
2673        // The 5s outer timeout is the real "didn't hang" guard — a propagation
2674        // regression would block until the 10s sleep, past 5s.
2675        let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
2676            .await
2677            .expect("didn't hang")
2678            .expect("join");
2679        let elapsed = start.elapsed();
2680        assert!(outcome.was_cancelled());
2681        // "Aborts promptly", not a hard sub-200ms SLA — a tight bound measured
2682        // CI scheduling / process-teardown jitter and flaked on loaded windows
2683        // runners. 2s keeps a wide margin while still catching a real hang.
2684        assert!(
2685            elapsed < Duration::from_secs(2),
2686            "cancellation took {:?} — far slower than expected (regression?)",
2687            elapsed
2688        );
2689    }
2690
2691    #[tokio::test]
2692    async fn timeout_honored() {
2693        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2694        let outcome = ExecuteCommandTool
2695            .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
2696            .await;
2697        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2698        let output = outcome.as_tool_message_content();
2699        assert!(output.contains("timed out"));
2700        assert!(output.contains("was killed"));
2701        assert!(output.contains("mode=\"background\""));
2702    }
2703
2704    /// RC-1 regression: a foreground command that forks a grandchild must have
2705    /// its WHOLE process group reaped on timeout, not just the shell. The old
2706    /// outer-`select!` form dropped the driver future on timeout, which only
2707    /// detached the task owning the `Child`, leaking the tree.
2708    #[cfg(not(target_os = "windows"))]
2709    #[tokio::test]
2710    async fn timeout_kills_process_tree() {
2711        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2712        // The grandchild records its own pid, then sleeps far past the timeout.
2713        let marker =
2714            std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
2715        let _ = std::fs::remove_file(&marker);
2716        let command = format!(
2717            "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
2718            marker.display()
2719        );
2720        let outcome = ExecuteCommandTool
2721            .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
2722            .await;
2723        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
2724
2725        // Read the grandchild pid the command recorded (poll briefly in case the
2726        // write lands a touch after spawn).
2727        let mut pid = None;
2728        for _ in 0..30 {
2729            if let Ok(s) = std::fs::read_to_string(&marker)
2730                && let Ok(p) = s.trim().parse::<u32>()
2731            {
2732                pid = Some(p);
2733                break;
2734            }
2735            tokio::time::sleep(Duration::from_millis(50)).await;
2736        }
2737        let pid = pid.expect("grandchild never recorded its pid");
2738
2739        // It must be dead — poll to let SIGKILL + reparent/reap settle.
2740        let mut alive = true;
2741        for _ in 0..40 {
2742            if !process_running(pid).await {
2743                alive = false;
2744                break;
2745            }
2746            tokio::time::sleep(Duration::from_millis(50)).await;
2747        }
2748        let _ = std::fs::remove_file(&marker);
2749        assert!(!alive, "grandchild pid {pid} leaked past the timeout");
2750    }
2751
2752    #[cfg(not(target_os = "windows"))]
2753    #[tokio::test]
2754    async fn background_mode_returns_pid_log_and_detected_url() {
2755        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2756        let outcome = ExecuteCommandTool
2757            .execute(
2758                serde_json::json!({
2759                    "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
2760                    "mode": "background",
2761                    "startup_timeout_secs": 2,
2762                    "ready_pattern": "ready"
2763                }),
2764                ctx,
2765            )
2766            .await;
2767
2768        assert!(
2769            outcome.is_success(),
2770            "expected background success: {:?}",
2771            outcome
2772        );
2773        let output = outcome.output().to_string();
2774        assert!(output.contains("Background command started"));
2775        assert!(output.contains("PID:"));
2776        assert!(output.contains("Log:"));
2777        assert!(output.contains("Ready: matched pattern"));
2778        assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
2779
2780        if let Some(pid) = parse_pid(&output) {
2781            let _ = Command::new("kill").arg(pid.to_string()).status().await;
2782        }
2783    }
2784
2785    #[cfg(target_os = "windows")]
2786    #[tokio::test]
2787    async fn background_mode_returns_pid_and_log_on_windows() {
2788        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2789        let outcome = ExecuteCommandTool
2790            .execute(
2791                serde_json::json!({
2792                    "command": "echo ready & ping -n 30 127.0.0.1",
2793                    "mode": "background",
2794                    "startup_timeout_secs": 3,
2795                    "ready_pattern": "ready"
2796                }),
2797                ctx,
2798            )
2799            .await;
2800
2801        assert!(
2802            outcome.is_success(),
2803            "expected background success on Windows: {:?}",
2804            outcome
2805        );
2806        let output = outcome.output().to_string();
2807        assert!(output.contains("Background command started"));
2808        assert!(output.contains("PID:"));
2809        assert!(output.contains("Ready: matched pattern"));
2810        // The ManagedProcess must be attached so /processes lists it.
2811        assert!(
2812            outcome.metadata.process.is_some(),
2813            "background outcome must carry a ManagedProcess"
2814        );
2815
2816        // Clean up the detached process (and its child ping) via the tree kill.
2817        if let Some(pid) = parse_pid(&output) {
2818            crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
2819        }
2820    }
2821
2822    #[tokio::test]
2823    async fn ctrl_b_backgrounds_a_running_foreground_command() {
2824        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
2825        let background = ctx.background.clone();
2826        // A command that keeps running so it's still live when we background it.
2827        let command = if cfg!(target_os = "windows") {
2828            "ping -n 30 127.0.0.1"
2829        } else {
2830            "sleep 30"
2831        };
2832
2833        // Press "Ctrl+B" shortly after the command starts.
2834        let canceller = tokio::spawn(async move {
2835            tokio::time::sleep(Duration::from_millis(300)).await;
2836            background.cancel();
2837        });
2838        let outcome = ExecuteCommandTool
2839            .execute(
2840                serde_json::json!({ "command": command, "timeout": 60 }),
2841                ctx,
2842            )
2843            .await;
2844        let _ = canceller.await;
2845
2846        assert!(
2847            outcome.is_success(),
2848            "backgrounding should yield success: {:?}",
2849            outcome
2850        );
2851        let output = outcome.output().to_string();
2852        assert!(output.contains("Moved to background"), "got: {output}");
2853        // It must register as a managed process so /processes lists it.
2854        let process = outcome.metadata.process.clone();
2855        assert!(
2856            process.is_some(),
2857            "background outcome must carry a ManagedProcess"
2858        );
2859
2860        // Clean up the still-running detached process (tree kill).
2861        if let Some(p) = process {
2862            crate::utils::terminate_tree(p.pid, crate::utils::Grace::Graceful).await;
2863        }
2864    }
2865
2866    fn parse_pid(output: &str) -> Option<u32> {
2867        output
2868            .lines()
2869            .find_map(|line| line.strip_prefix("PID: "))
2870            .and_then(|pid| pid.trim().parse().ok())
2871    }
2872
2873    #[test]
2874    fn dangerous_detection_covers_known_shapes() {
2875        assert!(contains_dangerous_command("rm -rf /"));
2876        assert!(contains_dangerous_command(":(){ :|:& };:"));
2877        assert!(contains_dangerous_command("ncat -l 8080"));
2878        assert!(!contains_dangerous_command("ls -la"));
2879        assert!(!contains_dangerous_command("cargo build"));
2880        assert!(!contains_dangerous_command(
2881            r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
2882        ));
2883    }
2884
2885    #[test]
2886    fn dangerous_detection_resists_substring_evasion() {
2887        // The old lowercased-substring blocklist let these through; the
2888        // tokenized, segment-aware check now catches them (#114).
2889        assert!(contains_dangerous_command("RM -RF /"));
2890        assert!(contains_dangerous_command("rm  -rf  /"));
2891        assert!(contains_dangerous_command("echo hi; rm -rf /"));
2892        assert!(contains_dangerous_command("echo hi&&rm -rf /"));
2893        assert!(contains_dangerous_command("curl http://x | sh"));
2894        assert!(contains_dangerous_command("curl http://x|sh"));
2895        assert!(contains_dangerous_command("/bin/rm -rf /"));
2896        // Benign commands that merely *contain* a scary substring stay allowed.
2897        assert!(!contains_dangerous_command("bash build.sh"));
2898        assert!(!contains_dangerous_command("echo done > /dev/null"));
2899        assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
2900    }
2901
2902    #[tokio::test]
2903    async fn read_capped_keeps_head_and_tail_on_overflow() {
2904        // The tail (where a failing command's actual error lives) must survive.
2905        let mut data = Vec::new();
2906        data.extend_from_slice(b"HEAD_START");
2907        data.extend(std::iter::repeat_n(b'x', 5000));
2908        data.extend_from_slice(b"TAIL_ERROR_HERE");
2909        let (out, truncated) = read_capped(&data[..], 100, 10_000, None, None).await;
2910        assert!(truncated, "oversized output must be marked truncated");
2911        assert!(out.contains("HEAD_START"), "head must survive: {out}");
2912        assert!(out.contains("TAIL_ERROR_HERE"), "tail must survive: {out}");
2913        assert!(out.contains("elided"), "must mark the elision: {out}");
2914    }
2915
2916    #[tokio::test]
2917    async fn read_capped_small_output_is_verbatim() {
2918        let (out, truncated) = read_capped(&b"short output"[..], 100, 10_000, None, None).await;
2919        assert!(!truncated, "small output must not be truncated");
2920        assert_eq!(out, "short output");
2921    }
2922
2923    #[test]
2924    fn scratch_prover_accepts_only_provably_contained_commands() {
2925        let scratch = Path::new("/tmp/mermaid_scratch/proj/sess");
2926
2927        // Provable: bare words, flags, relative paths under the scratch cwd,
2928        // and absolute paths inside the scratchpad.
2929        for cmd in [
2930            "ls",
2931            "ls -la",
2932            "mkdir out",
2933            "touch notes.txt",
2934            "cp a.txt sub/b.txt",
2935            "cat /tmp/mermaid_scratch/proj/sess/notes.txt",
2936            "rm -f old.log",
2937        ] {
2938            assert!(
2939                command_provably_in_scratch(cmd, scratch),
2940                "{cmd:?} should prove scratch-contained",
2941            );
2942        }
2943
2944        // Unprovable — every one must fail closed.
2945        for cmd in [
2946            "",                            // nothing to prove
2947            "cat ../secret",               // parent escape
2948            "cat /etc/passwd",             // absolute path outside
2949            "/bin/rm -rf notes.txt",       // absolute argv0 outside
2950            "echo hi > out.txt",           // redirection
2951            "ls; touch pwned",             // separator
2952            "true && touch pwned",         // chaining
2953            "cat file | tee other",        // pipe
2954            "cat $(pwd)/x",                // command substitution
2955            "cat `pwd`/x",                 // backtick substitution
2956            "cat $HOME/x",                 // variable expansion
2957            "ls ~",                        // tilde expansion
2958            "rm *",                        // glob
2959            "cp -t/etc x",                 // flag-embedded absolute path
2960            "tar --directory=/ x",         // flag=value absolute path
2961            "env VAR=/etc cmd",            // assignment-embedded path
2962            "curl https://evil.example/x", // URL shape (`:/`)
2963            "type C:secret.txt",           // Windows drive-relative path
2964            "copy C:\\evil x",             // Windows drive-absolute path
2965            "unclosed 'quote",             // parse failure
2966        ] {
2967            assert!(
2968                !command_provably_in_scratch(cmd, scratch),
2969                "{cmd:?} must NOT prove scratch-contained",
2970            );
2971        }
2972    }
2973
2974    #[test]
2975    fn classify_cwd_three_way_containment() {
2976        let base = std::env::temp_dir().join(format!("mermaid_cwd3_{}", std::process::id()));
2977        let _ = std::fs::remove_dir_all(&base);
2978        let project = base.join("project");
2979        let scratch = base.join("scratch");
2980        std::fs::create_dir_all(&project).unwrap();
2981        std::fs::create_dir_all(&scratch).unwrap();
2982        let scratch_real = std::fs::canonicalize(&scratch).unwrap();
2983        let outside = std::fs::canonicalize(&base).unwrap();
2984
2985        // In-project wins regardless of scratchpad.
2986        assert_eq!(
2987            classify_cwd(true, &project, Some(&scratch)),
2988            CwdContainment::Project
2989        );
2990        // A cwd inside the scratchpad is Scratchpad, not External — no
2991        // ExternalDirectory escalation for scratch work.
2992        assert_eq!(
2993            classify_cwd(false, &scratch_real, Some(&scratch)),
2994            CwdContainment::Scratchpad
2995        );
2996        // Without a scratchpad the same cwd stays External.
2997        assert_eq!(
2998            classify_cwd(false, &scratch_real, None),
2999            CwdContainment::External
3000        );
3001        // Outside both roots is External even with a scratchpad bound.
3002        assert_eq!(
3003            classify_cwd(false, &outside, Some(&scratch)),
3004            CwdContainment::External
3005        );
3006        // A missing scratch dir can't match — fails closed to External.
3007        assert_eq!(
3008            classify_cwd(false, &scratch_real, Some(&base.join("missing"))),
3009            CwdContainment::External
3010        );
3011
3012        let _ = std::fs::remove_dir_all(&base);
3013    }
3014
3015    #[tokio::test]
3016    async fn scratch_cwd_is_not_escalated_to_external_directory() {
3017        // Mirror of `out_of_project_working_dir_is_escalated_and_blocked`: the
3018        // same read-only command that is BLOCKED in a random outside dir must
3019        // RUN when the outside dir is the session scratchpad — proving the
3020        // scratch cwd keeps the plain Shell category.
3021        let base = std::env::temp_dir().join(format!("mermaid_scwd_{}", std::process::id()));
3022        let _ = std::fs::remove_dir_all(&base);
3023        let project = base.join("project");
3024        let scratch = base.join("scratch");
3025        std::fs::create_dir_all(&project).unwrap();
3026        std::fs::create_dir_all(&scratch).unwrap();
3027
3028        // ReadOnly gate: an ExternalDirectory escalation would classify as
3029        // ExternalAccess and be denied; a Shell read-only command is allowed.
3030        let (tx, _rx) = tokio::sync::mpsc::channel(64);
3031        let mut config = crate::app::Config::default();
3032        config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
3033        let mut ctx = crate::providers::ctx::ExecContext::new(
3034            tokio_util::sync::CancellationToken::new(),
3035            tx,
3036            ToolCallId(1),
3037            TurnId(1),
3038            project.clone(),
3039            std::sync::Arc::new(config),
3040            String::new(),
3041            None,
3042            None,
3043            None,
3044            crate::runtime::SafetyMode::ReadOnly,
3045            None,
3046            None,
3047            None,
3048            None,
3049            None,
3050        );
3051        ctx.scratchpad = Some(scratch.clone());
3052        let outcome = ExecuteCommandTool
3053            .execute(
3054                serde_json::json!({
3055                    "command": "echo hi",
3056                    "working_dir": scratch.display().to_string(),
3057                }),
3058                ctx,
3059            )
3060            .await;
3061        assert!(
3062            outcome.is_success(),
3063            "scratch cwd must not be escalated to ExternalDirectory: {outcome:?}",
3064        );
3065
3066        let _ = std::fs::remove_dir_all(&base);
3067    }
3068
3069    #[tokio::test]
3070    async fn child_env_carries_scratchpad_export() {
3071        // cfg-gated sh/cmd probe: the exported MERMAID_SCRATCHPAD must reach
3072        // the child, and must be absent when the session has no scratchpad.
3073        let dir = std::env::temp_dir().join(format!("mermaid_env_{}", std::process::id()));
3074        std::fs::create_dir_all(&dir).unwrap();
3075        #[cfg(unix)]
3076        let probe = r#"printf %s "${MERMAID_SCRATCHPAD:-UNSET}""#;
3077        #[cfg(windows)]
3078        let probe = "if defined MERMAID_SCRATCHPAD (echo %MERMAID_SCRATCHPAD%) else (echo UNSET)";
3079
3080        let run = |scratchpad: Option<PathBuf>| {
3081            let dir = dir.clone();
3082            async move {
3083                let mut cmd = build_sandboxed_shell(probe, false, None);
3084                cmd.current_dir(&dir)
3085                    .stdin(Stdio::null())
3086                    .stdout(Stdio::piped())
3087                    .stderr(Stdio::null())
3088                    // The parent test process must not leak a value into the
3089                    // negative case.
3090                    .env_remove(SCRATCHPAD_ENV_VAR);
3091                export_scratchpad_env(&mut cmd, scratchpad.as_deref());
3092                let out = cmd.output().await.expect("probe spawns");
3093                String::from_utf8_lossy(&out.stdout).trim().to_string()
3094            }
3095        };
3096
3097        let exported = run(Some(dir.clone())).await;
3098        assert_eq!(
3099            exported,
3100            dir.display().to_string(),
3101            "child must see the scratchpad path",
3102        );
3103        let absent = run(None).await;
3104        assert_eq!(absent, "UNSET", "no scratchpad -> no exported variable");
3105
3106        let _ = std::fs::remove_dir_all(&dir);
3107    }
3108}