Files
codex/codex-rs/core/src/powershell.rs
T
Michael Bolin 9009490357 fix: use PowerShell to parse PowerShell (#7607)
Previous to this PR, we used a hand-rolled PowerShell parser in
`windows_safe_commands.rs` to take a `&str` of PowerShell script see if
it is equivalent to a list of `execvp(3)` invocations, and if so, we
then test each using `is_safe_powershell_command()` to determine if the
overall command is safe:


https://github.com/openai/codex/blob/6e6338aa876bb4258abe25b02ac6417b8ea9dff0/codex-rs/core/src/command_safety/windows_safe_commands.rs#L89-L98

Unfortunately, our PowerShell parser did not recognize `@(...)` as a
special construct, so it was treated as an ordinary token. This meant
that the following would erroneously be considered "safe:"

```powershell
ls @(calc.exe)
```

The fix introduced in this PR is to do something comparable what we do
for Bash/Zsh, which is to use a "proper" parser to derive the list of
`execvp(3)` calls. For Bash/Zsh, we rely on
https://crates.io/crates/tree-sitter-bash, but there does not appear to
be a crate of comparable quality for parsing PowerShell statically
(https://github.com/airbus-cert/tree-sitter-powershell/ is the best
thing I found).

Instead, in this PR, we use a PowerShell script to parse the input
PowerShell program to produce the AST.
2025-12-12 13:06:49 -08:00

184 lines
6.2 KiB
Rust

use std::path::PathBuf;
#[cfg(any(windows, test))]
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::shell::ShellType;
use crate::shell::detect_shell_type;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
/// Extract the PowerShell script body from an invocation such as:
///
/// - ["pwsh", "-NoProfile", "-Command", "Get-ChildItem -Recurse | Select-String foo"]
/// - ["powershell.exe", "-Command", "Write-Host hi"]
/// - ["powershell", "-NoLogo", "-NoProfile", "-Command", "...script..."]
///
/// Returns (`shell`, `script`) when the first arg is a PowerShell executable and a
/// `-Command` (or `-c`) flag is present followed by a script string.
pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
if command.len() < 3 {
return None;
}
let shell = &command[0];
if detect_shell_type(&PathBuf::from(shell)) != Some(ShellType::PowerShell) {
return None;
}
// Find the first occurrence of -Command (accept common short alias -c as well)
let mut i = 1usize;
while i + 1 < command.len() {
let flag = &command[i];
// Reject unknown flags
if !POWERSHELL_FLAGS.contains(&flag.to_ascii_lowercase().as_str()) {
return None;
}
if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") {
let script = &command[i + 1];
return Some((shell, script.as_str()));
}
i += 1;
}
None
}
/// This function attempts to find a valid PowerShell executable on the system.
/// It first tries to find pwsh.exe, and if that fails, it tries to find
/// powershell.exe.
#[cfg(windows)]
#[allow(dead_code)]
pub(crate) fn try_find_powershellish_executable_blocking() -> Option<AbsolutePathBuf> {
if let Some(pwsh_path) = try_find_pwsh_executable_blocking() {
Some(pwsh_path)
} else {
try_find_powershell_executable_blocking()
}
}
/// This function attempts to find a powershell.exe executable on the system.
#[cfg(any(windows, test))]
pub(crate) fn try_find_powershell_executable_blocking() -> Option<AbsolutePathBuf> {
try_find_powershellish_executable_in_path(&["powershell.exe"])
}
/// This function attempts to find a pwsh.exe executable on the system.
/// Note that pwsh.exe and powershell.exe are different executables:
///
/// - pwsh.exe is the cross-platform PowerShell Core (v6+) executable
/// - powershell.exe is the Windows PowerShell (v5.1 and earlier) executable
///
/// Further, while powershell.exe is included by default on Windows systems,
/// pwsh.exe must be installed separately by the user. And even when the user
/// has installed pwsh.exe, it may not be available in the system PATH, in which
/// case we attempt to locate it via other means.
#[cfg(any(windows, test))]
pub(crate) fn try_find_pwsh_executable_blocking() -> Option<AbsolutePathBuf> {
if let Some(ps_home) = std::process::Command::new("cmd")
.args(["/C", "pwsh", "-NoProfile", "-Command", "$PSHOME"])
.output()
.ok()
.and_then(|out| {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
{
let candidate = AbsolutePathBuf::resolve_path_against_base("pwsh.exe", &ps_home);
if let Ok(candidate_abs_path) = candidate
&& is_powershellish_executable_available(candidate_abs_path.as_path())
{
return Some(candidate_abs_path);
}
}
try_find_powershellish_executable_in_path(&["pwsh.exe"])
}
#[cfg(any(windows, test))]
fn try_find_powershellish_executable_in_path(candidates: &[&str]) -> Option<AbsolutePathBuf> {
for candidate in candidates {
let Ok(resolved_path) = which::which(candidate) else {
continue;
};
if !is_powershellish_executable_available(&resolved_path) {
continue;
}
let Ok(abs_path) = AbsolutePathBuf::from_absolute_path(resolved_path) else {
continue;
};
return Some(abs_path);
}
None
}
#[cfg(any(windows, test))]
fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Path) -> bool {
// This test works for both powershell.exe and pwsh.exe.
std::process::Command::new(powershell_or_pwsh_exe)
.args(["-NoLogo", "-NoProfile", "-Command", "Write-Output ok"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::extract_powershell_command;
#[test]
fn extracts_basic_powershell_command() {
let cmd = vec![
"powershell".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
];
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Write-Host hi");
}
#[test]
fn extracts_lowercase_flags() {
let cmd = vec![
"powershell".to_string(),
"-nologo".to_string(),
"-command".to_string(),
"Write-Host hi".to_string(),
];
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Write-Host hi");
}
#[test]
fn extracts_full_path_powershell_command() {
let command = if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string()
} else {
"/usr/local/bin/powershell.exe".to_string()
};
let cmd = vec![command, "-Command".to_string(), "Write-Host hi".to_string()];
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Write-Host hi");
}
#[test]
fn extracts_with_noprofile_and_alias() {
let cmd = vec![
"pwsh".to_string(),
"-NoProfile".to_string(),
"-c".to_string(),
"Get-ChildItem | Select-String foo".to_string(),
];
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
}