Share Git safe-command logic on Windows (#21275)

## Why

BUGB-15601 showed that the Windows safe-command path had drifted from
the generic Git classifier. The Windows-specific Git parser could
classify a PowerShell-wrapped `git` command as safe as soon as it found
a safelisted subcommand, without applying the generic checks for unsafe
subcommand options such as `--output`, `--ext-diff`, `--textconv`,
`--paginate`, or `cat-file --filters`.

The generic classifier already models the Git command boundary and the
read-only argument checks more carefully, so Windows should reuse that
logic instead of maintaining a smaller parallel parser.

## What Changed

- Extracted the existing generic Git classification logic into
`is_safe_git_command`.
- Updated `windows_safe_commands.rs` to call that shared helper for
parsed PowerShell `git` commands.
- Removed the Windows-only Git subcommand safelist, including the
`cat-file` allowance that was part of the reported bypass.
- Added a Windows regression test that keeps PowerShell-wrapped Git
commands with side-effecting options classified unsafe.
- Made the full-path PowerShell test discover the installed PowerShell
executable instead of depending on one hard-coded `pwsh.exe` path.

## Verification

- `cargo test -p codex-shell-command
rejects_git_subcommand_options_with_side_effects`
- `cargo test -p codex-shell-command
git_global_override_flags_are_not_safe`
- `cargo test -p codex-shell-command
windows_powershell_full_path_is_safe -- --nocapture`

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
iceweasel-oai
2026-05-05 17:49:42 -07:00
committed by GitHub
Unverified
parent 794c240f25
commit db22c91e61
2 changed files with 78 additions and 60 deletions
@@ -1,4 +1,4 @@
use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt;
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;
@@ -221,37 +221,11 @@ fn is_safe_ripgrep(words: &[String]) -> bool {
})
}
/// Ensures a Git command sticks to whitelisted read-only subcommands and flags.
fn is_safe_git_command(words: &[String]) -> bool {
const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"];
for arg in words.iter().skip(1) {
let arg_lc = arg.to_ascii_lowercase();
if arg.starts_with('-') {
if git_global_option_requires_prompt(&arg_lc)
|| arg.eq_ignore_ascii_case("--config")
|| arg_lc.starts_with("--config=")
{
// Examples rejected here: "pwsh -Command 'git --git-dir=.evil-git diff'" and
// "pwsh -Command 'git -c core.pager=cat show HEAD:foo.rs'".
return false;
}
continue;
}
return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str());
}
// Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'".
false
}
#[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.
@@ -342,7 +316,7 @@ mod tests {
assert!(is_safe_command_windows(&[
pwsh.clone(),
"-Command".to_string(),
"-git cat-file -p HEAD:foo.rs".to_string()
"git show HEAD:foo.rs".to_string()
]));
assert!(is_safe_command_windows(&[
@@ -393,6 +367,43 @@ mod tests {
}
}
#[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 log --paginate -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 log --paginate -1", false),
("git show --output=codex_poc.txt HEAD", false),
("git cat-file --filters HEAD:a.txt", false),
],
results
);
}
#[test]
fn rejects_powershell_commands_with_side_effects() {
assert!(!is_safe_command_windows(&vec_str(&[