diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 60ee5c420c..be2f94983e 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -152,10 +152,10 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option { - words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); + words.push(parse_unquoted_literal(child, src)?); } "string" => { let parsed = parse_double_quoted_string(child, src)?; @@ -172,8 +172,7 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option { - concatenated - .push_str(part.utf8_text(src.as_bytes()).ok()?.to_owned().as_str()); + concatenated.push_str(&parse_unquoted_literal(part, src)?); } "string" => { let parsed = parse_double_quoted_string(part, src)?; @@ -197,6 +196,18 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option Option { + let text = node.utf8_text(src.as_bytes()).ok()?; + if text.starts_with('~') || text.chars().any(is_unquoted_shell_expansion_char) { + return None; + } + Some(text.to_string()) +} + +fn is_unquoted_shell_expansion_char(c: char) -> bool { + matches!(c, '*' | '?' | '[' | ']' | '{' | '}') +} + fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> { if cmd.kind() != "command" { return None; @@ -500,6 +511,26 @@ mod tests { assert!(parse_seq("rg -g\"$(echo '*.py')\" pattern").is_none()); } + #[test] + fn rejects_unquoted_shell_expansion_words() { + assert!(parse_seq("base64 -i input.txt -* cc06_out").is_none()); + assert!(parse_seq("ls *.rs").is_none()); + assert!(parse_seq("echo -{o,}").is_none()); + } + + #[test] + fn accepts_quoted_shell_expansion_literals() { + assert_eq!( + parse_seq(r#"echo "-*" '*.rs' "-{o,}""#).unwrap(), + vec![vec![ + "echo".to_string(), + "-*".to_string(), + "*.rs".to_string(), + "-{o,}".to_string(), + ]] + ); + } + #[test] fn parse_shell_lc_single_command_prefix_supports_heredoc() { let command = vec![ diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index b35144a12b..ae04e75c93 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -738,6 +738,18 @@ mod tests { ); } + #[test] + fn bash_lc_glob_expansion_option_injection_is_not_safe() { + assert!( + !is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "base64 -i input.txt -* cc06_out", + ])), + "Unquoted shell expansion can rewrite argv into base64 -o at runtime" + ); + } + #[test] fn direct_powershell_words_use_windows_safelist() { let command = vec_str(&["Get-Content", "Cargo.toml"]);