From bf38def44e5b14a1c4b6c497c220a7b5128f8388 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 27 Apr 2026 22:06:47 -0700 Subject: [PATCH] permissions: make SessionConfigured profile-only (#19774) ## Why `SessionConfiguredEvent` is the internal event that tells clients what permissions are active for a session. Emitting both `sandbox_policy` and `permission_profile` leaves two possible authorities and forces every consumer to decide which one to honor. At this point in the migration, the profile is expressive enough to represent managed, disabled, and external sandbox enforcement, so the internal event can be profile-only. The wire compatibility concern is older serialized events or rollout data that only contain `sandbox_policy`; those still need to deserialize. ## What Changed - Removes `sandbox_policy` from `SessionConfiguredEvent` and makes `permission_profile` required. - Adds custom deserialization so old payloads with only `sandbox_policy` are upgraded to a cwd-anchored `PermissionProfile`. - Updates core event emission and TUI session handling to sync permissions from the profile directly. - Updates app-server response construction to derive the legacy `sandbox` response field from the active thread snapshot instead of from `SessionConfiguredEvent`. - Updates yolo-mode display logic to treat both `PermissionProfile::Disabled` and managed unrestricted filesystem plus enabled network as full-access, while still preserving the distinction between no sandbox and external sandboxing. ## Verification - `cargo test -p codex-protocol session_configured_event --lib` - `cargo test -p codex-protocol serialize_event --lib` - `cargo test -p codex-exec session_configured --lib` - `cargo test -p codex-app-server thread_response_permission_profile_preserves_enforcement --lib` - `cargo test -p codex-core session_configured_reports_permission_profile_for_external_sandbox --lib` - `cargo test -p codex-tui session_configured --lib` - `cargo test -p codex-tui yolo_mode_includes_managed_full_access_profiles --lib` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19774). * #19900 * #19899 * #19776 * #19775 * __->__ #19774 --- .../app-server/src/codex_message_processor.rs | 39 +++++-- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 7 +- codex-rs/exec/src/lib.rs | 5 +- .../tests/event_processor_with_json_output.rs | 5 +- codex-rs/mcp-server/src/outgoing_message.rs | 19 +--- codex-rs/protocol/src/protocol.rs | 106 +++++++++++++++--- codex-rs/tui/src/app/config_persistence.rs | 4 +- codex-rs/tui/src/app/tests.rs | 18 +-- codex-rs/tui/src/chatwidget.rs | 35 +----- .../chatwidget/tests/composer_submission.rs | 29 ++--- .../tui/src/chatwidget/tests/exec_flow.rs | 3 +- .../src/chatwidget/tests/history_replay.rs | 30 ++--- .../tui/src/chatwidget/tests/permissions.rs | 20 ++-- .../tui/src/chatwidget/tests/plan_mode.rs | 6 +- .../src/chatwidget/tests/status_and_layout.rs | 3 +- codex-rs/tui/src/history_cell.rs | 55 +++++++-- 17 files changed, 227 insertions(+), 161 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6a1e6fbae..6e8170475 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4235,9 +4235,13 @@ impl CodexMessageProcessor { thread_status, /*has_live_in_progress_turn*/ false, ); - let permission_profile = thread_response_permission_profile( - codex_thread.config_snapshot().await.permission_profile, + let config_snapshot = codex_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), ); + let permission_profile = + thread_response_permission_profile(config_snapshot.permission_profile.clone()); let response = ThreadResumeResponse { thread, @@ -4248,7 +4252,7 @@ impl CodexMessageProcessor { instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), - sandbox: session_configured.sandbox_policy.into(), + sandbox, permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -4831,9 +4835,13 @@ impl CodexMessageProcessor { .await, /*has_in_progress_turn*/ false, ); - let permission_profile = thread_response_permission_profile( - forked_thread.config_snapshot().await.permission_profile, + let config_snapshot = forked_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), ); + let permission_profile = + thread_response_permission_profile(config_snapshot.permission_profile); let response = ThreadForkResponse { thread: thread.clone(), @@ -4844,7 +4852,7 @@ impl CodexMessageProcessor { instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), - sandbox: session_configured.sandbox_policy.into(), + sandbox, permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -8091,13 +8099,14 @@ async fn handle_pending_thread_resume_request( service_tier, approval_policy, approvals_reviewer, - sandbox_policy, + sandbox_policy: _, permission_profile, cwd, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; + let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let permission_profile = thread_response_permission_profile(permission_profile); let response = ThreadResumeResponse { @@ -8109,7 +8118,7 @@ async fn handle_pending_thread_resume_request( instruction_sources, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), - sandbox: sandbox_policy.into(), + sandbox, permission_profile, reasoning_effort, }; @@ -9240,6 +9249,20 @@ fn thread_response_permission_profile( Some(permission_profile.into()) } +fn thread_response_sandbox_policy( + permission_profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> codex_app_server_protocol::SandboxPolicy { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + permission_profile, + &file_system_policy, + permission_profile.network_sandbox_policy(), + cwd, + ); + sandbox_policy.into() +} + fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { if matches!( overrides.sandbox_mode, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index d4c0d337c..176865ee6 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -867,7 +867,6 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - let session_sandbox_policy = session_configuration.sandbox_policy(); let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -879,8 +878,7 @@ impl Session { service_tier: session_configuration.service_tier, approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, - sandbox_policy: session_sandbox_policy.clone(), - permission_profile: Some(session_configuration.permission_profile()), + permission_profile: session_configuration.permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 42a074567..9079f5990 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1527,17 +1527,12 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> let test = builder.build(&server).await?; - assert_eq!( - test.session_configured.sandbox_policy, - expected_sandbox_policy - ); let expected_permission_profile = codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( &expected_sandbox_policy, ); assert_eq!( - test.session_configured.permission_profile, - Some(expected_permission_profile), + test.session_configured.permission_profile, expected_permission_profile, "ExternalSandbox is represented explicitly instead of as a lossy root-write profile" ); Ok(()) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9e9d9bc14..fc3392db2 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1046,8 +1046,9 @@ fn session_configured_from_thread_response( service_tier, approval_policy, approvals_reviewer, - sandbox_policy, - permission_profile, + permission_profile: permission_profile.unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd.as_path()) + }), cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 3a7b5d0fc..23dcd6733 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -28,9 +28,9 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -114,8 +114,7 @@ fn session_configured_produces_thread_started_event() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 67ceb91dd..78ef4cacf 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -231,10 +231,10 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; + use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -304,8 +304,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,8 +348,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -389,9 +387,7 @@ mod tests { "model_provider_id": "test-provider", "approval_policy": "never", "approvals_reviewer": "user", - "sandbox_policy": { - "type": "read-only" - }, + "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, "history_log_id": session_configured_event.history_log_id, @@ -419,8 +415,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -460,9 +455,7 @@ mod tests { "model_provider_id": "test-provider", "approval_policy": "never", "approvals_reviewer": "user", - "sandbox_policy": { - "type": "read-only" - }, + "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, "history_log_id": session_configured_event.history_log_id, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 85ef863b8..c731dfae3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3541,7 +3541,7 @@ pub struct SessionNetworkProxyRuntime { pub socks_addr: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { pub session_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] @@ -3569,16 +3569,8 @@ pub struct SessionConfiguredEvent { #[serde(default)] pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox projection for commands executed in the system. - /// - /// Consumers should prefer `permission_profile` when it is present. This - /// field remains available as a compatibility fallback for older emitters - /// and sessions that only expose legacy sandbox state. - pub sandbox_policy: SandboxPolicy, - /// Canonical effective permissions for commands executed in the session. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_profile: Option, + pub permission_profile: PermissionProfile, /// Working directory that should be treated as the *root* of the /// session. @@ -3609,6 +3601,70 @@ pub struct SessionConfiguredEvent { pub rollout_path: Option, } +impl<'de> Deserialize<'de> for SessionConfiguredEvent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + session_id: ThreadId, + forked_from_id: Option, + #[serde(default)] + thread_name: Option, + model: String, + model_provider_id: String, + service_tier: Option, + approval_policy: AskForApproval, + #[serde(default)] + approvals_reviewer: ApprovalsReviewer, + // `SessionConfiguredEvent` is persisted into rollout history. Older + // rollouts only have `sandbox_policy`, so accept it on deserialize + // and immediately project it into the canonical `permission_profile`. + sandbox_policy: Option, + permission_profile: Option, + cwd: AbsolutePathBuf, + reasoning_effort: Option, + history_log_id: u64, + history_entry_count: usize, + initial_messages: Option>, + network_proxy: Option, + rollout_path: Option, + } + + let wire = Wire::deserialize(deserializer)?; + let permission_profile = match (wire.permission_profile, wire.sandbox_policy) { + (Some(permission_profile), _) => permission_profile, + (None, Some(sandbox_policy)) => PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + wire.cwd.as_path(), + ), + (None, None) => { + return Err(serde::de::Error::missing_field("permission_profile")); + } + }; + + Ok(Self { + session_id: wire.session_id, + forked_from_id: wire.forked_from_id, + thread_name: wire.thread_name, + model: wire.model, + model_provider_id: wire.model_provider_id, + service_tier: wire.service_tier, + approval_policy: wire.approval_policy, + approvals_reviewer: wire.approvals_reviewer, + permission_profile, + cwd: wire.cwd, + reasoning_effort: wire.reasoning_effort, + history_log_id: wire.history_log_id, + history_entry_count: wire.history_entry_count, + initial_messages: wire.initial_messages, + network_proxy: wire.network_proxy, + rollout_path: wire.rollout_path, + }) + } +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ThreadNameUpdatedEvent { pub thread_id: ThreadId, @@ -5088,6 +5144,7 @@ mod tests { fn serialize_event() -> Result<()> { let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let rollout_file = NamedTempFile::new()?; + let permission_profile = PermissionProfile::read_only(); let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -5099,8 +5156,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: permission_profile.clone(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -5120,9 +5176,7 @@ mod tests { "model_provider_id": "openai", "approval_policy": "never", "approvals_reviewer": "user", - "sandbox_policy": { - "type": "read-only" - }, + "permission_profile": permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": "medium", "history_log_id": 0, @@ -5134,6 +5188,28 @@ mod tests { Ok(()) } + #[test] + fn deserialize_legacy_session_configured_event_uses_sandbox_policy() -> Result<()> { + let cwd = test_path_buf("/home/user/project"); + let value = json!({ + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "codex-mini-latest", + "model_provider_id": "openai", + "approval_policy": "never", + "approvals_reviewer": "user", + "sandbox_policy": { + "type": "read-only" + }, + "cwd": cwd, + "history_log_id": 0, + "history_entry_count": 0, + }); + + let event: SessionConfiguredEvent = serde_json::from_value(value)?; + assert_eq!(event.permission_profile, PermissionProfile::read_only()); + Ok(()) + } + #[test] fn vec_u8_as_base64_serialization_and_deserialization() -> Result<()> { let event = ExecCommandOutputDeltaEvent { diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 9f6b63122..316465537 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -556,6 +556,7 @@ mod tests { use crate::app::test_support::app_enabled_in_effective_config; use crate::app::test_support::make_test_app; use crate::test_support::PathBufExt; + use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionConfiguredEvent; @@ -653,8 +654,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: next_cwd.clone().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8a08f8bf1..6c82dac63 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3566,8 +3566,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -4317,8 +4316,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4381,8 +4379,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4475,8 +4472,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4859,8 +4855,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4972,8 +4967,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f69eea4af..2b1871886 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -142,12 +142,8 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::SandboxEnforcement; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; #[cfg(test)] @@ -1620,8 +1616,7 @@ fn thread_session_state_to_legacy_event( service_tier: session.service_tier, approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, - sandbox_policy: session.sandbox_policy, - permission_profile: Some(session.permission_profile), + permission_profile: session.permission_profile, cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, @@ -2349,32 +2344,14 @@ impl ChatWidget { self.config.permissions.approval_policy = Constrained::allow_only(event.approval_policy); } - let permission_sync = match event.permission_profile.clone() { - Some(permission_profile) => self - .config - .permissions - .set_permission_profile(permission_profile), - None => self - .config - .permissions - .set_legacy_sandbox_policy(event.sandbox_policy.clone(), event.cwd.as_path()), - }; + let permission_sync = self + .config + .permissions + .set_permission_profile(event.permission_profile.clone()); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - let permission_profile = event.permission_profile.clone().unwrap_or_else(|| { - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &event.sandbox_policy, - event.cwd.as_path(), - ); - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&event.sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(&event.sandbox_policy), - ) - }); self.config.permissions.permission_profile = - Constrained::allow_only(permission_profile); + Constrained::allow_only(event.permission_profile.clone()); } self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 302c4ea38..d21febaa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -16,8 +16,7 @@ async fn submission_preserves_text_elements_and_local_images() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -121,8 +120,7 @@ async fn submission_includes_configured_permission_profile() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: Some(expected_permission_profile.clone()), + permission_profile: expected_permission_profile.clone(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -172,10 +170,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }, - permission_profile: Some(expected_permission_profile.clone()), + permission_profile: expected_permission_profile.clone(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -218,8 +213,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -314,8 +308,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -380,8 +373,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -421,8 +413,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -462,8 +453,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -506,8 +496,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 4d87914aa..ec1d03f9f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1004,8 +1004,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 77f52524f..443cac053 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -24,8 +24,7 @@ async fn resumed_initial_messages_render_history() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -138,8 +137,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -200,8 +198,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -256,7 +253,6 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .expect("set sandbox policy"); chat.config.cwd = test_path_buf("/home/user/main").abs(); - let legacy_fallback_sandbox = SandboxPolicy::new_read_only_policy(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); let expected_file_system_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -289,8 +285,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: legacy_fallback_sandbox, - permission_profile: Some(expected_permission_profile.clone()), + permission_profile: expected_permission_profile.clone(), cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -355,8 +350,9 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: expected_sandbox.clone(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, cwd: test_path_buf("/home/user/external").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -405,8 +401,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -459,8 +454,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -781,8 +775,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -829,8 +822,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: codex_protocol::models::PermissionProfile::read_only(), cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 388bc67f8..4666e6804 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -483,8 +483,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure service_tier: None, approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: None, + permission_profile: PermissionProfile::workspace_write(), cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -519,6 +518,13 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w .set_enabled(Feature::GuardianApproval, /*enabled*/ true); let extra_root = test_path_buf("/tmp/guardian-approvals-extra").abs(); + let cwd = test_project_path().abs(); + let permission_profile = PermissionProfile::workspace_write_with( + &[extra_root], + codex_protocol::protocol::NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -531,14 +537,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w service_tier: None, approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, - sandbox_policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![extra_root], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - permission_profile: None, - cwd: test_project_path().abs(), + permission_profile, + cwd, reasoning_effort: None, history_log_id: 0, history_entry_count: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 65f6ae0c3..c6e1f7376 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1059,8 +1059,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1305,8 +1304,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7aefbe4b..19890a5d3 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1741,8 +1741,7 @@ async fn session_configured_clears_goal_status_footer() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 33e2f25c6..af77b6ecb 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -54,6 +54,8 @@ use codex_protocol::account::PlanType; use codex_protocol::mcp::Resource; #[cfg(test)] use codex_protocol::mcp::ResourceTemplate; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; use codex_protocol::models::local_image_label_text; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -64,7 +66,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -1240,7 +1241,7 @@ pub(crate) fn new_session_info( model, reasoning_effort, approval_policy, - sandbox_policy, + permission_profile, .. } = event; // Header box rendered as history (so it appears at the very top) @@ -1251,7 +1252,7 @@ pub(crate) fn new_session_info( config.cwd.to_path_buf(), CODEX_CLI_VERSION, ) - .with_yolo_mode(has_yolo_permissions(approval_policy, &sandbox_policy)); + .with_yolo_mode(has_yolo_permissions(approval_policy, &permission_profile)); let mut parts: Vec> = vec![Box::new(header)]; if is_first_event { @@ -1313,14 +1314,23 @@ pub(crate) fn new_session_info( pub(crate) fn is_yolo_mode(config: &Config) -> bool { has_yolo_permissions( config.permissions.approval_policy.value(), - &config - .permissions - .legacy_sandbox_policy(config.cwd.as_path()), + &config.permissions.permission_profile(), ) } -fn has_yolo_permissions(approval_policy: AskForApproval, sandbox_policy: &SandboxPolicy) -> bool { - approval_policy == AskForApproval::Never && *sandbox_policy == SandboxPolicy::DangerFullAccess +fn has_yolo_permissions( + approval_policy: AskForApproval, + permission_profile: &PermissionProfile, +) -> bool { + approval_policy == AskForApproval::Never + && matches!( + permission_profile, + PermissionProfile::Disabled + | PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, + } + ) } pub(crate) fn new_user_prompt( @@ -2993,7 +3003,6 @@ mod tests { use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -3184,8 +3193,7 @@ mod tests { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4205,6 +4213,31 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn yolo_mode_includes_managed_full_access_profiles() { + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, + }; + + assert!(has_yolo_permissions( + AskForApproval::Never, + &permission_profile + )); + } + + #[test] + fn yolo_mode_excludes_external_sandbox_profiles() { + let permission_profile = PermissionProfile::External { + network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, + }; + + assert!(!has_yolo_permissions( + AskForApproval::Never, + &permission_profile + )); + } + #[test] fn session_header_directory_center_truncates() { let mut dir = home_dir().expect("home directory");