From d4998d611ad37de0aa9723b6fdd2d9a2f8ff4763 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 26 Aug 2026 14:55:41 +0000 Subject: [PATCH] Record reviewed actions with security risk scores (#40901) ## What changed - Add optional `call_id` and `action` fields to `SecurityRiskScore` so a persisted score identifies the tool call and bounded action that produced it. - Populate the fields after Guardian V2 classifies an action while leaving fail-closed and legacy scores without provenance. ## Testing - Verify scores preserve action details for direct tool calls and nested code-mode calls. - Verify rollout serialization retains the new fields. GitOrigin-RevId: fce69af37f368cecf1d9bce42bfc38a969e969b7 --- .../app-server-exports-stable.json.zst | Bin 142104 -> 142153 bytes .../thread_history_projection_tests.rs | 2 + .../session/rollout_reconstruction_tests.rs | 2 + .../guardian-v2/src/async_scorer/extension.rs | 6 + .../src/async_scorer/extension_tests.rs | 111 +++++++++++++++++- .../src/sessions/append_tests.rs | 2 + codex-rs/memories/write/src/phase1.rs | 2 + codex-rs/protocol/src/security_risk.rs | 6 + codex-rs/rollout/src/recorder_tests.rs | 2 + 9 files changed, 130 insertions(+), 3 deletions(-) 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 11383fee3df00b3896644201b8d5f64699caf6ba..71ff2b933e9ee8229aa96b24d8deee4b8a434da4 100644 GIT binary patch delta 5584 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 delta 5534 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 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 42624fa408..6cd4744d6a 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 @@ -206,6 +206,8 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() { })); let security_risk = project(RolloutItem::SecurityRiskScore(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), + call_id: None, + action: None, sampled_at: None, })); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index abc875c3ba..4f8c6ceebd 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -157,6 +157,8 @@ async fn record_initial_history_ignores_security_risk_scores() { let user_item = user_message("visible user input"); let security_risk = SecurityRiskScore { scores: BTreeMap::from([("credential_access".to_string(), 0.92)]), + call_id: None, + action: None, sampled_at: None, }; diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index 3d47429f0b..550c01ab6d 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -453,6 +453,8 @@ impl GuardianV2Extension { fn record_fail_closed_score(thread_store: &ExtensionData, sampled_at: SystemTime) { let score = SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]), + call_id: None, + action: None, sampled_at: Some(sampled_at.into()), }; thread_store.insert_if(score.clone(), |previous| { @@ -772,6 +774,10 @@ impl GuardianV2Extension { }; let score = SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), action_risk)]), + call_id: Some(call_id.clone()), + action: Some( + serde_json::from_str(&planned_action).map_err(|error| error.to_string())?, + ), sampled_at: Some(sampled_at.into()), }; let accepted = diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index f486ddc016..a4b6dfe048 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -259,6 +259,8 @@ fn fail_closed_score_preserves_classification_order() { let newest_sampled_at = newer_sampled_at + Duration::from_secs(1); let newer_score = SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, sampled_at: Some(newer_sampled_at.into()), }; thread_store.insert(newer_score.clone()); @@ -272,6 +274,8 @@ fn fail_closed_score_preserves_classification_order() { GuardianV2Extension::record_fail_closed_score(&thread_store, newest_sampled_at); let fail_closed_score = SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]), + call_id: None, + action: None, sampled_at: Some(newest_sampled_at.into()), }; assert!(!thread_store.insert_if(newer_score.clone(), |previous| { @@ -412,6 +416,8 @@ async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> { thread_store.insert(config); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, sampled_at: None, }); let progress = thread_store @@ -673,6 +679,25 @@ async fn sample_configured_conversation_history( guardian_policy: Option<&str>, guardian_config: &str, model_defaults: Option, +) -> Result<(serde_json::Value, TestCodex, ExtensionRegistry)> { + sample_configured_conversation_history_with_source( + conversation_history, + arguments, + guardian_policy, + guardian_config, + model_defaults, + ToolCallSource::Direct, + ) + .await +} + +async fn sample_configured_conversation_history_with_source( + conversation_history: Vec, + arguments: &str, + guardian_policy: Option<&str>, + guardian_config: &str, + model_defaults: Option, + source: ToolCallSource, ) -> Result<(serde_json::Value, TestCodex, ExtensionRegistry)> { let thread_server = responses::start_mock_server().await; let guardian_policy = guardian_policy.map(str::to_owned); @@ -789,7 +814,7 @@ async fn sample_configured_conversation_history( tool_name: &tool_name, payload: &tool_payload, conversation_history: Arc::new(conversation_history), - source: ToolCallSource::Direct, + source, }) .await; @@ -837,6 +862,8 @@ impl GuardianFailureFixture { let thread_store = self.test.codex.thread_extension_data(); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, sampled_at: None, }); let turn_store = ExtensionData::new("turn-1"); @@ -1196,6 +1223,8 @@ max_recent_non_user_entries = 8 assert_eq!(thread_store.get::(), None); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.65)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1217,6 +1246,8 @@ max_recent_non_user_entries = 8 ); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.55)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1561,14 +1592,23 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu (2, false, 384, true) ); assert!(thread_store.get::().is_some()); - tokio::time::timeout(Duration::from_secs(5), async { - while thread_store.get::().is_none() { + let score = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(score) = thread_store.get::() { + return score; + } tokio::task::yield_now().await; } }) .await?; + assert_eq!( + score.action, + Some(serde_json::from_str::(action)?) + ); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.65)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1723,6 +1763,8 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< score.as_ref(), &SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 1.0)]), + call_id: Some("call-1".to_owned()), + action: Some(json!({"path": "README.md", "tool": "read_file"})), sampled_at: score.sampled_at, } ); @@ -1761,6 +1803,8 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< ); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.5)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1777,6 +1821,8 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.49)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1794,6 +1840,8 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< let disabled_thread_store = ExtensionData::new("disabled-thread"); disabled_thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.25)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1811,6 +1859,57 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn contributor_persists_nested_code_mode_action_with_score() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (_request, test, _registry) = sample_configured_conversation_history_with_source( + Vec::new(), + r#"{"path":"README.md"}"#, + Some(TEST_GUARDIAN_POLICY), + "", + /*model_defaults*/ None, + ToolCallSource::CodeMode { + cell_id: "cell-1".to_owned(), + runtime_tool_call_id: "nested-1".to_owned(), + }, + ) + .await?; + test.codex.ensure_rollout_materialized().await; + + let score = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(score) = test + .codex + .load_history(/*include_archived*/ false) + .await? + .items + .into_iter() + .find_map(|item| match item { + RolloutItem::SecurityRiskScore(score) => Some(score), + _ => None, + }) + { + return Ok::<_, anyhow::Error>(score); + } + tokio::task::yield_now().await; + } + }) + .await??; + + assert_eq!( + score, + SecurityRiskScore { + scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]), + call_id: Some("call-1".to_owned()), + action: Some(json!({"path": "README.md", "tool": "read_file"})), + sampled_at: score.sampled_at, + } + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn contributor_skips_models_requiring_managed_guardian_review() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1881,6 +1980,8 @@ async fn contributor_skips_models_requiring_managed_guardian_review() -> Result< thread_store.insert(model_info); thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -1961,6 +2062,8 @@ async fn contributor_counts_failed_thread_lookups_toward_score_lag() -> Result<( .await?; thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, sampled_at: None, }); assert_eq!( @@ -2451,6 +2554,8 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< fail_closed_score.as_ref(), &SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_owned(), 1.0)]), + call_id: None, + action: None, sampled_at: fail_closed_score.sampled_at, } ); 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 17d1c48b05..81f3973f79 100644 --- a/codex-rs/external-agent-migration/src/sessions/append_tests.rs +++ b/codex-rs/external-agent-migration/src/sessions/append_tests.rs @@ -66,6 +66,8 @@ fn requires_a_strict_nonempty_model_prefix() { } let security_risk = RolloutItem::SecurityRiskScore(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), + call_id: None, + action: None, sampled_at: None, }); metadata_changed.push(security_risk.clone()); diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 4d98599b93..89d87e39ee 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -728,6 +728,8 @@ mod tests { RolloutItem::ResponseItem(skill_message.into()), RolloutItem::SecurityRiskScore(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), + call_id: None, + action: None, sampled_at: None, }), 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 ca16fae4f5..151a0a98d8 100644 --- a/codex-rs/protocol/src/security_risk.rs +++ b/codex-rs/protocol/src/security_risk.rs @@ -12,6 +12,12 @@ use std::collections::BTreeMap; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SecurityRiskScore { pub scores: BTreeMap, + /// The tool call whose action produced this score, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call_id: Option, + /// The bounded tool action that was reviewed to produce this score. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, /// When sampling started, if this snapshot was written by a timestamp-aware client. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 42cf907028..8ccad419b4 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -445,6 +445,8 @@ async fn load_rollout_items_preserves_security_risk_scores() -> std::io::Result< ("action_risk".to_string(), 0.76), ("data_exfiltration".to_string(), 0.31), ]), + call_id: Some("call-1".to_owned()), + action: Some(serde_json::json!({"path": "README.md", "tool": "read_file"})), sampled_at: None, }; let security_risk_item = RolloutItem::SecurityRiskScore(security_risk.clone());