Add model catalog fields for granular approval templates (#57325)

## What changed

Add optional `approvals.granular` catalog fields for the policy introduction and prompted and rejected category lists. Preserve template strings verbatim in `GranularApprovalMessages`, leaving validation and rendering to consumers.

Document the 500-byte template limit, required `{{ policy }}` and `{{ categories }}` markers, and fallback rules.

## Testing

Extend serialization tests to cover null, empty, and populated granular templates, and verify their preservation through model responses and base instruction overrides.

GitOrigin-RevId: ce32e41a9b37e2e7c45ac9b079708a640c99326c
This commit is contained in:
goodman-oai
2026-10-27 01:50:46 +00:00
committed by copyberry
parent ce7adb6a31
commit c587d6e54c
8 changed files with 89 additions and 0 deletions
@@ -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()),
@@ -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,
@@ -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`",
@@ -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,
@@ -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 {
@@ -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()
};
@@ -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,
+69
View File
@@ -775,6 +775,27 @@ pub struct ApprovalMessages {
pub on_request_auto_review: Option<String>,
pub never: Option<String>,
pub unless_trusted: Option<String>,
/// Optional catalog templates for harness-owned granular approval facts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granular: Option<GranularApprovalMessages>,
}
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
#[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()),