From 9c17b2dc0390cc4eddb1e473d2009f795ddefd9a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 30 Apr 2026 08:02:30 -0700 Subject: [PATCH] protocol: canonicalize turn context permissions --- .../core/src/context_manager/history_tests.rs | 4 +- .../session/rollout_reconstruction_tests.rs | 40 ++--- codex-rs/core/src/session/tests.rs | 28 +--- codex-rs/core/src/session/turn_context.rs | 4 +- codex-rs/core/tests/suite/resume_warning.rs | 4 +- codex-rs/protocol/src/protocol.rs | 143 +++++++++++++----- codex-rs/rollout/src/recorder_tests.rs | 4 +- codex-rs/state/src/extract.rs | 21 +-- codex-rs/tui/src/app/tests.rs | 4 +- codex-rs/tui/src/lib.rs | 4 +- 10 files changed, 129 insertions(+), 127 deletions(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 2b8148f24df7..1167eebfcfc0 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -129,10 +129,8 @@ fn reference_context_item() -> TurnContextItem { current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, - sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile, network: None, - file_system_sandbox_policy: None, model: "gpt-test".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0e07d4b4b055..206f48142c40 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -9,7 +9,6 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ResumedHistory; -use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -53,13 +52,12 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { } } -fn legacy_sandbox_policy_for_rollout_fixture(turn_context: &TurnContext) -> SandboxPolicy { +fn permission_profile_for_rollout_fixture(turn_context: &TurnContext) -> PermissionProfile { let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); - codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &turn_context.permission_profile, + PermissionProfile::from_runtime_permissions_with_enforcement( + turn_context.permission_profile.enforcement(), &file_system_sandbox_policy, turn_context.network_sandbox_policy(), - turn_context.cwd.as_path(), ) } @@ -75,10 +73,8 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -116,10 +112,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -926,10 +920,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1004,10 +996,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1035,10 +1025,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1150,10 +1138,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: current_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1264,10 +1250,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1416,10 +1400,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy_for_rollout_fixture(&turn_context)), - permission_profile: None, + permission_profile: permission_profile_for_rollout_fixture(&turn_context), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d35ffecd8339..2b363462924e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1738,14 +1738,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; let previous_model = "forked-rollout-model"; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); - let legacy_sandbox_policy = - codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &turn_context.permission_profile, - &file_system_sandbox_policy, - turn_context.network_sandbox_policy(), - turn_context.cwd.as_path(), - ); let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), @@ -1753,10 +1745,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), - sandbox_policy: Some(legacy_sandbox_policy), - permission_profile: None, + permission_profile: turn_context.permission_profile(), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -5934,12 +5924,7 @@ async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() let item = turn_context.to_turn_context_item(); - assert_eq!(item.sandbox_policy, None); - assert_eq!(item.file_system_sandbox_policy, None); - assert_eq!( - item.permission_profile, - Some(turn_context.permission_profile()) - ); + assert_eq!(item.permission_profile, turn_context.permission_profile()); } #[tokio::test] @@ -5954,12 +5939,7 @@ async fn turn_context_item_stores_split_file_system_policy_in_permission_profile let item = turn_context.to_turn_context_item(); - assert_eq!(item.sandbox_policy, None); - assert_eq!(item.file_system_sandbox_policy, None); - assert_eq!( - item.permission_profile, - Some(turn_context.permission_profile()) - ); + assert_eq!(item.permission_profile, turn_context.permission_profile()); } #[tokio::test] @@ -6103,7 +6083,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_profile_ panic!("expected resumed rollout history"); }; let persisted_permission_profile = resumed.history.iter().find_map(|item| match item { - RolloutItem::TurnContext(ctx) => ctx.permission_profile.clone(), + RolloutItem::TurnContext(ctx) => Some(ctx.permission_profile.clone()), _ => None, }); assert_eq!( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e334f73ca269..342aabbf9bd7 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -305,10 +305,8 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), - sandbox_policy: None, - permission_profile: Some(self.permission_profile()), + permission_profile: self.permission_profile(), network: self.turn_context_network_item(), - file_system_sandbox_policy: None, model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index e5df4ecd238c..8f708db4b90a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -32,10 +32,8 @@ fn resume_history( current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), - sandbox_policy: Some(config.legacy_sandbox_policy()), - permission_profile: None, + permission_profile: config.permissions.permission_profile(), network: None, - file_system_sandbox_policy: None, model: previous_model.to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d4adaa39a673..3f3c29fc953e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2849,7 +2849,7 @@ pub struct TurnContextNetworkItem { /// context updates, and again after mid-turn compaction when replacement /// history re-establishes full context, so resume/fork replay can recover the /// latest durable baseline. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, @@ -2861,14 +2861,9 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, pub approval_policy: AskForApproval, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox_policy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_profile: Option, + pub permission_profile: PermissionProfile, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub file_system_sandbox_policy: Option, pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, @@ -2891,24 +2886,96 @@ pub struct TurnContextItem { impl TurnContextItem { pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.clone().unwrap_or_else(|| { - let Some(sandbox_policy) = self.sandbox_policy.as_ref() else { - panic!( - "turn context item must contain permission_profile or legacy sandbox_policy" - ); - }; - let file_system_sandbox_policy = - self.file_system_sandbox_policy.clone().unwrap_or_else(|| { - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - sandbox_policy, - &self.cwd, - ) - }); - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(sandbox_policy), - ) + self.permission_profile.clone() + } +} + +impl<'de> Deserialize<'de> for TurnContextItem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + #[serde(default)] + turn_id: Option, + #[serde(default)] + trace_id: Option, + cwd: PathBuf, + #[serde(default)] + current_date: Option, + #[serde(default)] + timezone: Option, + approval_policy: AskForApproval, + #[serde(default)] + sandbox_policy: Option, + #[serde(default)] + permission_profile: Option, + #[serde(default)] + network: Option, + #[serde(default)] + file_system_sandbox_policy: Option, + model: String, + #[serde(default)] + personality: Option, + #[serde(default)] + collaboration_mode: Option, + #[serde(default)] + realtime_active: Option, + #[serde(default)] + effort: Option, + summary: ReasoningSummaryConfig, + #[serde(default)] + user_instructions: Option, + #[serde(default)] + developer_instructions: Option, + #[serde(default)] + final_output_json_schema: Option, + #[serde(default)] + truncation_policy: 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)) => { + let file_system_sandbox_policy = + wire.file_system_sandbox_policy.unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + &wire.cwd, + ) + }); + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&sandbox_policy), + ) + } + (None, None) => { + return Err(serde::de::Error::missing_field("permission_profile")); + } + }; + + Ok(Self { + turn_id: wire.turn_id, + trace_id: wire.trace_id, + cwd: wire.cwd, + current_date: wire.current_date, + timezone: wire.timezone, + approval_policy: wire.approval_policy, + permission_profile, + network: wire.network, + model: wire.model, + personality: wire.personality, + collaboration_mode: wire.collaboration_mode, + realtime_active: wire.realtime_active, + effort: wire.effort, + summary: wire.summary, + user_instructions: wire.user_instructions, + developer_instructions: wire.developer_instructions, + final_output_json_schema: wire.final_output_json_schema, + truncation_policy: wire.truncation_policy, }) } } @@ -5099,12 +5166,21 @@ mod tests { assert_eq!(item.trace_id, None); assert_eq!(item.network, None); - assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!(item.permission_profile, PermissionProfile::Disabled); Ok(()) } #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/private/**/*.txt".to_string(), + }, + access: FileSystemAccessMode::None, + }]), + NetworkSandboxPolicy::Restricted, + ); let item = TurnContextItem { turn_id: None, trace_id: None, @@ -5112,20 +5188,11 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, - sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: None, + permission_profile, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], }), - file_system_sandbox_policy: Some(FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/private/**/*.txt".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ])), model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -5147,9 +5214,9 @@ mod tests { }) ); assert_eq!( - value["file_system_sandbox_policy"], + value["permission_profile"]["file_system"], json!({ - "kind": "restricted", + "type": "restricted", "entries": [{ "path": { "type": "glob_pattern", diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 69f133e05082..1c200f8107bb 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1105,10 +1105,8 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re current_date: None, timezone: None, approval_policy: AskForApproval::Never, - sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile, network: None, - file_system_sandbox_policy: None, model: "test-model".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ba5093ca1231..e1383506e637 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -168,7 +168,6 @@ mod tests { use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; - use std::path::Path; use std::path::PathBuf; use uuid::Uuid; @@ -303,10 +302,8 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, - sandbox_policy: None, - permission_profile: Some(PermissionProfile::Disabled), + permission_profile: PermissionProfile::Disabled, network: None, - file_system_sandbox_policy: None, model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -340,14 +337,8 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, - sandbox_policy: Some( - PermissionProfile::read_only() - .to_legacy_sandbox_policy(Path::new("/")) - .expect("read-only profile should project to legacy sandbox"), - ), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), network: None, - file_system_sandbox_policy: None, model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -378,14 +369,8 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, - sandbox_policy: Some( - PermissionProfile::read_only() - .to_legacy_sandbox_policy(Path::new("/")) - .expect("read-only profile should project to legacy sandbox"), - ), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), network: None, - file_system_sandbox_policy: None, model: "gpt-5".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 99216e959d9a..3de31cb3ae16 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2746,10 +2746,8 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re current_date: None, timezone: None, approval_policy: primary_session.approval_policy, - sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile, network: None, - file_system_sandbox_policy: None, model: "gpt-agent".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9d40b95cecd1..98919bada446 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2217,10 +2217,8 @@ mod tests { current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), - sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile, network: None, - file_system_sandbox_policy: None, model, personality: None, collaboration_mode: None,