magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::process_environment::{self, SubprocessEnvProfile};
use std::{
    io,
    path::Path,
    process::{Child, Command, Stdio},
};

#[cfg(unix)]
use std::os::unix::process::CommandExt;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ShellInvocation {
    program: &'static str,
    args: &'static [&'static str],
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShellStdin {
    Null,
    Piped,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShellEnvPolicy {
    /// Inherit ambient environment for user-facing shell tools.
    Ambient,
    /// Deny by default and never load shell startup profiles.
    Sanitized,
}
#[cfg(unix)]
fn shell_invocations(env: ShellEnvPolicy) -> &'static [ShellInvocation] {
    match env {
        ShellEnvPolicy::Ambient => &[ShellInvocation {
            program: "/bin/bash",
            args: &["-lc"],
        }],
        ShellEnvPolicy::Sanitized => &[ShellInvocation {
            program: "/bin/bash",
            args: &["--noprofile", "--norc", "-c"],
        }],
    }
}

#[cfg(windows)]
fn shell_invocations(_env: ShellEnvPolicy) -> &'static [ShellInvocation] {
    &[
        ShellInvocation {
            program: "pwsh",
            args: &["-NoProfile", "-NonInteractive", "-Command"],
        },
        ShellInvocation {
            program: "powershell.exe",
            args: &["-NoProfile", "-NonInteractive", "-Command"],
        },
    ]
}

#[cfg(not(any(unix, windows)))]
fn shell_invocations(_env: ShellEnvPolicy) -> &'static [ShellInvocation] {
    &[]
}

pub(crate) fn spawn_platform_shell(
    command: &str,
    cwd: &Path,
    stdin: ShellStdin,
    env: ShellEnvPolicy,
) -> io::Result<Child> {
    try_shell_invocations(shell_invocations(env), |invocation| {
        let mut command_builder = Command::new(invocation.program);
        command_builder
            .args(invocation.args)
            .arg(command)
            .current_dir(cwd)
            .stdin(match stdin {
                ShellStdin::Null => Stdio::null(),
                ShellStdin::Piped => Stdio::piped(),
            })
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if env == ShellEnvPolicy::Sanitized {
            process_environment::apply_profile(&mut command_builder, SubprocessEnvProfile::Shell);
        }
        #[cfg(unix)]
        {
            // Put the shell in a new process group so timeout cleanup can signal descendants
            // launched by this shell without touching unrelated process groups.
            command_builder.process_group(0);
        }
        command_builder.spawn()
    })
}

fn try_shell_invocations<T>(
    invocations: &[ShellInvocation],
    mut spawn: impl FnMut(&ShellInvocation) -> io::Result<T>,
) -> io::Result<T> {
    let mut last_not_found = None;
    for invocation in invocations {
        match spawn(invocation) {
            Ok(child) => return Ok(child),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                last_not_found = Some(error);
            }
            Err(error) => return Err(error),
        }
    }
    Err(last_not_found.unwrap_or_else(|| {
        io::Error::new(
            io::ErrorKind::Unsupported,
            "shell execution is unsupported on this platform",
        )
    }))
}

pub(crate) fn preflight_bash_cwd_scope(
    command: &str,
    allow_absolute_paths: bool,
    allow_shell_expansion: bool,
) -> anyhow::Result<()> {
    // Conservative MVP preflight only; this is not an OS sandbox. Deny shell
    // expansion and path escape forms before execution as configured accidental guardrails.
    if !allow_shell_expansion
        && let Some(denied) = command
            .chars()
            .find(|ch| matches!(ch, '$' | '~' | '`' | '{' | '}'))
    {
        anyhow::bail!(
            "bash command rejected by cwd-scope preflight: denied shell expansion character '{denied}' (tools.bash.shell_expansion is false)"
        );
    }

    for token in shell_like_tokens(command) {
        if !allow_absolute_paths {
            if token == "cd" {
                anyhow::bail!(
                    "bash command rejected by cwd-scope preflight: unsafe cd target (tools.bash.absolute_paths is false)"
                );
            }
            if let Some(target) = token.strip_prefix("cd ")
                && target != "."
            {
                anyhow::bail!(
                    "bash command rejected by cwd-scope preflight: unsafe cd target (tools.bash.absolute_paths is false)"
                );
            }
        }

        for word in token.split_whitespace() {
            if has_parent_directory_component(word) {
                anyhow::bail!(
                    "bash command rejected by cwd-scope preflight: parent-directory path component"
                );
            }
            if is_absolute_or_drive_qualified_path(word) && !allow_absolute_paths {
                anyhow::bail!(
                    "bash command rejected by cwd-scope preflight: absolute path (tools.bash.absolute_paths is false)"
                );
            }
        }
    }
    Ok(())
}

fn has_parent_directory_component(word: &str) -> bool {
    cwd_scope_path_candidate_matches(word, |candidate| {
        let normalized = candidate.replace('\\', "/");
        normalized == ".."
            || normalized.starts_with("../")
            || normalized.contains("/../")
            || normalized.ends_with("/..")
    })
}

fn is_absolute_or_drive_qualified_path(word: &str) -> bool {
    cwd_scope_path_candidate_matches(word, |candidate| {
        candidate.starts_with('/')
            || candidate.starts_with('\\')
            || has_windows_drive_prefix(candidate)
    })
}

fn cwd_scope_path_candidate_matches(word: &str, mut matches: impl FnMut(&str) -> bool) -> bool {
    let word = trim_path_candidate(word);
    if matches(word) {
        return true;
    }
    if let Some((_, value)) = word.split_once('=')
        && matches(trim_path_candidate(value))
    {
        return true;
    }
    if let Some(value) = compact_option_path_candidate(word)
        && matches(trim_path_candidate(value))
    {
        return true;
    }
    false
}

fn trim_path_candidate(word: &str) -> &str {
    word.trim_matches(['\'', '"'])
}

fn compact_option_path_candidate(word: &str) -> Option<&str> {
    if word.starts_with("--") || !word.starts_with('-') {
        return None;
    }
    let value = word.get(2..)?;
    (!value.is_empty()).then_some(value)
}

fn has_windows_drive_prefix(word: &str) -> bool {
    let bytes = word.as_bytes();
    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}

fn shell_like_tokens(command: &str) -> impl Iterator<Item = &str> {
    command
        .split(is_shell_token_separator)
        .map(|token| token.trim_matches(['\'', '"', ',', ' ', '\t']))
        .filter(|token| !token.is_empty())
}

fn is_shell_token_separator(character: char) -> bool {
    matches!(
        character,
        ';' | '|' | '&' | '<' | '>' | '(' | ')' | '`' | '\n' | '\r'
    )
}