From 19ff74dfd4abf9da97573f4c7d2742fb62a3d547 Mon Sep 17 00:00:00 2001 From: David Wiesen Date: Fri, 20 Mar 2026 10:30:00 -0700 Subject: [PATCH] Normalize simple PowerShell execpolicy commands --- codex-rs/core/src/exec_policy.rs | 28 +++++++++++ codex-rs/core/src/exec_policy_tests.rs | 67 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a027f2461b..89fc61c4eb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -33,9 +33,11 @@ use tracing::instrument; use crate::bash::parse_shell_lc_plain_commands; use crate::bash::parse_shell_lc_single_command_prefix; use crate::config::Config; +use crate::powershell::extract_powershell_command; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use codex_utils_absolute_path::AbsolutePathBuf; +use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; const PROMPT_CONFLICT_REASON: &str = @@ -642,9 +644,35 @@ fn commands_for_exec_policy(command: &[String]) -> (Vec>, bool) { return (vec![single_command], true); } + if let Some(single_command) = parse_powershell_plain_command(command) { + return (vec![single_command], false); + } + (vec![command.to_vec()], 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 wrapper cases where the PowerShell script is + // effectively a plain external command invocation. Anything that looks like + // real PowerShell syntax should continue to use the opaque argv form. + if script.chars().any(|c| { + matches!( + c, + '\n' | '\r' | ';' | '|' | '&' | '(' | ')' | '{' | '}' | '$' | '@' | '`' | '<' | '>' + ) + }) { + return None; + } + + shlex_split(script).filter(|tokens| !tokens.is_empty()) +} + /// Derive a proposed execpolicy amendment when a command requires user approval /// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. /// - Otherwise return the first heuristics Prompt. diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index f062bcc741..eb46f711e6 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -572,6 +572,73 @@ async fn evaluates_heredoc_script_against_prefix_rules() { .await; } +#[tokio::test] +async fn powershell_wrapped_plain_command_matches_prefix_rules() { + let policy_src = r#"prefix_rule(pattern=["git", "add"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec![ + "pwsh".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "git add -A".to_string(), + ]; + + let requirement = ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + 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, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn requested_prefix_rule_can_approve_powershell_wrapped_plain_commands() { + let command = vec![ + "pwsh".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "git add -A".to_string(), + ]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: Some(vec!["git".to_string(), "add".to_string()]), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "git".to_string(), + "add".to_string(), + ])), + } + ); +} + #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { assert_exec_approval_requirement_for_command(