From 3964f175be64add85875dbaaf8ba042bbe5974c5 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 13:12:19 -0800 Subject: [PATCH 1/9] fix(core): canonicalize wrapper approvals and support heredoc prefix matching --- codex-rs/core/src/bash.rs | 107 ++++++++++++++ codex-rs/core/src/command_canonicalization.rs | 130 ++++++++++++++++++ codex-rs/core/src/exec_policy.rs | 50 ++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 3 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- 6 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/command_canonicalization.rs diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index bb0ae7fe90e..b795e54fe14 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -119,6 +119,25 @@ pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option Option> { + let (_, script) = extract_bash_command(command)?; + // Keep this fallback narrow: only apply to here-doc style scripts that are + // otherwise rejected by the strict word-only parser. + if !script.contains("<<") { + return None; + } + + let tree = try_parse_shell(script)?; + if tree.root_node().has_error() { + return None; + } + + let command_node = find_single_command_node(tree.root_node())?; + parse_heredoc_command_words(command_node, script) +} + fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option> { if cmd.kind() != "command" { return None; @@ -177,6 +196,55 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option, src: &str) -> Option> { + 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") { + return None; + } + words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned()); + } + "word" | "number" => { + words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); + } + // Allow shell constructs that attach IO to a single command without + // changing argv matching semantics for the executable prefix. + "variable_assignment" | "comment" => {} + kind if kind.contains("redirect") || kind.contains("heredoc") => {} + _ => return None, + } + } + + if words.is_empty() { None } else { Some(words) } +} + +fn find_single_command_node(root: Node<'_>) -> Option> { + 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 parse_double_quoted_string(node: Node, src: &str) -> Option { if node.kind() != "string" { return None; @@ -375,4 +443,43 @@ 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); + } } diff --git a/codex-rs/core/src/command_canonicalization.rs b/codex-rs/core/src/command_canonicalization.rs new file mode 100644 index 00000000000..0708e41e193 --- /dev/null +++ b/codex-rs/core/src/command_canonicalization.rs @@ -0,0 +1,130 @@ +use crate::bash::extract_bash_command; +use crate::bash::parse_shell_lc_plain_commands; +use crate::powershell::extract_powershell_command; + +const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__"; +const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__"; + +/// Canonicalize command argv for approval-cache matching. +/// +/// This keeps approval decisions stable across wrapper-path differences (for +/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while +/// preserving exact script text for complex scripts where we cannot safely +/// recover a tokenized command sequence. +pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec { + if let Some(commands) = parse_shell_lc_plain_commands(command) + && let [single_command] = commands.as_slice() + { + return single_command.clone(); + } + + if let Some((_shell, script)) = extract_bash_command(command) { + let shell_mode = command.get(1).cloned().unwrap_or_default(); + return vec![ + CANONICAL_BASH_SCRIPT_PREFIX.to_string(), + shell_mode, + script.to_string(), + ]; + } + + if let Some((_shell, script)) = extract_powershell_command(command) { + return vec![ + CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(), + script.to_string(), + ]; + } + + command.to_vec() +} + +#[cfg(test)] +mod tests { + use super::canonicalize_command_for_approval; + use pretty_assertions::assert_eq; + + #[test] + fn canonicalizes_word_only_shell_scripts_to_inner_command() { + let command_a = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "cargo test -p codex-core".to_string(), + ]; + let command_b = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo test -p codex-core".to_string(), + ]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "cargo".to_string(), + "test".to_string(), + "-p".to_string(), + "codex-core".to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); + } + + #[test] + fn canonicalizes_heredoc_scripts_to_stable_script_key() { + let script = "python3 <<'PY'\nprint('hello')\nPY"; + let command_a = vec![ + "/bin/zsh".to_string(), + "-lc".to_string(), + script.to_string(), + ]; + let command_b = vec!["zsh".to_string(), "-lc".to_string(), script.to_string()]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "__codex_shell_script__".to_string(), + "-lc".to_string(), + script.to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); + } + + #[test] + fn canonicalizes_powershell_wrappers_to_stable_script_key() { + let script = "Write-Host hi"; + let command_a = vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + script.to_string(), + ]; + let command_b = vec![ + "powershell".to_string(), + "-Command".to_string(), + script.to_string(), + ]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "__codex_powershell_script__".to_string(), + script.to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); + } + + #[test] + fn preserves_non_shell_commands() { + let command = vec!["cargo".to_string(), "fmt".to_string()]; + assert_eq!(canonicalize_command_for_approval(&command), command); + } +} diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2ae5a08e4d4..7dd01fcc310 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -25,6 +25,7 @@ use tokio::fs; use tokio::task::spawn_blocking; use crate::bash::parse_shell_lc_plain_commands; +use crate::bash::parse_shell_lc_single_command_prefix; use crate::features::Feature; use crate::features::Features; use crate::sandboxing::SandboxPermissions; @@ -132,8 +133,7 @@ impl ExecPolicyManager { prefix_rule, } = req; let exec_policy = self.current(); - let commands = - parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); + let commands = commands_for_exec_policy(command); let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, @@ -360,6 +360,18 @@ fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } +fn commands_for_exec_policy(command: &[String]) -> Vec> { + if let Some(commands) = parse_shell_lc_plain_commands(command) { + return commands; + } + + if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { + return vec![single_command]; + } + + vec![command.to_vec()] +} + /// Derive a proposed execpolicy amendment when a command requires user approval /// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. /// - Otherwise return the first heuristics Prompt. @@ -826,6 +838,40 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } + #[tokio::test] + async fn evaluates_heredoc_script_against_prefix_rules() { + let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + + let requirement = ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + features: &Features::with_defaults(), + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); + } + #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { let policy_src = r#" diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f1534cf0f70..aca73649f2f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ pub use codex_thread::CodexThread; pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; +mod command_canonicalization; mod command_safety; pub mod config; pub mod config_loader; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index fc862a9aea5..51fe4fd06a3 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -4,6 +4,7 @@ Runtime: shell Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ +use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecToolCallOutput; use crate::features::Feature; use crate::powershell::prefix_powershell_script_with_utf8; @@ -76,7 +77,7 @@ impl Approvable for ShellRuntime { fn approval_keys(&self, req: &ShellRequest) -> Vec { vec![ApprovalKey { - command: req.command.clone(), + command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, }] diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2505b10ed20..2e6d59b46f7 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -4,6 +4,7 @@ Runtime: unified exec Handles approval + sandbox orchestration for unified exec requests, delegating to the process manager to spawn PTYs once an ExecEnv is prepared. */ +use crate::command_canonicalization::canonicalize_command_for_approval; use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; @@ -98,7 +99,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { - command: req.command.clone(), + command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, From 826a6ada4140c0b54573b50625e2c1b5bdf07459 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 14:44:14 -0800 Subject: [PATCH 2/9] fix(core): make heredoc attachment matching explicit --- codex-rs/core/src/bash.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index b795e54fe14..9514da552ee 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -218,7 +218,7 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> // Allow shell constructs that attach IO to a single command without // changing argv matching semantics for the executable prefix. "variable_assignment" | "comment" => {} - kind if kind.contains("redirect") || kind.contains("heredoc") => {} + kind if is_allowed_heredoc_attachment_kind(kind) => {} _ => return None, } } @@ -226,6 +226,18 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> if words.is_empty() { None } else { Some(words) } } +fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool { + matches!( + kind, + "heredoc_body" + | "simple_heredoc_body" + | "heredoc_redirect" + | "herestring_redirect" + | "file_redirect" + | "redirected_statement" + ) +} + fn find_single_command_node(root: Node<'_>) -> Option> { let mut stack = vec![root]; let mut single_command = None; @@ -482,4 +494,17 @@ mod tests { ]; assert_eq!(parse_shell_lc_single_command_prefix(&command), None); } + + #[test] + fn parse_shell_lc_single_command_prefix_accepts_heredoc_with_extra_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), + Some(vec!["python3".to_string()]) + ); + } } From e683b6fce86e2721d155259ae66494df462dd3b6 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 15:52:42 -0800 Subject: [PATCH 3/9] fix(core): skip auto execpolicy amendment for heredoc fallback --- codex-rs/core/src/exec_policy.rs | 100 +++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 7dd01fcc310..24f064e8c09 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -133,7 +133,8 @@ impl ExecPolicyManager { prefix_rule, } = req; let exec_policy = self.current(); - let commands = commands_for_exec_policy(command); + let parsed_commands = commands_for_exec_policy(command); + let auto_amendment_allowed = !parsed_commands.used_heredoc_fallback; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, @@ -142,7 +143,8 @@ impl ExecPolicyManager { sandbox_permissions, ) }; - let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback); + let evaluation = + exec_policy.check_multiple(parsed_commands.commands.iter(), &exec_policy_fallback); let requested_amendment = derive_requested_execpolicy_amendment( features, @@ -164,9 +166,13 @@ impl ExecPolicyManager { reason: derive_prompt_reason(command, &evaluation), proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { requested_amendment.or_else(|| { - try_derive_execpolicy_amendment_for_prompt_rules( - &evaluation.matched_rules, - ) + if auto_amendment_allowed { + try_derive_execpolicy_amendment_for_prompt_rules( + &evaluation.matched_rules, + ) + } else { + None + } }) } else { None @@ -179,7 +185,9 @@ impl ExecPolicyManager { bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| { is_policy_match(rule_match) && rule_match.decision() == Decision::Allow }), - proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) + && auto_amendment_allowed + { try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules) } else { None @@ -360,16 +368,31 @@ fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } -fn commands_for_exec_policy(command: &[String]) -> Vec> { +#[derive(Default)] +struct ExecPolicyCommands { + commands: Vec>, + used_heredoc_fallback: bool, +} + +fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands { if let Some(commands) = parse_shell_lc_plain_commands(command) { - return commands; + return ExecPolicyCommands { + commands, + used_heredoc_fallback: false, + }; } if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { - return vec![single_command]; + return ExecPolicyCommands { + commands: vec![single_command], + used_heredoc_fallback: true, + }; } - vec![command.to_vec()] + ExecPolicyCommands { + commands: vec![command.to_vec()], + used_heredoc_fallback: false, + } } /// Derive a proposed execpolicy amendment when a command requires user approval @@ -872,6 +895,63 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } + #[tokio::test] + async fn omits_auto_amendment_for_heredoc_fallback_prompts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + features: &Features::with_defaults(), + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); + } + + #[tokio::test] + async fn keeps_requested_amendment_for_heredoc_fallback_prompts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + features: &Features::with_defaults(), + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: Some(requested_prefix.clone()), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(requested_prefix)), + } + ); + } + #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { let policy_src = r#" From 22179d81b53b8278e3107fd838fde57d27804be8 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 16:09:28 -0800 Subject: [PATCH 4/9] refactor(core): simplify heredoc fallback parse result plumbing --- codex-rs/core/src/exec_policy.rs | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 24f064e8c09..dce4831c2e6 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -133,8 +133,8 @@ impl ExecPolicyManager { prefix_rule, } = req; let exec_policy = self.current(); - let parsed_commands = commands_for_exec_policy(command); - let auto_amendment_allowed = !parsed_commands.used_heredoc_fallback; + let (commands, used_heredoc_fallback) = commands_for_exec_policy(command); + let auto_amendment_allowed = !used_heredoc_fallback; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, @@ -143,8 +143,7 @@ impl ExecPolicyManager { sandbox_permissions, ) }; - let evaluation = - exec_policy.check_multiple(parsed_commands.commands.iter(), &exec_policy_fallback); + let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback); let requested_amendment = derive_requested_execpolicy_amendment( features, @@ -368,31 +367,16 @@ fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } -#[derive(Default)] -struct ExecPolicyCommands { - commands: Vec>, - used_heredoc_fallback: bool, -} - -fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands { +fn commands_for_exec_policy(command: &[String]) -> (Vec>, bool) { if let Some(commands) = parse_shell_lc_plain_commands(command) { - return ExecPolicyCommands { - commands, - used_heredoc_fallback: false, - }; + return (commands, false); } if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { - return ExecPolicyCommands { - commands: vec![single_command], - used_heredoc_fallback: true, - }; + return (vec![single_command], true); } - ExecPolicyCommands { - commands: vec![command.to_vec()], - used_heredoc_fallback: false, - } + (vec![command.to_vec()], false) } /// Derive a proposed execpolicy amendment when a command requires user approval From a929f4e052530ac36a9b59a7b439e96eac1a6eb7 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 16:24:12 -0800 Subject: [PATCH 5/9] fix(core): preserve mainline exec_policy feature test context --- codex-rs/core/src/exec_policy.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 16483477daf..642b0e190eb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -548,6 +548,8 @@ mod tests { use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use crate::features::Feature; + use crate::features::Features; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -1026,6 +1028,8 @@ prefix_rule( "cargo-insta".to_string(), ]; let manager = ExecPolicyManager::default(); + let mut features = Features::with_defaults(); + features.enable(Feature::RequestRule); let requirement = manager .create_exec_approval_requirement_for_command(ExecApprovalRequest { From 6404ad3cf62a6356d4331d6d2cf5a10d3899a544 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 16:54:44 -0800 Subject: [PATCH 6/9] fix(core): tighten heredoc fallback parsing --- codex-rs/core/src/bash.rs | 71 ++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 9514da552ee..fcb8f9ae174 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -123,18 +123,16 @@ pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option Option> { let (_, script) = extract_bash_command(command)?; - // Keep this fallback narrow: only apply to here-doc style scripts that are - // otherwise rejected by the strict word-only parser. - if !script.contains("<<") { + let tree = try_parse_shell(script)?; + let root = tree.root_node(); + if root.has_error() { return None; } - - let tree = try_parse_shell(script)?; - if tree.root_node().has_error() { + if !has_named_descendant_kind(root, "heredoc_redirect") { return None; } - let command_node = find_single_command_node(tree.root_node())?; + let command_node = find_single_command_node(root)?; parse_heredoc_command_words(command_node, script) } @@ -207,12 +205,17 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> match child.kind() { "command_name" => { let word_node = child.named_child(0)?; - if !matches!(word_node.kind(), "word" | "number") { + if !matches!(word_node.kind(), "word" | "number") + || !is_literal_word_or_number(word_node) + { return None; } words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned()); } "word" | "number" => { + if !is_literal_word_or_number(child) { + return None; + } words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); } // Allow shell constructs that attach IO to a single command without @@ -226,6 +229,28 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> if words.is_empty() { None } else { Some(words) } } +fn is_literal_word_or_number(node: Node<'_>) -> bool { + if !matches!(node.kind(), "word" | "number") { + return false; + } + let mut cursor = node.walk(); + node.named_children(&mut cursor).next().is_none() +} + +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 is_allowed_heredoc_attachment_kind(kind: &str) -> bool { matches!( kind, @@ -507,4 +532,34 @@ mod tests { Some(vec!["python3".to_string()]) ); } + + #[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); + } } From cd8c2970c9ffc5746dbbb245b655a0cd355716cc Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Feb 2026 19:37:59 -0800 Subject: [PATCH 7/9] docs(core): clarify heredoc fallback amendment behavior --- codex-rs/core/src/exec_policy.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 642b0e190eb..514b4f0af59 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -123,6 +123,9 @@ impl ExecPolicyManager { } = req; let exec_policy = self.current(); let (commands, used_heredoc_fallback) = commands_for_exec_policy(command); + // Keep heredoc prefix parsing for rule evaluation so existing + // allow/prompt/forbidden rules still apply, but avoid auto-derived + // amendments when only the heredoc fallback parser matched. let auto_amendment_allowed = !used_heredoc_fallback; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( From 8bf5e4181dab57de47d038095ffd1e63538afa14 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sun, 8 Feb 2026 14:51:43 -0800 Subject: [PATCH 8/9] test: add execpolicy prefix safety matrix script --- scripts/execpolicy_prefix_matrix.sh | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100755 scripts/execpolicy_prefix_matrix.sh diff --git a/scripts/execpolicy_prefix_matrix.sh b/scripts/execpolicy_prefix_matrix.sh new file mode 100755 index 00000000000..180081a8067 --- /dev/null +++ b/scripts/execpolicy_prefix_matrix.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CODEX_RS_DIR="$ROOT_DIR/codex-rs" + +usage() { + cat <<'EOF' +Run an execpolicy prefix-safety matrix and optionally audit an existing rules file. + +Usage: + scripts/execpolicy_prefix_matrix.sh + scripts/execpolicy_prefix_matrix.sh --audit /path/to/default.rules + +Notes: + - Runs `cargo run -p codex-execpolicy -- check ...` from codex-rs/. + - Default mode compares broad vs narrow rules for python3/npm/git push. + - Audit mode probes a real rules file for broad/risky allow matches. +EOF +} + +execpolicy_check() { + local rules_file="$1" + shift + + ( + cd "$CODEX_RS_DIR" + cargo run -q -p codex-execpolicy -- check --rules "$rules_file" -- "$@" + ) +} + +decision_for() { + local rules_file="$1" + shift + + local json + json="$(execpolicy_check "$rules_file" "$@")" + local decision + decision="$(printf '%s' "$json" | sed -n 's/.*"decision":"\([^"]*\)".*/\1/p')" + if [[ -z "$decision" ]]; then + decision="no-match" + fi + printf '%s' "$decision" +} + +run_case() { + local rules_file="$1" + shift + local decision + decision="$(decision_for "$rules_file" "$@")" + printf ' %-10s %s\n' "$decision" "$(printf '%q ' "$@")" +} + +run_matrix() { + local tmp_dir + tmp_dir="$(mktemp -d)" + trap "rm -rf '$tmp_dir'" EXIT + + local broad_rules="$tmp_dir/broad.rules" + local narrow_rules="$tmp_dir/narrow.rules" + + cat >"$broad_rules" <<'EOF' +prefix_rule(pattern=["python3"], decision="allow") +prefix_rule(pattern=["npm"], decision="allow") +prefix_rule(pattern=["git", "push"], decision="allow") +EOF + + cat >"$narrow_rules" <<'EOF' +prefix_rule(pattern=["python3", "-V"], decision="allow") +prefix_rule(pattern=["npm", "install"], decision="allow") +prefix_rule(pattern=["git", "push", "origin", "main"], decision="allow") +EOF + + echo "=== Broad rules (unsafe convenience) ===" + run_case "$broad_rules" python3 -V + run_case "$broad_rules" python3 -c 'import os; print("x")' + run_case "$broad_rules" npm install left-pad + run_case "$broad_rules" npm publish + run_case "$broad_rules" git push origin main + run_case "$broad_rules" git push upstream dev + echo + + echo "=== Narrow rules (recommended) ===" + run_case "$narrow_rules" python3 -V + run_case "$narrow_rules" python3 -c 'import os; print("x")' + run_case "$narrow_rules" npm install left-pad + run_case "$narrow_rules" npm publish + run_case "$narrow_rules" git push origin main + run_case "$narrow_rules" git push upstream dev + echo + + cat <<'EOF' +Recommendation: + Prefer task-specific prefixes (e.g. ["npm","install"], ["python3","-V"], + ["git","push","origin","main"]) over blanket command roots. +EOF +} + +audit_rules() { + local rules_file="$1" + if [[ ! -f "$rules_file" ]]; then + echo "error: rules file not found: $rules_file" >&2 + exit 1 + fi + + echo "=== Audit: $rules_file ===" + local has_warning=0 + + local decision + decision="$(decision_for "$rules_file" python3 -c 'import os; print("x")')" + if [[ "$decision" == "allow" ]]; then + echo " WARN broad python allow: python3 -c is allowed" + has_warning=1 + fi + + decision="$(decision_for "$rules_file" npm publish)" + if [[ "$decision" == "allow" ]]; then + echo " WARN broad npm allow: npm publish is allowed" + has_warning=1 + fi + + decision="$(decision_for "$rules_file" git push upstream dev)" + if [[ "$decision" == "allow" ]]; then + echo " WARN broad git push allow: arbitrary remote/branch push is allowed" + has_warning=1 + fi + + if [[ "$has_warning" -eq 0 ]]; then + echo " OK no broad allow matches found in default probes" + fi +} + +main() { + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 + fi + + if [[ "${1:-}" == "--audit" ]]; then + if [[ $# -ne 2 ]]; then + usage + exit 1 + fi + audit_rules "$2" + exit 0 + fi + + if [[ $# -ne 0 ]]; then + usage + exit 1 + fi + + run_matrix +} + +main "$@" From 631cd90ec94841ca562c17fe6a1fc50019e313b1 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sun, 8 Feb 2026 15:36:34 -0800 Subject: [PATCH 9/9] chore: remove local-only execpolicy matrix script --- scripts/execpolicy_prefix_matrix.sh | 156 ---------------------------- 1 file changed, 156 deletions(-) delete mode 100755 scripts/execpolicy_prefix_matrix.sh diff --git a/scripts/execpolicy_prefix_matrix.sh b/scripts/execpolicy_prefix_matrix.sh deleted file mode 100755 index 180081a8067..00000000000 --- a/scripts/execpolicy_prefix_matrix.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CODEX_RS_DIR="$ROOT_DIR/codex-rs" - -usage() { - cat <<'EOF' -Run an execpolicy prefix-safety matrix and optionally audit an existing rules file. - -Usage: - scripts/execpolicy_prefix_matrix.sh - scripts/execpolicy_prefix_matrix.sh --audit /path/to/default.rules - -Notes: - - Runs `cargo run -p codex-execpolicy -- check ...` from codex-rs/. - - Default mode compares broad vs narrow rules for python3/npm/git push. - - Audit mode probes a real rules file for broad/risky allow matches. -EOF -} - -execpolicy_check() { - local rules_file="$1" - shift - - ( - cd "$CODEX_RS_DIR" - cargo run -q -p codex-execpolicy -- check --rules "$rules_file" -- "$@" - ) -} - -decision_for() { - local rules_file="$1" - shift - - local json - json="$(execpolicy_check "$rules_file" "$@")" - local decision - decision="$(printf '%s' "$json" | sed -n 's/.*"decision":"\([^"]*\)".*/\1/p')" - if [[ -z "$decision" ]]; then - decision="no-match" - fi - printf '%s' "$decision" -} - -run_case() { - local rules_file="$1" - shift - local decision - decision="$(decision_for "$rules_file" "$@")" - printf ' %-10s %s\n' "$decision" "$(printf '%q ' "$@")" -} - -run_matrix() { - local tmp_dir - tmp_dir="$(mktemp -d)" - trap "rm -rf '$tmp_dir'" EXIT - - local broad_rules="$tmp_dir/broad.rules" - local narrow_rules="$tmp_dir/narrow.rules" - - cat >"$broad_rules" <<'EOF' -prefix_rule(pattern=["python3"], decision="allow") -prefix_rule(pattern=["npm"], decision="allow") -prefix_rule(pattern=["git", "push"], decision="allow") -EOF - - cat >"$narrow_rules" <<'EOF' -prefix_rule(pattern=["python3", "-V"], decision="allow") -prefix_rule(pattern=["npm", "install"], decision="allow") -prefix_rule(pattern=["git", "push", "origin", "main"], decision="allow") -EOF - - echo "=== Broad rules (unsafe convenience) ===" - run_case "$broad_rules" python3 -V - run_case "$broad_rules" python3 -c 'import os; print("x")' - run_case "$broad_rules" npm install left-pad - run_case "$broad_rules" npm publish - run_case "$broad_rules" git push origin main - run_case "$broad_rules" git push upstream dev - echo - - echo "=== Narrow rules (recommended) ===" - run_case "$narrow_rules" python3 -V - run_case "$narrow_rules" python3 -c 'import os; print("x")' - run_case "$narrow_rules" npm install left-pad - run_case "$narrow_rules" npm publish - run_case "$narrow_rules" git push origin main - run_case "$narrow_rules" git push upstream dev - echo - - cat <<'EOF' -Recommendation: - Prefer task-specific prefixes (e.g. ["npm","install"], ["python3","-V"], - ["git","push","origin","main"]) over blanket command roots. -EOF -} - -audit_rules() { - local rules_file="$1" - if [[ ! -f "$rules_file" ]]; then - echo "error: rules file not found: $rules_file" >&2 - exit 1 - fi - - echo "=== Audit: $rules_file ===" - local has_warning=0 - - local decision - decision="$(decision_for "$rules_file" python3 -c 'import os; print("x")')" - if [[ "$decision" == "allow" ]]; then - echo " WARN broad python allow: python3 -c is allowed" - has_warning=1 - fi - - decision="$(decision_for "$rules_file" npm publish)" - if [[ "$decision" == "allow" ]]; then - echo " WARN broad npm allow: npm publish is allowed" - has_warning=1 - fi - - decision="$(decision_for "$rules_file" git push upstream dev)" - if [[ "$decision" == "allow" ]]; then - echo " WARN broad git push allow: arbitrary remote/branch push is allowed" - has_warning=1 - fi - - if [[ "$has_warning" -eq 0 ]]; then - echo " OK no broad allow matches found in default probes" - fi -} - -main() { - if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - usage - exit 0 - fi - - if [[ "${1:-}" == "--audit" ]]; then - if [[ $# -ne 2 ]]; then - usage - exit 1 - fi - audit_rules "$2" - exit 0 - fi - - if [[ $# -ne 0 ]]; then - usage - exit 1 - fi - - run_matrix -} - -main "$@"