Compare commits

...

6 Commits

Author SHA1 Message Date
iceweasel-oai
0c61c48eec only use inner powershell command for rule matching 2026-04-29 21:23:56 -07:00
iceweasel-oai
c23edf2838 only use inner powershell command for rule matching 2026-04-29 21:17:35 -07:00
iceweasel-oai
0c30ead917 only use inner powershell command for rule matching 2026-04-29 21:11:39 -07:00
iceweasel-oai
57a1d792af Fix non-Windows exec policy compile 2026-04-29 17:16:36 -07:00
iceweasel-oai
cd822ab0e1 clean up from review comments. 2026-04-29 17:06:08 -07:00
iceweasel-oai
4a93d01fa3 Match Windows execpolicy rules inside PowerShell wrappers 2026-04-29 16:21:50 -07:00
7 changed files with 714 additions and 188 deletions

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,43 +250,100 @@ impl ExecPolicyManager {
prefix_rule,
} = req;
let exec_policy = self.current();
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
#[cfg(windows)]
let powershell_commands = try_parse_powershell_command_sequence(
original_command,
PowershellCommandSequenceParseMode::ExecPolicy,
)
.filter(|commands| !commands.is_empty());
#[cfg(not(windows))]
let powershell_commands: Option<Vec<Vec<String>>> = None;
let match_options = MatchOptions {
resolve_host_executables: true,
};
let (evaluation, requested_amendment, used_complex_parsing) =
if let Some(powershell_commands) = powershell_commands.as_ref() {
// PowerShell wrappers have two useful views: the outer argv for
// heuristics and wrapper-targeted rules, and the recovered
// inner commands for explicit prefix matching.
let exec_policy_fallback = |_: &[String]| {
render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
original_command,
sandbox_permissions,
/*used_complex_parsing*/ false,
)
};
let mut evaluation = exec_policy.check_multiple_with_options(
powershell_commands.iter(),
&exec_policy_fallback,
&match_options,
);
evaluation
.matched_rules
.extend(exec_policy.matches_for_command_with_options(
original_command,
/*heuristics_fallback*/ None,
&match_options,
));
evaluation.decision = evaluation
.matched_rules
.iter()
.map(RuleMatch::decision)
.max()
.expect("invariant failed: matched_rules must be non-empty");
let requested_amendment =
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
prefix_rule.as_ref(),
&evaluation.matched_rules,
exec_policy.as_ref(),
powershell_commands,
&match_options,
);
(evaluation, requested_amendment, false)
} else {
let (commands, used_complex_parsing) = commands_for_exec_policy(original_command);
let exec_policy_fallback = |parsed_command: &[String]| {
render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
parsed_command,
sandbox_permissions,
used_complex_parsing,
)
};
let evaluation = exec_policy.check_multiple_with_options(
commands.iter(),
&exec_policy_fallback,
&match_options,
);
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule.as_ref(),
&evaluation.matched_rules,
exec_policy.as_ref(),
&commands,
&exec_policy_fallback,
&match_options,
);
(evaluation, requested_amendment, used_complex_parsing)
};
// 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,
)
};
let match_options = MatchOptions {
resolve_host_executables: true,
};
let evaluation = exec_policy.check_multiple_with_options(
commands.iter(),
&exec_policy_fallback,
&match_options,
);
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule.as_ref(),
&evaluation.matched_rules,
exec_policy.as_ref(),
&commands,
&exec_policy_fallback,
&match_options,
);
let auto_prompt_amendment_allowed = auto_amendment_allowed
&& !(powershell_commands.is_some()
&& evaluation.matched_rules.iter().any(is_policy_match));
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,9 +354,9 @@ 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 {
if auto_prompt_amendment_allowed {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
@@ -307,19 +368,10 @@ impl ExecPolicyManager {
}
}
Decision::Allow => ExecApprovalRequirement::Skip {
// Bypass sandbox only when every parsed command segment is
// explicitly allowed by execpolicy.
bypass_sandbox: commands.iter().all(|command| {
exec_policy
.matches_for_command_with_options(
command,
/*heuristics_fallback*/ None,
&match_options,
)
.iter()
.any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
})
// Bypass sandbox only when the allow decision came entirely
// from explicit execpolicy allow rules.
bypass_sandbox: evaluation.matched_rules.iter().all(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
}),
proposed_execpolicy_amendment: if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
@@ -749,7 +801,10 @@ fn try_derive_execpolicy_amendment_for_prompt_rules(
fn try_derive_execpolicy_amendment_for_allow_rules(
matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
if matched_rules.iter().any(is_policy_match) {
if matched_rules
.iter()
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
{
return None;
}
@@ -764,6 +819,16 @@ fn try_derive_execpolicy_amendment_for_allow_rules(
})
}
fn prefix_rule_is_banned(prefix_rule: &[String]) -> bool {
BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
prefix_rule.len() == banned.len()
&& prefix_rule
.iter()
.map(String::as_str)
.eq(banned.iter().copied())
})
}
fn derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule: Option<&Vec<String>>,
matched_rules: &[RuleMatch],
@@ -776,18 +841,19 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
if prefix_rule.is_empty() {
return None;
}
if BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
prefix_rule.len() == banned.len()
&& prefix_rule
.iter()
.map(String::as_str)
.eq(banned.iter().copied())
}) {
if prefix_rule_is_banned(prefix_rule) {
return None;
}
// if any policy rule already matches, don't suggest an additional rule that might conflict or not apply
if matched_rules.iter().any(is_policy_match) {
// PowerShell evaluation can combine outer wrapper matches with unmatched
// inner commands. Existing explicit allow rules should not suppress a
// requested inner-command prefix rule that would resolve the remaining
// prompt, but explicit prompt/forbidden matches still should.
if matched_rules
.iter()
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
{
return None;
}
@@ -805,6 +871,55 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
}
}
fn derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
prefix_rule: Option<&Vec<String>>,
matched_rules: &[RuleMatch],
exec_policy: &Policy,
powershell_commands: &[Vec<String>],
match_options: &MatchOptions,
) -> Option<ExecPolicyAmendment> {
let prefix_rule = prefix_rule?;
if prefix_rule.is_empty() {
return None;
}
if prefix_rule_is_banned(prefix_rule) {
return None;
}
// PowerShell evaluation can combine outer wrapper matches with unmatched
// inner commands. Existing explicit allow rules should not suppress a
// requested inner-command prefix rule that would resolve the remaining
// prompt, but explicit prompt/forbidden matches still should.
if matched_rules
.iter()
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
{
return None;
}
let amendment = ExecPolicyAmendment::new(prefix_rule.clone());
let mut policy_with_prefix_rule = exec_policy.clone();
if policy_with_prefix_rule
.add_prefix_rule(&amendment.command, Decision::Allow)
.is_err()
{
return None;
}
powershell_commands
.iter()
.all(|command| {
let matched_rules = policy_with_prefix_rule.matches_for_command_with_options(
command,
/*heuristics_fallback*/ None,
match_options,
);
!matched_rules.is_empty()
&& matched_rules.iter().map(RuleMatch::decision).max() == Some(Decision::Allow)
})
.then_some(amendment)
}
fn prefix_rule_would_approve_all_commands(
exec_policy: &Policy,
prefix_rule: &[String],

View File

@@ -656,6 +656,247 @@ 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"]),
),
(
r#"prefix_rule(pattern=["git", "push"], decision="forbidden")"#.to_string(),
vec_str(&[
"powershell.exe",
"-Version",
"5.1",
"-NoExit",
"-Command",
"git push origin main",
]),
vec_str(&["git", "push"]),
),
];
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 powershell_wrapper_rules_still_apply_when_inner_commands_are_parsed() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(
concat!(
r#"prefix_rule(pattern=["powershell.exe"], decision="forbidden")"#,
"\n",
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::Forbidden {
reason: "`powershell.exe -Command 'Get-Content Cargo.toml'` rejected: policy forbids commands starting with `powershell.exe`".to_string(),
},
)
.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;
}
#[cfg(windows)]
#[tokio::test]
async fn powershell_outer_allow_does_not_override_unmatched_wrapper_prompt() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(
r#"prefix_rule(pattern=["powershell.exe"], decision="allow")"#.to_string(),
),
command: vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-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::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
)
.await;
}
#[cfg(windows)]
#[tokio::test]
async fn powershell_outer_allow_still_allows_inner_prefix_rule_suggestion() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(
r#"prefix_rule(pattern=["powershell.exe"], decision="allow")"#.to_string(),
),
command: vec_str(&[
"powershell.exe",
"-WindowStyle",
"Hidden",
"-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: Some(vec_str(&["Get-Content"])),
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
"Get-Content",
]))),
},
)
.await;
}
#[test]
fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
@@ -1739,6 +1980,53 @@ fn derive_requested_execpolicy_amendment_allows_non_exact_banned_prefix_rule_mat
);
}
#[test]
fn derive_requested_execpolicy_amendment_for_powershell_uses_inner_commands() {
let prefix_rule = vec!["git".to_string(), "push".to_string()];
let powershell_commands = vec![vec![
"git".to_string(),
"push".to_string(),
"origin".to_string(),
"main".to_string(),
]];
assert_eq!(
Some(ExecPolicyAmendment::new(prefix_rule.clone())),
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
Some(&prefix_rule),
&[],
&Policy::empty(),
&powershell_commands,
&MatchOptions::default(),
)
);
}
#[test]
fn derive_requested_execpolicy_amendment_for_powershell_requires_all_inner_commands_to_match() {
let prefix_rule = vec!["git".to_string(), "push".to_string()];
let powershell_commands = vec![
vec![
"git".to_string(),
"push".to_string(),
"origin".to_string(),
"main".to_string(),
],
vec!["whoami".to_string()],
];
assert_eq!(
None,
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
Some(&prefix_rule),
&[],
&Policy::empty(),
&powershell_commands,
&MatchOptions::default(),
)
);
}
#[test]
fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() {
let prefix_rule = vec!["cargo".to_string(), "build".to_string()];

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(

View File

@@ -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;

View File

@@ -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>>),

View File

@@ -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]

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,199 @@ 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 looks_like_powershell_flag(&lower) => {
if mode == PowershellCommandSequenceParseMode::SafeCommand {
return None;
}
idx += powershell_wrapper_flag_length(args, idx);
continue;
}
_ => {
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 looks_like_powershell_flag(lower: &str) -> bool {
lower.starts_with('-') || lower.starts_with('/')
}
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(':')
}
fn powershell_wrapper_flag_length(args: &[String], idx: usize) -> usize {
let Some(next_arg) = args.get(idx + 1) else {
return 1;
};
if looks_like_powershell_flag(&next_arg.to_ascii_lowercase()) {
1
} else {
2
}
}
/// 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"])
@@ -138,7 +342,10 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
#[cfg(test)]
mod tests {
use super::PowershellCommandSequenceParseMode;
use super::extract_powershell_command;
use super::try_parse_powershell_command_sequence;
use pretty_assertions::assert_eq;
#[test]
fn extracts_basic_powershell_command() {
@@ -186,4 +393,27 @@ mod tests {
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[cfg(windows)]
#[test]
fn exec_policy_parsing_ignores_ordinary_wrapper_flags() {
let command = vec![
"powershell.exe".to_string(),
"-Version".to_string(),
"5.1".to_string(),
"-NoExit".to_string(),
"-Command".to_string(),
"Get-Content 'foo bar'".to_string(),
];
assert_eq!(
try_parse_powershell_command_sequence(
&command,
PowershellCommandSequenceParseMode::ExecPolicy,
),
Some(vec![
vec!["Get-Content".to_string(), "foo bar".to_string(),]
]),
);
}
}