From 70a59eee3d7a48cba4b3bd831584268fc3f45552 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 16:46:52 -0500 Subject: [PATCH 01/15] Add approval allow-prefix flow in core and tui --- .../app-server/src/bespoke_event_handling.rs | 3 + codex-rs/core/src/codex.rs | 67 +++++- codex-rs/core/src/codex_delegate.rs | 9 +- codex-rs/core/src/exec_policy.rs | 213 ++++++++++++++---- codex-rs/core/src/tools/handlers/shell.rs | 3 +- codex-rs/core/src/tools/orchestrator.rs | 2 +- .../core/src/tools/runtimes/apply_patch.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 10 +- .../core/src/tools/runtimes/unified_exec.rs | 10 +- codex-rs/core/src/tools/sandboxing.rs | 22 +- .../core/src/unified_exec/session_manager.rs | 3 +- codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/codex_delegate.rs | 1 + codex-rs/core/tests/suite/otel.rs | 6 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/exec_approval.rs | 1 + codex-rs/protocol/src/approvals.rs | 4 + codex-rs/protocol/src/protocol.rs | 3 + .../tui/src/bottom_pane/approval_overlay.rs | 105 +++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 6 + 22 files changed, 404 insertions(+), 69 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d4b00e67f9..ff6ebc563d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -153,6 +153,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, + allow_prefix: _allow_prefix, parsed_cmd, }) => match api_version { ApiVersion::V1 => { @@ -610,6 +611,7 @@ async fn on_exec_approval_response( .submit(Op::ExecApproval { id: event_id, decision: response.decision, + allow_prefix: None, }) .await { @@ -783,6 +785,7 @@ async fn on_command_execution_request_approval_response( .submit(Op::ExecApproval { id: event_id, decision, + allow_prefix: None, }) .await { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4d9285dd24..f038030ce7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -68,6 +68,7 @@ use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; +use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; @@ -845,11 +846,43 @@ impl Session { .await } + pub(crate) async fn persist_command_allow_prefix( + &self, + prefix: &[String], + ) -> Result<(), ExecPolicyUpdateError> { + let (features, codex_home) = { + let state = self.state.lock().await; + ( + state.session_configuration.features.clone(), + state + .session_configuration + .original_config_do_not_use + .codex_home + .clone(), + ) + }; + + let policy = + crate::exec_policy::append_allow_prefix_rule_and_reload(&features, &codex_home, prefix) + .await?; + + let mut state = self.state.lock().await; + state.session_configuration.exec_policy = policy; + + Ok(()) + } + + pub(crate) async fn current_exec_policy(&self) -> Arc { + let state = self.state.lock().await; + state.session_configuration.exec_policy.clone() + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `sub_id`/`call_id` so matching responses are delivered /// to the correct in-flight turn. If the task is aborted, this returns the /// default `ReviewDecision` (`Denied`). + #[allow(clippy::too_many_arguments)] pub async fn request_command_approval( &self, turn_context: &TurnContext, @@ -858,6 +891,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: Option, + allow_prefix: Option>, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -885,6 +919,7 @@ impl Session { cwd, reason, risk, + allow_prefix, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -1383,8 +1418,12 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op, &mut previous_context) .await; } - Op::ExecApproval { id, decision } => { - handlers::exec_approval(&sess, id, decision).await; + Op::ExecApproval { + id, + decision, + allow_prefix, + } => { + handlers::exec_approval(&sess, id, decision, allow_prefix).await; } Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; @@ -1453,6 +1492,7 @@ mod handlers { use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use std::sync::Arc; @@ -1538,7 +1578,28 @@ mod handlers { *previous_context = Some(turn_context); } - pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { + pub async fn exec_approval( + sess: &Arc, + id: String, + decision: ReviewDecision, + allow_prefix: Option>, + ) { + if let Some(prefix) = allow_prefix + && matches!( + decision, + ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ) + && let Err(err) = sess.persist_command_allow_prefix(&prefix).await + { + let message = format!("Failed to update execpolicy allow list: {err}"); + tracing::warn!("{message}"); + let warning = EventMsg::Warning(WarningEvent { message }); + sess.send_event_raw(Event { + id: id.clone(), + msg: warning, + }) + .await; + } match decision { ReviewDecision::Abort => { sess.interrupt_task().await; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4cb4d4a06a..110848b157 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -235,6 +235,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, + event.allow_prefix, ); let decision = await_approval_with_cancel( approval_fut, @@ -244,7 +245,13 @@ async fn handle_exec_approval( ) .await; - let _ = codex.submit(Op::ExecApproval { id, decision }).await; + let _ = codex + .submit(Op::ExecApproval { + id, + decision, + allow_prefix: None, + }) + .await; } /// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying. diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 9525cc1acf..78114ab27f 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -4,10 +4,12 @@ use std::path::PathBuf; use std::sync::Arc; use crate::command_safety::is_dangerous_command::requires_initial_appoval; +use codex_execpolicy::AmendError; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::append_allow_prefix_rule; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; @@ -23,6 +25,7 @@ const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; +const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; #[derive(Debug, Error)] pub enum ExecPolicyError { @@ -45,6 +48,15 @@ pub enum ExecPolicyError { }, } +#[derive(Debug, Error)] +pub enum ExecPolicyUpdateError { + #[error("failed to update execpolicy file {path}: {source}")] + AppendRule { path: PathBuf, source: AmendError }, + + #[error("failed to reload execpolicy after updating policy: {0}")] + Reload(#[from] ExecPolicyError), +} + pub(crate) async fn exec_policy_for( features: &Features, codex_home: &Path, @@ -84,33 +96,64 @@ pub(crate) async fn exec_policy_for( Ok(policy) } -fn evaluate_with_policy( - policy: &Policy, - command: &[String], - approval_policy: AskForApproval, -) -> Option { - let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = policy.check_multiple(commands.iter()); +pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { + codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) +} - match evaluation { - Evaluation::Match { decision, .. } => match decision { - Decision::Forbidden => Some(ApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }), - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - Some(ApprovalRequirement::Forbidden { reason }) - } else { - Some(ApprovalRequirement::NeedsApproval { - reason: Some(reason), - }) +pub(crate) async fn append_allow_prefix_rule_and_reload( + features: &Features, + codex_home: &Path, + prefix: &[String], +) -> Result, ExecPolicyUpdateError> { + let policy_path = default_policy_path(codex_home); + append_allow_prefix_rule(&policy_path, prefix).map_err(|source| { + ExecPolicyUpdateError::AppendRule { + path: policy_path, + source, + } + })?; + + exec_policy_for(features, codex_home) + .await + .map_err(ExecPolicyUpdateError::Reload) +} + +fn requirement_from_decision( + decision: Decision, + approval_policy: AskForApproval, +) -> ApprovalRequirement { + match decision { + Decision::Forbidden => ApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + let reason = PROMPT_REASON.to_string(); + if matches!(approval_policy, AskForApproval::Never) { + ApprovalRequirement::Forbidden { reason } + } else { + ApprovalRequirement::NeedsApproval { + reason: Some(reason), + allow_prefix: None, } } - Decision::Allow => Some(ApprovalRequirement::Skip), - }, - Evaluation::NoMatch => None, + } + Decision::Allow => ApprovalRequirement::Skip, + } +} + +/// Return an allow-prefix option when a single plain command needs approval without +/// any matching policy rule. We only surface the prefix opt-in when execpolicy did +/// not already drive the decision (NoMatch) and when the command is a single +/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix). +fn allow_prefix_if_applicable( + commands: &[Vec], + evaluation: &Evaluation, +) -> Option> { + if matches!(evaluation, Evaluation::NoMatch) && commands.len() == 1 { + return Some(commands[0].clone()); } + + None } pub(crate) fn create_approval_requirement_for_command( @@ -120,19 +163,26 @@ pub(crate) fn create_approval_requirement_for_command( sandbox_policy: &SandboxPolicy, sandbox_permissions: SandboxPermissions, ) -> ApprovalRequirement { - if let Some(requirement) = evaluate_with_policy(policy, command, approval_policy) { - return requirement; - } + let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); + let evaluation = policy.check_multiple(commands.iter()); - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ApprovalRequirement::NeedsApproval { reason: None } - } else { - ApprovalRequirement::Skip + match evaluation { + Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), + Evaluation::NoMatch => { + if requires_initial_appoval( + approval_policy, + sandbox_policy, + command, + sandbox_permissions, + ) { + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: allow_prefix_if_applicable(&commands, &evaluation), + } + } else { + ApprovalRequirement::Skip + } + } } } @@ -249,7 +299,7 @@ mod tests { let temp_dir = tempdir().expect("create temp dir"); fs::write( temp_dir.path().join("root.codexpolicy"), - r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + r#"prefix_rule(pattern=[\"ls\"], decision=\"prompt\")"#, ) .expect("write policy file"); @@ -280,9 +330,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") "rm -rf /tmp".to_string(), ]; - let requirement = - evaluate_with_policy(&policy, &forbidden_script, AskForApproval::OnRequest) - .expect("expected match for forbidden command"); + let requirement = create_approval_requirement_for_command( + &policy, + &forbidden_script, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ); assert_eq!( requirement, @@ -313,7 +367,8 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, ApprovalRequirement::NeedsApproval { - reason: Some(PROMPT_REASON.to_string()) + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, } ); } @@ -359,7 +414,83 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::NeedsApproval { reason: None } + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[test] + fn allow_prefix_is_present_for_single_command_without_policy_match() { + let command = vec!["python".to_string()]; + + let empty_policy = Policy::empty(); + let requirement = create_approval_requirement_for_command( + &empty_policy, + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ); + + assert_eq!( + requirement, + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[test] + fn allow_prefix_is_omitted_when_policy_prompts() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = parser.build(); + let command = vec!["rm".to_string()]; + + let requirement = create_approval_requirement_for_command( + &policy, + &command, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ); + + assert_eq!( + requirement, + ApprovalRequirement::NeedsApproval { + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, + } + ); + } + + #[test] + fn allow_prefix_is_omitted_for_multi_command_scripts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python && echo ok".to_string(), + ]; + let requirement = create_approval_requirement_for_command( + &Policy::empty(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ); + + assert_eq!( + requirement, + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } ); } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index fcd2f5b0c9..c86ef71957 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -297,6 +297,7 @@ impl ShellHandler { let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); emitter.begin(event_ctx).await; + let exec_policy = session.current_exec_policy().await; let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), @@ -305,7 +306,7 @@ impl ShellHandler { with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), approval_requirement: create_approval_requirement_for_command( - &turn.exec_policy, + exec_policy.as_ref(), &exec_params.command, turn.approval_policy, &turn.sandbox_policy, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7e8e152f67..e37e753018 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -63,7 +63,7 @@ impl ToolOrchestrator { ApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason)); } - ApprovalRequirement::NeedsApproval { reason } => { + ApprovalRequirement::NeedsApproval { reason, .. } => { let mut risk = None; if let Some(metadata) = req.sandbox_retry_data() { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 0cdddd5087..cbc93af284 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -127,6 +127,7 @@ impl Approvable for ApplyPatchRuntime { cwd, Some(reason), risk, + None, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index d71c4498e6..db40476fa6 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -106,7 +106,15 @@ impl Approvable for ShellRuntime { Box::pin(async move { with_cached_approval(&session.services, key, move || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.approval_requirement.allow_prefix().cloned(), + ) .await }) .await diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5b18476bfc..0c6cf0bc73 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -123,7 +123,15 @@ impl Approvable for UnifiedExecRuntime<'_> { Box::pin(async move { with_cached_approval(&session.services, key, || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.approval_requirement.allow_prefix().cloned(), + ) .await }) .await diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index e694c7fbef..057bfc2dee 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -92,11 +92,26 @@ pub(crate) enum ApprovalRequirement { /// No approval required for this tool call Skip, /// Approval required for this tool call - NeedsApproval { reason: Option }, + NeedsApproval { + reason: Option, + allow_prefix: Option>, + }, /// Execution forbidden for this tool call Forbidden { reason: String }, } +impl ApprovalRequirement { + pub fn allow_prefix(&self) -> Option<&Vec> { + match self { + Self::NeedsApproval { + allow_prefix: Some(prefix), + .. + } => Some(prefix), + _ => None, + } + } +} + /// - Never, OnFailure: do not ask /// - OnRequest: ask unless sandbox policy is DangerFullAccess /// - UnlessTrusted: always ask @@ -111,7 +126,10 @@ pub(crate) fn default_approval_requirement( }; if needs_approval { - ApprovalRequirement::NeedsApproval { reason: None } + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } } else { ApprovalRequirement::Skip } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 93340bb2d4..527c38f23d 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -445,6 +445,7 @@ impl UnifiedExecSessionManager { ) -> Result { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); + let exec_policy = context.session.current_exec_policy().await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, @@ -452,7 +453,7 @@ impl UnifiedExecSessionManager { with_escalated_permissions, justification, create_approval_requirement_for_command( - &context.turn.exec_policy, + exec_policy.as_ref(), command, context.turn.approval_policy, &context.turn.sandbox_policy, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a106d2eae0..a09594ca8e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1524,6 +1524,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { .submit(Op::ExecApproval { id: "0".into(), decision: *decision, + allow_prefix: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 6339bfa71a..50ff1df986 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -93,6 +93,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, + allow_prefix: None, }) .await .expect("submit exec approval"); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 8665d3a8ea..045c42e941 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -843,6 +843,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, + allow_prefix: None, }) .await .unwrap(); @@ -901,6 +902,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::ApprovedForSession, + allow_prefix: None, }) .await .unwrap(); @@ -959,6 +961,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, + allow_prefix: None, }) .await .unwrap(); @@ -1017,6 +1020,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Denied, + allow_prefix: None, }) .await .unwrap(); @@ -1075,6 +1079,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::ApprovedForSession, + allow_prefix: None, }) .await .unwrap(); @@ -1134,6 +1139,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Denied, + allow_prefix: None, }) .await .unwrap(); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 8dccb51250..7cfad0bcb5 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,6 +180,7 @@ async fn run_codex_tool_session_inner( call_id, reason: _, risk, + allow_prefix: _, parsed_cmd, }) => { handle_exec_approval_request( diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 033523ac0d..e5b2e00f45 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -150,6 +150,7 @@ async fn on_exec_approval_response( .submit(Op::ExecApproval { id: event_id, decision: response.decision, + allow_prefix: None, }) .await { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 25f5e90e9e..e6c51a4f81 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -50,6 +50,10 @@ pub struct ExecApprovalRequestEvent { /// Optional model-provided risk assessment describing the blocked command. #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, + /// Prefix rule that can be added to the user's execpolicy to allow future runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "Array")] + pub allow_prefix: Option>, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f20e412831..338d5f03df 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -143,6 +143,9 @@ pub enum Op { id: String, /// The user's decision in response to the request. decision: ReviewDecision, + /// When set, persist this prefix to the execpolicy allow list. + #[serde(default, skip_serializing_if = "Option::is_none")] + allow_prefix: Option>, }, /// Approve a code patch diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ef709f0051..7ad8168153 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -41,6 +41,7 @@ pub(crate) enum ApprovalRequest { command: Vec, reason: Option, risk: Option, + allow_prefix: Option>, }, ApplyPatch { id: String, @@ -97,8 +98,8 @@ impl ApprovalOverlay { header: Box, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { - ApprovalVariant::Exec { .. } => ( - exec_options(), + ApprovalVariant::Exec { allow_prefix, .. } => ( + exec_options(allow_prefix.clone()), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -150,8 +151,8 @@ impl ApprovalOverlay { }; if let Some(variant) = self.current_variant.as_ref() { match (&variant, option.decision) { - (ApprovalVariant::Exec { id, command }, decision) => { - self.handle_exec_decision(id, command, decision); + (ApprovalVariant::Exec { id, command, .. }, decision) => { + self.handle_exec_decision(id, command, decision, option.allow_prefix.clone()); } (ApprovalVariant::ApplyPatch { id, .. }, decision) => { self.handle_patch_decision(id, decision); @@ -163,12 +164,19 @@ impl ApprovalOverlay { self.advance_queue(); } - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + fn handle_exec_decision( + &self, + id: &str, + command: &[String], + decision: ReviewDecision, + allow_prefix: Option>, + ) { let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), decision, + allow_prefix, })); } @@ -238,8 +246,8 @@ impl BottomPaneView for ApprovalOverlay { && let Some(variant) = self.current_variant.as_ref() { match &variant { - ApprovalVariant::Exec { id, command } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); + ApprovalVariant::Exec { id, command, .. } => { + self.handle_exec_decision(id, command, ReviewDecision::Abort, None); } ApprovalVariant::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -291,6 +299,7 @@ impl From for ApprovalRequestState { command, reason, risk, + allow_prefix, } => { let reason = reason.filter(|item| !item.is_empty()); let has_reason = reason.is_some(); @@ -310,7 +319,11 @@ impl From for ApprovalRequestState { } header.extend(full_cmd_lines); Self { - variant: ApprovalVariant::Exec { id, command }, + variant: ApprovalVariant::Exec { + id, + command, + allow_prefix, + }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } } @@ -364,8 +377,14 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { #[derive(Clone)] enum ApprovalVariant { - Exec { id: String, command: Vec }, - ApplyPatch { id: String }, + Exec { + id: String, + command: Vec, + allow_prefix: Option>, + }, + ApplyPatch { + id: String, + }, } #[derive(Clone)] @@ -374,6 +393,7 @@ struct ApprovalOption { decision: ReviewDecision, display_shortcut: Option, additional_shortcuts: Vec, + allow_prefix: Option>, } impl ApprovalOption { @@ -384,27 +404,39 @@ impl ApprovalOption { } } -fn exec_options() -> Vec { +fn exec_options(allow_prefix: Option>) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), decision: ReviewDecision::Approved, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + allow_prefix: None, }, ApprovalOption { label: "Yes, and don't ask again for this command".to_string(), decision: ReviewDecision::ApprovedForSession, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - }, - ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ReviewDecision::Abort, - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + allow_prefix: None, }, ] + .into_iter() + .chain(allow_prefix.map(|prefix| ApprovalOption { + label: "Yes, and don't ask again for commands with this prefix".to_string(), + decision: ReviewDecision::ApprovedForSession, + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + allow_prefix: Some(prefix), + })) + .chain([ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ReviewDecision::Abort, + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + allow_prefix: None, + }]) + .collect() } fn patch_options() -> Vec { @@ -414,12 +446,14 @@ fn patch_options() -> Vec { decision: ReviewDecision::Approved, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + allow_prefix: None, }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ReviewDecision::Abort, display_shortcut: Some(key_hint::plain(KeyCode::Esc)), additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + allow_prefix: None, }, ] } @@ -437,6 +471,7 @@ mod tests { command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), risk: None, + allow_prefix: None, } } @@ -469,6 +504,41 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + #[test] + fn exec_prefix_option_emits_allow_prefix() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + risk: None, + allow_prefix: Some(vec!["echo".to_string()]), + }, + tx, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { + allow_prefix, + decision, + .. + }) = ev + { + assert_eq!(decision, ReviewDecision::ApprovedForSession); + assert_eq!(allow_prefix, Some(vec!["echo".to_string()])); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with allow prefix" + ); + } + #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -479,6 +549,7 @@ mod tests { command, reason: None, risk: None, + allow_prefix: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6738d7672d..0ddb9c03cb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -540,6 +540,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, risk: None, + allow_prefix: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a5728ab14f..2127f2fa16 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1012,6 +1012,7 @@ impl ChatWidget { command: ev.command, reason: ev.reason, risk: ev.risk, + allow_prefix: ev.allow_prefix, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1393e52886..ba520f349c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -587,6 +587,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -631,6 +632,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -681,6 +683,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1830,6 +1833,7 @@ fn approval_modal_exec_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1876,6 +1880,7 @@ fn approval_modal_exec_without_reason_snapshot() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2089,6 +2094,7 @@ fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { From 8b27a694403cc0046dd5eb9ea0c17e9bdbdec8d6 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 19:16:12 -0500 Subject: [PATCH 02/15] Add explicit prefix-approval decision and wire it through execpolicy/UI snapshots --- codex-rs/core/src/apply_patch.rs | 4 +++- codex-rs/core/src/codex.rs | 4 +++- codex-rs/core/src/tools/orchestrator.rs | 8 ++++++-- codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/bottom_pane/approval_overlay.rs | 4 ++-- ...tui__chatwidget__tests__approval_modal_exec.snap | 3 ++- ...idget__tests__approval_modal_exec_no_reason.snap | 3 ++- ...et__tests__status_widget_and_approval_modal.snap | 4 ++-- codex-rs/tui/src/history_cell.rs | 13 +++++++++++++ 9 files changed, 37 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index dffe94be61..b9b36a0d8a 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -70,7 +70,9 @@ pub(crate) async fn apply_patch( ) .await; match rx_approve.await.unwrap_or_default() { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, user_explicitly_approved_this_action: true, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f038030ce7..98331d499f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1587,7 +1587,9 @@ mod handlers { if let Some(prefix) = allow_prefix && matches!( decision, - ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedForSession ) && let Err(err) = sess.persist_command_allow_prefix(&prefix).await { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index e37e753018..387ecac96b 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -93,7 +93,9 @@ impl ToolOrchestrator { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedForSession => {} } already_approved = true; } @@ -173,7 +175,9 @@ impl ToolOrchestrator { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 338d5f03df..080a7f3298 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1538,6 +1538,10 @@ pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, + /// User has approved this command and wants to add the command prefix to + /// the execpolicy allow list so future matching commands are permitted. + ApprovedAllowPrefix, + /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the /// remainder of the session. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 7ad8168153..4c37278158 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -424,7 +424,7 @@ fn exec_options(allow_prefix: Option>) -> Vec { .into_iter() .chain(allow_prefix.map(|prefix| ApprovalOption { label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ReviewDecision::ApprovedForSession, + decision: ReviewDecision::ApprovedAllowPrefix, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], allow_prefix: Some(prefix), @@ -527,7 +527,7 @@ mod tests { .. }) = ev { - assert_eq!(decision, ReviewDecision::ApprovedForSession); + assert_eq!(decision, ReviewDecision::ApprovedAllowPrefix); assert_eq!(allow_prefix, Some(vec!["echo".to_string()])); saw_op = true; break; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index b84588e337..9a0cf18f7f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -11,6 +11,7 @@ expression: terminal.backend().vt100().screen().contents() › 1. Yes, proceed (y) 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 543d367d23..3571f5153f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -8,6 +8,7 @@ expression: terminal.backend().vt100().screen().contents() › 1. Yes, proceed (y) 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index f98c807878..990aa83680 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1548 expression: terminal.backend() --- " " @@ -14,6 +13,7 @@ expression: terminal.backend() " " "› 1. Yes, proceed (y) " " 2. Yes, and don't ask again for this command (a) " -" 3. No, and tell Codex what to do differently (esc) " +" 3. Yes, and don't ask again for commands with this prefix (p) " +" 4. No, and tell Codex what to do differently (esc) " " " " Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 394dc33919..d48967bf91 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -408,6 +408,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedAllowPrefix => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and added its prefix to your allow list".bold(), + ], + ) + } ApprovedForSession => { let snippet = Span::from(exec_snippet(&command)).dim(); ( From 4ad812082588a1fa64643f67c1a236c1f087c4c9 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 19:25:56 -0500 Subject: [PATCH 03/15] update doc --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index f47bf34643..1d52a0e941 100644 --- a/docs/config.md +++ b/docs/config.md @@ -610,7 +610,7 @@ metadata above): - `codex.tool_decision` - `tool_name` - `call_id` - - `decision` (`approved`, `approved_for_session`, `denied`, or `abort`) + - `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`) - `source` (`config` or `user`) - `codex.tool_result` - `tool_name` From 9f34ec7ccd2cfc7881348f151cd8898441fcf02d Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 19:56:48 -0500 Subject: [PATCH 04/15] mutating in memory policy instead of reloading --- codex-rs/core/src/codex.rs | 46 ++++++++++++---------- codex-rs/core/src/exec_policy.rs | 66 ++++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 98331d499f..dfe397f493 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -850,7 +850,7 @@ impl Session { &self, prefix: &[String], ) -> Result<(), ExecPolicyUpdateError> { - let (features, codex_home) = { + let (features, codex_home, current_policy) = { let state = self.state.lock().await; ( state.session_configuration.features.clone(), @@ -859,12 +859,20 @@ impl Session { .original_config_do_not_use .codex_home .clone(), + state.session_configuration.exec_policy.clone(), ) }; - let policy = - crate::exec_policy::append_allow_prefix_rule_and_reload(&features, &codex_home, prefix) - .await?; + if !features.enabled(Feature::ExecPolicy) { + error!("attempted to append execpolicy rule while execpolicy feature is disabled"); + return Err(ExecPolicyUpdateError::FeatureDisabled); + } + + let policy = crate::exec_policy::append_allow_prefix_rule_and_update( + &codex_home, + current_policy, + prefix, + )?; let mut state = self.state.lock().await; state.session_configuration.exec_policy = policy; @@ -1584,23 +1592,19 @@ mod handlers { decision: ReviewDecision, allow_prefix: Option>, ) { - if let Some(prefix) = allow_prefix - && matches!( - decision, - ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix - | ReviewDecision::ApprovedForSession - ) - && let Err(err) = sess.persist_command_allow_prefix(&prefix).await - { - let message = format!("Failed to update execpolicy allow list: {err}"); - tracing::warn!("{message}"); - let warning = EventMsg::Warning(WarningEvent { message }); - sess.send_event_raw(Event { - id: id.clone(), - msg: warning, - }) - .await; + if let Some(prefix) = allow_prefix { + if matches!(decision, ReviewDecision::ApprovedAllowPrefix) + && let Err(err) = sess.persist_command_allow_prefix(&prefix).await + { + let message = format!("Failed to update execpolicy allow list: {err}"); + tracing::warn!("{message}"); + let warning = EventMsg::Warning(WarningEvent { message }); + sess.send_event_raw(Event { + id: id.clone(), + msg: warning, + }) + .await; + } } match decision { ReviewDecision::Abort => { diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 78114ab27f..865398fac6 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use crate::command_safety::is_dangerous_command::requires_initial_appoval; use codex_execpolicy::AmendError; use codex_execpolicy::Decision; +use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; @@ -53,8 +54,14 @@ pub enum ExecPolicyUpdateError { #[error("failed to update execpolicy file {path}: {source}")] AppendRule { path: PathBuf, source: AmendError }, - #[error("failed to reload execpolicy after updating policy: {0}")] - Reload(#[from] ExecPolicyError), + #[error("failed to update in-memory execpolicy: {source}")] + AddRule { + #[from] + source: ExecPolicyRuleError, + }, + + #[error("cannot append execpolicy rule because execpolicy feature is disabled")] + FeatureDisabled, } pub(crate) async fn exec_policy_for( @@ -100,9 +107,9 @@ pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) } -pub(crate) async fn append_allow_prefix_rule_and_reload( - features: &Features, +pub(crate) fn append_allow_prefix_rule_and_update( codex_home: &Path, + mut current_policy: Arc, prefix: &[String], ) -> Result, ExecPolicyUpdateError> { let policy_path = default_policy_path(codex_home); @@ -113,9 +120,9 @@ pub(crate) async fn append_allow_prefix_rule_and_reload( } })?; - exec_policy_for(features, codex_home) - .await - .map_err(ExecPolicyUpdateError::Reload) + Arc::make_mut(&mut current_policy).add_prefix_rule(prefix, Decision::Allow)?; + + Ok(current_policy) } fn requirement_from_decision( @@ -241,6 +248,7 @@ mod tests { use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; + use std::sync::Arc; use tempfile::tempdir; #[tokio::test] @@ -421,6 +429,50 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } + #[test] + fn append_allow_prefix_rule_updates_policy_and_file() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(Policy::empty()); + let prefix = vec!["echo".to_string(), "hello".to_string()]; + + let updated_policy = + append_allow_prefix_rule_and_update(codex_home.path(), current_policy, &prefix) + .expect("update policy"); + + let evaluation = + updated_policy.check(&["echo".to_string(), "hello".to_string(), "world".to_string()]); + assert!(matches!( + evaluation, + Evaluation::Match { + decision: Decision::Allow, + .. + } + )); + + let contents = fs::read_to_string(default_policy_path(codex_home.path())) + .expect("policy file should have been created"); + assert_eq!( + contents, + "prefix_rule(pattern=[\"echo\", \"hello\"], decision=\"allow\")\n" + ); + } + + #[test] + fn append_allow_prefix_rule_rejects_empty_prefix() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(Policy::empty()); + + let result = append_allow_prefix_rule_and_update(codex_home.path(), current_policy, &[]); + + assert!(matches!( + result, + Err(ExecPolicyUpdateError::AppendRule { + source: AmendError::EmptyPrefix, + .. + }) + )); + } + #[test] fn allow_prefix_is_present_for_single_command_without_policy_match() { let command = vec!["python".to_string()]; From 5fccdabfa2e91342318288f661e192d4ecbff908 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 20:09:30 -0500 Subject: [PATCH 05/15] using RW locks --- codex-rs/core/src/codex.rs | 20 ++++--- codex-rs/core/src/exec_policy.rs | 54 +++++++++++-------- codex-rs/core/src/tools/handlers/shell.rs | 3 +- .../core/src/unified_exec/session_manager.rs | 3 +- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dfe397f493..18cef5ea07 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -286,7 +286,7 @@ pub(crate) struct TurnContext { pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, - pub(crate) exec_policy: Arc, + pub(crate) exec_policy: Arc>, pub(crate) truncation_policy: TruncationPolicy, } @@ -344,7 +344,7 @@ pub(crate) struct SessionConfiguration { /// Set of feature flags for this session features: Features, /// Execpolicy policy, applied only when enabled by feature flag. - exec_policy: Arc, + exec_policy: Arc>, // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, @@ -868,19 +868,17 @@ impl Session { return Err(ExecPolicyUpdateError::FeatureDisabled); } - let policy = crate::exec_policy::append_allow_prefix_rule_and_update( + crate::exec_policy::append_allow_prefix_rule_and_update( &codex_home, - current_policy, + ¤t_policy, prefix, - )?; - - let mut state = self.state.lock().await; - state.session_configuration.exec_policy = policy; + ) + .await?; Ok(()) } - pub(crate) async fn current_exec_policy(&self) -> Arc { + pub(crate) async fn current_exec_policy(&self) -> Arc> { let state = self.state.lock().await; state.session_configuration.exec_policy.clone() } @@ -2684,7 +2682,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), features: Features::default(), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; @@ -2762,7 +2760,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), features: Features::default(), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 865398fac6..4999f54073 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; use tokio::fs; +use tokio::sync::RwLock; use crate::bash::parse_shell_lc_plain_commands; use crate::features::Feature; @@ -67,9 +68,9 @@ pub enum ExecPolicyUpdateError { pub(crate) async fn exec_policy_for( features: &Features, codex_home: &Path, -) -> Result, ExecPolicyError> { +) -> Result>, ExecPolicyError> { if !features.enabled(Feature::ExecPolicy) { - return Ok(Arc::new(Policy::empty())); + return Ok(Arc::new(RwLock::new(Policy::empty()))); } let policy_dir = codex_home.join(POLICY_DIR_NAME); @@ -93,7 +94,7 @@ pub(crate) async fn exec_policy_for( })?; } - let policy = Arc::new(parser.build()); + let policy = Arc::new(RwLock::new(parser.build())); tracing::debug!( "loaded execpolicy from {} files in {}", policy_paths.len(), @@ -107,11 +108,11 @@ pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) } -pub(crate) fn append_allow_prefix_rule_and_update( +pub(crate) async fn append_allow_prefix_rule_and_update( codex_home: &Path, - mut current_policy: Arc, + current_policy: &Arc>, prefix: &[String], -) -> Result, ExecPolicyUpdateError> { +) -> Result<(), ExecPolicyUpdateError> { let policy_path = default_policy_path(codex_home); append_allow_prefix_rule(&policy_path, prefix).map_err(|source| { ExecPolicyUpdateError::AppendRule { @@ -120,9 +121,12 @@ pub(crate) fn append_allow_prefix_rule_and_update( } })?; - Arc::make_mut(&mut current_policy).add_prefix_rule(prefix, Decision::Allow)?; + current_policy + .write() + .await + .add_prefix_rule(prefix, Decision::Allow)?; - Ok(current_policy) + Ok(()) } fn requirement_from_decision( @@ -263,7 +267,7 @@ mod tests { let commands = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(commands.iter()), + policy.read().await.check_multiple(commands.iter()), Evaluation::NoMatch )); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); @@ -297,7 +301,7 @@ mod tests { .expect("policy result"); let command = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::Match { .. } )); } @@ -316,7 +320,7 @@ mod tests { .expect("policy result"); let command = [vec!["ls".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::NoMatch )); } @@ -429,18 +433,21 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } - #[test] - fn append_allow_prefix_rule_updates_policy_and_file() { + #[tokio::test] + async fn append_allow_prefix_rule_updates_policy_and_file() { let codex_home = tempdir().expect("create temp dir"); - let current_policy = Arc::new(Policy::empty()); + let current_policy = Arc::new(RwLock::new(Policy::empty())); let prefix = vec!["echo".to_string(), "hello".to_string()]; - let updated_policy = - append_allow_prefix_rule_and_update(codex_home.path(), current_policy, &prefix) - .expect("update policy"); + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &prefix) + .await + .expect("update policy"); - let evaluation = - updated_policy.check(&["echo".to_string(), "hello".to_string(), "world".to_string()]); + let evaluation = current_policy.read().await.check(&[ + "echo".to_string(), + "hello".to_string(), + "world".to_string(), + ]); assert!(matches!( evaluation, Evaluation::Match { @@ -457,12 +464,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } - #[test] - fn append_allow_prefix_rule_rejects_empty_prefix() { + #[tokio::test] + async fn append_allow_prefix_rule_rejects_empty_prefix() { let codex_home = tempdir().expect("create temp dir"); - let current_policy = Arc::new(Policy::empty()); + let current_policy = Arc::new(RwLock::new(Policy::empty())); - let result = append_allow_prefix_rule_and_update(codex_home.path(), current_policy, &[]); + let result = + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &[]).await; assert!(matches!( result, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c86ef71957..f1aa4e9f41 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -298,6 +298,7 @@ impl ShellHandler { emitter.begin(event_ctx).await; let exec_policy = session.current_exec_policy().await; + let exec_policy = exec_policy.read().await; let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), @@ -306,7 +307,7 @@ impl ShellHandler { with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), approval_requirement: create_approval_requirement_for_command( - exec_policy.as_ref(), + &exec_policy, &exec_params.command, turn.approval_policy, &turn.sandbox_policy, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 527c38f23d..4828a02177 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -446,6 +446,7 @@ impl UnifiedExecSessionManager { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let exec_policy = context.session.current_exec_policy().await; + let exec_policy = exec_policy.read().await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, @@ -453,7 +454,7 @@ impl UnifiedExecSessionManager { with_escalated_permissions, justification, create_approval_requirement_for_command( - exec_policy.as_ref(), + &exec_policy, command, context.turn.approval_policy, &context.turn.sandbox_policy, From 7fca57212e749c7c70118b3410e46f581a044e24 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 20:11:03 -0500 Subject: [PATCH 06/15] clippy --- codex-rs/core/src/codex.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 18cef5ea07..af6d5b3c93 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1590,19 +1590,18 @@ mod handlers { decision: ReviewDecision, allow_prefix: Option>, ) { - if let Some(prefix) = allow_prefix { - if matches!(decision, ReviewDecision::ApprovedAllowPrefix) - && let Err(err) = sess.persist_command_allow_prefix(&prefix).await - { - let message = format!("Failed to update execpolicy allow list: {err}"); - tracing::warn!("{message}"); - let warning = EventMsg::Warning(WarningEvent { message }); - sess.send_event_raw(Event { - id: id.clone(), - msg: warning, - }) - .await; - } + if let Some(prefix) = allow_prefix + && matches!(decision, ReviewDecision::ApprovedAllowPrefix) + && let Err(err) = sess.persist_command_allow_prefix(&prefix).await + { + let message = format!("Failed to update execpolicy allow list: {err}"); + tracing::warn!("{message}"); + let warning = EventMsg::Warning(WarningEvent { message }); + sess.send_event_raw(Event { + id: id.clone(), + msg: warning, + }) + .await; } match decision { ReviewDecision::Abort => { From d012bf51e43cc782cf8df9d60cbb2e7dd41c0308 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 20:25:25 -0500 Subject: [PATCH 07/15] refactor: adding allow_prefix into ApprovedAllowPrefix --- .../app-server/src/bespoke_event_handling.rs | 2 - codex-rs/core/src/apply_patch.rs | 2 +- codex-rs/core/src/codex.rs | 20 +++------ codex-rs/core/src/codex_delegate.rs | 8 +--- codex-rs/core/src/tools/orchestrator.rs | 8 ++-- codex-rs/core/tests/suite/approvals.rs | 5 +-- codex-rs/core/tests/suite/codex_delegate.rs | 1 - codex-rs/core/tests/suite/otel.rs | 6 --- codex-rs/mcp-server/src/exec_approval.rs | 1 - codex-rs/protocol/src/protocol.rs | 9 +--- .../tui/src/bottom_pane/approval_overlay.rs | 45 +++++++------------ codex-rs/tui/src/history_cell.rs | 2 +- 12 files changed, 33 insertions(+), 76 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ff6ebc563d..6179b76f79 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -611,7 +611,6 @@ async fn on_exec_approval_response( .submit(Op::ExecApproval { id: event_id, decision: response.decision, - allow_prefix: None, }) .await { @@ -785,7 +784,6 @@ async fn on_command_execution_request_approval_response( .submit(Op::ExecApproval { id: event_id, decision, - allow_prefix: None, }) .await { diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index b9b36a0d8a..5cf0ecee70 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -71,7 +71,7 @@ pub(crate) async fn apply_patch( .await; match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedAllowPrefix { .. } | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index af6d5b3c93..c784eddacc 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1424,12 +1424,8 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op, &mut previous_context) .await; } - Op::ExecApproval { - id, - decision, - allow_prefix, - } => { - handlers::exec_approval(&sess, id, decision, allow_prefix).await; + Op::ExecApproval { id, decision } => { + handlers::exec_approval(&sess, id, decision).await; } Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; @@ -1584,15 +1580,9 @@ mod handlers { *previous_context = Some(turn_context); } - pub async fn exec_approval( - sess: &Arc, - id: String, - decision: ReviewDecision, - allow_prefix: Option>, - ) { - if let Some(prefix) = allow_prefix - && matches!(decision, ReviewDecision::ApprovedAllowPrefix) - && let Err(err) = sess.persist_command_allow_prefix(&prefix).await + pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { + if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision + && let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await { let message = format!("Failed to update execpolicy allow list: {err}"); tracing::warn!("{message}"); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 110848b157..fff47eb20f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -245,13 +245,7 @@ async fn handle_exec_approval( ) .await; - let _ = codex - .submit(Op::ExecApproval { - id, - decision, - allow_prefix: None, - }) - .await; + let _ = codex.submit(Op::ExecApproval { id, decision }).await; } /// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying. diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 387ecac96b..fdf02172bb 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -87,14 +87,14 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone()); + otel.tool_decision(otel_tn, otel_ci, decision.clone(), otel_user.clone()); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedAllowPrefix { .. } | ReviewDecision::ApprovedForSession => {} } already_approved = true; @@ -169,14 +169,14 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user); + otel.tool_decision(otel_tn, otel_ci, decision.clone(), otel_user); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix + | ReviewDecision::ApprovedAllowPrefix { .. } | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a09594ca8e..ec8a00c7ec 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1523,8 +1523,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::ExecApproval { id: "0".into(), - decision: *decision, - allow_prefix: None, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; @@ -1545,7 +1544,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::PatchApproval { id: "0".into(), - decision: *decision, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 50ff1df986..6339bfa71a 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -93,7 +93,6 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, - allow_prefix: None, }) .await .expect("submit exec approval"); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 045c42e941..8665d3a8ea 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -843,7 +843,6 @@ async fn handle_container_exec_user_approved_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, - allow_prefix: None, }) .await .unwrap(); @@ -902,7 +901,6 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::ApprovedForSession, - allow_prefix: None, }) .await .unwrap(); @@ -961,7 +959,6 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Approved, - allow_prefix: None, }) .await .unwrap(); @@ -1020,7 +1017,6 @@ async fn handle_container_exec_user_denies_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Denied, - allow_prefix: None, }) .await .unwrap(); @@ -1079,7 +1075,6 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::ApprovedForSession, - allow_prefix: None, }) .await .unwrap(); @@ -1139,7 +1134,6 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { .submit(Op::ExecApproval { id: "0".into(), decision: ReviewDecision::Denied, - allow_prefix: None, }) .await .unwrap(); diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index e5b2e00f45..033523ac0d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -150,7 +150,6 @@ async fn on_exec_approval_response( .submit(Op::ExecApproval { id: event_id, decision: response.decision, - allow_prefix: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 080a7f3298..ca859e5c1a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -143,9 +143,6 @@ pub enum Op { id: String, /// The user's decision in response to the request. decision: ReviewDecision, - /// When set, persist this prefix to the execpolicy allow list. - #[serde(default, skip_serializing_if = "Option::is_none")] - allow_prefix: Option>, }, /// Approve a code patch @@ -1530,9 +1527,7 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive( - Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, -)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. @@ -1540,7 +1535,7 @@ pub enum ReviewDecision { /// User has approved this command and wants to add the command prefix to /// the execpolicy allow list so future matching commands are permitted. - ApprovedAllowPrefix, + ApprovedAllowPrefix { allow_prefix: Vec }, /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 4c37278158..220ab534f6 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -150,9 +150,9 @@ impl ApprovalOverlay { return; }; if let Some(variant) = self.current_variant.as_ref() { - match (&variant, option.decision) { + match (&variant, option.decision.clone()) { (ApprovalVariant::Exec { id, command, .. }, decision) => { - self.handle_exec_decision(id, command, decision, option.allow_prefix.clone()); + self.handle_exec_decision(id, command, decision); } (ApprovalVariant::ApplyPatch { id, .. }, decision) => { self.handle_patch_decision(id, decision); @@ -164,19 +164,14 @@ impl ApprovalOverlay { self.advance_queue(); } - fn handle_exec_decision( - &self, - id: &str, - command: &[String], - decision: ReviewDecision, - allow_prefix: Option>, - ) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); + fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + let decision_for_history = decision.clone(); + let cell = + history_cell::new_approval_decision_cell(command.to_vec(), decision_for_history); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), decision, - allow_prefix, })); } @@ -247,7 +242,7 @@ impl BottomPaneView for ApprovalOverlay { { match &variant { ApprovalVariant::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort, None); + self.handle_exec_decision(id, command, ReviewDecision::Abort); } ApprovalVariant::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -393,7 +388,6 @@ struct ApprovalOption { decision: ReviewDecision, display_shortcut: Option, additional_shortcuts: Vec, - allow_prefix: Option>, } impl ApprovalOption { @@ -411,30 +405,28 @@ fn exec_options(allow_prefix: Option>) -> Vec { decision: ReviewDecision::Approved, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - allow_prefix: None, }, ApprovalOption { label: "Yes, and don't ask again for this command".to_string(), decision: ReviewDecision::ApprovedForSession, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - allow_prefix: None, }, ] .into_iter() .chain(allow_prefix.map(|prefix| ApprovalOption { label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ReviewDecision::ApprovedAllowPrefix, + decision: ReviewDecision::ApprovedAllowPrefix { + allow_prefix: prefix, + }, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], - allow_prefix: Some(prefix), })) .chain([ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ReviewDecision::Abort, display_shortcut: Some(key_hint::plain(KeyCode::Esc)), additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - allow_prefix: None, }]) .collect() } @@ -446,14 +438,12 @@ fn patch_options() -> Vec { decision: ReviewDecision::Approved, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - allow_prefix: None, }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ReviewDecision::Abort, display_shortcut: Some(key_hint::plain(KeyCode::Esc)), additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - allow_prefix: None, }, ] } @@ -521,14 +511,13 @@ mod tests { view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); let mut saw_op = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { - allow_prefix, - decision, - .. - }) = ev - { - assert_eq!(decision, ReviewDecision::ApprovedAllowPrefix); - assert_eq!(allow_prefix, Some(vec!["echo".to_string()])); + if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + match decision { + ReviewDecision::ApprovedAllowPrefix { allow_prefix } => { + assert_eq!(allow_prefix, vec!["echo".to_string()]); + } + other => panic!("unexpected decision: {other:?}"), + } saw_op = true; break; } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d48967bf91..5bbe0f4135 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -408,7 +408,7 @@ pub fn new_approval_decision_cell( ], ) } - ApprovedAllowPrefix => { + ApprovedAllowPrefix { .. } => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✔ ".green(), From 80c63d31f946354c9d3d87ba618edd5582a9fd8b Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Thu, 20 Nov 2025 20:26:18 -0500 Subject: [PATCH 08/15] fmt --- codex-rs/tui/src/bottom_pane/approval_overlay.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 220ab534f6..668354b119 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -166,8 +166,7 @@ impl ApprovalOverlay { fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { let decision_for_history = decision.clone(); - let cell = - history_cell::new_approval_decision_cell(command.to_vec(), decision_for_history); + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision_for_history); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), From 2ce77718b0b47c8efb2d12515e3763ae4a1efd0d Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 13:00:07 -0500 Subject: [PATCH 09/15] fix test --- codex-rs/core/src/exec_policy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 4999f54073..d20202427a 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -460,7 +460,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") .expect("policy file should have been created"); assert_eq!( contents, - "prefix_rule(pattern=[\"echo\", \"hello\"], decision=\"allow\")\n" + "prefix_rule(pattern=[\"echo\",\"hello\"], decision=\"allow\")\n" ); } From 66372004db26531931ba0ff98fc028e312eeacf1 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 15:26:22 -0500 Subject: [PATCH 10/15] do not send allow_prefix if execpolicy is disabled --- codex-rs/core/src/codex.rs | 9 ++++ codex-rs/core/src/exec_policy.rs | 42 +++++++++++++++++-- codex-rs/core/src/tools/handlers/shell.rs | 2 + .../core/src/unified_exec/session_manager.rs | 2 + 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c784eddacc..63ddb878a7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1070,6 +1070,15 @@ impl Session { .enabled(feature) } + pub(crate) async fn features(&self) -> Features { + self.state + .lock() + .await + .session_configuration + .features + .clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index d20202427a..821ae9f3b4 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -155,12 +155,13 @@ fn requirement_from_decision( /// Return an allow-prefix option when a single plain command needs approval without /// any matching policy rule. We only surface the prefix opt-in when execpolicy did /// not already drive the decision (NoMatch) and when the command is a single -/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix). +/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and +/// when execpolicy feature is enabled. fn allow_prefix_if_applicable( commands: &[Vec], - evaluation: &Evaluation, + features: &Features, ) -> Option> { - if matches!(evaluation, Evaluation::NoMatch) && commands.len() == 1 { + if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { return Some(commands[0].clone()); } @@ -171,6 +172,7 @@ pub(crate) fn create_approval_requirement_for_command( policy: &Policy, command: &[String], approval_policy: AskForApproval, + features: &Features, sandbox_policy: &SandboxPolicy, sandbox_permissions: SandboxPermissions, ) -> ApprovalRequirement { @@ -188,7 +190,7 @@ pub(crate) fn create_approval_requirement_for_command( ) { ApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: allow_prefix_if_applicable(&commands, &evaluation), + allow_prefix: allow_prefix_if_applicable(&commands, features), } } else { ApprovalRequirement::Skip @@ -346,6 +348,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &policy, &forbidden_script, AskForApproval::OnRequest, + &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -372,6 +375,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &policy, &command, AskForApproval::OnRequest, + &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -399,6 +403,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &policy, &command, AskForApproval::Never, + &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -420,6 +425,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &empty_policy, &command, AskForApproval::UnlessTrusted, + &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); @@ -490,6 +496,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &empty_policy, &command, AskForApproval::UnlessTrusted, + &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); @@ -503,6 +510,31 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } + #[test] + fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() { + let command = vec!["python".to_string()]; + + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + + let requirement = create_approval_requirement_for_command( + &Policy::empty(), + &command, + AskForApproval::UnlessTrusted, + &features, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ); + + assert_eq!( + requirement, + ApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } + ); + } + #[test] fn allow_prefix_is_omitted_when_policy_prompts() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; @@ -517,6 +549,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &policy, &command, AskForApproval::OnRequest, + &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -541,6 +574,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") &Policy::empty(), &command, AskForApproval::UnlessTrusted, + &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index f1aa4e9f41..58a3dfbedb 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -297,6 +297,7 @@ impl ShellHandler { let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); emitter.begin(event_ctx).await; + let features = session.features().await; let exec_policy = session.current_exec_policy().await; let exec_policy = exec_policy.read().await; let req = ShellRequest { @@ -310,6 +311,7 @@ impl ShellHandler { &exec_policy, &exec_params.command, turn.approval_policy, + &features, &turn.sandbox_policy, SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), ), diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 4828a02177..41e9e28fe5 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -443,6 +443,7 @@ impl UnifiedExecSessionManager { justification: Option, context: &UnifiedExecContext, ) -> Result { + let features = context.session.features().await; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let exec_policy = context.session.current_exec_policy().await; @@ -457,6 +458,7 @@ impl UnifiedExecSessionManager { &exec_policy, command, context.turn.approval_policy, + &features, &context.turn.sandbox_policy, SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), ), From a634a613d4f96f732bd8f4f9fa29a083d7d41279 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 15:30:46 -0500 Subject: [PATCH 11/15] moving args around --- codex-rs/core/src/exec_policy.rs | 18 +++++++++--------- codex-rs/core/src/tools/handlers/shell.rs | 2 +- .../core/src/unified_exec/session_manager.rs | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 821ae9f3b4..955c75da40 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -170,9 +170,9 @@ fn allow_prefix_if_applicable( pub(crate) fn create_approval_requirement_for_command( policy: &Policy, + features: &Features, command: &[String], approval_policy: AskForApproval, - features: &Features, sandbox_policy: &SandboxPolicy, sandbox_permissions: SandboxPermissions, ) -> ApprovalRequirement { @@ -346,9 +346,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let requirement = create_approval_requirement_for_command( &policy, + &Features::with_defaults(), &forbidden_script, AskForApproval::OnRequest, - &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -373,9 +373,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let requirement = create_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::OnRequest, - &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -401,9 +401,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let requirement = create_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::Never, - &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -423,9 +423,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let empty_policy = Policy::empty(); let requirement = create_approval_requirement_for_command( &empty_policy, + &Features::with_defaults(), &command, AskForApproval::UnlessTrusted, - &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); @@ -494,9 +494,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let empty_policy = Policy::empty(); let requirement = create_approval_requirement_for_command( &empty_policy, + &Features::with_defaults(), &command, AskForApproval::UnlessTrusted, - &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); @@ -519,9 +519,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let requirement = create_approval_requirement_for_command( &Policy::empty(), + &features, &command, AskForApproval::UnlessTrusted, - &features, &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); @@ -547,9 +547,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") let requirement = create_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::OnRequest, - &Features::with_defaults(), &SandboxPolicy::DangerFullAccess, SandboxPermissions::UseDefault, ); @@ -572,9 +572,9 @@ prefix_rule(pattern=["rm"], decision="forbidden") ]; let requirement = create_approval_requirement_for_command( &Policy::empty(), + &Features::with_defaults(), &command, AskForApproval::UnlessTrusted, - &Features::with_defaults(), &SandboxPolicy::ReadOnly, SandboxPermissions::UseDefault, ); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 58a3dfbedb..f9377f64cd 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -309,9 +309,9 @@ impl ShellHandler { justification: exec_params.justification.clone(), approval_requirement: create_approval_requirement_for_command( &exec_policy, + &features, &exec_params.command, turn.approval_policy, - &features, &turn.sandbox_policy, SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), ), diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 41e9e28fe5..d51ecf6162 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -456,9 +456,9 @@ impl UnifiedExecSessionManager { justification, create_approval_requirement_for_command( &exec_policy, + &features, command, context.turn.approval_policy, - &features, &context.turn.sandbox_policy, SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), ), From 0cc1ec98f9aef65c884b5f5084754ea7bb57b573 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 15:44:48 -0500 Subject: [PATCH 12/15] cleanup exec_policy getters --- codex-rs/core/src/codex.rs | 10 +++++++--- codex-rs/core/src/tools/handlers/shell.rs | 1 - codex-rs/core/src/unified_exec/session_manager.rs | 1 - 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 63ddb878a7..2e948fc04b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -47,6 +47,7 @@ use mcp_types::ReadResourceResult; use serde_json; use serde_json::Value; use tokio::sync::Mutex; +use tokio::sync::OwnedRwLockReadGuard; use tokio::sync::RwLock; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; @@ -878,9 +879,12 @@ impl Session { Ok(()) } - pub(crate) async fn current_exec_policy(&self) -> Arc> { - let state = self.state.lock().await; - state.session_configuration.exec_policy.clone() + pub(crate) async fn current_exec_policy(&self) -> OwnedRwLockReadGuard { + let exec_policy = { + let state = self.state.lock().await; + state.session_configuration.exec_policy.clone() + }; + exec_policy.read_owned().await } /// Emit an exec approval request event and await the user's decision. diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index f9377f64cd..80d5871a1d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -299,7 +299,6 @@ impl ShellHandler { let features = session.features().await; let exec_policy = session.current_exec_policy().await; - let exec_policy = exec_policy.read().await; let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index d51ecf6162..599dcf1df1 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -447,7 +447,6 @@ impl UnifiedExecSessionManager { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let exec_policy = context.session.current_exec_policy().await; - let exec_policy = exec_policy.read().await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, From c26624591f938cb563f895fc6caa84ed09ec7b91 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 15:59:00 -0500 Subject: [PATCH 13/15] undo diff --- codex-rs/tui/src/bottom_pane/approval_overlay.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 668354b119..f1bdb807ee 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -165,8 +165,7 @@ impl ApprovalOverlay { } fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let decision_for_history = decision.clone(); - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision_for_history); + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), From 28323a955cf5b291f617ad3a96d6a9e62ab6e297 Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 17:01:16 -0500 Subject: [PATCH 14/15] fixing rw lock bug causing tui to hang --- codex-rs/core/src/tools/handlers/shell.rs | 21 +++++++++++-------- .../core/src/unified_exec/session_manager.rs | 19 ++++++++++------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 80d5871a1d..ee4e91a5fd 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -298,7 +298,17 @@ impl ShellHandler { emitter.begin(event_ctx).await; let features = session.features().await; - let exec_policy = session.current_exec_policy().await; + let approval_requirement = { + let exec_policy = session.current_exec_policy().await; + create_approval_requirement_for_command( + &exec_policy, + &features, + &exec_params.command, + turn.approval_policy, + &turn.sandbox_policy, + SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), + ) + }; let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), @@ -306,14 +316,7 @@ impl ShellHandler { env: exec_params.env.clone(), with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), - approval_requirement: create_approval_requirement_for_command( - &exec_policy, - &features, - &exec_params.command, - turn.approval_policy, - &turn.sandbox_policy, - SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), - ), + approval_requirement, }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ShellRuntime::new(); diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 599dcf1df1..c1bd543f3e 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -446,13 +446,8 @@ impl UnifiedExecSessionManager { let features = context.session.features().await; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); - let exec_policy = context.session.current_exec_policy().await; - let req = UnifiedExecToolRequest::new( - command.to_vec(), - cwd, - create_env(&context.turn.shell_environment_policy), - with_escalated_permissions, - justification, + let approval_requirement = { + let exec_policy = context.session.current_exec_policy().await; create_approval_requirement_for_command( &exec_policy, &features, @@ -460,7 +455,15 @@ impl UnifiedExecSessionManager { context.turn.approval_policy, &context.turn.sandbox_policy, SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), - ), + ) + }; + let req = UnifiedExecToolRequest::new( + command.to_vec(), + cwd, + create_env(&context.turn.shell_environment_policy), + with_escalated_permissions, + justification, + approval_requirement, ); let tool_ctx = ToolCtx { session: context.session.as_ref(), From 5128fb1ab3b39d648425ecfff5a8987549cae5be Mon Sep 17 00:00:00 2001 From: kevin zhao Date: Fri, 21 Nov 2025 17:02:27 -0500 Subject: [PATCH 15/15] updating phrasing --- codex-rs/tui/src/bottom_pane/approval_overlay.rs | 2 +- .../codex_tui__chatwidget__tests__approval_modal_exec.snap | 2 +- ...i__chatwidget__tests__approval_modal_exec_no_reason.snap | 2 +- ...ex_tui__chatwidget__tests__exec_approval_modal_exec.snap | 6 +++--- ...chatwidget__tests__status_widget_and_approval_modal.snap | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index f1bdb807ee..e0d988000f 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -405,7 +405,7 @@ fn exec_options(allow_prefix: Option>) -> Vec { additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, ApprovalOption { - label: "Yes, and don't ask again for this command".to_string(), + label: "Yes, and don't ask again this session".to_string(), decision: ReviewDecision::ApprovedForSession, display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 9a0cf18f7f..eaf0fed3a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -10,7 +10,7 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) + 2. Yes, and don't ask again this session (a) 3. Yes, and don't ask again for commands with this prefix (p) 4. No, and tell Codex what to do differently (esc) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 3571f5153f..ce2277ce62 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -7,7 +7,7 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) + 2. Yes, and don't ask again this session (a) 3. Yes, and don't ask again for commands with this prefix (p) 4. No, and tell Codex what to do differently (esc) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f986a927e5..ff70d7d492 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -15,7 +15,7 @@ Buffer { " $ echo hello world ", " ", "› 1. Yes, proceed (y) ", - " 2. Yes, and don't ask again for this command (a) ", + " 2. Yes, and don't ask again this session (a) ", " 3. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", @@ -30,8 +30,8 @@ Buffer { x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 44, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 990aa83680..5ce388c87b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -12,7 +12,7 @@ expression: terminal.backend() " $ echo 'hello world' " " " "› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for this command (a) " +" 2. Yes, and don't ask again this session (a) " " 3. Yes, and don't ask again for commands with this prefix (p) " " 4. No, and tell Codex what to do differently (esc) " " "