shell-command: parse literal PowerShell reads

This commit is contained in:
Adam Perry
2026-06-16 17:47:28 +00:00
parent 3bb5505847
commit 662eb6356d
3 changed files with 139 additions and 14 deletions
+64 -9
View File
@@ -2,6 +2,7 @@ use crate::bash::extract_bash_command;
use crate::bash::try_parse_shell;
use crate::bash::try_parse_word_only_commands_sequence;
use crate::powershell::extract_powershell_command;
use crate::powershell::extract_powershell_literal_read_path;
use codex_protocol::parse_command::ParsedCommand;
use shlex::split as shlex_split;
use shlex::try_join as shlex_try_join;
@@ -1257,19 +1258,68 @@ mod tests {
#[test]
fn powershell_with_path_is_stripped() {
let command = if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
} else {
"/usr/local/bin/powershell.exe"
};
assert_parsed(
&vec_str(&[command, "-NoProfile", "-c", "Write-Host hi"]),
&vec_str(&[
r"C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"-NoProfile",
"-c",
"Write-Host hi",
]),
vec![ParsedCommand::Unknown {
cmd: "Write-Host hi".to_string(),
}],
);
}
#[test]
fn powershell_literal_get_content_is_read_without_the_host_shell() {
let script = r"Get-Content 'C:\codex runtime\AGENTS.md' -ErrorAction Stop";
assert_parsed(
&vec_str(&[
r"C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"-NoProfile",
"-Command",
script,
]),
vec![ParsedCommand::Read {
cmd: script.to_string(),
name: "AGENTS.md".to_string(),
path: PathBuf::from(r"C:\codex runtime\AGENTS.md"),
}],
);
let alias_script = r"gc '.\input.txt'";
assert_parsed(
&vec_str(&["pwsh.exe", "-Command", alias_script]),
vec![ParsedCommand::Read {
cmd: alias_script.to_string(),
name: "input.txt".to_string(),
path: PathBuf::from(r".\input.txt"),
}],
);
}
#[test]
fn powershell_dynamic_or_compound_get_content_stays_unknown() {
for script in [
"Get-Content $path",
r#"Get-Content "$(Get-Location)\AGENTS.md""#,
"Get-Content AGENTS.md | Select-Object -First 1",
"Get-Content AGENTS.md; Remove-Item AGENTS.md",
"Get-Content *.md",
"Get-Content Env:PATH",
r"'Get-Content' 'C:\input.txt'",
r"Get-Content '-Path' 'C:\input.txt'",
r#"Get-Content "C:\input.txt""#,
] {
assert_parsed(
&vec_str(&["powershell.exe", "-Command", script]),
vec![ParsedCommand::Unknown {
cmd: script.to_string(),
}],
);
}
}
}
pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
@@ -1278,9 +1328,14 @@ pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
}
if let Some((_, script)) = extract_powershell_command(command) {
return vec![ParsedCommand::Unknown {
let parsed = extract_powershell_literal_read_path(script).map(|path| ParsedCommand::Read {
cmd: script.to_string(),
}];
name: short_display_path(path),
path: PathBuf::from(path),
});
return vec![parsed.unwrap_or_else(|| ParsedCommand::Unknown {
cmd: script.to_string(),
})];
}
let normalized = normalize_tokens(command);
+62
View File
@@ -70,6 +70,68 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
None
}
/// Extracts the literal path from the narrow `Get-Content` shape that can be recognized without
/// launching the selected execution environment's PowerShell on the Codex host.
pub(crate) fn extract_powershell_literal_read_path(script: &str) -> Option<&str> {
if script
.chars()
.any(|character| matches!(character, '\0' | '\r' | '\n'))
{
return None;
}
let script = script.trim();
let command_end = script.find(char::is_whitespace)?;
let command = &script[..command_end];
if !command.eq_ignore_ascii_case("Get-Content") && !command.eq_ignore_ascii_case("gc") {
return None;
}
let quoted_path = script[command_end..].trim_start().strip_prefix('\'')?;
let path_end = quoted_path.find('\'')?;
let path = &quoted_path[..path_end];
if path.is_empty()
|| path
.chars()
.any(|character| matches!(character, '*' | '?' | '[' | ']'))
|| is_powershell_provider_path(path)
{
return None;
}
let trailing = quoted_path[path_end + 1..].trim();
if !trailing.is_empty() {
let mut words = trailing.split_whitespace();
let flag = words.next()?;
let value = words.next()?;
if !flag.eq_ignore_ascii_case("-ErrorAction")
|| !value.eq_ignore_ascii_case("Stop")
|| words.next().is_some()
{
return None;
}
}
Some(path)
}
fn is_powershell_provider_path(path: &str) -> bool {
if path.contains("::") {
return true;
}
let bytes = path.as_bytes();
match bytes.iter().position(|byte| *byte == b':') {
None => false,
Some(1) => !matches!(
bytes,
[drive, b':', separator, ..]
if drive.is_ascii_alphabetic() && matches!(separator, b'\\' | b'/')
),
Some(_) => true,
}
}
/// Parse the script body from a top-level PowerShell wrapper into argv-like commands.
///
/// This is intentionally narrower than the Windows safe-command parser: it only unwraps the