mermaid-cli 0.29.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
//! Shell invocation and working-directory containment: which interpreter, and
//! whether the command is provably confined to the scratchpad.
use super::*;

/// Build the shell `Command` for a model command, optionally wrapped in the
/// `__sandbox-exec` launcher for the network kill-switch and/or filesystem
/// write-confinement (platform backend chosen by the launcher). The caller
/// sets stdio, process group, cwd, and env scrubbing on the returned command.
/// The resolved program + argv for a foreground command — one description
/// consumed by BOTH spawn paths (tokio pipes and the Unix PTY), so the PTY
/// child execs the exact same `__sandbox-exec` launcher (seccomp/Landlock
/// unchanged) as the pipe child.
pub(crate) struct ShellInvocation {
    pub(crate) program: PathBuf,
    pub(crate) args: Vec<std::ffi::OsString>,
}

/// The PowerShell executable model commands run under on Windows: PowerShell 7
/// (`pwsh`) when installed, else the always-present Windows PowerShell 5.1.
/// Resolved once — a PATH scan per spawn would be pure waste.
pub(crate) fn powershell_program() -> &'static str {
    static PROGRAM: std::sync::LazyLock<&'static str> = std::sync::LazyLock::new(|| {
        let has_pwsh = std::env::var_os("PATH").is_some_and(|path| {
            std::env::split_paths(&path).any(|dir| dir.join("pwsh.exe").is_file())
        });
        if has_pwsh { "pwsh" } else { "powershell" }
    });
    &PROGRAM
}

/// Wrap a model command for `-Command` so PowerShell behaves like a
/// non-interactive script runner: cmdlet errors terminate instead of limping
/// on, and the process exit code is the last native command's exit code
/// rather than PowerShell's bare 0/1. Same shape GitHub Actions uses for its
/// `powershell`/`pwsh` shells — without the trailer, `cargo build` failing
/// with 101 surfaces as exit 0.
pub(crate) fn powershell_wrap(command: &str) -> String {
    format!(
        "$ErrorActionPreference='Stop'\n{command}\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}"
    )
}

/// The binary that runs `__sandbox-exec`: this process. A `current_exe`
/// failure is an error here rather than a PATH lookup of `mermaid`: under a
/// sandbox policy a different binary on PATH must not become the launcher.
/// Tests drive the tool from a test binary that has no `__sandbox-exec`, so
/// with `test-support` the `MERMAID_LAUNCHER_EXE` variable names the real one.
fn launcher_exe() -> PathBuf {
    #[cfg(any(test, feature = "test-support"))]
    if let Some(exe) = std::env::var_os("MERMAID_LAUNCHER_EXE") {
        return PathBuf::from(exe);
    }
    std::env::current_exe().unwrap_or_else(|err| {
        tracing::error!(error = %err, "current_exe failed; the sandbox launcher cannot be located");
        // A path that does not exist: the spawn fails and the command does
        // not run, which is the fail-closed outcome the policy asks for.
        PathBuf::from("/nonexistent/mermaid-sandbox-launcher")
    })
}

pub(crate) fn shell_invocation(command: &str, sandbox: &SandboxPlan) -> ShellInvocation {
    if sandbox.read_only || sandbox.network || sandbox.confine_writes.is_some() {
        // `mermaid __sandbox-exec [--read-only | [--no-network] [--confine-fs]
        // [--confine-writes <dir>]…] -- sh -c <command>`: the launcher
        // installs the requested confinement on itself, then execs the shell.
        // Both PowerShell and `sh` come through here — `SandboxPlan::resolve`
        // sets these flags on Windows too, where the launcher runs the child
        // in an AppContainer.
        let exe = launcher_exe();
        let mut args: Vec<std::ffi::OsString> = vec!["__sandbox-exec".into()];
        if sandbox.read_only {
            args.push("--read-only".into());
        }
        if sandbox.network {
            args.push("--no-network".into());
        }
        if let Some(dirs) = &sandbox.confine_writes {
            // The marker, not the dir count, is what tells the launcher that
            // write-confinement was requested. An empty root list is a
            // deny-all-writes policy and must not be indistinguishable from
            // "confinement never asked for" — that read is what let a
            // confined command run unconfined while reporting success.
            args.push("--confine-fs".into());
            for dir in dirs {
                args.push("--confine-writes".into());
                args.push(dir.into());
            }
        }
        args.push("--".into());
        if mermaid_runtime::HostShell::current() == mermaid_runtime::HostShell::PowerShell {
            args.push(powershell_program().into());
            args.push("-NoProfile".into());
            args.push("-NonInteractive".into());
            args.push("-Command".into());
            args.push(powershell_wrap(command).into());
        } else {
            args.extend(["sh".into(), "-c".into(), command.into()]);
        }
        ShellInvocation { program: exe, args }
    } else if mermaid_runtime::HostShell::current() == mermaid_runtime::HostShell::PowerShell {
        // `HostShell::current()` is the shared predicate: risk
        // classification and the transcript label all read the command in the grammar of the interpreter chosen
        // HERE. A new host shell must be taught to all of them through that
        // one enum.
        ShellInvocation {
            program: PathBuf::from(powershell_program()),
            args: vec![
                "-NoProfile".into(),
                "-NonInteractive".into(),
                "-Command".into(),
                powershell_wrap(command).into(),
            ],
        }
    } else {
        ShellInvocation {
            program: PathBuf::from("sh"),
            args: vec!["-c".into(), command.into()],
        }
    }
}

