use crate::command_safety::is_safe_command::is_safe_git_command;use crate::command_safety::powershell_parser::PowershellParseOutcome;use crate::command_safety::powershell_parser::parse_with_powershell_ast;use std::path::Path;/// 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) { commands .iter() .all(|cmd| is_safe_powershell_words(cmd.as_slice())) } else { // 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<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.pub(crate) fn is_safe_powershell_words(words: &[String]) -> bool { if words.is_empty() { // Examples rejected here: "pwsh -Command ''" and "pwsh -Command \"\"". return false; } // Reject nested unsafe cmdlets inside parentheses or arguments for w in words.iter() { let inner = w .trim_matches(|c| c == '(' || c == ')') .trim_start_matches('-') .to_ascii_lowercase(); if matches!( inner.as_str(), "set-content" | "add-content" | "out-file" | "new-item" | "remove-item" | "move-item" | "copy-item" | "rename-item" | "start-process" | "stop-process" ) { // Examples rejected here: "Write-Output (Set-Content foo6.txt 'abc')" and "Get-Content (New-Item bar.txt)". return false; } } let command = words[0] .trim_matches(|c| c == '(' || c == ')') .trim_start_matches('-') .to_ascii_lowercase(); match command.as_str() { "echo" | "write-output" | "write-host" => true, // (no redirection allowed) "dir" | "ls" | "get-childitem" | "gci" => true, "cat" | "type" | "gc" | "get-content" => true, "select-string" | "sls" | "findstr" => true, "measure-object" | "measure" => true, "get-location" | "gl" | "pwd" => true, "test-path" | "tp" => true, "resolve-path" | "rvpa" => true, "select-object" | "select" => true, "get-item" => true, "git" => is_safe_git_command(words), "rg" => is_safe_ripgrep(words), // Extra safety: explicitly prohibit common side-effecting cmdlets regardless of args. "set-content" | "add-content" | "out-file" | "new-item" | "remove-item" | "move-item" | "copy-item" | "rename-item" | "start-process" | "stop-process" => { // Examples rejected here: "pwsh -Command 'Set-Content notes.txt data'" and "pwsh -Command 'Remove-Item temp.log'". false } _ => { // Examples rejected here: "pwsh -Command 'Invoke-WebRequest https://example.com'" and "pwsh -Command 'Start-Service Spooler'". false } }}/// Checks that an `rg` invocation avoids options that can spawn arbitrary executables.fn is_safe_ripgrep(words: &[String]) -> bool { const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &["--pre", "--hostname-bin"]; const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &["--search-zip", "-z"]; !words.iter().skip(1).any(|arg| { let arg_lc = arg.to_ascii_lowercase(); // Examples rejected here: "pwsh -Command 'rg --pre cat pattern'" and "pwsh -Command 'rg --search-zip pattern'". UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg_lc.as_str()) || UNSAFE_RIPGREP_OPTIONS_WITH_ARGS .iter() .any(|opt| arg_lc == *opt || arg_lc.starts_with(&format!("{opt}="))) })}#[cfg(all(test, windows))]mod tests { use super::*; use crate::powershell::try_find_pwsh_executable_blocking; use pretty_assertions::assert_eq; use std::string::ToString; /// Converts a slice of string literals into owned `String`s for the tests. fn vec_str(args: &[&str]) -> Vec<String> { args.iter().map(ToString::to_string).collect() } #[test] fn recognizes_safe_powershell_wrappers() { assert!(is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoLogo", "-Command", "Get-ChildItem -Path .", ]))); assert!(is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoProfile", "-Command", "git status", ]))); assert!(is_safe_command_windows(&vec_str(&[ "powershell.exe", "Get-Content", "Cargo.toml", ]))); // pwsh parity if let Some(pwsh) = try_find_pwsh_executable_blocking() { assert!(is_safe_command_windows(&[ pwsh.as_path().to_str().unwrap().into(), "-NoProfile".to_string(), "-Command".to_string(), "Get-ChildItem".to_string(), ])); } } #[test] fn accepts_full_path_powershell_invocations() { if !cfg!(windows) { // Windows only because on Linux path splitting doesn't handle `/` separators properly return; } if let Some(pwsh) = try_find_pwsh_executable_blocking() { assert!(is_safe_command_windows(&[ pwsh.as_path().to_str().unwrap().into(), "-NoProfile".to_string(), "-Command".to_string(), "Get-ChildItem -Path .".to_string(), ])); } assert!(is_safe_command_windows(&vec_str(&[ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-Command", "Get-Content Cargo.toml", ]))); } #[test] fn allows_read_only_pipelines_and_git_usage() { let Some(pwsh) = try_find_pwsh_executable_blocking() else { return; }; let pwsh: String = pwsh.as_path().to_str().unwrap().into(); assert!(is_safe_command_windows(&[ pwsh.clone(), "-NoLogo".to_string(), "-NoProfile".to_string(), "-Command".to_string(), "rg --files-with-matches foo | Measure-Object | Select-Object -ExpandProperty Count" .to_string() ])); assert!(is_safe_command_windows(&[ pwsh.clone(), "-NoLogo".to_string(), "-NoProfile".to_string(), "-Command".to_string(), "Get-Content foo.rs | Select-Object -Skip 200".to_string() ])); assert!(is_safe_command_windows(&[ pwsh.clone(), "-Command".to_string(), "git show HEAD:foo.rs".to_string() ])); assert!(is_safe_command_windows(&[ pwsh.clone(), "-Command".to_string(), "(Get-Content foo.rs -Raw)".to_string() ])); assert!(is_safe_command_windows(&[ pwsh, "-Command".to_string(), "Get-Item foo.rs | Select-Object Length".to_string() ])); } #[test] fn rejects_git_global_override_options() { let Some(pwsh) = try_find_pwsh_executable_blocking() else { return; }; let pwsh: String = pwsh.as_path().to_str().unwrap().into(); for script in [ "git -c core.pager=cat show HEAD:foo.rs", "git --config-env core.pager=PAGER show HEAD:foo.rs", "git --config-env=core.pager=PAGER show HEAD:foo.rs", "git --git-dir .evil-git diff HEAD~1..HEAD", "git --git-dir=.evil-git diff HEAD~1..HEAD", "git --work-tree . status", "git --work-tree=. status", "git --exec-path .git/helpers show HEAD:foo.rs", "git --exec-path=.git/helpers show HEAD:foo.rs", "git --namespace attacker show HEAD:foo.rs", "git --namespace=attacker show HEAD:foo.rs", "git --super-prefix attacker/ show HEAD:foo.rs", "git --super-prefix=attacker/ show HEAD:foo.rs", ] { assert!( !is_safe_command_windows(&[ pwsh.clone(), "-NoLogo".to_string(), "-NoProfile".to_string(), "-Command".to_string(), script.to_string(), ]), "expected {script:?} to require approval due to unsafe git global option", ); } } #[test] fn rejects_git_subcommand_options_with_side_effects() { let results: Vec<(&str, bool)> = [ "git diff --output codex_poc.txt", "git diff --ext-diff HEAD", "git log --textconv -1", "git show --output=codex_poc.txt HEAD", "git cat-file --filters HEAD:a.txt", ] .into_iter() .map(|script| { ( script, is_safe_command_windows(&[ "powershell.exe".to_string(), "-NoProfile".to_string(), "-Command".to_string(), script.to_string(), ]), ) }) .collect(); assert_eq!( vec![ ("git diff --output codex_poc.txt", false), ("git diff --ext-diff HEAD", false), ("git log --textconv -1", false), ("git show --output=codex_poc.txt HEAD", false), ("git cat-file --filters HEAD:a.txt", false), ], results ); } #[test] fn rejects_stop_parsing_git_forms() { assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoProfile", "-Command", "git log --% HEAD --output=codex_poc.txt", ]))); } #[test] fn rejects_powershell_commands_with_side_effects() { assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoLogo", "-Command", "Remove-Item foo.txt", ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoProfile", "-Command", "rg --pre cat", ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Set-Content foo.txt 'hello'", ]))); // Redirections are blocked assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "echo hi > out.txt", ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-Content x | Out-File y", ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Write-Output foo 2> err.txt", ]))); // Call operator is blocked assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "& Remove-Item foo", ]))); // Chained safe + unsafe must fail assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-ChildItem; Remove-Item foo", ]))); // Nested unsafe cmdlet inside safe command must fail assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Write-Output (Set-Content foo6.txt 'abc')", ]))); // Additional nested unsafe cmdlet examples must fail assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Write-Host (Remove-Item foo.txt)", ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-Content (New-Item bar.txt)", ]))); // Unsafe @ expansion. assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "ls @(calc.exe)" ]))); // Unsupported constructs that the AST parser refuses (no fallback to manual splitting). assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "ls && pwd" ]))); // Sub-expressions are rejected even if they contain otherwise safe commands. assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Write-Output $(Get-Content foo)" ]))); // Empty words from the parser (e.g. '') are rejected. assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "''" ]))); } #[test] fn accepts_constant_expression_arguments() { assert!(is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-Content 'foo bar'" ]))); assert!(is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-Content \"foo bar\"" ]))); } #[test] fn rejects_dynamic_arguments() { assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Get-Content $foo" ]))); assert!(!is_safe_command_windows(&vec_str(&[ "powershell.exe", "-Command", "Write-Output \"foo $bar\"" ]))); } #[test] fn uses_invoked_powershell_variant_for_parsing() { if !cfg!(windows) { return; } let chain = "pwd && ls"; assert!( !is_safe_command_windows(&vec_str(&[ "powershell.exe", "-NoProfile", "-Command", chain, ])), "`{chain}` is not recognized by powershell.exe" ); if let Some(pwsh) = try_find_pwsh_executable_blocking() { assert!( is_safe_command_windows(&[ pwsh.as_path().to_str().unwrap().into(), "-NoProfile".to_string(), "-Command".to_string(), chain.to_string(), ]), "`{chain}` should be considered safe to pwsh.exe" ); } }}