diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 725f327b4c..bbf729f458 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -625,6 +625,7 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, network: None, + approved_prefix_rules: None, model: model.to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 05981029c5..f34874b791 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -155,6 +155,7 @@ use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; use codex_config::CONFIG_TOML_FILE; +use codex_execpolicy::Policy; mod rollout_reconstruction; #[cfg(test)] @@ -771,6 +772,22 @@ impl TurnContext { } pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { + self.to_turn_context_item_with_approved_prefix_rules(None) + } + + pub(crate) fn to_turn_context_item_with_exec_policy( + &self, + exec_policy: &Policy, + ) -> TurnContextItem { + self.to_turn_context_item_with_approved_prefix_rules(format_allow_prefixes( + exec_policy.get_allowed_prefixes(), + )) + } + + fn to_turn_context_item_with_approved_prefix_rules( + &self, + approved_prefix_rules: Option, + ) -> TurnContextItem { TurnContextItem { turn_id: Some(self.sub_id.clone()), cwd: self.cwd.clone(), @@ -779,6 +796,7 @@ impl TurnContext { approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), + approved_prefix_rules, model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), @@ -1787,7 +1805,11 @@ impl Session { self.record_conversation_items(&turn_context, &items).await; { let mut state = self.state.lock().await; - state.set_reference_context_item(Some(turn_context.to_turn_context_item())); + state.set_reference_context_item(Some( + turn_context.to_turn_context_item_with_exec_policy( + self.services.exec_policy.current().as_ref(), + ), + )); } self.set_previous_turn_settings(None).await; // Ensure initial items are visible to immediate readers (e.g., tests, forks). @@ -1900,7 +1922,11 @@ impl Session { .await; { let mut state = self.state.lock().await; - state.set_reference_context_item(Some(turn_context.to_turn_context_item())); + state.set_reference_context_item(Some( + turn_context.to_turn_context_item_with_exec_policy( + self.services.exec_policy.current().as_ref(), + ), + )); } // Forked threads should remain file-backed immediately after startup. @@ -3087,9 +3113,13 @@ impl Session { .format_environment_context_subagents(self.conversation_id) .await; contextual_user_sections.push( - EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) - .with_subagents(subagents) - .serialize_to_xml(), + EnvironmentContext::from_turn_context( + turn_context, + shell.as_ref(), + format_allow_prefixes(self.services.exec_policy.current().get_allowed_prefixes()), + ) + .with_subagents(subagents) + .serialize_to_xml(), ); let mut items = Vec::with_capacity(2); @@ -3157,7 +3187,9 @@ impl Session { self.build_settings_update_items(reference_context_item.as_ref(), turn_context) .await }; - let turn_context_item = turn_context.to_turn_context_item(); + let turn_context_item = turn_context.to_turn_context_item_with_exec_policy( + self.services.exec_policy.current().as_ref(), + ); if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -7463,6 +7495,7 @@ mod tests { approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + approved_prefix_rules: 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/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 63deb5c808..bb19351df9 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -8,6 +8,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TurnContextItem; @@ -15,16 +16,19 @@ fn build_environment_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, shell: &Shell, + exec_policy: &Policy, ) -> Option { let prev = previous?; let prev_context = EnvironmentContext::from_turn_context_item(prev, shell); - let next_context = EnvironmentContext::from_turn_context(next, shell); + let approved_prefix_rules = format_allow_prefixes(exec_policy.get_allowed_prefixes()); + let next_context = + EnvironmentContext::from_turn_context(next, shell, approved_prefix_rules.clone()); if prev_context.equals_except_shell(&next_context) { return None; } Some(ResponseItem::from( - EnvironmentContext::diff_from_turn_context_item(prev, next, shell), + EnvironmentContext::diff_from_turn_context_item(prev, next, shell, approved_prefix_rules), )) } @@ -181,7 +185,7 @@ pub(crate) fn build_settings_update_items( exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { - let contextual_user_message = build_environment_update_item(previous, next, shell); + let contextual_user_message = build_environment_update_item(previous, next, shell, exec_policy); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 3e9ed871e3..8c98934b10 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -16,6 +16,7 @@ pub(crate) struct EnvironmentContext { pub current_date: Option, pub timezone: Option, pub network: Option, + pub approved_prefix_rules: Option, pub subagents: Option, } @@ -32,6 +33,7 @@ impl EnvironmentContext { current_date: Option, timezone: Option, network: Option, + approved_prefix_rules: Option, subagents: Option, ) -> Self { Self { @@ -40,6 +42,7 @@ impl EnvironmentContext { current_date, timezone, network, + approved_prefix_rules, subagents, } } @@ -53,6 +56,7 @@ impl EnvironmentContext { current_date, timezone, network, + approved_prefix_rules, subagents, shell: _, } = other; @@ -60,6 +64,7 @@ impl EnvironmentContext { && self.current_date == *current_date && self.timezone == *timezone && self.network == *network + && self.approved_prefix_rules == *approved_prefix_rules && self.subagents == *subagents } @@ -67,9 +72,11 @@ impl EnvironmentContext { before: &TurnContextItem, after: &TurnContext, shell: &Shell, + approved_prefix_rules: Option, ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); + let before_approved_prefix_rules = before.approved_prefix_rules.clone(); let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { @@ -82,16 +89,34 @@ impl EnvironmentContext { } else { before_network }; - EnvironmentContext::new(cwd, shell.clone(), current_date, timezone, network, None) + let approved_prefix_rules = if before_approved_prefix_rules != approved_prefix_rules { + approved_prefix_rules + } else { + before_approved_prefix_rules + }; + EnvironmentContext::new( + cwd, + shell.clone(), + current_date, + timezone, + network, + approved_prefix_rules, + None, + ) } - pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { + pub fn from_turn_context( + turn_context: &TurnContext, + shell: &Shell, + approved_prefix_rules: Option, + ) -> Self { Self::new( Some(turn_context.cwd.clone()), shell.clone(), turn_context.current_date.clone(), turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), + approved_prefix_rules, None, ) } @@ -103,6 +128,7 @@ impl EnvironmentContext { turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), + turn_context_item.approved_prefix_rules.clone(), None, ) } @@ -183,6 +209,13 @@ impl EnvironmentContext { // lines.push(" ".to_string()); } } + if let Some(approved_prefix_rules) = self.approved_prefix_rules { + lines.push(" ".to_string()); + for line in approved_prefix_rules.lines() { + lines.push(format!(" {line}")); + } + lines.push(" ".to_string()); + } if let Some(subagents) = self.subagents { lines.push(" ".to_string()); lines.extend(subagents.lines().map(|line| format!(" {line}"))); @@ -224,6 +257,7 @@ mod tests { Some("America/Los_Angeles".to_string()), None, None, + None, ); let expected = format!( @@ -252,6 +286,7 @@ mod tests { Some("America/Los_Angeles".to_string()), Some(network), None, + None, ); let expected = format!( @@ -281,6 +316,7 @@ mod tests { Some("America/Los_Angeles".to_string()), None, None, + None, ); let expected = r#" @@ -301,6 +337,7 @@ mod tests { Some("America/Los_Angeles".to_string()), None, None, + None, ); let expected = r#" @@ -321,6 +358,7 @@ mod tests { Some("America/Los_Angeles".to_string()), None, None, + None, ); let expected = r#" @@ -341,6 +379,7 @@ mod tests { Some("America/Los_Angeles".to_string()), None, None, + None, ); let expected = r#" @@ -352,6 +391,35 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_environment_context_with_approved_prefix_rules() { + let context = EnvironmentContext::new( + Some(test_path_buf("/repo")), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + Some("- [\"mkdir\"]\n- [\"gh\", \"api\"]".to_string()), + None, + ); + + let expected = format!( + r#" + {} + bash + 2026-02-26 + America/Los_Angeles + + - ["mkdir"] + - ["gh", "api"] + +"#, + test_path_buf("/repo").display() + ); + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( @@ -361,6 +429,7 @@ mod tests { None, None, None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -369,6 +438,7 @@ mod tests { None, None, None, + None, ); assert!(context1.equals_except_shell(&context2)); } @@ -382,6 +452,7 @@ mod tests { None, None, None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -390,6 +461,7 @@ mod tests { None, None, None, + None, ); assert!(context1.equals_except_shell(&context2)); @@ -404,6 +476,7 @@ mod tests { None, None, None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo2")), @@ -412,6 +485,7 @@ mod tests { None, None, None, + None, ); assert!(!context1.equals_except_shell(&context2)); @@ -430,6 +504,7 @@ mod tests { None, None, None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -442,6 +517,7 @@ mod tests { None, None, None, + None, ); assert!(context1.equals_except_shell(&context2)); @@ -455,6 +531,7 @@ mod tests { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + None, Some("- agent-1: atlas\n- agent-2".to_string()), ); @@ -474,4 +551,28 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + + #[test] + fn equals_except_shell_compares_approved_prefix_rules() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + Some("- [\"mkdir\"]".to_string()), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + Some("- [\"gh\", \"api\"]".to_string()), + None, + ); + + assert!(!context1.equals_except_shell(&context2)); + } } diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index ca40c0c191..dfdc02ca97 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -33,6 +33,7 @@ fn resume_history( approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, + approved_prefix_rules: None, model: previous_model.to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8461209823..90ede7a7af 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -352,7 +352,7 @@ impl DeveloperInstructions { pub fn from( approval_policy: AskForApproval, - exec_policy: &Policy, + _exec_policy: &Policy, request_permission_enabled: bool, ) -> DeveloperInstructions { let on_request_instructions = || { @@ -361,15 +361,7 @@ impl DeveloperInstructions { } else { APPROVAL_POLICY_ON_REQUEST_RULE }; - let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes()); - match command_prefixes { - Some(prefixes) => { - format!( - "{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" - ) - } - None => on_request_rule.to_string(), - } + on_request_rule.to_string() }; let text = match approval_policy { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), @@ -533,7 +525,10 @@ impl DeveloperInstructions { SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(), SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(), }; - let text = template.replace("{network_access}", &network_access.to_string()); + let text = format!( + "{} Approved command prefix rules (if any) are provided in `` under ``.", + template.replace("{network_access}", &network_access.to_string()) + ); DeveloperInstructions::new(text) } @@ -575,6 +570,10 @@ pub fn format_allow_prefixes(prefixes: Vec>) -> Option { output = output[..byte_idx].to_string(); } + if output.is_empty() { + return None; + } + if truncated { Some(format!("{output}{TRUNCATED_MARKER}")) } else { @@ -1331,7 +1330,7 @@ mod tests { assert_eq!( workspace_write, DeveloperInstructions::new( - "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted." + "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted. Approved command prefix rules (if any) are provided in `` under ``." ) ); @@ -1339,7 +1338,7 @@ mod tests { assert_eq!( read_only, DeveloperInstructions::new( - "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted." + "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted. Approved command prefix rules (if any) are provided in `` under ``." ) ); } @@ -1408,8 +1407,8 @@ mod tests { let text = instructions.into_text(); assert!(text.contains("prefix_rule")); - assert!(text.contains("Approved command prefixes")); - assert!(text.contains(r#"["git", "pull"]"#)); + assert!(text.contains("")); + assert!(!text.contains("Approved command prefixes")); } #[test] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3e9e9fe9a3..948c33175d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2146,6 +2146,8 @@ pub struct TurnContextItem { pub sandbox_policy: SandboxPolicy, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_prefix_rules: Option, pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, @@ -3380,6 +3382,7 @@ mod tests { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], }), + approved_prefix_rules: None, model: "gpt-5".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5d36ec441d..45d99d7246 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1277,6 +1277,7 @@ mod tests { approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, + approved_prefix_rules: None, model, personality: None, collaboration_mode: None,