execpolicy: unwrap PowerShell -Command wrappers on Windows (#20336)

## Why
On Windows, Codex runs shell commands through a top-level
`powershell.exe -NoProfile -Command ...` wrapper. `execpolicy` was
matching that wrapper instead of the inner command, so prefix rules like
`["git", "push"]` did not fire for PowerShell-wrapped commands even
though the same normalization already happens for `bash -lc` on Unix.

This change makes the Windows shell wrapper transparent to rule matching
while preserving the existing Windows unmatched-command safelist and
dangerous-command heuristics.

## What changed
- add `parse_powershell_command_plain_commands()` in
`shell-command/src/powershell.rs` to unwrap the top-level PowerShell
`-Command` body with `extract_powershell_command()` and parse it with
the existing PowerShell AST parser
- update `core/src/exec_policy.rs` so `commands_for_exec_policy()`
treats top-level PowerShell wrappers like `bash -lc` and evaluates rules
against the parsed inner commands
- carry a small `ExecPolicyCommandOrigin` through unmatched-command
evaluation and expose `is_safe_powershell_words()` /
`is_dangerous_powershell_words()` so Windows safelist and
dangerous-command checks still work after unwrap
- add Windows-focused tests for wrapped PowerShell prompt/allow matches,
wrapper parsing, and unmatched safe/dangerous inner commands, and
re-enable the end-to-end `execpolicy_blocks_shell_invocation` test on
Windows

## Testing
- `cargo test -p codex-shell-command`
This commit is contained in:
iceweasel-oai
2026-04-30 17:56:20 -07:00
committed by GitHub
Unverified
parent 0d9a5d20ec
commit 4f96001fa7
12 changed files with 434 additions and 77 deletions
+49
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::command_safety::try_parse_powershell_ast_commands;
use crate::shell_detect::ShellType;
use crate::shell_detect::detect_shell_type;
@@ -68,6 +69,18 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
None
}
/// Parse the script body from a top-level PowerShell wrapper into argv-like commands.
///
/// This is intentionally narrower than the Windows safe-command parser: it only unwraps the
/// `-Command`/`-c` body from a PowerShell invocation we already recognize, then delegates the
/// script itself to the PowerShell AST parser.
pub fn parse_powershell_command_into_plain_commands(
command: &[String],
) -> Option<Vec<Vec<String>>> {
let (executable, script) = extract_powershell_command(command)?;
try_parse_powershell_ast_commands(executable, script)
}
/// This function attempts to find a powershell.exe executable on the system.
pub fn try_find_powershell_executable_blocking() -> Option<AbsolutePathBuf> {
try_find_powershellish_executable_in_path(&["powershell.exe"])
@@ -139,6 +152,8 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
#[cfg(test)]
mod tests {
use super::extract_powershell_command;
#[cfg(windows)]
use super::parse_powershell_command_into_plain_commands;
#[test]
fn extracts_basic_powershell_command() {
@@ -186,4 +201,38 @@ mod tests {
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[cfg(windows)]
#[test]
fn parses_plain_powershell_commands() {
let commands = parse_powershell_command_into_plain_commands(&[
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo hi".to_string(),
])
.expect("parse");
assert_eq!(commands, vec![vec!["echo".to_string(), "hi".to_string()]]);
}
#[cfg(windows)]
#[test]
fn parses_multiple_plain_powershell_commands() {
let commands = parse_powershell_command_into_plain_commands(&[
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Write-Output foo | Measure-Object".to_string(),
])
.expect("parse");
assert_eq!(
commands,
vec![
vec!["Write-Output".to_string(), "foo".to_string()],
vec!["Measure-Object".to_string()],
]
);
}
}