From be8e4e41dee25d60ed5f68092790fbb5bac93ada Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 17:43:02 -0700 Subject: [PATCH 1/6] fix(exec_policy) heredoc parsing file_redirect --- codex-rs/core/src/exec_policy.rs | 4 ++ codex-rs/core/src/exec_policy_tests.rs | 71 ++++++++++++++++++++++++++ codex-rs/shell-command/src/bash.rs | 39 +++++++++++--- 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index d0926663697d..c36e5e62f4fc 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -37,6 +37,7 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::bash::parse_shell_lc_single_command_prefix; +use codex_shell_command::bash::shell_lc_contains_file_redirect; use codex_utils_absolute_path::AbsolutePathBuf; use shlex::try_join as shlex_try_join; @@ -708,6 +709,9 @@ fn commands_for_exec_policy(command: &[String]) -> (Vec>, bool) { if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { return (vec![single_command], true); } + if shell_lc_contains_file_redirect(command) { + return (vec![command.to_vec()], true); + } (vec![command.to_vec()], false) } diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 2db07a0fcef7..a58bcff6ce2c 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -739,6 +739,29 @@ async fn evaluates_heredoc_script_against_prefix_rules() { .await; } +#[tokio::test] +async fn forbidden_prefix_rule_still_applies_to_heredoc_script() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="forbidden")"#.to_string()), + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::Forbidden { + reason: "`bash -lc \"python3 <<'PY'\nprint('hello')\nPY\"` rejected: policy forbids commands starting with `python3`".to_string(), + }, + ) + .await; +} + #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { assert_exec_approval_requirement_for_command( @@ -791,6 +814,54 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat .await; } +#[tokio::test] +async fn heredoc_redirect_without_escalation_runs_inside_sandbox() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "zsh".to_string(), + "-lc".to_string(), + "cat <<'EOF' > /some/important/folder/test.txt\nhello world\nEOF".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + ) + .await; +} + +#[tokio::test] +async fn heredoc_redirect_with_escalation_requires_approval() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["cat"], decision="allow")"#.to_string()), + command: vec![ + "zsh".to_string(), + "-lc".to_string(), + "cat <<'EOF' > /some/important/folder/test.txt\nhello world\nEOF".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ) + .await; +} + #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { assert_exec_approval_requirement_for_command( diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 2fe299108c20..2418b5cf28c0 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -131,11 +131,25 @@ pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option bool { + let Some((_, script)) = extract_bash_command(command) else { + return false; + }; + let Some(tree) = try_parse_shell(script) else { + return false; + }; + let root = tree.root_node(); + !root.has_error() && has_named_descendant_kind(root, "file_redirect") +} + fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option> { if cmd.kind() != "command" { return None; @@ -218,8 +232,10 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> } 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. + // 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. "variable_assignment" | "comment" => {} kind if is_allowed_heredoc_attachment_kind(kind) => {} _ => return None, @@ -244,7 +260,6 @@ fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool { | "simple_heredoc_body" | "heredoc_redirect" | "herestring_redirect" - | "file_redirect" | "redirected_statement" ) } @@ -536,16 +551,24 @@ mod tests { } #[test] - fn parse_shell_lc_single_command_prefix_accepts_heredoc_with_extra_redirect() { + 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), - Some(vec!["python3".to_string()]) - ); + assert_eq!(parse_shell_lc_single_command_prefix(&command), None); + } + + #[test] + fn shell_lc_contains_file_redirect_detects_heredoc_with_extra_file_redirect() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cat <<'EOF' > ~/Pictures/sandbox_test.txt\nhello world\nEOF".to_string(), + ]; + + assert!(shell_lc_contains_file_redirect(&command)); } #[test] From 4e677e0b757094d83a3308608602f6dfb31a5be3 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 18:47:32 -0700 Subject: [PATCH 2/6] rm extra test --- codex-rs/core/src/exec_policy_tests.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index a58bcff6ce2c..9887547513b0 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -739,29 +739,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { .await; } -#[tokio::test] -async fn forbidden_prefix_rule_still_applies_to_heredoc_script() { - assert_exec_approval_requirement_for_command( - ExecApprovalRequirementScenario { - policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="forbidden")"#.to_string()), - command: vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ], - approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }, - ExecApprovalRequirement::Forbidden { - reason: "`bash -lc \"python3 <<'PY'\nprint('hello')\nPY\"` rejected: policy forbids commands starting with `python3`".to_string(), - }, - ) - .await; -} - #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { assert_exec_approval_requirement_for_command( From d17537536f259c5be9a0d292794958a044c28fbb Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 19:11:40 -0700 Subject: [PATCH 3/6] simplify --- codex-rs/core/src/exec_policy.rs | 4 ---- codex-rs/core/src/exec_policy_tests.rs | 12 ++++++++++-- codex-rs/shell-command/src/bash.rs | 22 ---------------------- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index c36e5e62f4fc..d0926663697d 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -37,7 +37,6 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::bash::parse_shell_lc_single_command_prefix; -use codex_shell_command::bash::shell_lc_contains_file_redirect; use codex_utils_absolute_path::AbsolutePathBuf; use shlex::try_join as shlex_try_join; @@ -709,9 +708,6 @@ fn commands_for_exec_policy(command: &[String]) -> (Vec>, bool) { if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { return (vec![single_command], true); } - if shell_lc_contains_file_redirect(command) { - return (vec![command.to_vec()], true); - } (vec![command.to_vec()], false) } diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 9887547513b0..8058ce829418 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -809,7 +809,11 @@ async fn heredoc_redirect_without_escalation_runs_inside_sandbox() { }, ExecApprovalRequirement::Skip { bypass_sandbox: false, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "zsh".to_string(), + "-lc".to_string(), + "cat <<'EOF' > /some/important/folder/test.txt\nhello world\nEOF".to_string(), + ])), }, ) .await; @@ -833,7 +837,11 @@ async fn heredoc_redirect_with_escalation_requires_approval() { }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "zsh".to_string(), + "-lc".to_string(), + "cat <<'EOF' > /some/important/folder/test.txt\nhello world\nEOF".to_string(), + ])), }, ) .await; diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 2418b5cf28c0..ad6feaefe224 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -139,17 +139,6 @@ pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option bool { - let Some((_, script)) = extract_bash_command(command) else { - return false; - }; - let Some(tree) = try_parse_shell(script) else { - return false; - }; - let root = tree.root_node(); - !root.has_error() && has_named_descendant_kind(root, "file_redirect") -} - fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option> { if cmd.kind() != "command" { return None; @@ -560,17 +549,6 @@ mod tests { assert_eq!(parse_shell_lc_single_command_prefix(&command), None); } - #[test] - fn shell_lc_contains_file_redirect_detects_heredoc_with_extra_file_redirect() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cat <<'EOF' > ~/Pictures/sandbox_test.txt\nhello world\nEOF".to_string(), - ]; - - assert!(shell_lc_contains_file_redirect(&command)); - } - #[test] fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() { let command = vec![ From 5cba36885014d01cb7d3a54634838a1a245e9633 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 19:41:26 -0700 Subject: [PATCH 4/6] integraiton test --- codex-rs/core/tests/suite/approvals.rs | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index ef22cfbfc1a2..f780082a2148 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -95,6 +95,10 @@ enum ActionKind { RunCommand { command: &'static str, }, + RunCommandWithPrefixRule { + command: &'static str, + prefix_rule: &'static [&'static str], + }, RunUnifiedExecCommand { command: &'static str, justification: Option<&'static str>, @@ -203,6 +207,19 @@ impl ActionKind { )?; Ok((event, Some(command.to_string()))) } + ActionKind::RunCommandWithPrefixRule { + command, + prefix_rule, + } => { + let event = shell_event_with_prefix_rule( + call_id, + command, + /*timeout_ms*/ 30_000, + sandbox_permissions, + Some(prefix_rule.iter().map(|part| (*part).to_string()).collect()), + )?; + Ok((event, Some(command.to_string()))) + } ActionKind::RunUnifiedExecCommand { command, justification, @@ -912,6 +929,25 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, + ScenarioSpec { + name: "cat_heredoc_file_redirect_prefix_rule_requires_escalation_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunCommandWithPrefixRule { + command: "cat <<'EOF' > /tmp/out.txt\nhello\nEOF", + prefix_rule: &["cat"], + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![], + model_override: Some("gpt-5.2"), + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, ScenarioSpec { name: "danger_full_access_on_failure_allows_outside_write", approval_policy: OnFailure, @@ -1702,7 +1738,8 @@ fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup { ActionKind::WriteFile { .. } | ActionKind::FetchUrlNoProxy { .. } | ActionKind::FetchUrl { .. } - | ActionKind::RunCommand { .. } => match &scenario.sandbox_policy { + | ActionKind::RunCommand { .. } + | ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy { SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess, SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly, SandboxPolicy::WorkspaceWrite { .. } => ScenarioGroup::WorkspaceWrite, From 54ad74d9a0d03e73fb4ce77d67a271ce9a7f6b24 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 20:16:04 -0700 Subject: [PATCH 5/6] env vars and amendments --- codex-rs/core/src/exec_policy.rs | 20 +++-- codex-rs/core/src/exec_policy_tests.rs | 52 +++++++++++ codex-rs/core/tests/suite/approvals.rs | 117 +++++++++++++++++++++++++ codex-rs/shell-command/src/bash.rs | 12 ++- 4 files changed, 192 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index d0926663697d..367a16d53235 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -271,14 +271,18 @@ impl ExecPolicyManager { &match_options, ); - let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule( - prefix_rule.as_ref(), - &evaluation.matched_rules, - exec_policy.as_ref(), - &commands, - &exec_policy_fallback, - &match_options, - ); + let requested_amendment = if auto_amendment_allowed { + derive_requested_execpolicy_amendment_from_prefix_rule( + prefix_rule.as_ref(), + &evaluation.matched_rules, + exec_policy.as_ref(), + &commands, + &exec_policy_fallback, + &match_options, + ) + } else { + None + }; match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 8058ce829418..442c534f7a0e 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -791,6 +791,58 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat .await; } +#[tokio::test] +async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: Some(vec!["python3".to_string()]), + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ) + .await; +} + +#[tokio::test] +async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["cat"], decision="allow")"#.to_string()), + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "bash".to_string(), + "-lc".to_string(), + "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(), + ])), + }, + ) + .await; +} + #[tokio::test] async fn heredoc_redirect_without_escalation_runs_inside_sandbox() { assert_exec_approval_requirement_for_command( diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f780082a2148..72d4b6a90772 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -95,6 +95,10 @@ enum ActionKind { RunCommand { command: &'static str, }, + RunCommandWithPolicy { + command: &'static str, + policy_src: &'static str, + }, RunCommandWithPrefixRule { command: &'static str, prefix_rule: &'static [&'static str], @@ -117,6 +121,20 @@ const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str = "Requires escalated permissions to bypass the sandbox in tests."; impl ActionKind { + fn policy_src(&self) -> Option<&'static str> { + match self { + ActionKind::RunCommandWithPolicy { policy_src, .. } => Some(*policy_src), + ActionKind::WriteFile { .. } + | ActionKind::FetchUrlNoProxy { .. } + | ActionKind::FetchUrl { .. } + | ActionKind::RunCommand { .. } + | ActionKind::RunCommandWithPrefixRule { .. } + | ActionKind::RunUnifiedExecCommand { .. } + | ActionKind::ApplyPatchFunction { .. } + | ActionKind::ApplyPatchShell { .. } => None, + } + } + async fn prepare( &self, test: &TestCodex, @@ -207,6 +225,18 @@ impl ActionKind { )?; Ok((event, Some(command.to_string()))) } + ActionKind::RunCommandWithPolicy { command, .. } => { + // Bazel Linux runners can be heavily oversubscribed while this + // matrix runs, so avoid making scheduling latency look like an + // approval behavior failure. + let event = shell_event( + call_id, + command, + /*timeout_ms*/ 30_000, + sandbox_permissions, + )?; + Ok((event, Some(command.to_string()))) + } ActionKind::RunCommandWithPrefixRule { command, prefix_rule, @@ -565,6 +595,11 @@ enum Outcome { decision: ReviewDecision, expected_reason: Option<&'static str>, }, + ExecApprovalWithAmendment { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + expected_execpolicy_amendment: Option<&'static [&'static str]>, + }, PatchApproval { decision: ReviewDecision, expected_reason: Option<&'static str>, @@ -948,6 +983,45 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, + ScenarioSpec { + name: "cat_heredoc_variable_assignment_policy_requires_escalation_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunCommandWithPolicy { + command: "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF", + policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#, + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![], + model_override: Some("gpt-5.2"), + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, + ScenarioSpec { + name: "python_heredoc_requested_prefix_rule_omits_amendment", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunCommandWithPrefixRule { + command: "python3 <<'PY'\nprint('hello')\nPY", + prefix_rule: &["python3"], + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![], + model_override: Some("gpt-5.2"), + outcome: Outcome::ExecApprovalWithAmendment { + decision: ReviewDecision::Denied, + expected_reason: None, + expected_execpolicy_amendment: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, ScenarioSpec { name: "danger_full_access_on_failure_allows_outside_write", approval_policy: OnFailure, @@ -1739,6 +1813,7 @@ fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup { | ActionKind::FetchUrlNoProxy { .. } | ActionKind::FetchUrl { .. } | ActionKind::RunCommand { .. } + | ActionKind::RunCommandWithPolicy { .. } | ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy { SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess, SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly, @@ -1756,6 +1831,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let features = scenario.features.clone(); let model_override = scenario.model_override; let model = model_override.unwrap_or("gpt-5.4"); + let policy_src = scenario.action.policy_src(); let mut builder = test_codex().with_model(model).with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1769,6 +1845,13 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { .expect("test config should allow feature update"); } }); + if let Some(policy_src) = policy_src { + builder = builder.with_pre_build_hook(move |home| { + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).expect("create rules dir"); + fs::write(rules_dir.join("default.rules"), policy_src).expect("write policy"); + }); + } let test = builder.build(&server).await?; let call_id = scenario.name; @@ -1835,6 +1918,40 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { .await?; wait_for_completion(&test).await; } + Outcome::ExecApprovalWithAmendment { + decision, + expected_reason, + expected_execpolicy_amendment, + } => { + let command = expected_command + .as_deref() + .expect("exec approval requires shell command"); + let approval = expect_exec_approval(&test, command).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected approval reason for {}", + scenario.name + ); + } + let expected_execpolicy_amendment = expected_execpolicy_amendment.map(|command| { + ExecPolicyAmendment::new(command.iter().map(|part| (*part).to_string()).collect()) + }); + assert_eq!( + approval.proposed_execpolicy_amendment, expected_execpolicy_amendment, + "unexpected execpolicy amendment for {}", + scenario.name + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: decision.clone(), + }) + .await?; + wait_for_completion(&test).await; + } Outcome::PatchApproval { decision, expected_reason, diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index ad6feaefe224..60ee5c420c51 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -225,7 +225,7 @@ fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option> // 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. - "variable_assignment" | "comment" => {} + "comment" => {} kind if is_allowed_heredoc_attachment_kind(kind) => {} _ => return None, } @@ -549,6 +549,16 @@ mod tests { 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![ From f3e0a64efc0adba396672cf1167f04da6127a56d Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 28 Apr 2026 20:48:30 -0700 Subject: [PATCH 6/6] test(exec-policy): skip heredoc assignment regression on windows Windows read-only sandbox policy prompts for the unreduced fallback command, so keep this assertion on platforms where the sandboxed fallback result is stable. Co-authored-by: Codex --- codex-rs/core/src/exec_policy_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 442c534f7a0e..ef11572cdcd9 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -816,6 +816,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( } #[tokio::test] +#[cfg(not(windows))] async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { assert_exec_approval_requirement_for_command( ExecApprovalRequirementScenario {