Store security risk scores as a snapshot (#38567)

## What changed

- Replace the single `category` and `score` fields in `SecurityRiskScore` with a
  deterministic map of category names to scores.
- Validate the full Guardian V2 classifier response before storing it as one
  thread extension value and, for non-ephemeral threads, one rollout item.
- Cover serialization and rollout loading with snapshots containing multiple
  score categories.

GitOrigin-RevId: 24525de691f96bd34ccfe04acab2f01aca309837
This commit is contained in:
jif
2026-08-14 12:16:51 +00:00
committed by copyberry
parent 08c48e3700
commit aa905bb962
10 changed files with 41 additions and 32 deletions
@@ -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());
@@ -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
+17 -16
View File
@@ -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::<Result<_, _>>()?;
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;
@@ -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;
@@ -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));
+6 -1
View File
@@ -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",
+2 -2
View File
@@ -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()),
])
+3 -3
View File
@@ -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<String, f64>,
}
+5 -2
View File
@@ -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] {