Compare commits

...

1 Commits

Author SHA1 Message Date
xli-oai
b49b900d78 Unwrap PowerShell commands for exec policy 2026-04-09 10:14:28 -07:00
6 changed files with 138 additions and 135 deletions

View File

@@ -25,6 +25,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
use codex_shell_command::is_safe_command::is_known_safe_command;
use codex_shell_command::powershell::parse_powershell_command_sequence;
use thiserror::Error;
use tokio::fs;
use tokio::task::spawn_blocking;
@@ -631,6 +632,12 @@ fn default_policy_path(codex_home: &Path) -> PathBuf {
}
fn commands_for_exec_policy(command: &[String]) -> (Vec<Vec<String>>, bool) {
if let Some(commands) = parse_powershell_command_sequence(command)
&& !commands.is_empty()
{
return (commands, false);
}
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
{

View File

@@ -76,11 +76,6 @@ fn assert_no_matched_rules_invariant(output_item: &Value) {
#[tokio::test]
async fn execpolicy_blocks_shell_invocation() -> Result<()> {
// TODO execpolicy doesn't parse powershell commands yet
if cfg!(windows) {
return Ok(());
}
let mut builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("rules").join("policy.rules");
fs::create_dir_all(

View File

@@ -3,3 +3,6 @@ mod powershell_parser;
pub mod is_dangerous_command;
pub mod is_safe_command;
pub(crate) mod windows_safe_commands;
pub(crate) use powershell_parser::PowershellParseOutcome;
pub(crate) use powershell_parser::parse_with_powershell_ast;

View File

@@ -24,7 +24,7 @@ const POWERSHELL_PARSER_SCRIPT: &str = include_str!("powershell_parser.ps1");
/// We keep the cache behind one mutex because each child process speaks a simple
/// request/response protocol over a single stdin/stdout pair, so callers targeting the same
/// executable must serialize access anyway.
pub(super) fn parse_with_powershell_ast(executable: &str, script: &str) -> PowershellParseOutcome {
pub(crate) fn parse_with_powershell_ast(executable: &str, script: &str) -> PowershellParseOutcome {
static PARSER_PROCESSES: LazyLock<Mutex<HashMap<String, PowershellParserProcess>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -35,7 +35,7 @@ pub(super) fn parse_with_powershell_ast(executable: &str, script: &str) -> Power
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum PowershellParseOutcome {
pub(crate) enum PowershellParseOutcome {
Commands(Vec<Vec<String>>),
Unsupported,
Failed,

View File

@@ -1,12 +1,10 @@
use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt;
use crate::command_safety::powershell_parser::PowershellParseOutcome;
use crate::command_safety::powershell_parser::parse_with_powershell_ast;
use std::path::Path;
use crate::powershell::parse_powershell_command_sequence;
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
pub fn is_safe_command_windows(command: &[String]) -> bool {
if let Some(commands) = try_parse_powershell_command_sequence(command) {
if let Some(commands) = parse_powershell_command_sequence(command) {
commands
.iter()
.all(|cmd| is_safe_powershell_command(cmd.as_slice()))
@@ -16,130 +14,6 @@ pub fn is_safe_command_windows(command: &[String]) -> bool {
}
}
/// Returns each command sequence if the invocation starts with a PowerShell binary.
/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences.
fn try_parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
let (exe, rest) = command.split_first()?;
if is_powershell_executable(exe) {
parse_powershell_invocation(exe, rest)
} else {
None
}
}
/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns.
fn parse_powershell_invocation(executable: &str, args: &[String]) -> Option<Vec<Vec<String>>> {
if args.is_empty() {
// Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments.
return None;
}
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
let lower = arg.to_ascii_lowercase();
match lower.as_str() {
"-command" | "/command" | "-c" => {
let script = args.get(idx + 1)?;
if idx + 2 != args.len() {
// Reject if there is more than one token representing the actual command.
// Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra".
return None;
}
return parse_powershell_script(executable, script);
}
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
if idx + 1 != args.len() {
// Reject if there are more tokens after the command itself.
// Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args.
return None;
}
let script = arg.split_once(':')?.1;
return parse_powershell_script(executable, script);
}
// Benign, no-arg flags we tolerate.
"-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => {
idx += 1;
continue;
}
// Explicitly forbidden/opaque or unnecessary for read-only operations.
"-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy"
| "-workingdirectory" => {
// Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1".
return None;
}
// Unknown switch → bail conservatively.
_ if lower.starts_with('-') => {
// Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar".
return None;
}
// If we hit non-flag tokens, treat the remainder as a command sequence.
// This happens if powershell is invoked without -Command, e.g.
// ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"]
_ => {
let script = join_arguments_as_script(&args[idx..]);
return parse_powershell_script(executable, &script);
}
}
}
// Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script.
None
}
/// Tokenizes an inline PowerShell script and delegates to the command splitter.
/// Examples of when this is called: pwsh.exe -Command '<script>' or pwsh.exe -Command:<script>
fn parse_powershell_script(executable: &str, script: &str) -> Option<Vec<Vec<String>>> {
if let PowershellParseOutcome::Commands(commands) =
parse_with_powershell_ast(executable, script)
{
Some(commands)
} else {
None
}
}
/// Returns true when the executable name is one of the supported PowerShell binaries.
fn is_powershell_executable(exe: &str) -> bool {
let executable_name = Path::new(exe)
.file_name()
.and_then(|osstr| osstr.to_str())
.unwrap_or(exe)
.to_ascii_lowercase();
matches!(
executable_name.as_str(),
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
)
}
fn join_arguments_as_script(args: &[String]) -> String {
let mut words = Vec::with_capacity(args.len());
if let Some((first, rest)) = args.split_first() {
words.push(first.clone());
for arg in rest {
words.push(quote_argument(arg));
}
}
words.join(" ")
}
fn quote_argument(arg: &str) -> String {
if arg.is_empty() {
return "''".to_string();
}
if arg.chars().all(|ch| !ch.is_whitespace()) {
return arg.to_string();
}
format!("'{}'", arg.replace('\'', "''"))
}
/// Validates that a parsed PowerShell command stays within our read-only safelist.
/// Everything before this is parsing, and rejecting things that make us feel uncomfortable.
fn is_safe_powershell_command(words: &[String]) -> bool {

View File

@@ -1,11 +1,33 @@
use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::command_safety::PowershellParseOutcome;
use crate::command_safety::parse_with_powershell_ast;
use crate::shell_detect::ShellType;
use crate::shell_detect::detect_shell_type;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
const POWERSHELL_BENIGN_FLAGS: &[&str] =
&["-nologo", "-noprofile", "-noninteractive", "-mta", "-sta"];
/// Parse a PowerShell wrapper invocation into one or more inner command vectors.
///
/// Examples:
/// - `["pwsh", "-NoProfile", "-Command", "ssh -V"]` -> `[["ssh", "-V"]]`
/// - `["powershell.exe", "git", "status"]` -> `[["git", "status"]]`
///
/// Returns `None` when the invocation is not PowerShell-backed or when the
/// script shape cannot be safely normalized into concrete command tokens.
pub fn parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
let (executable, args) = command.split_first()?;
if !is_powershell_executable(executable) {
return None;
}
parse_powershell_invocation(executable, args)
}
/// Prefixed command for powershell shell calls to force UTF-8 console output.
pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n";
@@ -68,6 +90,92 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
None
}
fn parse_powershell_invocation(executable: &str, args: &[String]) -> Option<Vec<Vec<String>>> {
if args.is_empty() {
return None;
}
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
let lower = arg.to_ascii_lowercase();
match lower.as_str() {
"-command" | "/command" | "-c" => {
let script = args.get(idx + 1)?;
if idx + 2 != args.len() {
return None;
}
return parse_powershell_script(executable, script);
}
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
if idx + 1 != args.len() {
return None;
}
let (_, script) = arg.split_once(':')?;
return parse_powershell_script(executable, script);
}
_ if POWERSHELL_BENIGN_FLAGS.contains(&lower.as_str()) => {
idx += 1;
}
_ if lower.starts_with('-') => {
return None;
}
_ => {
let script = join_arguments_as_script(&args[idx..]);
return parse_powershell_script(executable, &script);
}
}
}
None
}
fn parse_powershell_script(executable: &str, script: &str) -> Option<Vec<Vec<String>>> {
if let PowershellParseOutcome::Commands(commands) =
parse_with_powershell_ast(executable, script)
{
Some(commands)
} else {
None
}
}
fn is_powershell_executable(exe: &str) -> bool {
let executable_name = Path::new(exe)
.file_name()
.and_then(|osstr| osstr.to_str())
.unwrap_or(exe)
.to_ascii_lowercase();
matches!(
executable_name.as_str(),
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
)
}
fn join_arguments_as_script(args: &[String]) -> String {
let mut words = Vec::with_capacity(args.len());
if let Some((first, rest)) = args.split_first() {
words.push(first.clone());
for arg in rest {
words.push(quote_argument(arg));
}
}
words.join(" ")
}
fn quote_argument(arg: &str) -> String {
if arg.is_empty() {
return "''".to_string();
}
if arg.chars().all(|ch| !ch.is_whitespace()) {
return arg.to_string();
}
format!("'{}'", arg.replace('\'', "''"))
}
/// 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.
@@ -152,6 +260,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_sequence;
#[test]
fn extracts_basic_powershell_command() {
@@ -199,4 +309,18 @@ mod tests {
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[cfg(windows)]
#[test]
fn parses_powershell_wrapper_into_inner_command() {
let cmd = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"ssh -V".to_string(),
];
let commands =
parse_powershell_command_sequence(&cmd).expect("PowerShell wrapper should parse");
assert_eq!(commands, vec![vec!["ssh".to_string(), "-V".to_string()]]);
}
}