From 2a452d7dc1f97e22e126fc3a111237a3e828de0c Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 14 Aug 2026 13:37:54 +0000 Subject: [PATCH] Keep the latest Guardian risk score during concurrent sampling (#38580) ## Why Concurrent Guardian samples can finish out of order, allowing an older sample to replace the thread's newer in-memory security risk score. ## What changed - Record when each `SecurityRiskScore` sample starts while preserving compatibility with records that have no timestamp. - Add `ExtensionData::insert_if` so checking and replacing a typed attachment happen under the same lock. - Update Guardian V2 to replace the thread's risk score only when the completed sample is newer than the stored one. ## Testing - Verify concurrent conditional inserts retain the newest value. - Verify Guardian-generated risk scores include a sampling timestamp. GitOrigin-RevId: 6543a4e5497da4c623331050789e91613f931c4b --- .../app-server-exports-stable.json.zst | Bin 133502 -> 133536 bytes .../thread_history_projection_tests.rs | 1 + .../session/rollout_reconstruction_tests.rs | 1 + codex-rs/ext/extension-api/src/state.rs | 19 +++++++++++ codex-rs/ext/extension-api/tests/state.rs | 31 ++++++++++++++++++ codex-rs/ext/guardian-v2/src/extension.rs | 13 ++++++-- .../ext/guardian-v2/src/extension_tests.rs | 4 +++ .../src/sessions/append_tests.rs | 1 + codex-rs/memories/write/src/phase1.rs | 1 + codex-rs/protocol/src/security_risk.rs | 6 ++++ codex-rs/rollout/src/recorder_tests.rs | 1 + 11 files changed, 76 insertions(+), 2 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 5e41454f63308f80d7e5b37c5d3b4cee42d67ee7..a78af9ad4534b012a01eff57ee8c4fb2858711f1 100644 GIT binary patch delta 5410 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 M_)YEwE96BqOw)=+LI3~& delta 5375 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 z***zFI0U(&self, value: T, should_insert: impl FnOnce(Option<&T>) -> bool) -> bool + where + T: Any + Send + Sync, + { + let mut entries = self.entries(); + let existing = entries + .get(&TypeId::of::()) + .map(|value| downcast_data::(Arc::clone(value))); + if !should_insert(existing.as_deref()) { + return false; + } + entries.insert(TypeId::of::(), Arc::new(value)); + true + } + /// Removes and returns the attached value of type `T`, if one exists. pub fn remove(&self) -> Option> where diff --git a/codex-rs/ext/extension-api/tests/state.rs b/codex-rs/ext/extension-api/tests/state.rs index 31b643c29b..6e7720c913 100644 --- a/codex-rs/ext/extension-api/tests/state.rs +++ b/codex-rs/ext/extension-api/tests/state.rs @@ -1,5 +1,6 @@ use std::panic::AssertUnwindSafe; use std::sync::Arc; +use std::sync::Barrier; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -29,6 +30,36 @@ fn typed_values_can_be_inserted_replaced_and_removed() { assert_eq!(data.get::().as_deref(), Some(&42)); } +#[test] +fn conditional_insert_keeps_the_newest_concurrent_value() { + const CALLER_COUNT: u64 = 16; + + let data = Arc::new(ExtensionData::new("thread-1")); + let callers_ready = Arc::new(Barrier::new(CALLER_COUNT as usize)); + let handles = (0..CALLER_COUNT) + .map(|value| { + let data = Arc::clone(&data); + let callers_ready = Arc::clone(&callers_ready); + std::thread::spawn(move || { + callers_ready.wait(); + data.insert_if(value, |existing| { + existing.is_none_or(|existing| value > *existing) + }); + }) + }) + .collect::>(); + + for handle in handles { + handle.join().expect("insertion thread should succeed"); + } + + assert_eq!(data.get::().as_deref(), Some(&(CALLER_COUNT - 1))); + assert!(!data.insert_if(/*value*/ 0_u64, |existing| { + existing.is_none_or(|existing| *existing == 0) + })); + assert_eq!(data.get::().as_deref(), Some(&(CALLER_COUNT - 1))); +} + #[test] fn get_or_init_initializes_once_and_returns_shared_value() { const CALLER_COUNT: usize = 8; diff --git a/codex-rs/ext/guardian-v2/src/extension.rs b/codex-rs/ext/guardian-v2/src/extension.rs index 915ab266f2..836db2bd1b 100644 --- a/codex-rs/ext/guardian-v2/src/extension.rs +++ b/codex-rs/ext/guardian-v2/src/extension.rs @@ -1,5 +1,6 @@ use std::sync::Arc; use std::sync::Weak; +use std::time::SystemTime; use codex_core::ThreadManager; use codex_core::config::Config; @@ -133,6 +134,7 @@ impl ToolLifecycleContributor for GuardianV2Extension { let Some(sampler) = input.thread_store.get::() else { return Box::pin(std::future::ready(())); }; + let sampled_at = SystemTime::now(); let event_sink = Arc::clone(&self.event_sink); let thread_manager = self.thread_manager.clone(); let thread_id = input.thread_store.level_id().to_owned(); @@ -239,8 +241,15 @@ impl ToolLifecycleContributor for GuardianV2Extension { .ok_or_else(|| format!("invalid security risk score for {category}")) }) .collect::>()?; - let score = SecurityRiskScore { scores }; - thread.thread_extension_data().insert(score.clone()); + let score = SecurityRiskScore { + scores, + sampled_at: Some(sampled_at.into()), + }; + thread + .thread_extension_data() + .insert_if(score.clone(), |previous| { + previous.is_none_or(|previous| previous.sampled_at < score.sampled_at) + }); if !ephemeral { thread .append_rollout_items(&[RolloutItem::SecurityRiskScore(score)]) diff --git a/codex-rs/ext/guardian-v2/src/extension_tests.rs b/codex-rs/ext/guardian-v2/src/extension_tests.rs index a1eaa03745..c3eaee3e8f 100644 --- a/codex-rs/ext/guardian-v2/src/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/extension_tests.rs @@ -284,8 +284,10 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< score.as_ref(), &SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.8)]), + sampled_at: score.sampled_at, } ); + assert!(score.sampled_at.is_some()); assert_eq!( registry.approval_requirement(thread_store), ApprovalRequirement::RequireAutomaticReview @@ -307,6 +309,7 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< thread_store.insert(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.25)]), + sampled_at: None, }); assert_eq!( registry.approval_requirement(thread_store), @@ -316,6 +319,7 @@ 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.8)]), + sampled_at: None, }); assert_eq!( registry.approval_requirement(&disabled_thread_store), 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 23de57961d..17d1c48b05 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,7 @@ fn requires_a_strict_nonempty_model_prefix() { } let security_risk = RolloutItem::SecurityRiskScore(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), + sampled_at: None, }); metadata_changed.push(security_risk.clone()); assert!(model_transcripts_match(&history, &metadata_changed)); diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 954eb6b3a8..820b59030f 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -727,6 +727,7 @@ mod tests { RolloutItem::ResponseItem(skill_message.into()), RolloutItem::SecurityRiskScore(SecurityRiskScore { scores: BTreeMap::from([("action_risk".to_string(), 0.92)]), + 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 70c9a83d64..7d1dde8aab 100644 --- a/codex-rs/protocol/src/security_risk.rs +++ b/codex-rs/protocol/src/security_risk.rs @@ -1,3 +1,5 @@ +use chrono::DateTime; +use chrono::Utc; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -10,4 +12,8 @@ use std::collections::BTreeMap; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SecurityRiskScore { pub scores: BTreeMap, + /// When sampling started, if this snapshot was written by a timestamp-aware client. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub sampled_at: Option>, } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index a6f1237a89..a69919dec5 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -443,6 +443,7 @@ async fn load_rollout_items_preserves_security_risk_scores() -> std::io::Result< ("action_risk".to_string(), 0.76), ("data_exfiltration".to_string(), 0.31), ]), + sampled_at: None, }; let security_risk_item = RolloutItem::SecurityRiskScore(security_risk.clone()); for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {