From 11ffb668415768ec2d2149713161b73a1d7fde87 Mon Sep 17 00:00:00 2001 From: David Wiesen Date: Fri, 20 Mar 2026 12:05:07 -0700 Subject: [PATCH] Normalize intercepted PowerShell execpolicy commands --- .../tools/runtimes/shell/unix_escalation.rs | 55 +++++++++++++++---- .../runtimes/shell/unix_escalation_tests.rs | 45 +++++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 948018dae6..5a6c193fda 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -19,6 +19,7 @@ use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; +use crate::powershell::extract_powershell_command; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::MatchOptions; @@ -53,6 +54,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use shlex::split as shlex_split; use std::time::Duration; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -766,10 +768,16 @@ fn evaluate_intercepted_exec_policy( sandbox_permissions, enable_shell_wrapper_parsing, } = context; + let wrapper_command = join_program_and_argv(program, argv); let CandidateCommands { commands, used_complex_parsing, - } = if enable_shell_wrapper_parsing { + } = if let Some(command) = parse_powershell_plain_command(&wrapper_command) { + CandidateCommands { + commands: vec![command], + used_complex_parsing: false, + } + } else if enable_shell_wrapper_parsing { // In this codepath, the first argument in `commands` could be a bare // name like `find` instead of an absolute path like `/usr/bin/find`. // It could also be a shell built-in like `echo`. @@ -778,7 +786,7 @@ fn evaluate_intercepted_exec_policy( // In this codepath, `commands` has a single entry where the program // is always an absolute path. CandidateCommands { - commands: vec![join_program_and_argv(program, argv)], + commands: vec![wrapper_command], used_complex_parsing: false, } }; @@ -821,19 +829,22 @@ fn commands_for_intercepted_exec_policy( program: &AbsolutePathBuf, argv: &[String], ) -> CandidateCommands { - if let [_, flag, script] = argv { - let shell_command = [ - program.to_string_lossy().to_string(), - flag.clone(), - script.clone(), - ]; - if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) { + let wrapper_command = join_program_and_argv(program, argv); + if let Some(command) = parse_powershell_plain_command(&wrapper_command) { + return CandidateCommands { + commands: vec![command], + used_complex_parsing: false, + }; + } + + if argv.len() == 3 { + if let Some(commands) = parse_shell_lc_plain_commands(&wrapper_command) { return CandidateCommands { commands, used_complex_parsing: false, }; } - if let Some(single_command) = parse_shell_lc_single_command_prefix(&shell_command) { + if let Some(single_command) = parse_shell_lc_single_command_prefix(&wrapper_command) { return CandidateCommands { commands: vec![single_command], used_complex_parsing: true, @@ -842,11 +853,33 @@ fn commands_for_intercepted_exec_policy( } CandidateCommands { - commands: vec![join_program_and_argv(program, argv)], + commands: vec![wrapper_command], used_complex_parsing: false, } } +fn parse_powershell_plain_command(command: &[String]) -> Option> { + let (_, script) = extract_powershell_command(command)?; + let script = script.trim(); + if script.is_empty() { + return None; + } + + // Only normalize simple external-command wrappers. Anything that looks like + // actual PowerShell syntax should remain opaque and rely on the exact + // wrapper argv. + if script.chars().any(|c| { + matches!( + c, + '\n' | '\r' | ';' | '|' | '&' | '(' | ')' | '{' | '}' | '$' | '@' | '`' | '<' | '>' + ) + }) { + return None; + } + + shlex_split(script).filter(|tokens| !tokens.is_empty()) +} + struct CoreShellCommandExecutor { command: Vec, cwd: PathBuf, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 37c1b53a13..4d69c0986a 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -519,6 +519,51 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() ); } +#[test] +fn evaluate_intercepted_exec_policy_matches_simple_powershell_wrapped_commands() { + let policy_src = r#"prefix_rule(pattern = ["git", "add"], decision = "allow")"#; + let mut parser = PolicyParser::new(); + parser.parse("test.rules", policy_src).unwrap(); + let policy = parser.build(); + let program = AbsolutePathBuf::try_from(if cfg!(windows) { + r"C:\Program Files\PowerShell\7\pwsh.exe".to_string() + } else { + "/usr/local/bin/pwsh".to_string() + }) + .unwrap(); + + let evaluation = evaluate_intercepted_exec_policy( + &policy, + &program, + &[ + "pwsh".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "git add -A".to_string(), + ], + InterceptedExecPolicyContext { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + enable_shell_wrapper_parsing: false, + }, + ); + + assert_eq!( + evaluation, + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["git".to_string(), "add".to_string()], + decision: Decision::Allow, + resolved_program: None, + justification: None, + }], + } + ); +} + #[test] fn intercepted_exec_policy_uses_host_executable_mappings() { let git_path = host_absolute_path(&["usr", "bin", "git"]);