Match Windows execpolicy rules inside PowerShell wrappers

This commit is contained in:
iceweasel-oai
2026-04-29 16:21:50 -07:00
parent afbddabc8b
commit 4a93d01fa3
7 changed files with 398 additions and 140 deletions
+47 -7
View File
@@ -37,6 +37,10 @@ use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
#[cfg(windows)]
use codex_shell_command::powershell::PowershellCommandSequenceParseMode;
#[cfg(windows)]
use codex_shell_command::powershell::try_parse_powershell_command_sequence;
use codex_utils_absolute_path::AbsolutePathBuf;
use shlex::try_join as shlex_try_join;
@@ -237,7 +241,7 @@ impl ExecPolicyManager {
req: ExecApprovalRequest<'_>,
) -> ExecApprovalRequirement {
let ExecApprovalRequest {
command,
command: original_command,
approval_policy,
permission_profile,
file_system_sandbox_policy,
@@ -246,18 +250,32 @@ impl ExecPolicyManager {
prefix_rule,
} = req;
let exec_policy = self.current();
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
let (commands, used_complex_parsing) = commands_for_exec_policy(original_command);
#[cfg(windows)]
let powershell_commands = try_parse_powershell_command_sequence(
original_command,
PowershellCommandSequenceParseMode::ExecPolicy,
)
.filter(|commands| !commands.is_empty());
// 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]| {
let exec_policy_fallback = |parsed_command: &[String]| {
#[cfg(windows)]
let command_for_heuristics = if powershell_commands.is_some() {
original_command
} else {
parsed_command
};
#[cfg(not(windows))]
let command_for_heuristics = parsed_command;
render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
command_for_heuristics,
sandbox_permissions,
used_complex_parsing,
)
@@ -265,12 +283,25 @@ impl ExecPolicyManager {
let match_options = MatchOptions {
resolve_host_executables: true,
};
let evaluation = exec_policy.check_multiple_with_options(
let mut evaluation = exec_policy.check_multiple_with_options(
commands.iter(),
&exec_policy_fallback,
&match_options,
);
#[cfg(windows)]
if powershell_commands.is_some() {
for rule_match in &mut evaluation.matched_rules {
if let RuleMatch::HeuristicsRuleMatch {
command: matched_command,
..
} = rule_match
{
*matched_command = original_command.to_vec();
}
}
}
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule.as_ref(),
&evaluation.matched_rules,
@@ -282,7 +313,7 @@ impl ExecPolicyManager {
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: derive_forbidden_reason(command, &evaluation),
reason: derive_forbidden_reason(original_command, &evaluation),
},
Decision::Prompt => {
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
@@ -293,7 +324,7 @@ impl ExecPolicyManager {
reason: reason.to_string(),
},
None => ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
reason: derive_prompt_reason(original_command, &evaluation),
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_prompt_rules(
@@ -705,6 +736,15 @@ fn commands_for_exec_policy(command: &[String]) -> (Vec<Vec<String>>, bool) {
return (commands, false);
}
#[cfg(windows)]
if let Some(commands) = try_parse_powershell_command_sequence(
command,
PowershellCommandSequenceParseMode::ExecPolicy,
) && !commands.is_empty()
{
return (commands, false);
}
if let Some(single_command) = parse_shell_lc_single_command_prefix(command) {
return (vec![single_command], true);
}
+142
View File
@@ -656,6 +656,148 @@ async fn evaluates_bash_lc_inner_commands() {
.await;
}
#[cfg(windows)]
#[tokio::test]
async fn evaluates_powershell_wrapped_inner_commands_against_prefix_rules() {
let cases = vec![
(
r#"prefix_rule(pattern=["git", "push"], decision="forbidden")"#.to_string(),
vec_str(&["powershell.exe", "-Command", "git push origin main"]),
vec_str(&["git", "push"]),
),
(
r#"prefix_rule(pattern=["git", "fetch"], decision="forbidden")"#.to_string(),
vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-Command",
"git fetch origin main",
]),
vec_str(&["git", "fetch"]),
),
(
r#"prefix_rule(pattern=["Invoke-WebRequest"], decision="forbidden")"#.to_string(),
vec_str(&[
"powershell.exe",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Invoke-WebRequest https://example.com",
]),
vec_str(&["Invoke-WebRequest"]),
),
(
r#"prefix_rule(pattern=["Remove-Item"], decision="forbidden")"#.to_string(),
vec_str(&[
"powershell.exe",
"-WorkingDirectory",
"C:\\repo",
"-Command",
"Remove-Item foo.txt",
]),
vec_str(&["Remove-Item"]),
),
];
for (policy_src, command, matched_prefix) in cases {
let expected_reason = format!(
"`{}` rejected: policy forbids commands starting with `{}`",
render_shlex_command(&command),
render_shlex_command(&matched_prefix),
);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(policy_src),
command,
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::Forbidden {
reason: expected_reason,
},
)
.await;
}
}
#[cfg(windows)]
#[tokio::test]
async fn unmatched_powershell_wrappers_keep_outer_command_heuristics() {
for (command, expected) in [
(
vec_str(&["powershell.exe", "-Command", "Get-Content Cargo.toml"]),
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
"powershell.exe",
"-Command",
"Get-Content Cargo.toml",
]))),
},
),
(
vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-Command",
"Get-Content Cargo.toml",
]),
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-Command",
"Get-Content Cargo.toml",
]))),
},
),
] {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
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,
},
expected,
)
.await;
}
}
#[cfg(windows)]
#[tokio::test]
async fn allow_rule_matches_powershell_wrapped_inner_commands() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(
r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#.to_string(),
),
command: vec_str(&["powershell.exe", "-Command", "Get-Content Cargo.toml"]),
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,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
},
)
.await;
}
#[test]
fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
-5
View File
@@ -81,11 +81,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(
@@ -2,4 +2,5 @@ mod powershell_parser;
pub mod is_dangerous_command;
pub mod is_safe_command;
pub(crate) use powershell_parser::try_parse_powershell_ast_commands;
pub(crate) mod windows_safe_commands;
@@ -34,6 +34,16 @@ pub(super) fn parse_with_powershell_ast(executable: &str, script: &str) -> Power
parse_with_cached_process(&mut parser_processes, executable, script)
}
pub(crate) fn try_parse_powershell_ast_commands(
executable: &str,
script: &str,
) -> Option<Vec<Vec<String>>> {
match parse_with_powershell_ast(executable, script) {
PowershellParseOutcome::Commands(commands) => Some(commands),
PowershellParseOutcome::Unsupported | PowershellParseOutcome::Failed => None,
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum PowershellParseOutcome {
Commands(Vec<Vec<String>>),
@@ -1,12 +1,14 @@
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::PowershellCommandSequenceParseMode;
use crate::powershell::try_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) = try_parse_powershell_command_sequence(
command,
PowershellCommandSequenceParseMode::SafeCommand,
) {
commands
.iter()
.all(|cmd| is_safe_powershell_command(cmd.as_slice()))
@@ -16,130 +18,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 {
@@ -490,6 +368,15 @@ mod tests {
"-Command",
"''"
])));
// Wrapper flags that exec policy may still parse stay unsafe here.
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-Command",
"Get-Content Cargo.toml",
])));
}
#[test]
+183
View File
@@ -2,10 +2,21 @@ 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;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
const POWERSHELL_NO_ARG_PARSE_FLAGS: &[&str] =
&["-nologo", "-noprofile", "-noninteractive", "-mta", "-sta"];
const POWERSHELL_VALUE_PARSE_FLAGS: &[&str] =
&["-windowstyle", "-executionpolicy", "-workingdirectory"];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PowershellCommandSequenceParseMode {
ExecPolicy,
SafeCommand,
}
/// 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 +79,178 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
None
}
/// Recover discrete inner command vectors from an explicit one-layer PowerShell wrapper.
///
/// This recognizes top-level `powershell` / `powershell.exe` / `pwsh` / `pwsh.exe`
/// invocations with an explicit `-Command` / `/Command` / `-c` body and parses that
/// script with the PowerShell AST parser. Unsupported or opaque forms such as
/// `-EncodedCommand` return `None`.
pub fn try_parse_powershell_command_sequence(
command: &[String],
mode: PowershellCommandSequenceParseMode,
) -> Option<Vec<Vec<String>>> {
let (executable, args) = command.split_first()?;
if is_powershell_executable(executable) {
parse_powershell_invocation(executable, args, mode)
} else {
None
}
}
fn parse_powershell_invocation(
executable: &str,
args: &[String],
mode: PowershellCommandSequenceParseMode,
) -> 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_to_commands(executable, script);
}
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
if idx + 1 != args.len() {
return None;
}
let script = arg.split_once(':')?.1;
return parse_powershell_script_to_commands(executable, script);
}
_ if is_powershell_no_arg_parse_flag(&lower) => {
idx += 1;
continue;
}
_ if is_powershell_value_parse_flag(&lower) => {
if mode == PowershellCommandSequenceParseMode::SafeCommand {
return None;
}
args.get(idx + 1)?;
idx += 2;
continue;
}
_ if is_powershell_value_parse_flag_with_inline_value(&lower) => {
if mode == PowershellCommandSequenceParseMode::SafeCommand {
return None;
}
idx += 1;
continue;
}
_ if is_unsupported_powershell_parse_flag(&lower)
|| has_unsupported_powershell_parse_flag_inline_value(&lower) =>
{
return None;
}
_ if lower.starts_with('-') => {
return None;
}
_ => {
if mode == PowershellCommandSequenceParseMode::ExecPolicy {
return None;
}
let script = join_arguments_as_script(&args[idx..]);
return parse_powershell_script_to_commands(executable, &script);
}
}
}
None
}
pub(crate) fn parse_powershell_script_to_commands(
executable: &str,
script: &str,
) -> Option<Vec<Vec<String>>> {
try_parse_powershell_ast_commands(executable, script)
}
pub(crate) fn is_powershell_executable(exe: &str) -> bool {
let executable_name = std::path::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"
)
}
pub(crate) 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('\'', "''"))
}
fn is_powershell_no_arg_parse_flag(lower: &str) -> bool {
POWERSHELL_NO_ARG_PARSE_FLAGS.contains(&lower)
}
fn is_powershell_value_parse_flag(lower: &str) -> bool {
POWERSHELL_VALUE_PARSE_FLAGS.contains(&lower)
|| matches!(
lower,
"/windowstyle" | "/executionpolicy" | "/workingdirectory"
)
}
fn is_powershell_value_parse_flag_with_inline_value(lower: &str) -> bool {
matches!(
split_flag_inline_value(lower),
Some((
"-windowstyle"
| "/windowstyle"
| "-executionpolicy"
| "/executionpolicy"
| "-workingdirectory"
| "/workingdirectory",
_
))
)
}
fn is_unsupported_powershell_parse_flag(lower: &str) -> bool {
matches!(lower, "-encodedcommand" | "-ec" | "-file" | "/file")
}
fn has_unsupported_powershell_parse_flag_inline_value(lower: &str) -> bool {
matches!(
split_flag_inline_value(lower),
Some(("-encodedcommand" | "-ec" | "-file" | "/file", _))
)
}
fn split_flag_inline_value(lower: &str) -> Option<(&str, &str)> {
lower.split_once(':')
}
/// 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"])