diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 692b461c35..3b9dd6d520 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -41,6 +41,7 @@ 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; +mod executable_identity; mod model_policy; pub(crate) use model_policy::AllowPrefixRules; @@ -314,6 +315,20 @@ impl ExecPolicyManager { pub(crate) async fn create_exec_approval_requirement_for_command( &self, req: ExecApprovalRequest<'_>, + ) -> ExecApprovalRequirement { + let commands = commands_for_exec_policy(req.command); + self.create_exec_approval_requirement_for_parsed_commands(req, commands) + .await + } + + async fn create_exec_approval_requirement_for_parsed_commands( + &self, + req: ExecApprovalRequest<'_>, + ExecPolicyCommands { + commands, + used_complex_parsing, + command_origin, + }: ExecPolicyCommands, ) -> ExecApprovalRequirement { let ExecApprovalRequest { command, @@ -326,11 +341,6 @@ impl ExecPolicyManager { allow_prefix_rules, } = req; let exec_policy = self.current_for_environment(environment_policy, allow_prefix_rules); - let ExecPolicyCommands { - commands, - used_complex_parsing, - command_origin, - } = commands_for_exec_policy(command); // Keep heredoc prefix parsing for the rules that apply to this model, // but avoid reusable approvals for cyber models or when only the // heredoc fallback parser matched. diff --git a/codex-rs/core/src/exec_policy/executable_identity.rs b/codex-rs/core/src/exec_policy/executable_identity.rs new file mode 100644 index 0000000000..f585a04160 --- /dev/null +++ b/codex-rs/core/src/exec_policy/executable_identity.rs @@ -0,0 +1,106 @@ +use super::ExecApprovalRequest; +#[cfg(windows)] +use super::ExecPolicyCommandOrigin; +#[cfg(windows)] +use super::ExecPolicyCommands; +use super::ExecPolicyManager; +use super::commands_for_exec_policy; +use crate::shell::Shell; +use crate::tools::sandboxing::ExecApprovalRequirement; +#[cfg(windows)] +use codex_shell_command::powershell::extract_powershell_command; +#[cfg(windows)] +use codex_shell_command::powershell::parse_powershell_script_into_plain_commands; +use codex_tools::UnifiedExecShellMode; +use std::path::Path; + +impl ExecPolicyManager { + pub(crate) async fn create_exec_approval_requirement_for_shell( + &self, + mut request: ExecApprovalRequest<'_>, + configured_shell: &Shell, + shell_mode: &UnifiedExecShellMode, + ) -> ExecApprovalRequirement { + let command = request.command; + let executable = shell_approval_command(command, configured_shell, shell_mode); + if executable.len() == command.len() { + return self + .create_exec_approval_requirement_for_command(request) + .await; + } + + #[cfg(windows)] + let mut policy_commands = match extract_powershell_command(command) { + Some((_, script)) => ExecPolicyCommands { + commands: parse_powershell_script_into_plain_commands(script) + .unwrap_or_else(|| vec![command.to_vec()]), + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::PowerShell, + }, + None => commands_for_exec_policy(command), + }; + + #[cfg(not(windows))] + let mut policy_commands = commands_for_exec_policy(command); + + // Evaluate the executable alongside its apparent commands. Inner + // commands can add restrictions, but cannot grant the executable trust. + policy_commands.commands.insert(0, executable.to_vec()); + request.command = executable; + self.create_exec_approval_requirement_for_parsed_commands(request, policy_commands) + .await + } +} + +fn shell_approval_command<'a>( + command: &'a [String], + configured_shell: &Shell, + shell_mode: &UnifiedExecShellMode, +) -> &'a [String] { + let Some(executable) = command.first() else { + return command; + }; + let executable_path = Path::new(executable); + + #[cfg(windows)] + let is_system_shell = std::env::var_os("SystemRoot").is_some_and(|system_root| { + let system_directory = Path::new(&system_root).join("System32"); + let powershell_directory = system_directory.join("WindowsPowerShell").join("v1.0"); + executable_path.parent().is_some_and(|parent| { + parent + .as_os_str() + .eq_ignore_ascii_case(system_directory.as_os_str()) + || parent + .as_os_str() + .eq_ignore_ascii_case(powershell_directory.as_os_str()) + }) + }); + + #[cfg(not(windows))] + let is_system_shell = executable_path + .parent() + .is_some_and(|parent| parent == Path::new("/bin") || parent == Path::new("/usr/bin")); + + #[cfg(windows)] + let is_configured_shell = executable_path + .as_os_str() + .eq_ignore_ascii_case(configured_shell.shell_path.as_os_str()); + + #[cfg(not(windows))] + let is_configured_shell = executable_path == configured_shell.shell_path.as_path(); + + if is_configured_shell + || is_system_shell + || matches!(shell_mode, UnifiedExecShellMode::ZshFork(_)) + { + command + } else { + // An unfamiliar executable can ignore its arguments, so evaluate the + // executable separately from any restrictions on its apparent command. + std::slice::from_ref(executable) + } +} + +#[cfg(test)] +#[path = "executable_identity_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/exec_policy/executable_identity_tests.rs b/codex-rs/core/src/exec_policy/executable_identity_tests.rs new file mode 100644 index 0000000000..9f90cd629b --- /dev/null +++ b/codex-rs/core/src/exec_policy/executable_identity_tests.rs @@ -0,0 +1,72 @@ +use super::shell_approval_command; +use crate::shell::Shell; +use crate::shell::ShellType; +use codex_tools::UnifiedExecShellMode; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn parent_directory_traversal_is_not_a_trusted_system_shell() { + let (configured_executable, unfamiliar_executable) = if cfg!(windows) { + let system_root = std::env::var_os("SystemRoot").expect("Windows SystemRoot"); + let system_directory = PathBuf::from(system_root).join("System32"); + ( + system_directory.join("cmd.exe"), + system_directory + .join("..") + .join("workspace") + .join("powershell.exe"), + ) + } else { + ( + PathBuf::from("/bin/sh"), + PathBuf::from("/bin/../workspace/bash"), + ) + }; + let shell = Shell { + shell_type: if cfg!(windows) { + ShellType::Cmd + } else { + ShellType::Sh + }, + shell_path: configured_executable, + }; + let command = vec![ + unfamiliar_executable.to_string_lossy().into_owned(), + "-c".to_string(), + "ls".to_string(), + ]; + + assert_eq!( + shell_approval_command(&command, &shell, &UnifiedExecShellMode::Direct), + &command[..1], + ); +} + +#[cfg(windows)] +#[test] +fn windows_shell_identity_is_case_insensitive() { + let configured_executable = PathBuf::from(r"C:\Custom\pwsh.exe"); + let configured_shell = Shell { + shell_type: ShellType::PowerShell, + shell_path: configured_executable.clone(), + }; + let system_root = std::env::var_os("SystemRoot").expect("Windows SystemRoot"); + let system_executable = PathBuf::from(system_root.to_string_lossy().to_ascii_uppercase()) + .join("SYSTEM32") + .join("WINDOWSPOWERSHELL") + .join("V1.0") + .join("POWERSHELL.EXE"); + + for executable in [ + configured_executable.to_string_lossy().to_ascii_uppercase(), + system_executable.to_string_lossy().into_owned(), + ] { + let command = vec![executable, "-Command".to_string(), "echo safe".to_string()]; + + assert_eq!( + shell_approval_command(&command, &configured_shell, &UnifiedExecShellMode::Direct), + command.as_slice(), + ); + } +} diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 0fb457a112..37a3481fee 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -237,6 +237,7 @@ impl ApprovalAction { .. } => vec![ApprovalCacheKey::ExecCommand(UnifiedExecApprovalKey { environment_id: environment_id.clone(), + executable: command.first().cloned(), command: canonicalize_command_for_approval(command), cwd: cwd.clone(), tty: *tty, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 38d7fa6bcb..f55061cb23 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -88,6 +88,7 @@ pub struct UnifiedExecRequest { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub environment_id: String, + pub executable: Option, pub command: Vec, pub cwd: PathUri, pub tty: bool, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 8b2924ffbd..70601f9b9d 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1238,24 +1238,34 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); + let session_shell = context.session.user_shell(); + let configured_shell = request + .turn_environment + .shell + .as_ref() + .unwrap_or(session_shell.as_ref()); let exec_approval_requirement = context .session .services .exec_policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &request.command, - approval_policy: turn.approval_policy(), - permission_profile: request.turn_environment.permission_profile().clone(), - environment_policy: request.turn_environment.config().exec_policy.as_ref(), - windows_sandbox_level: turn.windows_sandbox_level, - sandbox_permissions: if request.additional_permissions_preapproved { - crate::sandboxing::SandboxPermissions::UseDefault - } else { - request.sandbox_permissions + .create_exec_approval_requirement_for_shell( + ExecApprovalRequest { + command: &request.command, + approval_policy: turn.approval_policy(), + permission_profile: request.turn_environment.permission_profile().clone(), + environment_policy: request.turn_environment.config().exec_policy.as_ref(), + windows_sandbox_level: turn.windows_sandbox_level, + sandbox_permissions: if request.additional_permissions_preapproved { + crate::sandboxing::SandboxPermissions::UseDefault + } else { + request.sandbox_permissions + }, + prefix_rule: request.prefix_rule.clone(), + allow_prefix_rules: context.step_context.turn.allow_prefix_rules(), }, - prefix_rule: request.prefix_rule.clone(), - allow_prefix_rules: context.step_context.turn.allow_prefix_rules(), - }) + configured_shell, + &request.shell_mode, + ) .await; let req = UnifiedExecToolRequest { command: request.command.clone(), diff --git a/codex-rs/core/tests/suite/executable_identity.rs b/codex-rs/core/tests/suite/executable_identity.rs new file mode 100644 index 0000000000..77f87d6e3a --- /dev/null +++ b/codex-rs/core/tests/suite/executable_identity.rs @@ -0,0 +1,349 @@ +use anyhow::Result; +use codex_config::Constrained; +use codex_core::TurnInputRequest; +use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_remote; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use test_case::test_case; + +#[derive(Clone, Copy)] +enum ShellAttack { + ExactShellName, + ExactShellNameWithAllowedInnerCommand, + ExactShellNameWithForbiddenInnerCommand, + DangerousCommandOnRequest, + DangerousCommandNever, + ApprovedCustomShell, + SessionApprovalDoesNotTrustDifferentShell, + SpoofedShellExtension, +} + +#[test_case(ShellAttack::ExactShellName; "workspace shell requires approval")] +#[test_case(ShellAttack::ExactShellNameWithAllowedInnerCommand; "inner allow does not trust workspace shell")] +#[test_case(ShellAttack::ExactShellNameWithForbiddenInnerCommand; "inner forbidden rule still rejects workspace shell")] +#[test_case(ShellAttack::DangerousCommandOnRequest; "dangerous inner command requires approval")] +#[test_case(ShellAttack::DangerousCommandNever; "dangerous inner command is forbidden without approval")] +#[test_case(ShellAttack::ApprovedCustomShell; "approved custom shell still runs")] +#[test_case(ShellAttack::SessionApprovalDoesNotTrustDifferentShell; "session approval does not trust a different shell")] +#[test_case(ShellAttack::SpoofedShellExtension; "workspace shell with an extra extension requires approval")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_provided_shell_cannot_inherit_inner_command_trust( + attack: ShellAttack, +) -> Result<()> { + skip_if_remote!( + Ok(()), + "remote executors already replace requested shell paths with their reported shell" + ); + + let approval_policy = match attack { + ShellAttack::DangerousCommandOnRequest => AskForApproval::OnRequest, + ShellAttack::DangerousCommandNever => AskForApproval::Never, + ShellAttack::ExactShellName + | ShellAttack::ExactShellNameWithAllowedInnerCommand + | ShellAttack::ExactShellNameWithForbiddenInnerCommand + | ShellAttack::ApprovedCustomShell + | ShellAttack::SessionApprovalDoesNotTrustDifferentShell + | ShellAttack::SpoofedShellExtension => AskForApproval::UnlessTrusted, + }; + let server = start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("enable unified exec"); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.approvals_reviewer = ApprovalsReviewer::User; + let inner_command_rule = match attack { + ShellAttack::ExactShellNameWithAllowedInnerCommand => { + Some("prefix_rule(pattern=[\"echo\"], decision=\"allow\")\n") + } + ShellAttack::ExactShellNameWithForbiddenInnerCommand => { + if cfg!(windows) { + Some( + r#"prefix_rule(pattern=["Remove-Item", "C:\\important"], decision="forbidden")"#, + ) + } else { + Some("prefix_rule(pattern=[\"rm\"], decision=\"forbidden\")\n") + } + } + ShellAttack::ExactShellName + | ShellAttack::DangerousCommandOnRequest + | ShellAttack::DangerousCommandNever + | ShellAttack::ApprovedCustomShell + | ShellAttack::SessionApprovalDoesNotTrustDifferentShell + | ShellAttack::SpoofedShellExtension => None, + }; + if let Some(inner_command_rule) = inner_command_rule { + let policy_path = config.codex_home.join("rules/default.rules"); + fs::create_dir_all(policy_path.parent().expect("rules directory")) + .expect("create rules directory"); + fs::write(policy_path, inner_command_rule).expect("write execution policy rule"); + } + }); + #[cfg(windows)] + if matches!( + attack, + ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::ApprovedCustomShell + ) { + let system_root = std::env::var_os("SystemRoot").expect("Windows SystemRoot"); + let configured_shell = std::path::Path::new(&system_root) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + builder = builder.with_user_shell(codex_core::shell::get_shell_by_model_provided_path( + &configured_shell, + )); + } + let test = builder.build_with_auto_env(&server).await?; + let shell_name = match attack { + ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::ApprovedCustomShell + if cfg!(windows) => + { + "pwsh.exe" + } + ShellAttack::ExactShellName + | ShellAttack::ExactShellNameWithAllowedInnerCommand + | ShellAttack::ExactShellNameWithForbiddenInnerCommand + | ShellAttack::DangerousCommandOnRequest + | ShellAttack::DangerousCommandNever + | ShellAttack::ApprovedCustomShell + | ShellAttack::SessionApprovalDoesNotTrustDifferentShell => { + if cfg!(windows) { + "powershell.exe" + } else { + "bash" + } + } + ShellAttack::SpoofedShellExtension => { + if cfg!(windows) { + "powershell.evil" + } else { + "bash.evil" + } + } + }; + let shell = test.workspace_path(shell_name); + let marker = test.workspace_path("attacker-executed"); + #[cfg(unix)] + { + fs::write(&shell, "#!/bin/sh\nprintf ran > attacker-executed\n")?; + fs::set_permissions(&shell, fs::Permissions::from_mode(0o755))?; + } + #[cfg(windows)] + { + let test_executable = std::env::current_exe()?; + fs::hard_link(&test_executable, &shell) + .or_else(|_| fs::copy(&test_executable, &shell).map(|_| ()))?; + fs::write( + shell.with_file_name(".codex-executable-identity-fixture"), + b"fake shell", + )?; + } + let other_shell = if matches!( + attack, + ShellAttack::SessionApprovalDoesNotTrustDifferentShell + ) { + let other_shell = test.workspace_path("another").join(shell_name); + fs::create_dir_all(other_shell.parent().expect("alternate shell directory"))?; + fs::copy(&shell, &other_shell)?; + #[cfg(windows)] + fs::write( + other_shell.with_file_name(".codex-executable-identity-fixture"), + b"fake shell", + )?; + Some(other_shell) + } else { + None + }; + let call_id = "untrusted-shell-path"; + let other_call_id = "different-untrusted-shell-path"; + let command = match attack { + ShellAttack::ExactShellName if cfg!(windows) => "Write-Output $env:USERNAME", + ShellAttack::DangerousCommandOnRequest | ShellAttack::DangerousCommandNever => { + if cfg!(windows) { + "Remove-Item important -Force" + } else { + "rm -rf important" + } + } + ShellAttack::ExactShellNameWithForbiddenInnerCommand => { + if cfg!(windows) { + r"echo shell-safe && Remove-Item C:\important" + } else { + "echo shell-safe; rm important" + } + } + ShellAttack::ExactShellName + | ShellAttack::ExactShellNameWithAllowedInnerCommand + | ShellAttack::ApprovedCustomShell + | ShellAttack::SessionApprovalDoesNotTrustDifferentShell + | ShellAttack::SpoofedShellExtension => "echo shell-safe", + }; + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-untrusted-shell-1"), + ev_function_call( + call_id, + "exec_command", + &json!({ "cmd": command, "shell": shell }).to_string(), + ), + ev_completed("resp-untrusted-shell-1"), + ]), + ) + .await; + if let Some(other_shell) = other_shell.as_ref() { + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-different-untrusted-shell"), + ev_function_call( + other_call_id, + "exec_command", + &json!({ "cmd": command, "shell": other_shell }).to_string(), + ), + ev_completed("resp-different-untrusted-shell"), + ]), + ) + .await; + } + let completed = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-untrusted-shell", "done"), + ev_completed("resp-untrusted-shell-2"), + ]), + ) + .await; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect the repository".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }), + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + if matches!( + attack, + ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::DangerousCommandNever + ) { + assert!(matches!(event, EventMsg::TurnComplete(_))); + let output = completed + .single_request() + .function_call_output_text(call_id) + .expect("forbidden command output"); + assert!( + output.contains("rejected"), + "the forbidden command should be rejected: {output}" + ); + #[cfg(windows)] + if matches!(attack, ShellAttack::ExactShellNameWithForbiddenInnerCommand) { + assert!( + output.contains("Remove-Item"), + "the forbidden PowerShell command should remain visible to policy: {output}" + ); + } + } else { + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("workspace shell bypassed approval"); + }; + assert_eq!(approval.call_id, call_id); + assert!(!marker.exists(), "the shell ran before approval"); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: match attack { + ShellAttack::ApprovedCustomShell => ReviewDecision::Approved, + ShellAttack::SessionApprovalDoesNotTrustDifferentShell => { + ReviewDecision::ApprovedForSession + } + _ => ReviewDecision::denied("untrusted shell"), + }, + }) + .await?; + if other_shell.is_some() { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("a different workspace shell reused the first shell's session approval"); + }; + assert_eq!(approval.call_id, other_call_id); + assert!( + marker.exists(), + "the session-approved shell should have run" + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::denied("different untrusted shell"), + }) + .await?; + } + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + + assert_eq!( + marker.exists(), + matches!( + attack, + ShellAttack::ApprovedCustomShell + | ShellAttack::SessionApprovalDoesNotTrustDifferentShell + ), + "only an explicitly approved custom shell should run" + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 4aa10fdc7b..8c5f742bc9 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -16,6 +16,21 @@ use ctor::ctor; #[ctor] pub static CODEX_ALIASES_TEMP_DIR: Option = { configure_test_binary_dispatch("codex-core-tests", |exe_name, argv1| { + #[cfg(windows)] + if exe_name.eq_ignore_ascii_case("powershell.exe") + || exe_name.eq_ignore_ascii_case("powershell.evil") + || exe_name.eq_ignore_ascii_case("pwsh.exe") + { + let executable = std::env::current_exe().expect("locate fake PowerShell executable"); + if executable + .with_file_name(".codex-executable-identity-fixture") + .is_file() + { + let marker = executable.with_file_name("attacker-executed"); + std::fs::write(marker, b"ran").expect("record fake PowerShell execution"); + std::process::exit(0); + } + } if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } @@ -62,6 +77,7 @@ mod cyber_exec_policy; mod deprecation_notice; mod exec; mod exec_policy; +mod executable_identity; #[cfg(not(target_os = "windows"))] mod extension_sandbox; mod external_auth; diff --git a/codex-rs/shell-command/src/command_safety/mod.rs b/codex-rs/shell-command/src/command_safety/mod.rs index 2f9fc5b15a..8c65079cee 100644 --- a/codex-rs/shell-command/src/command_safety/mod.rs +++ b/codex-rs/shell-command/src/command_safety/mod.rs @@ -1,6 +1,4 @@ mod powershell_parser; -// Production safety and exec-policy callers migrate to this lowerer in a follow-up. -#[allow(dead_code)] mod powershell_tree_sitter; pub mod is_dangerous_command; @@ -8,3 +6,4 @@ pub mod is_safe_command; #[cfg(windows)] pub(crate) mod windows_safe_commands; pub(crate) use powershell_parser::try_parse_powershell_ast_commands; +pub(crate) use powershell_tree_sitter::try_parse_powershell_commands; diff --git a/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs index 71f2c77855..a904e9249d 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs +++ b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs @@ -10,7 +10,7 @@ use tree_sitter::Parser; /// Unknown syntax, parse recovery, and dynamic expressions fail closed instead of being guessed /// at. The accepted CST shapes intentionally cover only common literal command forms; rare /// PowerShell syntax and value-conversion cases stay opaque. -pub(super) fn try_parse_powershell_commands(script: &str) -> Option>> { +pub(crate) fn try_parse_powershell_commands(script: &str) -> Option>> { lower_with_tree_sitter(script).ok() } diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index 9730439bea..9a55f89f49 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use codex_utils_absolute_path::AbsolutePathBuf; use crate::command_safety::try_parse_powershell_ast_commands; +use crate::command_safety::try_parse_powershell_commands; use crate::shell_detect::ShellType; use crate::shell_detect::detect_shell_type; @@ -82,6 +83,14 @@ pub fn parse_powershell_command_into_plain_commands( try_parse_powershell_ast_commands(executable, script) } +/// Parse literal PowerShell commands without starting a PowerShell executable. +/// +/// Unknown or dynamic syntax stays opaque so unfamiliar executables can be +/// evaluated without running them before approval. +pub fn parse_powershell_script_into_plain_commands(script: &str) -> Option>> { + try_parse_powershell_commands(script) +} + /// This function attempts to find a powershell.exe executable on the system. pub fn try_find_powershell_executable_blocking() -> Option { try_find_powershellish_executable_in_path(&["powershell.exe"]) @@ -156,6 +165,7 @@ mod tests { use super::extract_powershell_command; #[cfg(windows)] use super::parse_powershell_command_into_plain_commands; + use super::parse_powershell_script_into_plain_commands; use super::prefix_powershell_script_with_utf8; #[test] @@ -205,6 +215,17 @@ mod tests { assert_eq!(script, "Get-ChildItem | Select-String foo"); } + #[test] + fn parses_powershell_command_chains_and_preserves_windows_paths() { + assert_eq!( + parse_powershell_script_into_plain_commands(r"echo safe && Remove-Item C:\important"), + Some(vec![ + vec!["echo".to_string(), "safe".to_string()], + vec!["Remove-Item".to_string(), r"C:\important".to_string()], + ]), + ); + } + #[test] fn prefixes_powershell_command_with_best_effort_utf8() { let cmd = vec![