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 {
Ambient,
Sanitized,
}
#[cfg(unix)]
fn shell_invocations() -> &'static [ShellInvocation] {
&[ShellInvocation {
program: "/bin/bash",
args: &["-lc"],
}]
}
#[cfg(windows)]
fn shell_invocations() -> &'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() -> &'static [ShellInvocation] {
&[]
}
pub(crate) fn spawn_platform_shell(
command: &str,
cwd: &Path,
stdin: ShellStdin,
env: ShellEnvPolicy,
) -> io::Result<Child> {
try_shell_invocations(shell_invocations(), |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 {
apply_sanitized_shell_env(&mut command_builder);
}
#[cfg(unix)]
{
command_builder.process_group(0);
}
command_builder.spawn()
})
}
fn apply_sanitized_shell_env(command: &mut Command) {
command.env_clear();
for (key, value) in std::env::vars_os() {
if allowed_sanitized_shell_env(&key) {
command.env(key, value);
}
}
}
fn allowed_sanitized_shell_env(key: &std::ffi::OsStr) -> bool {
let Some(key) = key.to_str() else {
return false;
};
matches!(
key,
"PATH" | "HOME" | "USER" | "LOGNAME" | "SHELL" | "TMPDIR" | "LANG"
) || key.starts_with("LC_")
}
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<()> {
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'
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{cell::RefCell, rc::Rc};
const FAKE_INVOCATIONS: [ShellInvocation; 2] = [
ShellInvocation {
program: "first-shell",
args: &["-first"],
},
ShellInvocation {
program: "second-shell",
args: &["-second"],
},
];
#[test]
fn shell_fallback_attempts_next_candidate_on_not_found() {
let attempts = Rc::new(RefCell::new(Vec::new()));
let attempts_for_spawn = Rc::clone(&attempts);
let result = try_shell_invocations(&FAKE_INVOCATIONS, move |invocation| {
attempts_for_spawn.borrow_mut().push(invocation.program);
if invocation.program == "first-shell" {
Err(io::Error::new(io::ErrorKind::NotFound, "missing first"))
} else {
Ok(invocation.program)
}
})
.unwrap();
assert_eq!(result, "second-shell");
assert_eq!(&*attempts.borrow(), &["first-shell", "second-shell"]);
}
#[test]
fn shell_fallback_stops_on_non_not_found_error() {
let attempts = Rc::new(RefCell::new(Vec::new()));
let attempts_for_spawn = Rc::clone(&attempts);
let error = try_shell_invocations(&FAKE_INVOCATIONS, move |invocation| {
attempts_for_spawn.borrow_mut().push(invocation.program);
Err::<(), _>(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(&*attempts.borrow(), &["first-shell"]);
}
#[test]
fn shell_fallback_returns_final_not_found_error() {
let error = try_shell_invocations(&FAKE_INVOCATIONS, |invocation| {
Err::<(), _>(io::Error::new(
io::ErrorKind::NotFound,
format!("{} missing", invocation.program),
))
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert_eq!(error.to_string(), "second-shell missing");
}
#[test]
fn cwd_scope_preflight_detects_windows_parent_directory_components() {
for word in ["..\\outside", "foo\\..\\bar", "foo/..\\bar", "foo\\../bar"] {
assert!(
has_parent_directory_component(word),
"accepted parent-directory component in {word}"
);
}
}
#[test]
fn cwd_scope_preflight_detects_windows_absolute_and_drive_paths() {
for word in [
"C:\\Windows\\win.ini",
"c:relative\\path",
"\\Windows\\win.ini",
"\\\\server\\share\\secret.txt",
] {
assert!(
is_absolute_or_drive_qualified_path(word),
"accepted Windows absolute or drive-qualified path {word}"
);
}
}
#[test]
fn cwd_scope_preflight_detects_quoted_windows_escape_paths() {
for word in [
"\"C:\\Windows\\win.ini\"",
"'C:\\Windows\\win.ini'",
"'..\\secret'",
"\"..\\secret\"",
"\"\\Windows\\win.ini\"",
"\"\\\\server\\share\\secret.txt\"",
] {
assert!(
has_parent_directory_component(word) || is_absolute_or_drive_qualified_path(word),
"accepted quoted Windows cwd escape path {word}"
);
}
}
#[test]
fn cwd_scope_preflight_allows_plain_windows_relative_path() {
assert!(!has_parent_directory_component("foo\\bar\\baz.txt"));
assert!(!is_absolute_or_drive_qualified_path("foo\\bar\\baz.txt"));
}
#[test]
fn bash_cwd_scope_preflight_allows_logical_and_separator() {
preflight_bash_cwd_scope("echo left && echo right", true, true)
.expect("logical AND command separator should be allowed");
}
#[test]
fn bash_cwd_scope_preflight_still_checks_paths_after_logical_and_separator() {
let parent = preflight_bash_cwd_scope("echo ok && cat ../secret", true, true)
.unwrap_err()
.to_string();
assert!(
parent.contains("parent-directory path component"),
"{parent}"
);
let absolute = preflight_bash_cwd_scope("echo ok && cat /tmp/secret", false, true)
.unwrap_err()
.to_string();
assert!(absolute.contains("tools.bash.absolute_paths"), "{absolute}");
}
#[test]
fn bash_cwd_scope_preflight_rejects_newline_cd_separator() {
let error = preflight_bash_cwd_scope("echo ok\ncd subdir", false, true)
.unwrap_err()
.to_string();
assert!(error.contains("unsafe cd target"), "{error}");
assert!(error.contains("tools.bash.absolute_paths"), "{error}");
}
#[test]
fn bash_cwd_scope_preflight_allows_cd_when_absolute_paths_enabled() {
preflight_bash_cwd_scope("cd subdir", true, true)
.expect("cd should be allowed when tools.bash.absolute_paths is true");
preflight_bash_cwd_scope("echo ok && cd subdir", true, true)
.expect("cd after separator should be allowed when tools.bash.absolute_paths is true");
}
#[test]
fn bash_cwd_scope_preflight_rejects_embedded_option_and_assignment_paths() {
let absolute = preflight_bash_cwd_scope("tool --config=/tmp/x", false, true)
.unwrap_err()
.to_string();
assert!(absolute.contains("tools.bash.absolute_paths"), "{absolute}");
let assignment = preflight_bash_cwd_scope("OUT=../x echo ok", true, true)
.unwrap_err()
.to_string();
assert!(
assignment.contains("parent-directory path component"),
"{assignment}"
);
let compact_option = preflight_bash_cwd_scope("git -C../repo status", true, true)
.unwrap_err()
.to_string();
assert!(
compact_option.contains("parent-directory path component"),
"{compact_option}"
);
assert!(is_absolute_or_drive_qualified_path("C:\\path"));
}
#[test]
fn bash_cwd_scope_preflight_preserves_rejection_messages() {
let parent = preflight_bash_cwd_scope("cat ../secret", true, true)
.unwrap_err()
.to_string();
assert!(
parent.contains("parent-directory path component"),
"{parent}"
);
let absolute = preflight_bash_cwd_scope("cat /tmp/secret", false, true)
.unwrap_err()
.to_string();
assert!(absolute.contains("tools.bash.absolute_paths"), "{absolute}");
preflight_bash_cwd_scope("echo $HOME", true, true)
.expect("shell expansion is allowed by default settings");
let expansion = preflight_bash_cwd_scope("echo $HOME", true, false)
.unwrap_err()
.to_string();
assert!(
expansion.contains("tools.bash.shell_expansion"),
"{expansion}"
);
let cd = preflight_bash_cwd_scope("cd subdir", false, true)
.unwrap_err()
.to_string();
assert!(cd.contains("unsafe cd target"), "{cd}");
assert!(cd.contains("tools.bash.absolute_paths"), "{cd}");
}
#[cfg(unix)]
#[test]
fn shell_env_policy_controls_credential_inheritance() {
let temp = tempfile::TempDir::new().unwrap();
let key = "MAGI_CODE_TEST_ENV_PROBE_SHELL_POLICY";
unsafe { std::env::set_var(key, "leaked") };
let sanitized = spawn_platform_shell(
&format!("printf '%s' \"${key}\""),
temp.path(),
ShellStdin::Null,
ShellEnvPolicy::Sanitized,
)
.unwrap()
.wait_with_output()
.unwrap();
let ambient = spawn_platform_shell(
&format!("printf '%s' \"${key}\""),
temp.path(),
ShellStdin::Null,
ShellEnvPolicy::Ambient,
)
.unwrap()
.wait_with_output()
.unwrap();
unsafe { std::env::remove_var(key) };
assert_eq!(String::from_utf8(sanitized.stdout).unwrap(), "");
assert_eq!(String::from_utf8(ambient.stdout).unwrap(), "leaked");
}
#[cfg(unix)]
#[test]
fn unix_shell_invocation_uses_bash_lc() {
assert_eq!(
shell_invocations(),
&[ShellInvocation {
program: "/bin/bash",
args: &["-lc"],
}]
);
}
#[cfg(windows)]
#[test]
fn windows_shell_invocations_prefer_pwsh_then_windows_powershell() {
assert_eq!(
shell_invocations(),
&[
ShellInvocation {
program: "pwsh",
args: &["-NoProfile", "-NonInteractive", "-Command"],
},
ShellInvocation {
program: "powershell.exe",
args: &["-NoProfile", "-NonInteractive", "-Command"],
},
]
);
}
}