pub(crate) fn build_sandboxed_shell(command: &str, sandbox: &SandboxPlan) -> Command {
    let invocation = shell_invocation(command, sandbox);
    let mut cmd = Command::new(&invocation.program);
    cmd.args(&invocation.args);
    cmd
}

/// Where the effective working directory landed: inside the project, inside
/// the session scratchpad, or outside both (escalated to `ExternalDirectory`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CwdContainment {
    Project,
    Scratchpad,
    External,
}

/// Classify the (already-canonicalized) effective workdir. The scratchpad
/// check canonicalizes the scratch root itself; if that fails (dir missing,
/// permissions) the cwd fails closed to `External` — never to a downgrade.
pub(crate) fn classify_cwd(
    within_project: bool,
    effective_workdir: &Path,
    scratchpad: Option<&Path>,
) -> CwdContainment {
    if within_project {
        return CwdContainment::Project;
    }
    match scratchpad.and_then(|s| std::fs::canonicalize(s).ok()) {
        Some(scratch) if effective_workdir.starts_with(&scratch) => CwdContainment::Scratchpad,
        _ => CwdContainment::External,
    }
}

/// Fail-closed lexical prover: true only when the command, run with its cwd
/// inside the scratchpad, provably cannot touch anything outside it. Any
/// construct we cannot reason about — shell metacharacters, substitutions,
/// expansions, `..`, absolute or embedded paths pointing elsewhere, even a
/// parse failure — fails the proof and the command keeps its normal gating.
/// Over-rejecting is fine here (the command merely prompts as usual);
/// under-rejecting would silently skip an approval.
pub(crate) fn command_provably_in_scratch(command: &str, scratch: &Path) -> bool {
    // Metacharacters make the command opaque to token-level reasoning:
    // separators/pipes can chain arbitrary commands, redirection retargets
    // writes, `$`/backtick substitute or expand unseen text, `~`/globs
    // re-expand at run time, and grouping braces/parens introduce subshells.
    // Checked on the RAW string so even quoted occurrences fail closed.
    const OPAQUE: &[char] = &[
        ';', '|', '&', '<', '>', '$', '`', '~', '*', '?', '[', ']', '(', ')', '{', '}', '!', '\n',
        '\r',
    ];
    if command.contains(OPAQUE) {
        return false;
    }
    let Ok(tokens) = shell_words::split(command) else {
        return false;
    };
    if tokens.is_empty() {
        return false;
    }
    tokens.iter().all(|t| token_provably_in_scratch(t, scratch))
}

/// Re-exported from the policy crate: there is ONE containment rule, and it
/// lives there.
pub(crate) use mermaid_runtime::token_provably_in_scratch;

/// Advertised to spawned commands so scripts have a ready-made place for
/// throwaway files that never dirties the project tree.
pub(crate) const SCRATCHPAD_ENV_VAR: &str = "MERMAID_SCRATCHPAD";

/// Export the session scratchpad to a child command (pipe + background spawn
/// paths; the PTY path sets the same variable on its `CommandBuilder`). No-op
/// when the session has no scratchpad materialized.
pub(crate) fn export_scratchpad_env(cmd: &mut Command, scratchpad: Option<&Path>) {
    if let Some(dir) = scratchpad {
        cmd.env(SCRATCHPAD_ENV_VAR, dir);
    }
}

pub(crate) fn tail_lines(text: &str, max_lines: usize) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let start = lines.len().saturating_sub(max_lines);
    lines[start..].join("\n")
}

pub(crate) fn first_url(text: &str) -> Option<String> {
    text.split_whitespace()
        .find(|part| part.starts_with("http://") || part.starts_with("https://"))
        .map(|url| {
            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
                .to_string()
        })
}

pub(crate) fn all_urls(text: &str) -> Vec<String> {
    text.split_whitespace()
        .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
        .map(|url| {
            url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
                .to_string()
        })
        .collect()
}

pub(crate) async fn open_browser_url(url: &str) -> Result<(), String> {
    // Only ever hand a plain http(s) URL to the OS launcher — reject
    // `file:`/`javascript:`/`data:`/etc. supplied by the model. On Windows this
    // is also what lets us drop the `cmd` shell below safely.
    super::super::web::require_http_scheme(url)?;

    #[cfg(target_os = "macos")]
    let mut command = {
        let mut cmd = Command::new("open");
        cmd.arg(url);
        cmd
    };

    #[cfg(target_os = "linux")]
    let mut command = {
        let mut cmd = Command::new("xdg-open");
        cmd.arg(url);
        cmd
    };

    #[cfg(target_os = "windows")]
    let mut command = {
        // Launch via `rundll32` (a real executable) rather than `cmd /C start`,
        // so the URL is passed as a single argv and never re-parsed by a shell —
        // `& | > ^ "` in a model-supplied URL can't break out into arbitrary
        // commands the way they can inside `cmd`.
        let mut cmd = Command::new("rundll32");
        cmd.args(["url.dll,FileProtocolHandler", url]);
        cmd
    };

    command
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .kill_on_drop(false)
        .spawn()
        .map(|_| ())
        .map_err(|e| e.to_string())
}