mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Preserve unparsed shell wrappers in exec policy (#39588)
## Why Reducing a heredoc shell script to its inner executable lets a prefix rule for that executable apply to the entire wrapper, even though the full script was not parsed as a plain command. ## What changed - Fall back to evaluating the complete shell wrapper when plain-command parsing fails, including for heredoc scripts. - Keep these commands sandboxed when only the inner executable is allowed. - Propose the full wrapper as the exec policy amendment when approval is needed. ## Testing Added exec policy, Unix escalation, and approval scenario coverage for unparsed and heredoc shell wrappers. GitOrigin-RevId: 8f65133acb6b7c638263917e1d9137e45990772c
This commit is contained in:
@@ -159,26 +159,6 @@ pub(crate) fn parse_shell_lc_literal_commands(command: &[String]) -> Option<Vec<
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Returns the parsed argv for a single shell command in a here-doc style
|
||||
/// script (`<<`), as long as the script contains exactly one command node.
|
||||
pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option<Vec<String>> {
|
||||
let (_, script) = extract_bash_command(command)?;
|
||||
let tree = try_parse_shell(script)?;
|
||||
let root = tree.root_node();
|
||||
if root.has_error() {
|
||||
return None;
|
||||
}
|
||||
if !has_named_descendant_kind(root, "heredoc_redirect") {
|
||||
return None;
|
||||
}
|
||||
if has_named_descendant_kind(root, "file_redirect") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command_node = find_single_command_node(root)?;
|
||||
parse_heredoc_command_words(command_node, script)
|
||||
}
|
||||
|
||||
fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Vec<String>> {
|
||||
if cmd.kind() != "command" {
|
||||
return None;
|
||||
@@ -277,43 +257,6 @@ fn parse_literal_shell_word(node: Node<'_>, src: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
|
||||
if cmd.kind() != "command" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut words = Vec::new();
|
||||
let mut cursor = cmd.walk();
|
||||
for child in cmd.named_children(&mut cursor) {
|
||||
match child.kind() {
|
||||
"command_name" => {
|
||||
let word_node = child.named_child(0)?;
|
||||
if !matches!(word_node.kind(), "word" | "number")
|
||||
|| !is_literal_word_or_number(word_node, src)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
}
|
||||
"word" | "number" => {
|
||||
if !is_literal_word_or_number(child, src) {
|
||||
return None;
|
||||
}
|
||||
words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
}
|
||||
// Allow heredoc constructs that attach stdin to a single command
|
||||
// without changing argv matching semantics for the executable
|
||||
// prefix. Other file redirects may write outside the sandbox and
|
||||
// must not be collapsed to the executable prefix for execpolicy.
|
||||
"comment" => {}
|
||||
kind if is_allowed_heredoc_attachment_kind(kind) => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
if words.is_empty() { None } else { Some(words) }
|
||||
}
|
||||
|
||||
fn is_literal_word_or_number(node: Node<'_>, src: &str) -> bool {
|
||||
if !matches!(node.kind(), "word" | "number") {
|
||||
return false;
|
||||
@@ -330,50 +273,6 @@ fn is_literal_word_or_number(node: Node<'_>, src: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"heredoc_body"
|
||||
| "simple_heredoc_body"
|
||||
| "heredoc_redirect"
|
||||
| "herestring_redirect"
|
||||
| "redirected_statement"
|
||||
)
|
||||
}
|
||||
|
||||
fn find_single_command_node(root: Node<'_>) -> Option<Node<'_>> {
|
||||
let mut stack = vec![root];
|
||||
let mut single_command = None;
|
||||
while let Some(node) = stack.pop() {
|
||||
if node.kind() == "command" {
|
||||
if single_command.is_some() {
|
||||
return None;
|
||||
}
|
||||
single_command = Some(node);
|
||||
}
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
single_command
|
||||
}
|
||||
|
||||
fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool {
|
||||
let mut stack = vec![node];
|
||||
while let Some(current) = stack.pop() {
|
||||
if current.kind() == kind {
|
||||
return true;
|
||||
}
|
||||
let mut cursor = current.walk();
|
||||
for child in current.named_children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_double_quoted_string(node: Node, src: &str) -> Option<String> {
|
||||
if node.kind() != "string" {
|
||||
return None;
|
||||
@@ -591,30 +490,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heredoc_prefix_does_not_restore_dynamic_words() {
|
||||
for script in [
|
||||
"find . -{delete,print}",
|
||||
"find . -del*",
|
||||
r"find . -de\lete",
|
||||
"cat ~",
|
||||
"cat ~HOME",
|
||||
"cat =sh",
|
||||
"c*",
|
||||
] {
|
||||
let command = [
|
||||
"bash".to_owned(),
|
||||
"-lc".to_owned(),
|
||||
format!("{script} <<'EOF'\nEOF"),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_shell_lc_single_command_prefix(&command),
|
||||
None,
|
||||
"{script:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_variable_assignment_prefix() {
|
||||
assert!(parse_seq("FOO=bar ls").is_none());
|
||||
@@ -689,103 +564,4 @@ mod tests {
|
||||
assert!(parse_seq("rg -g\"$(pwd)\" pattern").is_none());
|
||||
assert!(parse_seq("rg -g\"$(echo '*.py')\" pattern").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_supports_heredoc() {
|
||||
let command = vec![
|
||||
"zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
let parsed = parse_shell_lc_single_command_prefix(&command);
|
||||
assert_eq!(parsed, Some(vec!["python3".to_string()]));
|
||||
|
||||
let command_unquoted = vec![
|
||||
"zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 << PY\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
let parsed_unquoted = parse_shell_lc_single_command_prefix(&command_unquoted);
|
||||
assert_eq!(parsed_unquoted, Some(vec!["python3".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_multi_command_scripts() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY\necho done".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_non_heredoc_redirects() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo hello > /tmp/out.txt".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_extra_file_redirect() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY' > /tmp/out.txt\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_variable_assignment() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"echo hello > /tmp/out.txt && cat /tmp/out.txt"#.to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_herestring_with_substitution() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"python3 <<< "$(rm -rf /)""#.to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_arithmetic_shift_non_heredoc_script() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo $((1<<2))".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_single_command_prefix_rejects_heredoc_command_with_word_expansion() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 $((1<<2)) <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user