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 authored and GitHub committed 2026-05-01 00:56:20 +00:00
1 parent 0d9a5d20ec
commit 4f96001fa7
12 files changed
+434 -77

No files matched your search

+109 -21
View File
@@ -98,6 +98,43 @@ static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[
&["osascript"],
];
/// Describes which unmatched-command heuristics should classify the command
/// words being evaluated by exec-policy.
///
/// The command tokens may be the original argv or a shell-specific lowering of
/// a wrapper such as `bash -lc ...` or `powershell.exe -Command ...`. We only
/// need to distinguish the PowerShell case because its safelist and dangerous
/// heuristics operate on PowerShell-flavored inner command words rather than
/// the generic command classifier.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ExecPolicyCommandOrigin {
/// Use the generic unmatched-command heuristics.
Generic,
#[cfg(windows)]
/// The command words came from the `-Command` body of a top-level
/// PowerShell wrapper, so use PowerShell-specific unmatched-command
/// heuristics for the lowered words.
PowerShell,
}
#[derive(Clone, Copy)]
pub(crate) struct UnmatchedCommandContext<'a> {
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: &'a PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) used_complex_parsing: bool,
pub(crate) command_origin: ExecPolicyCommandOrigin,
}
#[derive(Debug, Eq, PartialEq)]
struct ExecPolicyCommands {
commands: Vec<Vec<String>>,
used_complex_parsing: bool,
command_origin: ExecPolicyCommandOrigin,
}
pub(crate) fn child_uses_parent_exec_policy(parent_config: &Config, child_config: &Config) -> bool {
fn exec_policy_config_folders(config: &Config) -> Vec<AbsolutePathBuf> {
config
@@ -246,20 +283,27 @@ impl ExecPolicyManager {
prefix_rule,
} = req;
let exec_policy = self.current();
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
let ExecPolicyCommands {
commands,
used_complex_parsing,
command_origin,
} = commands_for_exec_policy(command);
// Keep heredoc prefix parsing for rule evaluation so existing
// allow/prompt/forbidden rules still apply, but avoid auto-derived
// amendments when only the heredoc fallback parser matched.
let auto_amendment_allowed = !used_complex_parsing;
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
sandbox_permissions,
used_complex_parsing,
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
},
)
};
let match_options = MatchOptions {
@@ -581,16 +625,27 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
}
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
pub fn render_decision_for_unmatched_command(
approval_policy: AskForApproval,
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
pub(crate) fn render_decision_for_unmatched_command(
command: &[String],
sandbox_permissions: SandboxPermissions,
used_complex_parsing: bool,
context: UnmatchedCommandContext<'_>,
) -> Decision {
if is_known_safe_command(command) && !used_complex_parsing {
let UnmatchedCommandContext {
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
} = context;
let is_known_safe = match command_origin {
ExecPolicyCommandOrigin::Generic => is_known_safe_command(command),
#[cfg(windows)]
ExecPolicyCommandOrigin::PowerShell => {
codex_shell_command::is_safe_command::is_safe_powershell_words(command)
}
};
if is_known_safe && !used_complex_parsing {
return Decision::Allow;
}
@@ -609,7 +664,14 @@ pub fn render_decision_for_unmatched_command(
// We prefer to prompt the user rather than outright forbid the command,
// but if the user has explicitly disabled prompts, we must
// forbid the command.
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
let command_is_dangerous = match command_origin {
ExecPolicyCommandOrigin::Generic => command_might_be_dangerous(command),
#[cfg(windows)]
ExecPolicyCommandOrigin::PowerShell => {
codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command)
}
};
if command_is_dangerous || environment_lacks_sandbox_protections {
return match approval_policy {
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
@@ -637,7 +699,7 @@ pub fn render_decision_for_unmatched_command(
Decision::Allow
}
AskForApproval::UnlessTrusted => {
// We already checked `is_known_safe_command(command)` and it
// We already checked the unmatched-command safelist and it
// returned false, so we must prompt.
Decision::Prompt
}
@@ -698,18 +760,44 @@ fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
fn commands_for_exec_policy(command: &[String]) -> (Vec<Vec<String>>, bool) {
fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
{
return (commands, false);
return ExecPolicyCommands {
commands,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
};
}
#[cfg(windows)]
{
if let Some(commands) =
codex_shell_command::powershell::parse_powershell_command_into_plain_commands(command)
&& !commands.is_empty()
{
return ExecPolicyCommands {
commands,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
};
}
}
if let Some(single_command) = parse_shell_lc_single_command_prefix(command) {
return (vec![single_command], true);
return ExecPolicyCommands {
commands: vec![single_command],
used_complex_parsing: true,
command_origin: ExecPolicyCommandOrigin::Generic,
};
}
(vec![command.to_vec()], false)
ExecPolicyCommands {
commands: vec![command.to_vec()],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
}
/// Derive a proposed execpolicy amendment when a command requires user approval
+68 -35
View File
@@ -34,6 +34,10 @@ use tempfile::TempDir;
use tempfile::tempdir;
use toml::Value as TomlValue;
#[cfg(windows)]
#[path = "exec_policy_windows_tests.rs"]
mod windows_tests;
fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack {
let dot_codex_folder =
AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder");
@@ -660,7 +664,14 @@ async fn evaluates_bash_lc_inner_commands() {
fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
assert_eq!(commands_for_exec_policy(&command), (vec![command], false));
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![command],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
);
}
#[test]
@@ -671,7 +682,14 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() {
" \n\t ".to_string(),
];
assert_eq!(commands_for_exec_policy(&command), (vec![command], false));
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![command],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
);
}
#[tokio::test]
@@ -961,19 +979,24 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
assert_eq!(
Decision::Prompt,
render_decision_for_unmatched_command(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
&permission_profile_from_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
&read_only_file_system_sandbox_policy(),
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
UnmatchedCommandContext {
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
},
)
);
}
@@ -986,13 +1009,16 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
assert_eq!(
Decision::Prompt,
render_decision_for_unmatched_command(
AskForApproval::OnRequest,
&PermissionProfile::Disabled,
&restricted_file_system_policy,
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::Disabled,
file_system_sandbox_policy: &restricted_file_system_policy,
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
},
)
);
}
@@ -1976,10 +2002,20 @@ struct ExecApprovalRequirementScenario {
prefix_rule: Option<Vec<String>>,
}
async fn assert_exec_approval_requirement_for_command(
fn policy_from_src(policy_src: Option<&str>) -> Arc<Policy> {
match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser.parse("test.rules", src).expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
}
}
async fn exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
) -> ExecApprovalRequirement {
let ExecApprovalRequirementScenario {
policy_src,
command,
@@ -1990,19 +2026,10 @@ async fn assert_exec_approval_requirement_for_command(
prefix_rule,
} = test;
let policy = match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser
.parse("test.rules", src.as_str())
.expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
};
let policy = policy_from_src(policy_src.as_deref());
let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy);
let requirement = ExecPolicyManager::new(policy)
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
@@ -2012,8 +2039,14 @@ async fn assert_exec_approval_requirement_for_command(
sandbox_permissions,
prefix_rule,
})
.await;
.await
}
async fn assert_exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
let requirement = exec_approval_requirement_for_command(test).await;
assert_eq!(requirement, expected_requirement);
}
@@ -0,0 +1,125 @@
use super::*;
use pretty_assertions::assert_eq;
use std::path::Path;
#[tokio::test]
async fn evaluates_powershell_inner_commands_against_prompt_rules() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="prompt")"#.to_string()),
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
],
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Forbidden {
reason: PROMPT_CONFLICT_REASON.to_string(),
},
)
.await;
}
#[tokio::test]
async fn evaluates_powershell_inner_commands_against_allow_rules() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
],
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: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
},
)
.await;
}
#[test]
fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
let command = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
];
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![vec!["echo".to_string(), "blocked".to_string()]],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
}
);
}
#[test]
fn unmatched_safe_powershell_words_are_allowed() {
let command = vec!["Get-Content".to_string(), "Cargo.toml".to_string()];
assert_eq!(
Decision::Allow,
render_decision_for_unmatched_command(
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
},
)
);
}
#[tokio::test]
async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
let inner_command = vec![
"Remove-Item".to_string(),
"test".to_string(),
"-Force".to_string(),
];
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Remove-Item test -Force".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(inner_command)),
},
)
.await;
}
@@ -674,13 +674,16 @@ fn evaluate_intercepted_exec_policy(
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
sandbox_permissions,
used_complex_parsing,
crate::exec_policy::UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic,
},
)
};