diff --git a/codex-rs/core/src/command_safety/windows_safe_commands.rs b/codex-rs/core/src/command_safety/windows_safe_commands.rs index c6e781f8ae..ff0a3d2e7b 100644 --- a/codex-rs/core/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/core/src/command_safety/windows_safe_commands.rs @@ -1,25 +1,431 @@ -// This is a WIP. This will eventually contain a real list of common safe Windows commands. -pub fn is_safe_command_windows(_command: &[String]) -> bool { +use shlex::split as shlex_split; + +/// 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) { + return commands + .iter() + .all(|cmd| is_safe_powershell_command(cmd.as_slice())); + } + // Only PowerShell invocations are allowed on Windows for now; anything else is unsafe. + false +} + +/// 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>> { + let (exe, rest) = command.split_first()?; + if !is_powershell_executable(exe) { + return None; + } + parse_powershell_invocation(rest) +} + +/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns. +fn parse_powershell_invocation(args: &[String]) -> Option>> { + 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(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(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"] + _ => { + return split_into_commands(args[idx..].to_vec()); + } + } + } + + // 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 '