Harden PowerShell exec-policy classification

This commit is contained in:
Chris Bookholt
2026-07-02 19:40:51 -07:00
parent 9f9db61ddf
commit c1ff6221c1
11 changed files with 435 additions and 105 deletions
+3 -13
View File
@@ -1,16 +1,13 @@
use codex_shell_command::bash::extract_bash_command;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::powershell::extract_powershell_command;
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";
/// Canonicalize command argv for approval-cache matching.
///
/// This keeps approval decisions stable across wrapper-path differences (for
/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while
/// preserving exact script text for complex scripts where we cannot safely
/// recover a tokenized command sequence.
/// Bash word-only scripts retain their historical inner-command identity. For
/// PowerShell, the executable, wrapper flags, and exact script are all part of
/// the key because each affects the authorization boundary.
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& let [single_command] = commands.as_slice()
@@ -27,13 +24,6 @@ pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<Strin
];
}
if let Some((_shell, script)) = extract_powershell_command(command) {
return vec![
CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(),
script.to_string(),
];
}
command.to_vec()
}
@@ -1,5 +1,15 @@
use super::canonicalize_command_for_approval;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec<String> {
vec![
executable.to_string(),
"-NoProfile".to_string(),
command_flag.to_string(),
script.to_string(),
]
}
#[test]
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
@@ -54,30 +64,60 @@ fn canonicalizes_heredoc_scripts_to_stable_script_key() {
}
#[test]
fn canonicalizes_powershell_wrappers_to_stable_script_key() {
let script = "Write-Host hi";
let command_a = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
script.to_string(),
];
let command_b = vec![
"powershell".to_string(),
"-Command".to_string(),
script.to_string(),
fn preserves_exact_powershell_approval_identity() {
let base = powershell("C:/workspace/powershell.exe", "-Command", "Write-Host hi");
let variants = [
powershell("powershell.exe", "-Command", "Write-Host hi"),
powershell("C:/other/powershell.exe", "-Command", "Write-Host hi"),
powershell("C:/workspace/pwsh.exe", "-Command", "Write-Host hi"),
powershell("C:/workspace/powershell.exe", "-c", "Write-Host hi"),
powershell(
"C:/workspace/powershell.exe",
"-Command",
"Write-Host changed",
),
powershell("C:/workspace/powershell.exe.", "-Command", "Write-Host hi"),
powershell("C:/workspace/powershell.exe ", "-Command", "Write-Host hi"),
powershell(
r"\\?\C:\workspace\powershell.exe.",
"-Command",
"Write-Host hi",
),
powershell(
r"\\.\UNC\server\share\powershell.exe ",
"-Command",
"Write-Host hi",
),
];
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"__codex_powershell_script__".to_string(),
script.to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
let mut commands = vec![base];
commands.extend(variants);
let keys: HashSet<_> = commands
.iter()
.map(|command| canonicalize_command_for_approval(command))
.collect();
assert_eq!(keys.len(), commands.len());
for command in commands {
assert_eq!(canonicalize_command_for_approval(&command), command);
}
}
#[test]
fn powershell_identity_does_not_collide_with_raw_marker_argv() {
let powershell = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
];
let mut marker_argv = vec!["__codex_powershell_script__".to_string()];
marker_argv.extend(powershell.iter().cloned());
assert_eq!(canonicalize_command_for_approval(&powershell), powershell);
assert_eq!(canonicalize_command_for_approval(&marker_argv), marker_argv);
assert_ne!(
canonicalize_command_for_approval(&powershell),
canonicalize_command_for_approval(&marker_argv)
);
}
+25 -15
View File
@@ -40,6 +40,10 @@ use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_utils_absolute_path::AbsolutePathBuf;
use shlex::try_join as shlex_try_join;
#[cfg(windows)]
#[path = "exec_policy_powershell.rs"]
mod powershell_policy;
const PROMPT_CONFLICT_REASON: &str =
"approval required by policy, but AskForApproval is set to Never";
const REJECT_SANDBOX_APPROVAL_REASON: &str =
@@ -279,11 +283,31 @@ impl ExecPolicyManager {
prefix_rule,
} = req;
let exec_policy = self.current();
#[cfg(windows)]
let parsed_powershell = match powershell_policy::prepare(command) {
Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => {
return requirement;
}
Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => Some(parsed),
None => None,
};
#[cfg(windows)]
let exec_policy_commands = if let Some(parsed) = parsed_powershell.as_ref() {
ExecPolicyCommands {
commands: parsed.commands().to_vec(),
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
}
} else {
commands_for_exec_policy(command)
};
#[cfg(not(windows))]
let exec_policy_commands = commands_for_exec_policy(command);
let ExecPolicyCommands {
commands,
used_complex_parsing,
command_origin,
} = commands_for_exec_policy(command);
} = exec_policy_commands;
// 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.
@@ -767,20 +791,6 @@ fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands {
};
}
#[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 ExecPolicyCommands {
commands: vec![single_command],
@@ -0,0 +1,49 @@
use codex_shell_command::powershell::PowerShellExecPolicyParse;
use super::render_shlex_command;
use crate::tools::sandboxing::ExecApprovalRequirement;
pub(super) enum PreparedPowerShell {
Terminal(ExecApprovalRequirement),
Parsed(ParsedPowerShell),
}
pub(super) struct ParsedPowerShell {
commands: Vec<Vec<String>>,
}
pub(super) fn prepare(command: &[String]) -> Option<PreparedPowerShell> {
match codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)? {
PowerShellExecPolicyParse::TrustedRuntime {
commands: Some(commands),
} if !commands.is_empty() => {
Some(PreparedPowerShell::Parsed(ParsedPowerShell { commands }))
}
PowerShellExecPolicyParse::TrustedRuntime { .. } => Some(forbidden(
command,
"the PowerShell script could not be inspected",
)),
PowerShellExecPolicyParse::UntrustedRuntime {
commands: Some(commands),
} if !commands.is_empty() => Some(forbidden(
command,
"the PowerShell runtime is not a protected system executable",
)),
PowerShellExecPolicyParse::UntrustedRuntime { .. } => Some(forbidden(
command,
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
)),
}
}
fn forbidden(command: &[String], reason: &str) -> PreparedPowerShell {
PreparedPowerShell::Terminal(ExecApprovalRequirement::Forbidden {
reason: format!("`{}` rejected: {reason}", render_shlex_command(command)),
})
}
impl ParsedPowerShell {
pub(super) fn commands(&self) -> &[Vec<String>] {
&self.commands
}
}
@@ -0,0 +1,97 @@
use super::*;
fn trusted_windows_powershell() -> String {
codex_shell_command::powershell::try_find_powershell_executable_blocking()
.expect("Windows PowerShell must be installed")
.as_path()
.to_str()
.expect("the Windows PowerShell path must be valid UTF-8")
.to_string()
}
fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval {
AskForApproval::Granular(GranularApprovalConfig {
rules,
sandbox_approval,
skill_approval: false,
request_permissions: false,
mcp_elicitations: false,
})
}
#[tokio::test]
async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
let cases = [
(
"untrusted parsed runtime",
vec_str(&["powershell.EXE.CmD", "-Command", "echo allowed"]),
Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#),
"the PowerShell runtime is not a protected system executable",
),
(
"trusted empty body",
vec![
trusted_windows_powershell(),
"-Command".into(),
String::new(),
],
None,
"the PowerShell script could not be inspected",
),
(
"untrusted opaque body",
vec_str(&[
"powershell.exe",
"-NonInteractive",
"-Command",
"echo blocked",
]),
None,
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
),
];
let policies = [
AskForApproval::Never,
AskForApproval::OnRequest,
AskForApproval::UnlessTrusted,
granular(/*rules*/ false, /*sandbox_approval*/ false),
granular(/*rules*/ false, /*sandbox_approval*/ true),
granular(/*rules*/ true, /*sandbox_approval*/ false),
granular(/*rules*/ true, /*sandbox_approval*/ true),
];
let profiles = [
PermissionProfile::Disabled,
PermissionProfile::read_only(),
PermissionProfile::default(),
PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
];
for (name, command, policy_src, reason) in cases {
let rendered = render_shlex_command(&command);
for approval_policy in policies {
for permission_profile in &profiles {
let requirement =
exec_approval_requirement_for_command(ExecApprovalRequirementScenario {
policy_src: policy_src.map(str::to_owned),
command: command.clone(),
approval_policy,
permission_profile: permission_profile.clone(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
reason: format!("`{rendered}` rejected: {reason}"),
},
"{} with {approval_policy:?} and {permission_profile:?}",
name
);
}
}
}
}
+11 -6
View File
@@ -39,6 +39,10 @@ use toml::Value as TomlValue;
#[path = "exec_policy_windows_tests.rs"]
mod windows_tests;
#[cfg(windows)]
#[path = "exec_policy_powershell_tests.rs"]
mod powershell_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");
@@ -2007,12 +2011,13 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
])));
let (pwsh_approval_reason, expected_req) = if cfg!(windows) {
(
r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean
that no sandbox is present, so anything that is not "provably
safe" should require approval."#,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: expected_amendment.clone(),
"On Windows, an untrusted PowerShell wrapper that cannot be inspected is rejected.",
ExecApprovalRequirement::Forbidden {
reason: format!(
"`{}` rejected: an untrusted PowerShell wrapper could not be inspected with \
the protected system parser",
render_shlex_command(&sneaky_command)
),
},
)
} else {
@@ -58,25 +58,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
.await;
}
#[test]
fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
let command = vec![
TRUSTED_WINDOWS_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 commands_for_exec_policy_keeps_bare_powershell_alias_opaque() {
let command = vec![
+16 -20
View File
@@ -9,8 +9,6 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
#[cfg(windows)]
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -109,7 +107,7 @@ fn enable_unified_exec(config: &mut codex_core::config::Config) {
#[cfg(windows)]
#[tokio::test]
async fn unified_exec_workspace_powershell_path_requires_approval_before_execution() -> Result<()> {
async fn unified_exec_workspace_powershell_path_is_rejected_before_execution() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(enable_unified_exec);
let test = builder.build(&server).await?;
@@ -122,7 +120,7 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
.expect("the test workspace path must be valid UTF-8")
.to_string();
let call_id = "unified-exec-workspace-powershell-requires-approval";
let call_id = "unified-exec-workspace-powershell-rejected";
let args = json!({
"shell": fake_powershell.clone(),
"cmd": "Get-Location",
@@ -137,7 +135,7 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
]),
)
.await;
mount_sse_once(
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-workspace-powershell-1", "done"),
@@ -164,22 +162,20 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
)
})
.await;
let EventMsg::ExecApprovalRequest(approval) = event else {
panic!("workspace PowerShell must request approval before execution, got {event:?}");
};
assert_eq!(approval.command.first(), Some(&fake_powershell));
assert!(
matches!(event, EventMsg::TurnComplete(_)),
"workspace PowerShell must be rejected before approval or execution, got {event:?}"
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Denied,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output_item = results_mock.single_request().function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("function call output should include a string output payload");
assert!(
output.contains("the PowerShell runtime is not a protected system executable"),
"unexpected output: {output}"
);
Ok(())
}
@@ -5,6 +5,7 @@ pub mod is_safe_command;
#[cfg(windows)]
pub(crate) mod windows_safe_commands;
pub(crate) use powershell_parser::TrustedPowerShellFlavor;
pub(crate) use powershell_parser::is_trusted_powershell_parser_executable;
#[cfg(all(test, windows))]
pub(crate) use powershell_parser::trusted_standard_pwsh_invocation_path;
#[cfg(all(test, windows))]
@@ -120,6 +120,10 @@ fn trusted_powershell_parser_executable(executable: &str) -> Option<PathBuf> {
}
}
pub(crate) fn is_trusted_powershell_parser_executable(executable: &str) -> bool {
trusted_powershell_parser_executable(executable).is_some()
}
/// Returns the authoritative Windows PowerShell invocation path only when the production trust
/// resolver accepts that exact known-folder-derived path.
#[cfg(all(test, windows))]
+167 -10
View File
@@ -1,12 +1,9 @@
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::command_safety::TrustedPowerShellFlavor;
use crate::command_safety::is_trusted_powershell_parser_executable;
use crate::command_safety::try_parse_powershell_ast_commands;
use crate::command_safety::try_parse_powershell_ast_commands_with_trusted_flavor;
use crate::shell_detect::ShellType;
use crate::shell_detect::detect_shell_type;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
@@ -48,12 +45,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
}
let shell = &command[0];
if !matches!(
detect_shell_type(PathBuf::from(shell)),
Some(ShellType::PowerShell)
) {
return None;
}
powershell_flavor_for_executable(shell)?;
// Find the first occurrence of -Command (accept common short alias -c as well)
let mut i = 1usize;
@@ -64,6 +56,9 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
return None;
}
if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") {
if i + 2 != command.len() {
return None;
}
let script = &command[i + 1];
return Some((shell, script));
}
@@ -91,6 +86,51 @@ pub enum PowerShellFlavor {
PowerShell7,
}
/// Result of inspecting a PowerShell wrapper for exec-policy evaluation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PowerShellExecPolicyParse {
/// The runtime wrapper is the same protected executable used for parsing.
TrustedRuntime { commands: Option<Vec<Vec<String>>> },
/// A protected parser inspected the body, but the runtime wrapper remains untrusted.
UntrustedRuntime { commands: Option<Vec<Vec<String>>> },
}
/// Inspects a PowerShell wrapper without spawning its model-selected runtime.
pub fn parse_powershell_command_for_exec_policy(
command: &[String],
) -> Option<PowerShellExecPolicyParse> {
let executable = command.first()?;
let flavor = powershell_flavor_for_executable(executable)?;
let commands = extract_powershell_command(command)
.and_then(|(_, script)| parse_powershell_script_with_trusted_parser(flavor, script));
if is_trusted_powershell_parser_executable(executable) {
Some(PowerShellExecPolicyParse::TrustedRuntime { commands })
} else {
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands })
}
}
fn powershell_flavor_for_executable(executable: &str) -> Option<PowerShellFlavor> {
let mut executable_name = std::path::Path::new(executable).file_name()?.to_str()?;
#[cfg(windows)]
{
executable_name = executable_name.trim_end_matches([' ', '.']);
}
loop {
let executable_stem = std::path::Path::new(executable_name)
.file_stem()?
.to_str()?;
if executable_stem.eq_ignore_ascii_case("powershell") {
return Some(PowerShellFlavor::WindowsPowerShell);
} else if executable_stem.eq_ignore_ascii_case("pwsh") {
return Some(PowerShellFlavor::PowerShell7);
} else if executable_stem == executable_name {
return None;
}
executable_name = executable_stem;
}
}
/// Parses a PowerShell script without using the runtime wrapper as the parser executable.
///
/// The selected parser comes from the authoritative Windows System or Program Files known folder.
@@ -176,14 +216,20 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::PowerShellExecPolicyParse;
#[cfg(windows)]
use super::PowerShellFlavor;
use super::UTF8_OUTPUT_PREFIX;
use super::extract_powershell_command;
#[cfg(windows)]
use super::parse_powershell_command_for_exec_policy;
#[cfg(windows)]
use super::parse_powershell_command_into_plain_commands;
#[cfg(windows)]
use super::parse_powershell_script_with_trusted_parser;
#[cfg(windows)]
use super::powershell_flavor_for_executable;
use super::prefix_powershell_script_with_utf8;
#[cfg(windows)]
@@ -241,6 +287,18 @@ mod tests {
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[test]
fn rejects_arguments_after_powershell_script() {
let command = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
"unexpected".to_string(),
];
assert_eq!(extract_powershell_command(&command), None);
}
#[test]
fn prefixes_powershell_command_with_best_effort_utf8() {
let cmd = vec![
@@ -330,4 +388,103 @@ mod tests {
);
}
}
#[cfg(windows)]
#[test]
fn exec_policy_parse_preserves_runtime_trust_and_aliases() {
assert_eq!(
powershell_flavor_for_executable(r"\\.\UNC\server\share\pwsh.EXE.bAt "),
Some(PowerShellFlavor::PowerShell7)
);
let trusted = trusted_windows_powershell_executable();
let parsed = Some(vec![vec!["echo".to_string(), "classified".to_string()]]);
let cases = [
(trusted, true),
("powershell.exe".to_string(), false),
("powershell.EXE.CmD".to_string(), false),
(r".\tools\powershell.exe".to_string(), false),
(r"C:\workspace\powershell.exe".to_string(), false),
("powershell.exe.".to_string(), false),
("powershell.exe ".to_string(), false),
(r"\\?\C:\workspace\powershell.exe.".to_string(), false),
(r"\\.\C:\workspace\powershell.exe ".to_string(), false),
(r"\\.\UNC\server\share\powershell.exe ".to_string(), false),
];
for (executable, trusted) in cases {
let result = parse_powershell_command_for_exec_policy(&[
executable.clone(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo classified".to_string(),
]);
let expected = if trusted {
PowerShellExecPolicyParse::TrustedRuntime {
commands: parsed.clone(),
}
} else {
PowerShellExecPolicyParse::UntrustedRuntime {
commands: parsed.clone(),
}
};
assert_eq!(result, Some(expected), "runtime {executable:?}");
}
}
#[cfg(windows)]
#[test]
fn ordinary_trailing_aliases_launch_but_remain_untrusted() {
let trusted = trusted_windows_powershell_executable();
for executable in [format!("{trusted}."), format!("{trusted} ")] {
assert!(super::is_powershellish_executable_available(
std::path::Path::new(&executable)
));
assert!(matches!(
parse_powershell_command_for_exec_policy(&[
executable,
"-Command".to_string(),
"echo launchable".to_string(),
]),
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands: Some(_) })
));
}
}
#[cfg(windows)]
#[test]
fn exec_policy_parse_marks_unsupported_bodies_opaque() {
let command = |executable: &str, args: &[&str]| {
std::iter::once(executable.to_string())
.chain(args.iter().map(|arg| (*arg).to_string()))
.collect::<Vec<_>>()
};
let cases = [
(
command(
&trusted_windows_powershell_executable(),
&["-Command", "param([string]$path) echo blocked"],
),
PowerShellExecPolicyParse::TrustedRuntime { commands: None },
),
(
command(
"powershell.exe",
&["-NonInteractive", "-Command", "echo blocked"],
),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
),
(
command("powershell.exe", &["-Command", "echo blocked", "trailing"]),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
),
];
for (command, expected) in cases {
assert_eq!(
parse_powershell_command_for_exec_policy(&command),
Some(expected),
"command {command:?}"
);
}
}
}