diff --git a/codex-rs/core/src/context/world_state/permissions_tests.rs b/codex-rs/core/src/context/world_state/permissions_tests.rs index 6d898123fe..0ea35c1b94 100644 --- a/codex-rs/core/src/context/world_state/permissions_tests.rs +++ b/codex-rs/core/src/context/world_state/permissions_tests.rs @@ -263,6 +263,7 @@ fn permissions_state( on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }; let permission_messages = PermissionMessages { danger_full_access: Some("Full access.".to_string()), diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index bf1a613c45..5d007aeada 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -49,6 +49,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent( on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }), collaboration_modes: None, auto_review: None, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index ae7bb02318..75cba3b93e 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -89,6 +89,7 @@ fn model_with_approval_messages( on_request_auto_review: Some(on_request_auto_review.to_string()), never: None, unless_trusted: None, + granular: None, }), ..Default::default() }); @@ -223,6 +224,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission on_request_auto_review: None, never: Some("catalog never approval instructions".to_string()), unless_trusted: None, + granular: None, }, "catalog never approval instructions", "Approval policy is currently never", @@ -234,6 +236,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission on_request_auto_review: None, never: None, unless_trusted: Some("catalog unless-trusted approval instructions".to_string()), + granular: None, }, "catalog unless-trusted approval instructions", "`approval_policy` is `unless-trusted`", diff --git a/codex-rs/core/tests/suite/settings_constraints.rs b/codex-rs/core/tests/suite/settings_constraints.rs index f5d35c64c7..a45c00416d 100644 --- a/codex-rs/core/tests/suite/settings_constraints.rs +++ b/codex-rs/core/tests/suite/settings_constraints.rs @@ -137,6 +137,7 @@ async fn protected_model_settings_use_the_proposed_permissions( on_request_auto_review: Some(REVIEW_INSTRUCTIONS.to_string()), never: None, unless_trusted: None, + granular: None, }); messages.permissions = Some(PermissionMessages { danger_full_access: None, diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index 5b89e9d2eb..a6303fdec3 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -1103,6 +1103,7 @@ async fn active_model_switch_updates_core_context_from_captured_settings( on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }); messages.token_budget = (initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig { diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index d7c7ab73c8..0f83d82539 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::CollaborationModeMessages; use codex_protocol::openai_models::ConfirmationPolicies; +use codex_protocol::openai_models::GranularApprovalMessages; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::MultiAgentMessages; @@ -35,6 +36,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { on_request_auto_review: Some("auto approvals".to_string()), never: Some("never approvals".to_string()), unless_trusted: Some("unless-trusted approvals".to_string()), + granular: Some(GranularApprovalMessages { + intro: Some("Catalog: {{ policy }}".to_string()), + prompted_categories: Some("Allowed: {{ categories }}".to_string()), + rejected_categories: Some("Denied: {{ categories }}".to_string()), + }), }; let collaboration_modes = CollaborationModeMessages { default: Some("default instructions".to_string()), @@ -167,6 +173,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }), ..Default::default() }; diff --git a/codex-rs/prompts/src/permissions_instructions_tests.rs b/codex-rs/prompts/src/permissions_instructions_tests.rs index d169cd1ace..218ae8c15d 100644 --- a/codex-rs/prompts/src/permissions_instructions_tests.rs +++ b/codex-rs/prompts/src/permissions_instructions_tests.rs @@ -426,6 +426,7 @@ fn catalog_approval_messages_select_reviewer_variant() { on_request_auto_review: Some("auto-review catalog approvals".to_string()), never: Some("never catalog approvals".to_string()), unless_trusted: Some("unless-trusted catalog approvals".to_string()), + granular: None, }; for (approval_policy, reviewer, expected) in [ @@ -474,6 +475,7 @@ fn empty_catalog_approval_message_suppresses_legacy_approval_section() { on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }; let approved_command_prefixes = vec![vec!["git".to_string(), "pull".to_string()]]; let writable_root = "/tmp/repo".to_string(); @@ -514,6 +516,7 @@ fn missing_catalog_key_uses_legacy_approval_text() { on_request_auto_review: Some("unused catalog approvals".to_string()), never: None, unless_trusted: None, + granular: None, }, ), ( @@ -523,6 +526,7 @@ fn missing_catalog_key_uses_legacy_approval_text() { on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }, ), ] { @@ -566,6 +570,7 @@ fn empty_catalog_non_on_request_approval_messages_suppress_legacy_approval_text( on_request_auto_review: None, never: Some(String::new()), unless_trusted: Some(String::new()), + granular: None, }; for approval_policy in [AskForApproval::Never, AskForApproval::UnlessTrusted] { @@ -734,6 +739,7 @@ fn preserves_supplied_path_spellings_and_order() { on_request_auto_review: None, never: None, unless_trusted: None, + granular: None, }; let permission_messages = PermissionMessages { danger_full_access: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 8eddf87990..3a2616934a 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -775,6 +775,27 @@ pub struct ApprovalMessages { pub on_request_auto_review: Option, pub never: Option, pub unless_trusted: Option, + /// Optional catalog templates for harness-owned granular approval facts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub granular: Option, +} + +/// Catalog presentation for granular approval policy; the harness owns the policy and lists. +/// This wire type preserves templates verbatim; validation and rendering are consumer concerns. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct GranularApprovalMessages { + /// Rendering contract: at most 500 UTF-8 bytes with exactly one `{{ policy }}` marker. + /// Consumers must use bundled text for missing, null, empty, invalid, or oversized templates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intro: Option, + /// Presentation around `{{ categories }}`, including the fixed prompted-bucket label. + /// Uses the same byte limit and fallback rules as `intro`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompted_categories: Option, + /// Presentation around `{{ categories }}`, including the fixed rejected-bucket label. + /// Uses the same byte limit and fallback rules as `intro`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rejected_categories: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] @@ -1277,8 +1298,51 @@ mod tests { on_request_auto_review: None, never: Some(String::new()), unless_trusted: None, + granular: None, }) ); + + for (granular, expected) in [ + (serde_json::json!(null), None), + (serde_json::json!({}), Some(serde_json::json!({}))), + ( + serde_json::json!({"intro": null, "prompted_categories": null, "rejected_categories": null}), + Some(serde_json::json!({})), + ), + ( + serde_json::json!({"intro": ""}), + Some(serde_json::json!({"intro": ""})), + ), + ( + serde_json::json!({ + "intro": "Catalog: {{ policy }}", + "prompted_categories": "Allowed: {{ categories }}", + "rejected_categories": "Denied: {{ categories }}", + }), + Some(serde_json::json!({ + "intro": "Catalog: {{ policy }}", + "prompted_categories": "Allowed: {{ categories }}", + "rejected_categories": "Denied: {{ categories }}", + })), + ), + ] { + let wire = serde_json::json!({"approvals": { + "on_request": "existing override", "granular": granular, + }}); + let messages: ModelMessages = + serde_json::from_value(wire).expect("sparse granular messages"); + let roundtrip = serde_json::to_value(messages.approvals).expect("serialize approvals"); + let mut expected_approvals = serde_json::json!({ + "on_request": "existing override", + "on_request_auto_review": null, + "never": null, + "unless_trusted": null, + }); + if let Some(expected) = expected { + expected_approvals["granular"] = expected; + } + assert_eq!(roundtrip, expected_approvals); + } } #[test] @@ -1605,6 +1669,11 @@ mod tests { on_request_auto_review: None, never: Some("never approval".to_string()), unless_trusted: Some("unless-trusted approval".to_string()), + granular: Some(GranularApprovalMessages { + intro: Some("Catalog: {{ policy }}".to_string()), + prompted_categories: Some("Allowed: {{ categories }}".to_string()), + rejected_categories: Some("Denied: {{ categories }}".to_string()), + }), }), collaboration_modes: Some(CollaborationModeMessages { default: Some("default collaboration".to_string()),