diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ea7a984f3a..58415147fa 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1796,6 +1796,7 @@ "tracing_0.1.44": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\"},{\"name\":\"tracing-attributes\",\"optional\":true,\"req\":\"^0.1.31\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.38\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"async-await\":[],\"attributes\":[\"tracing-attributes\"],\"default\":[\"std\",\"attributes\"],\"log-always\":[\"log\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\"]}}", "tree-sitter-bash_0.25.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"tree-sitter\",\"req\":\"^0.25\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"}],\"features\":{}}", "tree-sitter-language_0.1.7": "{\"dependencies\":[],\"features\":{}}", + "tree-sitter-powershell_0.26.4": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"tree-sitter\",\"req\":\"^0.26.5\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"}],\"features\":{}}", "tree-sitter_0.25.10": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71.1\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.10\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"features\":[\"preserve_order\"],\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1.0.137\"},{\"name\":\"streaming-iterator\",\"req\":\"^0.1.9\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"cranelift\",\"gc-drc\"],\"name\":\"wasmtime-c-api\",\"optional\":true,\"package\":\"wasmtime-c-api-impl\",\"req\":\"^29.0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"regex/std\",\"regex/perf\",\"regex-syntax/unicode\"],\"wasm\":[\"std\",\"wasmtime-c-api\"]}}", "tree_magic_mini_3.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.0\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"nom\",\"req\":\"^8.0\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.8.0\"},{\"name\":\"tree_magic_db\",\"optional\":true,\"req\":\"^3.0\"}],\"features\":{\"with-gpl-data\":[\"dep:tree_magic_db\"]}}", "triomphe_0.1.15": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1.3.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.1\"},{\"name\":\"unsize\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"default\":[\"serde\",\"stable_deref_trait\",\"std\"],\"std\":[],\"unstable_dropck_eyepatch\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb794ef447..47df1e8c56 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4143,6 +4143,7 @@ dependencies = [ "shlex", "tree-sitter", "tree-sitter-bash", + "tree-sitter-powershell", "url", "which 8.0.0", ] @@ -14522,6 +14523,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-powershell" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1a43d6c143..61f36f46fc 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -475,6 +475,7 @@ tonic = { version = "0.14.3", default-features = false, features = ["channel", " tonic-prost = "0.14.3" tree-sitter = "0.25.10" tree-sitter-bash = "0.25" +tree-sitter-powershell = "=0.26.4" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" diff --git a/codex-rs/shell-command/BUILD.bazel b/codex-rs/shell-command/BUILD.bazel index 9eaa1377a1..b71c95943f 100644 --- a/codex-rs/shell-command/BUILD.bazel +++ b/codex-rs/shell-command/BUILD.bazel @@ -2,6 +2,9 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "shell-command", - compile_data = ["src/command_safety/powershell_parser.ps1"], + compile_data = [ + "src/command_safety/fixtures/powershell_lowering.json", + "src/command_safety/powershell_parser.ps1", + ], crate_name = "codex_shell_command", ) diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index e918488f82..c5947d9155 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -19,6 +19,7 @@ serde_json = { workspace = true } shlex = { workspace = true } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } +tree-sitter-powershell = { workspace = true } url = { workspace = true } which = { workspace = true } diff --git a/codex-rs/shell-command/src/command_safety/fixtures/powershell_lowering.json b/codex-rs/shell-command/src/command_safety/fixtures/powershell_lowering.json new file mode 100644 index 0000000000..e1d8abcc3b --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/fixtures/powershell_lowering.json @@ -0,0 +1,70 @@ +[ + {"name":"echo","script":"echo hi","expected":[["echo","hi"]]}, + {"name":"single_quote","script":"Get-Content 'foo bar'","expected":[["Get-Content","foo bar"]]}, + {"name":"quoted_comma","script":"Get-Content 'foo,bar'","expected":[["Get-Content","foo,bar"]]}, + {"name":"double_quote","script":"Get-Content \"foo bar\"","expected":[["Get-Content","foo bar"]]}, + {"name":"doubled_single_quote","script":"Write-Output 'a''b'","expected":[["Write-Output","a'b"]]}, + {"name":"escaped_double_quote","script":"Write-Output \"a`\"b\"","expected":[["Write-Output","a\"b"]]}, + {"name":"powershell_7_escape_in_double_quote","script":"rg \"--pr`e=helper.exe\" pattern","expected":null}, + {"name":"unicode_escape_in_double_quote","script":"rg \"--pre=`u{68}elper.exe\" pattern","expected":null}, + {"name":"escaped_space","script":"Get-Content foo` bar","expected":[["Get-Content","foo bar"]]}, + {"name":"powershell_7_escape_in_bare_word","script":"rg --pr`e helper.exe pattern","expected":null}, + {"name":"command_parameter","script":"Get-ChildItem -Path .","expected":[["Get-ChildItem","-Path","."]]}, + {"name":"attached_parameter_argument","script":"Get-Content -Path:1","expected":null}, + {"name":"attached_quoted_parameter_argument","script":"Get-Content -Path:'foo bar'","expected":null}, + {"name":"pipeline","script":"Write-Output foo | Measure-Object","expected":[["Write-Output","foo"],["Measure-Object"]]}, + {"name":"semicolon","script":"Get-Content Cargo.toml; Test-Path Cargo.toml","expected":[["Get-Content","Cargo.toml"],["Test-Path","Cargo.toml"]]}, + {"name":"and_chain","script":"pwd && ls","expected":[["pwd"],["ls"]]}, + {"name":"or_chain","script":"pwd || ls","expected":[["pwd"],["ls"]]}, + {"name":"parenthesized_command","script":"(Get-Content foo.rs -Raw)","expected":null}, + {"name":"parenthesized_pipeline","script":"(Get-Content foo | Measure-Object)","expected":null}, + {"name":"parenthesized_chain","script":"(pwd && ls)","expected":null}, + {"name":"canonical_numeric_argument","script":"Get-Content -TotalCount 100","expected":[["Get-Content","-TotalCount","100"]]}, + {"name":"negative_numeric_argument","script":"git log -1 Cargo.toml","expected":[["git","log","-1","Cargo.toml"]]}, + {"name":"hexadecimal_numeric_argument","script":"Write-Output 0x10","expected":null}, + {"name":"signed_hexadecimal_numeric_argument","script":"Write-Output 0xFFFFFFFF","expected":null}, + {"name":"binary_numeric_argument","script":"Write-Output 0b101","expected":null}, + {"name":"type_suffixed_numeric_argument","script":"Write-Output 1d","expected":null}, + {"name":"powershell_7_type_suffixed_numeric_argument","script":"Write-Output 1u","expected":null}, + {"name":"size_suffixed_numeric_argument","script":"Write-Output 1kb","expected":null}, + {"name":"leading_zero_numeric_argument","script":"Write-Output 01","expected":null}, + {"name":"real_numeric_argument","script":"Write-Output 1.0","expected":null}, + {"name":"exponent_numeric_argument","script":"Write-Output 1e3","expected":null}, + {"name":"numeric_leading_filename","script":"Get-Content 1.0.0","expected":null}, + {"name":"inline_double_dash_parameter","script":"Get-Content --flag=value","expected":[["Get-Content","--flag=value"]]}, + {"name":"inline_double_dash_parameter_pipeline","script":"Get-Content --flag=value | Measure-Object","expected":[["Get-Content","--flag=value"],["Measure-Object"]]}, + {"name":"git_dir_inline","script":"git --git-dir=.git status","expected":[["git","--git-dir=.git","status"]]}, + {"name":"windows_path","script":"Get-Content C:\\tmp\\x","expected":[["Get-Content","C:\\tmp\\x"]]}, + {"name":"verbatim_here_string","script":"Write-Output @'\nhello\n'@","expected":null}, + {"name":"expandable_here_string_without_expansion","script":"Write-Output @\"\nhello\n\"@","expected":null}, + {"name":"expandable_here_string_with_escaped_dollar","script":"Write-Output @\"\n`$foo\n\"@","expected":null}, + {"name":"dangerous_literal_url","script":"Start-Process 'https://example.com'","expected":[["Start-Process","https://example.com"]]}, + {"name":"dangerous_literal_force","script":"Remove-Item test -Force","expected":[["Remove-Item","test","-Force"]]}, + {"name":"unicode_dash_parameter","script":"Remove-Item test –Force","expected":null}, + {"name":"trailing_comment","script":"Get-Content Cargo.toml # comment","expected":[["Get-Content","Cargo.toml"]]}, + {"name":"hash_inside_token","script":"Get-Content foo#bar","expected":[["Get-Content","foo#bar"]]}, + {"name":"embedded_hash_before_separator","script":"git status --short#; Remove-Item victim","expected":null}, + {"name":"smart_quote_delimiter","script":"rg “--pre=helper.exe” pattern","expected":null}, + {"name":"hash_inside_recovered_command_token","script":"pwd#evil --flag=value","expected":[["pwd#evil","--flag=value"]]}, + {"name":"block_comment_before_command","script":"<# note #>\nGet-Content foo","expected":[["Get-Content","foo"]]}, + {"name":"stop_parsing","script":"git log --% HEAD --output=codex_poc.txt","expected":null}, + {"name":"param_block","script":"param([string]$path = (Get-Location)) Write-Output test","expected":null}, + {"name":"named_blocks","script":"begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }","expected":null}, + {"name":"using_statement","script":"using module ./codex_poc.psm1\nGet-Content Cargo.toml","expected":null}, + {"name":"mixed_case_using_statement","script":"Using module ./codex_poc.psm1\nGet-Content Cargo.toml","expected":null}, + {"name":"trap_block","script":"trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop","expected":null}, + {"name":"redirection","script":"echo hi > out.txt","expected":null}, + {"name":"invocation_operator","script":"& Remove-Item foo","expected":null}, + {"name":"nested_command","script":"Write-Output (Set-Content foo6.txt 'abc')","expected":null}, + {"name":"subexpression","script":"Write-Output $(Get-Content foo)","expected":null}, + {"name":"array_expression","script":"ls @(calc.exe)","expected":null}, + {"name":"splat","script":"Get-Content @args","expected":null}, + {"name":"empty_word","script":"''","expected":null}, + {"name":"dynamic_argument","script":"Get-Content $foo","expected":null}, + {"name":"expandable_dynamic","script":"Write-Output \"foo $bar\"","expected":null}, + {"name":"comma_form","script":"Get-Content foo,bar","expected":null}, + {"name":"single_dash_equals_is_not_recovery","script":"Get-Content -foo=bar","expected":null}, + {"name":"uncovered_closing_paren","script":"Get-Content --flag=value )","expected":null}, + {"name":"uncovered_invalid_tail","script":"Get-Content --flag=value | if (","expected":null}, + {"name":"dangling_escape_after_recovery","script":"Get-Content --flag=value `","expected":null} +] diff --git a/codex-rs/shell-command/src/command_safety/mod.rs b/codex-rs/shell-command/src/command_safety/mod.rs index 60bed2effc..2f9fc5b15a 100644 --- a/codex-rs/shell-command/src/command_safety/mod.rs +++ b/codex-rs/shell-command/src/command_safety/mod.rs @@ -1,4 +1,7 @@ mod powershell_parser; +// Production safety and exec-policy callers migrate to this lowerer in a follow-up. +#[allow(dead_code)] +mod powershell_tree_sitter; pub mod is_dangerous_command; pub mod is_safe_command; diff --git a/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs new file mode 100644 index 0000000000..71f2c77855 --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter.rs @@ -0,0 +1,482 @@ +use tree_sitter::Node; +use tree_sitter::Parser; + +/// Lowers a literal PowerShell script into argv-like command vectors. +/// +/// This module does not decide whether a command is safe or dangerous; callers apply those +/// policies to the lowered words. Its job is only to recognize a deliberately small literal +/// PowerShell subset and fail closed for everything else. +/// +/// Unknown syntax, parse recovery, and dynamic expressions fail closed instead of being guessed +/// at. The accepted CST shapes intentionally cover only common literal command forms; rare +/// PowerShell syntax and value-conversion cases stay opaque. +pub(super) fn try_parse_powershell_commands(script: &str) -> Option>> { + lower_with_tree_sitter(script).ok() +} + +fn lower_with_tree_sitter(script: &str) -> Result>, String> { + // PowerShell treats these Unicode characters as syntax aliases even when tree-sitter leaves + // them inside generic tokens. Keep that whole spelling family opaque rather than guessing at + // whether a quote or dash is structural in a particular position. + if script + .chars() + .any(|ch| matches!(ch, '‘' | '’' | '“' | '”' | '–' | '—' | '―')) + { + return Err("PowerShell Unicode syntax alias".to_string()); + } + + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_powershell::LANGUAGE.into()) + .map_err(|error| format!("load grammar: {error}"))?; + // The grammar rejects native `--flag=value`. Mask only the `=` in conservatively + // recognized bare tokens; the one-byte replacement keeps CST ranges valid for `script`. + let mut parse_source = script.as_bytes().to_vec(); + for equals in inline_double_dash_parameter_equals(script) { + parse_source[equals] = b' '; + } + let parse_source = String::from_utf8(parse_source) + .map_err(|_| "masked command source is not UTF-8".to_string())?; + let tree = parser + .parse(&parse_source, None) + .ok_or_else(|| "tree-sitter returned no tree".to_string())?; + let root = tree.root_node(); + if root.has_error() { + return Err("tree contains ERROR or missing nodes".to_string()); + } + if has_requires_directive(root, script) { + return Err("requires directives can execute before command lowering".to_string()); + } + if let Some(kind) = first_unrecognized_named_kind(root) { + return Err(format!("unrecognized named node: {kind}")); + } + let mut command_nodes = Vec::new(); + collect_command_nodes(root, &mut command_nodes); + if command_nodes.is_empty() { + return Err("no literal command nodes".to_string()); + } + + let mut commands = Vec::with_capacity(command_nodes.len()); + let mut command_ranges = Vec::with_capacity(command_nodes.len()); + for node in command_nodes { + let text = node + .utf8_text(script.as_bytes()) + .map_err(|_| "command source is not UTF-8".to_string())?; + command_ranges.push(node.start_byte()..node.end_byte()); + commands.push(lower_command_text(text)?); + } + if !source_is_covered_by_commands(script, &command_ranges) { + return Err("source outside literal command nodes".to_string()); + } + if commands.iter().any(|command| { + command + .first() + .is_some_and(|word| word.eq_ignore_ascii_case("using")) + }) { + return Err("using declarations require the PowerShell AST oracle".to_string()); + } + if commands + .iter() + .any(|command| command.is_empty() || command.iter().any(String::is_empty)) + { + return Err("empty lowered command or word".to_string()); + } + Ok(commands) +} + +fn collect_command_nodes<'tree>(root: Node<'tree>, commands: &mut Vec>) { + // Script nesting is model-controlled, so keep CST traversal off the call stack. + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.kind() == "command" { + commands.push(node); + continue; + } + for child_index in (0..node.named_child_count()).rev() { + if let Some(child) = node.named_child(child_index) { + stack.push(child); + } + } + } +} + +fn has_requires_directive(root: Node<'_>, script: &str) -> bool { + // Tree-sitter exposes #requires as a comment, but PowerShell evaluates it before the + // script body and can load modules or assemblies. + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.kind() == "comment" + && node + .utf8_text(script.as_bytes()) + .ok() + .is_some_and(|comment| { + comment + .trim_start() + .to_ascii_lowercase() + .starts_with("#requires") + }) + { + return true; + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + false +} + +// These helpers are the allowlist for PowerShell CST forms we intentionally understand. A new +// named tree-sitter node is rejected until its lowering semantics are reviewed. +fn first_unrecognized_named_kind(root: Node<'_>) -> Option { + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.is_named() && !is_allowed_named_kind(node.kind()) { + return Some(node.kind().to_string()); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + stack.push(child); + } + } + None +} + +fn is_allowed_named_kind(kind: &str) -> bool { + matches!( + kind, + "program" + | "statement_list" + | "pipeline" + | "pipeline_chain" + | "pipeline_chain_tail" + | "command" + | "command_name" + | "command_elements" + | "command_argument_sep" + | "command_parameter" + | "generic_token" + | "array_literal_expression" + | "unary_expression" + | "string_literal" + | "verbatim_string_characters" + | "expandable_string_literal" + | "integer_literal" + | "decimal_integer_literal" + | "comment" + | "empty_statement" + // Negative numeric flags such as git log -1 use this wrapper. + | "expression_with_unary_operator" + ) +} + +// Find only bare literal `--flag=value` forms whose `=` can be masked before parsing. +fn inline_double_dash_parameter_equals(script: &str) -> Vec { + let mut equals = Vec::new(); + let mut start = 0; + for (index, ch) in script.char_indices() { + if is_inline_parameter_token_separator(ch) { + push_inline_parameter_equals(&mut equals, script, start, index); + start = index + ch.len_utf8(); + } + } + push_inline_parameter_equals(&mut equals, script, start, script.len()); + equals +} + +fn push_inline_parameter_equals(equals: &mut Vec, script: &str, start: usize, end: usize) { + let token = &script[start..end]; + if token.starts_with("--") + && token + .chars() + .all(|ch| !is_rejected_inline_parameter_character(ch)) + && let Some(relative_equals) = token.find('=') + && relative_equals > 2 + && relative_equals + 1 < token.len() + { + equals.push(start + relative_equals); + } +} + +fn is_inline_parameter_token_separator(ch: char) -> bool { + ch.is_whitespace() || "|&;#><(){}".contains(ch) +} + +fn is_rejected_inline_parameter_character(ch: char) -> bool { + ch.is_whitespace() || is_rejected_bare_character(ch) || matches!(ch, '\u{60}' | '#') +} + +fn source_is_covered_by_commands(script: &str, command_ranges: &[std::ops::Range]) -> bool { + // Command nodes alone are not enough: reject any source outside the literal commands and + // separators/comments we explicitly understand. + let mut index = 0; + let mut range_index = 0; + let mut can_chain = false; + let mut needs_command = false; + let mut paren_depth = 0; + while index < script.len() { + if let Some(range) = command_ranges.get(range_index) + && index == range.start + { + index = range.end; + range_index += 1; + can_chain = true; + needs_command = false; + continue; + } + + let Some(ch) = script[index..].chars().next() else { + return false; + }; + let next = index + ch.len_utf8(); + if ch == '\r' || ch == '\n' { + can_chain = false; + index = next; + continue; + } + if ch.is_whitespace() { + index = next; + continue; + } + if ch == ';' { + if needs_command { + return false; + } + can_chain = false; + index = next; + continue; + } + if ch == '(' && !can_chain { + paren_depth += 1; + index = next; + continue; + } + if ch == ')' && paren_depth > 0 && !needs_command { + paren_depth -= 1; + index = next; + continue; + } + if ch == '|' && can_chain { + can_chain = false; + needs_command = true; + index = if script[next..].starts_with('|') { + next + '|'.len_utf8() + } else { + next + }; + continue; + } + if ch == '&' && can_chain && script[next..].starts_with('&') { + can_chain = false; + needs_command = true; + index = next + '&'.len_utf8(); + continue; + } + if ch == '#' && !needs_command { + // `#` starts a comment only at a token boundary. Tree-sitter can split an + // embedded `#` out of a bare token, so reject that recovery instead of dropping + // the rest of the line. + if index > 0 + && !script[..index] + .chars() + .next_back() + .is_some_and(|previous| previous.is_whitespace() || previous == ';') + { + return false; + } + index = script[next..] + .find(['\r', '\n']) + .map_or(script.len(), |offset| next + offset); + continue; + } + if script[index..].starts_with("<#") + && !needs_command + && (index == 0 + || script[..index] + .chars() + .next_back() + .is_some_and(|previous| previous.is_whitespace() || previous == ';')) + { + let Some(end) = script[next..].find("#>") else { + return false; + }; + index = next + end + "#>".len(); + continue; + } + return false; + } + range_index == command_ranges.len() && !needs_command && paren_depth == 0 +} + +fn lower_command_text(command_text: &str) -> Result, String> { + // This is literal argv lowering, not safe/dangerous classification. Quoting and escapes are + // decoded only for forms whose runtime value is statically known. + let mut words = Vec::new(); + let chars: Vec = command_text.trim().chars().collect(); + let mut index = 0; + while index < chars.len() { + while index < chars.len() && chars[index].is_whitespace() { + index += 1; + } + if index == chars.len() || chars[index] == '#' { + break; + } + let (word, next, is_bare) = if chars[index] == '\'' { + let (word, next) = parse_single_quoted(&chars, index)?; + (word, next, false) + } else if chars[index] == '"' { + let (word, next) = parse_double_quoted(&chars, index)?; + (word, next, false) + } else { + let (word, next) = parse_bare_word(&chars, index)?; + (word, next, true) + }; + index = next; + if index < chars.len() && !chars[index].is_whitespace() && chars[index] != '#' { + return Err("adjacent/concatenated command elements".to_string()); + } + if word.is_empty() { + return Err("empty word".to_string()); + } + if is_bare { + reject_unsupported_bare_word(&word)?; + } + words.push(word); + } + + if words.is_empty() { + return Err("command lowered to no words".to_string()); + } + Ok(words) +} + +fn parse_single_quoted(chars: &[char], start: usize) -> Result<(String, usize), String> { + let mut value = String::new(); + let mut index = start + 1; + while index < chars.len() { + if chars[index] == '\'' { + if chars.get(index + 1) == Some(&'\'') { + value.push('\''); + index += 2; + continue; + } + return Ok((value, index + 1)); + } + value.push(chars[index]); + index += 1; + } + Err("unterminated single-quoted string".to_string()) +} + +fn parse_double_quoted(chars: &[char], start: usize) -> Result<(String, usize), String> { + let mut value = String::new(); + let mut index = start + 1; + while index < chars.len() { + match chars[index] { + '"' => return Ok((value, index + 1)), + '$' => return Err("expandable string contains variable syntax".to_string()), + '`' => { + let escaped = *chars + .get(index + 1) + .ok_or_else(|| "trailing PowerShell escape".to_string())?; + // `e is ESC only in PowerShell 6+, so it has no version-neutral value. + if escaped == 'e' { + return Err("PowerShell-version-dependent escape".to_string()); + } + if escaped == 'u' && chars.get(index + 2) == Some(&'{') { + return Err("PowerShell Unicode escape".to_string()); + } + value.push(decode_backtick_escape(escaped)); + index += 2; + } + ch => { + value.push(ch); + index += 1; + } + } + } + Err("unterminated double-quoted string".to_string()) +} + +fn decode_backtick_escape(ch: char) -> char { + match ch { + '0' => '\0', + 'a' => '\u{7}', + 'b' => '\u{8}', + 'f' => '\u{c}', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\u{b}', + other => other, + } +} + +fn parse_bare_word(chars: &[char], start: usize) -> Result<(String, usize), String> { + let mut value = String::new(); + let mut index = start; + while index < chars.len() && !chars[index].is_whitespace() { + let ch = chars[index]; + if ch == '`' { + let escaped = *chars + .get(index + 1) + .ok_or_else(|| "trailing PowerShell escape".to_string())?; + if escaped == 'e' { + return Err("PowerShell-version-dependent escape".to_string()); + } + value.push(decode_backtick_escape(escaped)); + index += 2; + continue; + } + if is_rejected_bare_character(ch) { + return Err(format!("dynamic or structural bare character: {ch:?}")); + } + value.push(ch); + index += 1; + } + if value.is_empty() { + return Err("empty bare word".to_string()); + } + Ok((value, index)) +} + +fn is_rejected_bare_character(ch: char) -> bool { + matches!( + ch, + '$' | '@' + | '\'' + | '"' + | '(' + | ')' + | '{' + | '}' + | '[' + | ']' + | ';' + | '|' + | '&' + | '>' + | '<' + | ',' + ) +} + +fn reject_unsupported_bare_word(word: &str) -> Result<(), String> { + // These forms require PowerShell-specific value conversion. Keeping them opaque is safer + // than reproducing that conversion in the policy parser. + if word.starts_with('-') && !word.starts_with("--") && word.contains(':') { + return Err("attached PowerShell parameter value".to_string()); + } + + // Tree-sitter leaves some PowerShell numerics as generic tokens. Keep only canonical decimal + // spellings whose runtime string is obviously identical to the source spelling. + if word.chars().next().is_some_and(|ch| ch.is_ascii_digit()) + && !(word == "0" || (!word.starts_with('0') && word.chars().all(|ch| ch.is_ascii_digit()))) + { + return Err("non-canonical numeric-leading bare word".to_string()); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "powershell_tree_sitter_tests.rs"] +mod tests; diff --git a/codex-rs/shell-command/src/command_safety/powershell_tree_sitter_tests.rs b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter_tests.rs new file mode 100644 index 0000000000..a5d4e269ad --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_tree_sitter_tests.rs @@ -0,0 +1,37 @@ +use pretty_assertions::assert_eq; +use serde::Deserialize; + +use super::try_parse_powershell_commands; + +#[derive(Debug, Deserialize)] +struct FixtureCase { + name: String, + script: String, + expected: Option>>, +} + +#[test] +fn lowers_compact_literal_fixture() { + // Supported outputs were captured from the PowerShell 7 AST subprocess parser. Rare forms + // that need additional PowerShell-specific lowering are intentionally recorded as unsupported. + let cases: Vec = + serde_json::from_str(include_str!("fixtures/powershell_lowering.json")) + .expect("valid PowerShell lowering fixture"); + + for case in cases { + assert_eq!( + try_parse_powershell_commands(&case.script), + case.expected, + "fixture case: {}", + case.name + ); + } +} + +#[test] +fn rejects_requires_directives() { + assert_eq!( + try_parse_powershell_commands("#requires -Modules Evil\nGet-Location"), + None + ); +}