diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index d5e25f6099..5e41454f63 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs index d240e3a7ef..3c3d4e2ef7 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs @@ -16,6 +16,7 @@ use codex_rollout::CompactedItem; use codex_rollout::RolloutItem; use codex_rollout::RolloutLine; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use super::*; use crate::protocol::v2::ThreadItem; @@ -202,8 +203,7 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() { window_id: None, })); let security_risk = project(RolloutItem::SecurityRiskScore(SecurityRiskScore { - category: "action_risk".to_string(), - score: 0.92, + scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), })); assert!(aborted.is_empty()); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 7e2a01f926..a1cbeafee9 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::WorldStateItem; use codex_protocol::security_risk::SecurityRiskScore; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use std::path::PathBuf; use uuid::Uuid; @@ -152,8 +153,7 @@ async fn record_initial_history_ignores_security_risk_scores() { let (session, _turn_context) = make_session_and_context().await; let user_item = user_message("visible user input"); let security_risk = SecurityRiskScore { - category: "credential_access".to_string(), - score: 0.92, + scores: BTreeMap::from([("credential_access".to_string(), 0.92)]), }; session diff --git a/codex-rs/ext/guardian-v2/src/extension.rs b/codex-rs/ext/guardian-v2/src/extension.rs index 66799ea388..bfb26ed7ce 100644 --- a/codex-rs/ext/guardian-v2/src/extension.rs +++ b/codex-rs/ext/guardian-v2/src/extension.rs @@ -202,23 +202,24 @@ impl ToolLifecycleContributor for GuardianV2Extension { .await .map_err(|error| error.to_string())?; let ephemeral = thread.config_snapshot().await.ephemeral; - for (category, value) in scores { - let score = value - .as_f64() - .filter(|score| (0.0..=1.0).contains(score)) - .ok_or_else(|| format!("invalid security risk score for {category}"))?; - let score = SecurityRiskScore { - category: category.clone(), - score, - }; - if !ephemeral { - thread - .append_rollout_items(&[RolloutItem::SecurityRiskScore(score.clone())]) - .await - .map_err(|error| error.to_string())?; - } - thread.thread_extension_data().insert(score); + let scores = scores + .iter() + .map(|(category, value)| { + value + .as_f64() + .filter(|score| (0.0..=1.0).contains(score)) + .map(|score| (category.clone(), score)) + .ok_or_else(|| format!("invalid security risk score for {category}")) + }) + .collect::>()?; + let score = SecurityRiskScore { scores }; + if !ephemeral { + thread + .append_rollout_items(&[RolloutItem::SecurityRiskScore(score.clone())]) + .await + .map_err(|error| error.to_string())?; } + thread.thread_extension_data().insert(score); Ok(()) } .await; diff --git a/codex-rs/ext/guardian-v2/src/extension_tests.rs b/codex-rs/ext/guardian-v2/src/extension_tests.rs index fdcadbbd98..0168a41413 100644 --- a/codex-rs/ext/guardian-v2/src/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/extension_tests.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; @@ -275,8 +276,7 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< assert_eq!( score.as_ref(), &SecurityRiskScore { - category: "action_risk".to_string(), - score: 0.25, + scores: BTreeMap::from([("action_risk".to_string(), 0.25)]), } ); test.codex.ensure_rollout_materialized().await; diff --git a/codex-rs/external-agent-migration/src/sessions/append_tests.rs b/codex-rs/external-agent-migration/src/sessions/append_tests.rs index 2a64e1a2cc..23de57961d 100644 --- a/codex-rs/external-agent-migration/src/sessions/append_tests.rs +++ b/codex-rs/external-agent-migration/src/sessions/append_tests.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::ContextCompactedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::security_risk::SecurityRiskScore; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; #[test] fn returns_the_missing_suffix_from_its_visible_boundary() { @@ -64,8 +65,7 @@ fn requires_a_strict_nonempty_model_prefix() { } } let security_risk = RolloutItem::SecurityRiskScore(SecurityRiskScore { - category: "action_risk".to_string(), - score: 0.92, + scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), }); metadata_changed.push(security_risk.clone()); assert!(model_transcripts_match(&history, &metadata_changed)); diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 056bad02f9..94f08c0ad8 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -354,7 +354,12 @@ fn rollout_item_variants_preserve_existing_payload_shapes() -> Result<()> { }), json!({ "type": "security_risk_score", - "payload": { "category": "action_risk", "score": 0.92 }, + "payload": { + "scores": { + "action_risk": 0.92, + "data_exfiltration": 0.31, + }, + }, }), json!({ "type": "event_msg", diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index f7caefbeda..954eb6b3a8 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -675,6 +675,7 @@ mod tests { use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::security_risk::SecurityRiskScore; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; #[test] fn serializes_memory_rollout_with_agents_removed_but_environment_kept() { @@ -725,8 +726,7 @@ mod tests { RolloutItem::ResponseItem(mixed_contextual_message.into()), RolloutItem::ResponseItem(skill_message.into()), RolloutItem::SecurityRiskScore(SecurityRiskScore { - category: "action_risk".to_string(), - score: 0.92, + scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), }), RolloutItem::ResponseItem(subagent_message.clone().into()), ]) diff --git a/codex-rs/protocol/src/security_risk.rs b/codex-rs/protocol/src/security_risk.rs index 73565eff82..70c9a83d64 100644 --- a/codex-rs/protocol/src/security_risk.rs +++ b/codex-rs/protocol/src/security_risk.rs @@ -1,13 +1,13 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; -/// A durable, thread-owned security risk classifier score. +/// A durable, thread-owned snapshot of security risk classifier scores. /// /// These records belong to rollout history only and must not enter model-visible /// conversation context or user-visible thread item projections. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SecurityRiskScore { - pub category: String, - pub score: f64, + pub scores: BTreeMap, } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 3c64aa6179..a6f1237a89 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::security_risk::SecurityRiskScore; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use std::fs; use std::fs::File; use std::io::Write; @@ -438,8 +439,10 @@ async fn load_rollout_items_preserves_security_risk_scores() -> std::io::Result< let rollout_path = home.path().join("rollout.jsonl"); let thread_id = ThreadId::new(); let security_risk = SecurityRiskScore { - category: "action_risk".to_string(), - score: 0.76, + scores: BTreeMap::from([ + ("action_risk".to_string(), 0.76), + ("data_exfiltration".to_string(), 0.31), + ]), }; let security_risk_item = RolloutItem::SecurityRiskScore(security_risk.clone()); for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {