mirror of
https://github.com/openai/codex.git
synced 2026-11-11 14:32:25 +00:00
Recognize PowerShell Get-Content file reads (#38415)
## What changed - Classify simple PowerShell `Get-Content` commands, including `gc` and `type` aliases, as file reads while preserving Windows paths. - Reuse the shared classification for implicit skill invocation detection on Windows and render recognized commands as `Read <file>` in the TUI. - Leave commands with unsupported flags, multiple operands, wildcards, or expressions unclassified. ## Testing - Cover supported and rejected PowerShell forms, Windows executor skill detection, and the TUI read summary. GitOrigin-RevId: 4e8f5470f2ae31c08d74091f9634c2926e516ccf
This commit is contained in:
1 parent
73862481e5
commit
2aba3219e6
5 files changed
+229
-62
No files matched your search
@@ -53,7 +53,6 @@ use core_test_support::responses::mount_sse_once;
|
|||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
use core_test_support::skip_if_remote;
|
use core_test_support::skip_if_remote;
|
||||||
use core_test_support::skip_if_target_windows;
|
|
||||||
use core_test_support::skip_if_wine_exec;
|
use core_test_support::skip_if_wine_exec;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
use core_test_support::wait_for_mcp_server;
|
use core_test_support::wait_for_mcp_server;
|
||||||
@@ -1241,7 +1240,6 @@ async fn production_turn_aliases_executor_skill_roots() -> Result<()> {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> Result<()> {
|
async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> Result<()> {
|
||||||
skip_if_target_windows!(Ok(()), "executes a POSIX cat command");
|
|
||||||
skip_if_remote!(Ok(()), "executor fixture uses a host-local skill path");
|
skip_if_remote!(Ok(()), "executor fixture uses a host-local skill path");
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
@@ -1280,8 +1278,13 @@ async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> R
|
|||||||
],
|
],
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
};
|
};
|
||||||
|
let read_command = if cfg!(windows) {
|
||||||
|
format!("Get-Content -LiteralPath \"{}\"", skill_path.display())
|
||||||
|
} else {
|
||||||
|
format!("cat {}", skill_path.display())
|
||||||
|
};
|
||||||
let command = json!({
|
let command = json!({
|
||||||
"cmd": format!("cat {}", skill_path.display()),
|
"cmd": read_command,
|
||||||
"login": false,
|
"login": false,
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ pub fn shlex_join(tokens: &[String]) -> String {
|
|||||||
.unwrap_or_else(|_| "<command included NUL byte>".to_string())
|
.unwrap_or_else(|_| "<command included NUL byte>".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tokenizes a PowerShell command while preserving Windows paths and reader aliases.
|
||||||
|
pub fn tokenize_powershell_command(command: &str) -> Vec<String> {
|
||||||
|
let normalized = command.replace('\\', "/");
|
||||||
|
let mut tokens = shlex_split(&normalized)
|
||||||
|
.unwrap_or_else(|| normalized.split_whitespace().map(str::to_string).collect());
|
||||||
|
if let Some(executable) = tokens.first_mut()
|
||||||
|
&& matches!(
|
||||||
|
executable.to_ascii_lowercase().as_str(),
|
||||||
|
"get-content" | "gc" | "type"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
*executable = "Get-Content".to_owned();
|
||||||
|
// POSIX shlex must not silently rewrite a PowerShell file path.
|
||||||
|
if tokens
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.any(|argument| !normalized.contains(argument))
|
||||||
|
{
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts the shell and script from a command, regardless of platform
|
/// Extracts the shell and script from a command, regardless of platform
|
||||||
pub fn extract_shell_command(command: &[String]) -> Option<(&str, &str)> {
|
pub fn extract_shell_command(command: &[String]) -> Option<(&str, &str)> {
|
||||||
extract_bash_command(command).or_else(|| extract_powershell_command(command))
|
extract_bash_command(command).or_else(|| extract_powershell_command(command))
|
||||||
@@ -1245,6 +1269,102 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn powershell_file_reads_are_classified() {
|
||||||
|
for (shell, script, path) in [
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content C:\skills\demo\SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r#"get-content -Raw "C:\skills and plugins\SKILL.md""#,
|
||||||
|
"C:/skills and plugins/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content 'C:\skills and plugins\SKILL.md'",
|
||||||
|
"C:/skills and plugins/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content -Path C:\skills\demo\SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content -LiteralPath C:\skills\demo\SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content C:\skills\demo\SKILL.md -Raw",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content -Raw -LiteralPath C:\skills\demo\SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
r"Get-Content C:\workspace\README.md",
|
||||||
|
"C:/workspace/README.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
"gc C:/skills/demo/SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"powershell",
|
||||||
|
"type C:/skills/demo/SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
|
||||||
|
"Get-Content C:/skills/demo/SKILL.md",
|
||||||
|
"C:/skills/demo/SKILL.md",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_parsed(
|
||||||
|
&vec_str(&[shell, "-NoProfile", "-Command", script]),
|
||||||
|
vec![ParsedCommand::Read {
|
||||||
|
cmd: script.to_string(),
|
||||||
|
name: PathBuf::from(path)
|
||||||
|
.file_name()
|
||||||
|
.expect("file path")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complex_powershell_file_reads_are_intentionally_not_classified() {
|
||||||
|
for script in [
|
||||||
|
r"Get-Content 'C:\Users\O''Brien\skill\SKILL.md'",
|
||||||
|
r#"Get-Content "$(Remove-Item C:/important)/skills/demo/SKILL.md""#,
|
||||||
|
"Get-Content -ReadCount:([IO.File]::Delete('C:/important')) C:/skills/demo/SKILL.md",
|
||||||
|
"Get-Content C:/Users/Alice/.ssh/id_rsa,C:/skills/demo/SKILL.md",
|
||||||
|
"Get-Content C:/skills/demo/SKILL.md -Raw; Remove-Item C:/important",
|
||||||
|
"Get-Content C:/skills/demo/SKILL.md C:/important",
|
||||||
|
"Get-Content C:/skills/*/SKILL.md",
|
||||||
|
"Get-Content -Encoding UTF8 C:/skills/demo/SKILL.md",
|
||||||
|
"Get-Content -Raw",
|
||||||
|
] {
|
||||||
|
assert_parsed(
|
||||||
|
&vec_str(&["powershell", "-Command", script]),
|
||||||
|
vec![ParsedCommand::Unknown {
|
||||||
|
cmd: script.to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pwsh_with_noprofile_and_c_alias_is_stripped() {
|
fn pwsh_with_noprofile_and_c_alias_is_stripped() {
|
||||||
assert_parsed(
|
assert_parsed(
|
||||||
@@ -1277,7 +1397,29 @@ pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
|
|||||||
return commands;
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, script)) = extract_powershell_command(command) {
|
let powershell_command = command
|
||||||
|
.first()
|
||||||
|
.filter(|shell| shell.contains('\\'))
|
||||||
|
.map(|shell| {
|
||||||
|
let mut normalized = command.to_vec();
|
||||||
|
normalized[0] = shell.rsplit(['/', '\\']).next().unwrap_or(shell).to_owned();
|
||||||
|
normalized
|
||||||
|
});
|
||||||
|
if let Some((_, script)) =
|
||||||
|
extract_powershell_command(powershell_command.as_deref().unwrap_or(command))
|
||||||
|
{
|
||||||
|
let tokens = tokenize_powershell_command(script);
|
||||||
|
if tokens
|
||||||
|
.first()
|
||||||
|
.is_some_and(|executable| executable == "Get-Content")
|
||||||
|
&& let [ParsedCommand::Read { name, path, .. }] = parse_command_impl(&tokens).as_slice()
|
||||||
|
{
|
||||||
|
return vec![ParsedCommand::Read {
|
||||||
|
cmd: script.to_string(),
|
||||||
|
name: name.clone(),
|
||||||
|
path: path.clone(),
|
||||||
|
}];
|
||||||
|
}
|
||||||
return vec![ParsedCommand::Unknown {
|
return vec![ParsedCommand::Unknown {
|
||||||
cmd: script.to_string(),
|
cmd: script.to_string(),
|
||||||
}];
|
}];
|
||||||
@@ -2253,8 +2395,32 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
|
|||||||
path,
|
path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((head, tail)) if head == "cat" => {
|
Some((head, tail)) if head == "cat" || head.eq_ignore_ascii_case("Get-Content") => {
|
||||||
if let Some(path) = single_non_flag_operand(tail, &[]) {
|
let path = if head == "cat" {
|
||||||
|
single_non_flag_operand(tail, &[])
|
||||||
|
} else {
|
||||||
|
// Intentionally miss complex reads: conservative presentation should not
|
||||||
|
// require implementing PowerShell's full expression and argument grammar.
|
||||||
|
if tail.iter().all(|argument| {
|
||||||
|
!argument.starts_with('-')
|
||||||
|
|| ["-Raw", "-Path", "-LiteralPath"]
|
||||||
|
.iter()
|
||||||
|
.any(|flag| argument.eq_ignore_ascii_case(flag))
|
||||||
|
}) {
|
||||||
|
single_non_flag_operand(tail, &[])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
.filter(|path| {
|
||||||
|
!path.is_empty()
|
||||||
|
&& !path.starts_with('-')
|
||||||
|
&& path.chars().all(|character| {
|
||||||
|
character.is_alphanumeric()
|
||||||
|
|| matches!(character, ' ' | '/' | '\\' | '.' | '-' | '_' | ':')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(path) = path {
|
||||||
let name = short_display_path(&path);
|
let name = short_display_path(&path);
|
||||||
ParsedCommand::Read {
|
ParsedCommand::Read {
|
||||||
cmd: shlex_join(main_cmd),
|
cmd: shlex_join(main_cmd),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use codex_protocol::parse_command::ParsedCommand;
|
use codex_protocol::parse_command::ParsedCommand;
|
||||||
use codex_shell_command::parse_command::parse_command_impl;
|
use codex_shell_command::parse_command::parse_command_impl;
|
||||||
|
use codex_shell_command::parse_command::tokenize_powershell_command;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_path_uri::PathConvention;
|
use codex_utils_path_uri::PathConvention;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
@@ -28,14 +29,17 @@ pub fn detect_implicit_skill_invocation_for_command(
|
|||||||
workdir: &AbsolutePathBuf,
|
workdir: &AbsolutePathBuf,
|
||||||
) -> Option<SkillMetadata> {
|
) -> Option<SkillMetadata> {
|
||||||
let workdir = canonicalize_if_exists(workdir);
|
let workdir = canonicalize_if_exists(workdir);
|
||||||
let tokens = tokenize_command(command);
|
let tokens = if PathConvention::native() == PathConvention::Windows {
|
||||||
|
tokenize_powershell_command(command)
|
||||||
|
} else {
|
||||||
|
tokenize_command(command)
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), &workdir) {
|
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), &workdir) {
|
||||||
return Some(candidate);
|
return Some(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
detect_skill_doc_read(outcome, tokens.as_slice(), &workdir)
|
detect_skill_doc_read(outcome, tokens.as_slice(), &workdir)
|
||||||
.or_else(|| detect_powershell_skill_doc_read(outcome, command, &workdir))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves statically recognizable skill accesses without consulting the host filesystem.
|
/// Resolves statically recognizable skill accesses without consulting the host filesystem.
|
||||||
@@ -43,20 +47,8 @@ pub fn implicit_skill_accesses_for_command(
|
|||||||
command: &str,
|
command: &str,
|
||||||
workdir: &PathUri,
|
workdir: &PathUri,
|
||||||
) -> Vec<ImplicitSkillAccess> {
|
) -> Vec<ImplicitSkillAccess> {
|
||||||
// Normalize Windows paths and recognize PowerShell reads using existing cat parsing.
|
|
||||||
let tokens = if workdir.infer_path_convention() == Some(PathConvention::Windows) {
|
let tokens = if workdir.infer_path_convention() == Some(PathConvention::Windows) {
|
||||||
let mut tokens = tokenize_command(&command.replace('\\', "/"));
|
tokenize_powershell_command(command)
|
||||||
|
|
||||||
if let Some(executable) = tokens.first_mut()
|
|
||||||
&& matches!(
|
|
||||||
executable.to_ascii_lowercase().as_str(),
|
|
||||||
"get-content" | "gc" | "type"
|
|
||||||
)
|
|
||||||
{
|
|
||||||
*executable = "cat".to_owned();
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens
|
|
||||||
} else {
|
} else {
|
||||||
tokenize_command(command)
|
tokenize_command(command)
|
||||||
};
|
};
|
||||||
@@ -151,44 +143,6 @@ fn detect_skill_doc_read(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_powershell_skill_doc_read(
|
|
||||||
outcome: &impl ImplicitSkillLookup,
|
|
||||||
command: &str,
|
|
||||||
workdir: &AbsolutePathBuf,
|
|
||||||
) -> Option<SkillMetadata> {
|
|
||||||
let path = powershell_get_content_path(command)?;
|
|
||||||
let candidate_path = canonicalize_if_exists(&workdir.join(Path::new(path)));
|
|
||||||
outcome
|
|
||||||
.implicit_skill_for_doc_path(&candidate_path)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn powershell_get_content_path(command: &str) -> Option<&str> {
|
|
||||||
let mut arguments = command.trim().strip_prefix("Get-Content ")?;
|
|
||||||
if let Some(remaining_arguments) = arguments.strip_prefix("-Raw ") {
|
|
||||||
arguments = remaining_arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (path, trailing) = if let Some(quoted_path) = arguments.strip_prefix('"') {
|
|
||||||
let closing_quote = quoted_path.find('"')?;
|
|
||||||
(
|
|
||||||
"ed_path[..closing_quote],
|
|
||||||
"ed_path[closing_quote + 1..],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let path_end = arguments
|
|
||||||
.char_indices()
|
|
||||||
.find_map(|(index, character)| character.is_whitespace().then_some(index))
|
|
||||||
.unwrap_or(arguments.len());
|
|
||||||
(&arguments[..path_end], &arguments[path_end..])
|
|
||||||
};
|
|
||||||
|
|
||||||
if path.is_empty() || path.starts_with('-') || !trailing.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn command_basename(command: &str) -> String {
|
fn command_basename(command: &str) -> String {
|
||||||
Path::new(command)
|
Path::new(command)
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ fn powershell_skill_doc_read_matches_common_forms() {
|
|||||||
format!("Get-Content -Raw {path}"),
|
format!("Get-Content -Raw {path}"),
|
||||||
format!("Get-Content \"{spaced_path}\""),
|
format!("Get-Content \"{spaced_path}\""),
|
||||||
format!("Get-Content -Raw \"{spaced_path}\""),
|
format!("Get-Content -Raw \"{spaced_path}\""),
|
||||||
|
format!("get-content -raw '{spaced_path}'"),
|
||||||
] {
|
] {
|
||||||
let found = detect_implicit_skill_invocation_for_command(
|
let found = detect_implicit_skill_invocation_for_command(
|
||||||
&outcome,
|
&outcome,
|
||||||
@@ -101,11 +102,26 @@ fn powershell_skill_doc_read_matches_common_forms() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn powershell_get_content_path_preserves_windows_backslashes() {
|
fn windows_executor_skill_reads_share_powershell_classification() {
|
||||||
|
let workdir = PathUri::parse("file:///C:/skills").expect("Windows workdir URI");
|
||||||
|
let document = PathUri::parse("file:///C:/skills/demo/SKILL.md").expect("skill URI");
|
||||||
|
|
||||||
|
for command in [
|
||||||
|
r"Get-Content C:\skills\demo\SKILL.md",
|
||||||
|
r"get-content -Raw C:\skills\demo\SKILL.md",
|
||||||
|
r"Get-Content -Path C:\skills\demo\SKILL.md",
|
||||||
|
r"Get-Content -LiteralPath C:\skills\demo\SKILL.md",
|
||||||
|
r"Get-Content C:\skills\demo\SKILL.md -Raw",
|
||||||
|
r"Get-Content -Raw -LiteralPath C:\skills\demo\SKILL.md",
|
||||||
|
r"gc C:\skills\demo\SKILL.md",
|
||||||
|
r"type C:\skills\demo\SKILL.md",
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
powershell_get_content_path(r#"Get-Content C:\skills\example\SKILL.md"#),
|
implicit_skill_accesses_for_command(command, &workdir),
|
||||||
Some(r#"C:\skills\example\SKILL.md"#)
|
vec![ImplicitSkillAccess::Document(document.clone())],
|
||||||
|
"command: {command}"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -964,6 +964,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn powershell_skill_read_snapshot() {
|
||||||
|
let command = vec![
|
||||||
|
"powershell.exe".to_string(),
|
||||||
|
"-Command".to_string(),
|
||||||
|
r"Get-Content C:\skills\demo\SKILL.md".to_string(),
|
||||||
|
];
|
||||||
|
let parsed = codex_shell_command::parse_command::parse_command(&command);
|
||||||
|
let cell = new_active_exec_command(
|
||||||
|
"call-id".to_string(),
|
||||||
|
command,
|
||||||
|
parsed,
|
||||||
|
ExecCommandSource::Agent,
|
||||||
|
/*interaction_input*/ None,
|
||||||
|
/*animations_enabled*/ false,
|
||||||
|
);
|
||||||
|
let rendered = cell
|
||||||
|
.display_lines(/*width*/ 80)
|
||||||
|
.iter()
|
||||||
|
.map(render_line_text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
insta::assert_snapshot!(rendered, @r"
|
||||||
|
• Exploring
|
||||||
|
└ Read SKILL.md
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
|
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
|
||||||
let truncated = ExecCell::limit_lines_from_start(
|
let truncated = ExecCell::limit_lines_from_start(
|
||||||
|
|||||||
Reference in new issue
Block a user