From ad8ee16a5f4c7445253b57a10cb1f8489c8c3e6a Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Thu, 3 Sep 2026 17:24:52 +0000 Subject: [PATCH] Require Guardian review for incompatible compaction checkpoints (#42588) ## Why Guardian can reuse an opaque parent compaction only when the checkpoint's recorded producer hash matches the scoring model. Missing or incompatible provenance must not allow an asynchronous score or cached approval to omit compacted context. ## What changed - Record the producing model's compatibility hash on compaction history items and expose the latest checkpoint hash through conversation snapshots. - In thread-owned context mode, fail closed and require synchronous review when checkpoint reuse is enabled but the latest checkpoint is incompatible or has unknown provenance. - Reject incompatible checkpoints in the sampler while preserving the legacy context behavior. ## Testing Add coverage for local, remote, resumed, compacted, and rolled-back histories, including matching, mismatched, missing, and empty compatibility hashes. GitOrigin-RevId: 1ce959cb276edc602fd7dfa442133c7fe5e53857 --- .../app-server-exports-stable.json.zst | Bin 147675 -> 147717 bytes .../suite/v2/guardian_v2_history_tests.rs | 157 ++++++++++++++---- codex-rs/core/src/codex_thread.rs | 8 +- codex-rs/core/src/compact.rs | 2 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 1 + .../src/context/guardian_review_evidence.rs | 5 + codex-rs/core/src/context_manager/history.rs | 14 ++ .../core/src/context_manager/history_tests.rs | 42 +++++ codex-rs/core/src/session/mod.rs | 14 ++ codex-rs/core/src/session/tests.rs | 1 + .../src/capabilities/conversation_history.rs | 6 + .../guardian-v2/src/async_scorer/extension.rs | 103 ++++++++++-- .../src/async_scorer/extension_tests.rs | 152 ++++++++++++++++- .../guardian-v2/src/async_scorer/sampler.rs | 30 ++-- .../src/async_scorer/sampler_tests.rs | 53 +++--- codex-rs/history/src/lib.rs | 4 + 17 files changed, 505 insertions(+), 88 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 c4ceee2265e74a22a2cd68e1025b53ec47769b46..1ce21e841b042a5f9c61ca58c9ecd94117b443b9 100644 GIT binary patch delta 18607 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, + luna_hash: Option<&str>, tool_traffic: usize, answer_size: AnswerSize, ) -> Result<()> { @@ -86,6 +92,8 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac const RESTRICTION: &str = "Only publish to a private repository."; const EVIDENCE: &str = "repository is private"; const SUMMARY: &str = "Repository inspection was summarized."; + let compatible = parent_hash == Some("matching") && luna_hash == parent_hash; + let requires_sync = matches!(context_path, ContextPath::ThreadOwned) && !compatible; let answer = match answer_size { AnswerSize::Normal => USER_INPUT_RESTRICTION.to_owned(), AnswerSize::Oversized => "Never publish this repository publicly. ".repeat(200), @@ -104,7 +112,11 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac (ContextPath::ThreadOwned, AnswerSize::Oversized) ); let classifier = Arc::new(MockResponsesState { - luna_score: if rejects_incomplete_score { 0.0 } else { 1.0 }, + luna_score: if rejects_incomplete_score || requires_sync { + 0.0 + } else { + 1.0 + }, ..Default::default() }); let router = Router::new() @@ -223,14 +235,18 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac }; mock_config.write(codex_home.path())?; let config = load_default_config_for_test(&codex_home).await; - let models = [(MODEL, "matching"), ("gpt-5.6-luna", luna_hash)] - .into_iter() - .map(|(model, hash)| { - let mut info = codex_core::test_support::construct_model_info_offline(model, &config); - info.comp_hash = Some(hash.to_owned()); - info - }) - .collect(); + let models = [ + (MODEL, parent_hash), + ("gpt-5.6-luna", luna_hash), + ("resumed-parent", Some("matching")), + ] + .into_iter() + .map(|(model, hash)| { + let mut info = codex_core::test_support::construct_model_info_offline(model, &config); + info.comp_hash = hash.map(str::to_owned); + info + }) + .collect(); write_models_cache_with_models(codex_home.path(), models)?; write_chatgpt_auth( codex_home.path(), @@ -263,7 +279,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac .into_iter() .enumerate() { - if (1..=2).contains(&index) { + if index == 2 || (index == 1 && !requires_sync) { app_server.shutdown_gracefully().await?; app_server = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -273,6 +289,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac let id = app_server .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.clone(), + model: parent_hash.is_none().then(|| "resumed-parent".to_owned()), ..Default::default() }) .await?; @@ -304,15 +321,26 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac let id = app_server .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), - input: vec![UserInput::Text { - text: prompt.to_owned(), - text_elements: Vec::new(), - }], + input: if index == 1 && requires_sync { + Vec::new() + } else { + vec![UserInput::Text { + text: prompt.to_owned(), + text_elements: Vec::new(), + }] + }, ..Default::default() }) .await?; let _: TurnStartResponse = timeout(TIMEOUT, app_server.read_response(id)).await??; if index == 0 { + // Wait for the pre-answer sample before replying; scoring runs asynchronously. + let before_answer = wait_for_luna_request(&classifier, /*index*/ 0).await?; + assert!( + !before_answer + .to_string() + .contains(">>> TRUSTED USER ANSWERS START") + ); submit_user_input_response( &mut app_server, json!({ @@ -320,19 +348,61 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac }), ) .await?; - // request_user_input itself is scored before the host receives the answer. - let before_answer = wait_for_luna_request(&classifier, /*index*/ 0).await?; - assert!( - !before_answer - .to_string() - .contains(">>> TRUSTED USER ANSWERS START") - ); classifier.allow_luna.notify_one(); } let completed: TurnCompletedNotification = timeout(TIMEOUT, app_server.read_notification("turn/completed")).await??; assert_eq!(completed.turn.status, TurnStatus::Completed); - let request = wait_for_luna_request(&classifier, index + 1).await?; + if requires_sync && (1..=3).contains(&index) { + let reviews = review_requests.lock().expect("request log lock"); + assert_eq!( + reviews.len(), + index + 1, + "incompatible checkpoints require sync review" + ); + let review = &reviews[index]; + assert!( + review["input"] + .as_array() + .expect("request input array") + .contains(&checkpoint) + ); + let text = review.to_string(); + if index >= 2 { + assert!( + text.contains(prompt), + "current user input missing from sync review: {text}" + ); + } + if index == 3 { + assert!( + !text.contains("Inspect after resume."), + "rolled-back user input remains in sync review: {text}" + ); + } + assert!(text.contains(USER_INPUT_RESTRICTION)); + assert!(text.contains("TRUSTED USER ANSWERS START")); + assert!(text.contains("TRANSCRIPT START")); + assert!(!text.contains("TRANSCRIPT DELTA START")); + assert_eq!( + classifier + .luna_requests + .lock() + .expect("classifier request lock") + .len(), + 2, + "no checkpoint-less async request may run after compaction" + ); + continue; + } + let sample_index = if !requires_sync { + index + 1 + } else if index == 0 { + 1 + } else { + 2 + }; + let request = wait_for_luna_request(&classifier, sample_index).await?; let content = request["input"] .as_array() .expect("request input array") @@ -365,7 +435,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac assert_eq!(sync_input.contains(&checkpoint), (1..=3).contains(&index)); assert_eq!( async_input.contains(&checkpoint), - (1..=3).contains(&index) && luna_hash == "matching" + (1..=3).contains(&index) && compatible ); let sync_text = sync_input .iter() @@ -485,7 +555,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac } } classifier.allow_luna.notify_one(); - if rejects_incomplete_score { + if rejects_incomplete_score || (index == 0 && requires_sync) { // Wait for host publication, not merely a mock response. The next action must // see a fresh low score with unchanged authorization, rather than a stale score. let path = thread.path.as_ref().expect("legacy rollout path"); @@ -507,6 +577,8 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac } }) .await?; + } + if rejects_incomplete_score { let completed = app_server .start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread_id.clone(), @@ -551,6 +623,27 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac usize::from(matches!(context_path, ContextPath::ThreadOwned)), "only the enabled path may persist a retained-answer event", ); + for line in &items { + if let RolloutItem::Compacted(checkpoint) = &line.item { + for envelope in checkpoint.replacement_history.iter().flatten() { + if matches!( + envelope.item, + codex_protocol::models::ResponseItem::Compaction { .. } + ) { + assert_eq!( + envelope + .metadata + .as_ref() + .and_then(|metadata| metadata.compaction_model_hash.as_deref()), + matches!(context_path, ContextPath::ThreadOwned) + .then_some(parent_hash) + .flatten(), + "only the enabled path records checkpoint producer provenance", + ); + } + } + } + } if matches!(context_path, ContextPath::Legacy) { for line in &items { if let RolloutItem::Compacted(checkpoint) = &line.item { diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 888d80fe2b..a2ec9fb99f 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -11,6 +11,7 @@ use crate::session::step_settings::StepSettingsUpdate; use codex_diagnostics::Gauge; use codex_diagnostics::GaugeGuard; use codex_exec_server::SelectedCapabilityRootsStatus; +use codex_extension_api::ConversationHistorySnapshot; use codex_extension_api::ThreadIdleCause; use codex_features::Feature; use codex_history::RolloutItem; @@ -804,9 +805,14 @@ impl CodexThread { self.session.multi_agent_version() } + /// Shares an immutable view of the live parent model context and retained host facts. + pub async fn conversation_history_snapshot(&self) -> Arc { + self.session.conversation_history_snapshot().await + } + /// Returns the current user-authorization revision for Guardian. pub async fn guardian_authorization_version(&self) -> GuardianAuthorizationVersion { - let history = self.session.conversation_history_snapshot().await; + let history = self.conversation_history_snapshot().await; self.thread_extension_data() .get_or_init(GuardianReviewEvidence::default) .authorization_version(history.as_ref()) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 49306c357c..5e473b0547 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -86,6 +86,7 @@ pub(crate) struct CompactedHistoryMetadata { pub(crate) window_number: u64, pub(crate) window_ids: AutoCompactWindowIds, pub(crate) compaction_response_id: Option, + pub(crate) compaction_model_hash: Option, } pub(crate) async fn build_compaction_initial_context( @@ -385,6 +386,7 @@ async fn run_compact_task_inner_impl( window_number, window_ids, compaction_response_id: Some(compaction_response_id), + compaction_model_hash: turn_context.model_info().comp_hash.clone(), }, ) .await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 442c4a0ce0..d1eb2124eb 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -301,6 +301,7 @@ async fn run_remote_compact_task_inner_impl( window_number: new_window_number, window_ids: new_window_ids, compaction_response_id: None, + compaction_model_hash: compaction_turn_context.model_info().comp_hash.clone(), }, ) .await; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 68e2eaeda6..f02e2db678 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -351,6 +351,7 @@ async fn run_remote_compact_task_inner_impl( window_number: new_window_number, window_ids: new_window_ids, compaction_response_id: Some(compaction_response_id), + compaction_model_hash: compaction_turn_context.model_info().comp_hash.clone(), }, ) .await; diff --git a/codex-rs/core/src/context/guardian_review_evidence.rs b/codex-rs/core/src/context/guardian_review_evidence.rs index d6824982eb..d36d785a14 100644 --- a/codex-rs/core/src/context/guardian_review_evidence.rs +++ b/codex-rs/core/src/context/guardian_review_evidence.rs @@ -64,6 +64,11 @@ struct GuardianReviewEvidenceState { } impl GuardianReviewEvidence { + /// Reports the fixed thread mode used for both capture and reviewer policy. + pub fn uses_thread_owned_context(&self) -> bool { + matches!(self.mode, GuardianContextMode::ThreadOwned) + } + pub(crate) fn from_features(features: &Features) -> Self { Self { mode: if features.enabled(Feature::GuardianThreadContext) { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 404088301f..b02d2188d8 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -95,6 +95,20 @@ pub(crate) enum HistoryReplacement { } impl ConversationHistorySnapshot for SharedConversationHistory { + fn latest_compaction_model_hash(&self) -> Option<&str> { + self.items + .iter() + .rev() + .find(|envelope| { + matches!( + envelope.item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + }) + .and_then(|envelope| envelope.metadata.as_ref()) + .and_then(|metadata| metadata.compaction_model_hash.as_deref()) + } + fn retained_context(&self) -> Option<&RetainedContext> { Some(&self.retained_context) } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 8fbca13c55..c6e926ee0a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -153,6 +153,48 @@ fn conversation_history_snapshot_shares_response_items_until_history_changes() { ); } +#[test] +fn conversation_history_snapshot_binds_compaction_hash_to_the_latest_item() { + let checkpoint = ResponseItemEnvelope { + item: serde_json::from_value(serde_json::json!({ + "type": "compaction", "id": "known", "encrypted_content": "opaque checkpoint" + })) + .expect("checkpoint fixture"), + metadata: Some(CodexHarnessMetadata { + compaction_model_hash: Some("producer-hash".to_owned()), + ..Default::default() + }), + }; + let mut history = ContextManager::new(); + history.replace_annotated(vec![checkpoint.clone()]); + let snapshot = history.conversation_history_snapshot(); + let mut unknown = checkpoint.clone(); + unknown.metadata = None; + unknown.item = serde_json::from_value(serde_json::json!({ + "type": "compaction", "id": "unknown", "encrypted_content": "newer opaque checkpoint" + })) + .expect("unknown checkpoint fixture"); + history.replace_annotated(vec![checkpoint.clone(), unknown]); + assert_eq!( + history + .conversation_history_snapshot() + .latest_compaction_model_hash(), + None + ); + assert_eq!( + snapshot.latest_compaction_model_hash(), + Some("producer-hash") + ); + // Checkpoint replay/rollback restores the item's own provenance, not a new model's metadata. + history.replace_annotated(vec![checkpoint]); + assert_eq!( + history + .conversation_history_snapshot() + .latest_compaction_model_hash(), + Some("producer-hash") + ); +} + #[test] fn conversation_history_snapshot_excludes_contextual_user_messages() { let contextual_message = crate::context::ContextualUserFragment::into(UserInstructions { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9153bfad4a..d08feebaef 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3759,6 +3759,19 @@ impl Session { for envelope in &mut items { Self::assign_missing_response_item_id(&mut envelope.item); } + if self.enabled(Feature::GuardianThreadContext) + && let Some(checkpoint) = items.iter_mut().rev().find(|envelope| { + matches!( + envelope.item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + }) + { + checkpoint + .metadata + .get_or_insert_default() + .compaction_model_hash = metadata.compaction_model_hash; + } let mut compacted_item = CompactedItem { message: metadata.message, replacement_history: Some(items.clone()), @@ -4227,6 +4240,7 @@ impl Session { window_number, window_ids, compaction_response_id: None, + compaction_model_hash: None, }, ) .await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1d30b0c219..d9c1f72214 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5952,6 +5952,7 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() { window_number, window_ids, compaction_response_id: None, + compaction_model_hash: None, }, ), )); diff --git a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs index 7e8ff75031..8942a384c5 100644 --- a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs +++ b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs @@ -22,6 +22,12 @@ pub trait ConversationHistorySnapshot: Send + Sync { None } + /// Producer compatibility recorded on the latest opaque checkpoint. Missing provenance + /// must not be inferred from the currently selected model, including after resume. + fn latest_compaction_model_hash(&self) -> Option<&str> { + None + } + /// Original review evidence retained across parent compaction, in conversation order. /// Hosts without separate retention provide their current history. fn review_items(&self) -> Box + Send + '_> { 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 bc125a3bd5..30159d0ee5 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -15,6 +15,7 @@ use codex_core::config::Config; use codex_core::context::GuardianReviewEvidence; use codex_core::context::NodeReplReviewEvidence; use codex_extension_api::ApprovalReviewContributor; +use codex_extension_api::ConversationHistorySnapshot; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; @@ -107,6 +108,7 @@ fn should_classify_tool( pub enum StrictReviewReason { ElevatedRisk, StaleScore, + IncompatibleCompaction, } enum ClassificationOutcome { @@ -273,6 +275,9 @@ impl ApprovalReviewContributor for GuardianV2Extension { Box::pin(async move { thread_store.get::()?; let guardian_config = thread_store.get::()?; + let guardian_evidence = thread_store.get_or_init(GuardianReviewEvidence::default); + let thread_context_enabled = guardian_evidence.uses_thread_owned_context(); + let mut initial_cua_call = false; if guardian_config.review_scope == GuardianV2ReviewScope::ComputerUseOnly { let Ok(action) = serde_json::from_str::(prompt) else { record_fast_decision(extension_metrics.as_deref(), "deferred", "out_of_scope"); @@ -290,9 +295,10 @@ impl ApprovalReviewContributor for GuardianV2Extension { record_fast_decision(extension_metrics.as_deref(), "deferred", "out_of_scope"); return None; } - // The first REPL execution never waits for synchronous Guardian review. - // The async classifier still runs, and later calls use the normal policy. - if action.get("tool_name").and_then(serde_json::Value::as_str) == Some("js") + // Legacy mode keeps the first REPL execution's review bypass. + // Thread-owned context first checks whether Luna can use the checkpoint. + initial_cua_call = action.get("tool_name").and_then(serde_json::Value::as_str) + == Some("js") && action .get("connector_id") .and_then(serde_json::Value::as_str) @@ -301,8 +307,8 @@ impl ApprovalReviewContributor for GuardianV2Extension { .get::()? .js_executions .load(Ordering::Acquire) - == 1 - { + == 1; + if initial_cua_call && !thread_context_enabled { record_fast_decision( extension_metrics.as_deref(), "approved", @@ -339,7 +345,31 @@ impl ApprovalReviewContributor for GuardianV2Extension { record_fast_decision(extension_metrics.as_deref(), "deferred", "scoring_failure"); return None; }; - let current_authorization = ScoreAuthorization::current(&thread).await; + let root_authorization = thread + .guardian_root_snapshot() + .await + .map(|snapshot| snapshot.authorization_version); + let history = thread.conversation_history_snapshot().await; + if thread_context_enabled { + let sampler = thread_store.get::()?; + if requires_sync_for_compaction(&guardian_config, history.as_ref(), &sampler) { + thread_store.insert(StrictReviewReason::IncompatibleCompaction); + record_fast_decision( + extension_metrics.as_deref(), + "deferred", + "incompatible_compaction", + ); + return None; + } + } + if initial_cua_call { + record_fast_decision(extension_metrics.as_deref(), "approved", "initial_cua_call"); + return Some(ReviewDecision::Approved); + } + let current_authorization = ScoreAuthorization { + local: guardian_evidence.authorization_version(history.as_ref()), + root: root_authorization, + }; if !current_authorization.local.retained_context_complete || current_authorization .root @@ -571,6 +601,38 @@ impl GuardianV2Extension { .enable_image_capture(); } input.thread_store.insert(guardian_config.clone()); + let guardian_evidence = input + .thread_store + .get_or_init(GuardianReviewEvidence::default); + let thread_context_enabled = guardian_evidence.uses_thread_owned_context(); + if thread_context_enabled + && requires_sync_for_compaction( + &guardian_config, + input.conversation_history.as_ref(), + &sampler, + ) + { + score_progress + .latest_failed_tool_call + .fetch_max(tool_call_index, Ordering::Release); + Self::record_fail_closed_score(input.thread_store, sampled_at); + record_classification( + metrics.as_deref(), + classification_started_at.elapsed(), + "skipped", + ); + return; + } + let parent_compaction_hash = if thread_context_enabled { + input + .conversation_history + .latest_compaction_model_hash() + .map(str::to_owned) + } else { + parent_model + .as_ref() + .and_then(|model| model.comp_hash.clone()) + }; let parent_compaction = if guardian_config.reuse_parent_compaction { match encrypted_parent_compaction( input.conversation_history.items(), @@ -590,22 +652,23 @@ impl GuardianV2Extension { } else { None }; + // Legacy requests may omit an incompatible checkpoint because their raw + // review transcript is still retained. The sampler rejects supplied + // incompatible checkpoints, so preserve that legacy omission here. + let parent_compaction = parent_compaction.filter(|_| { + thread_context_enabled + || sampler.supports_parent_compaction(parent_compaction_hash.as_deref()) + }); let call_id = input.call_id.to_owned(); let mcp_tool = input.mcp_tool.cloned(); let action = GuardianAction { tool_name: input.tool_name.clone(), payload: input.payload.clone(), }; - let parent_compaction_hash = parent_model - .as_ref() - .and_then(|model| model.comp_hash.clone()); let review_model_override = parent_model .as_ref() .and_then(|model| model.auto_review_model_override.clone()); // Snapshot before spawning so a delayed sample cannot see later reviews. - let guardian_evidence = input - .thread_store - .get_or_init(GuardianReviewEvidence::default); let sync_reviews = guardian_evidence.snapshot(); let codex_core::context::GuardianUserInputSnapshot { fragments: trusted_user_inputs, @@ -895,6 +958,22 @@ enum ParentCompactionError { Oversized, } +// Sampling and fast approval must apply the same checkpoint eligibility policy. +fn requires_sync_for_compaction( + config: &GuardianV2Config, + history: &dyn ConversationHistorySnapshot, + sampler: &LunaSampler, +) -> bool { + config.reuse_parent_compaction + && history.items().any(|item| { + matches!( + item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + }) + && !sampler.supports_parent_compaction(history.latest_compaction_model_hash()) +} + // An unusable latest compaction must never fall back to an older one. Missing // encrypted content can be omitted; content that cannot be bounded rejects the sample. fn encrypted_parent_compaction<'a>( 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 9a7011f879..64d25a2418 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 @@ -75,6 +75,7 @@ use super::should_classify_tool; use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS; use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS; use crate::async_scorer::config::DEFAULT_PARENT_COMPACTION_TOKENS; +use crate::async_scorer::config::GuardianV2Config; use crate::async_scorer::config::GuardianV2ReviewScope; use crate::async_scorer::metrics::CLASSIFICATION_DURATION_METRIC; use crate::async_scorer::metrics::CLASSIFICATION_METRIC; @@ -367,9 +368,13 @@ struct TestConversationHistory(Vec); struct TestRetainedHistory { current: TestConversationHistory, retained: Vec, + compaction_model_hash: Option, } impl ConversationHistorySnapshot for TestRetainedHistory { + fn latest_compaction_model_hash(&self) -> Option<&str> { + self.compaction_model_hash.as_deref() + } fn history_version(&self) -> u64 { self.current.history_version() } @@ -1097,10 +1102,16 @@ struct GuardianFailureFixture { impl GuardianFailureFixture { async fn new() -> Result { - let (_, test, registry) = sample_conversation_history( + Self::with_config("").await + } + + async fn with_config(guardian_config: &str) -> Result { + let (_, test, registry) = sample_configured_conversation_history( Vec::new(), r#"{"path":"README.md"}"#, Some(TEST_GUARDIAN_POLICY), + guardian_config, + /*model_defaults*/ None, ) .await?; let thread_store = test.codex.thread_extension_data(); @@ -2430,6 +2441,110 @@ async fn cached_score_survives_compaction_and_internal_context_but_not_user_inpu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn incompatible_compaction_blocks_cached_score_and_initial_cua_allowance() -> Result<()> { + assert_compaction_approval_policy(/*thread_context_enabled*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_incompatible_compaction_preserves_cached_score_and_initial_cua_allowance() +-> Result<()> { + assert_compaction_approval_policy(/*thread_context_enabled*/ false).await +} + +async fn assert_compaction_approval_policy(thread_context_enabled: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + + let fixture = GuardianFailureFixture::with_config(&format!( + "[features]\nguardian_thread_context = {thread_context_enabled}\n" + )) + .await?; + let thread_store = fixture.test.codex.thread_extension_data(); + thread_store.insert(SecurityRiskScore { + scores: BTreeMap::from([("action_risk".to_owned(), 0.0)]), + call_id: None, + action: None, + sampled_at: None, + }); + assert_eq!( + fixture + .registry + .fast_approval_decision( + &fixture.session_store, + thread_store, + "review action", + /*extension_metrics*/ None + ) + .await, + Some(ReviewDecision::Approved) + ); + let authorization = fixture.test.codex.guardian_authorization_version().await; + fixture + .test + .codex + .inject_response_items(vec![ResponseItem::Compaction { + id: Some(ResponseItemId::from_server( + "incompatible-checkpoint".to_owned(), + )), + encrypted_content: "opaque parent summary".to_owned(), + internal_chat_message_metadata_passthrough: None, + }]) + .await?; + let mut model = (*thread_store + .get::() + .expect("parent model metadata")) + .clone(); + model.comp_hash = Some("incompatible-parent".to_owned()); + model.node_repl_auto_review_required = true; + thread_store.insert(model); + assert_eq!( + fixture.test.codex.guardian_authorization_version().await, + authorization + ); + // No new sample runs: only the enabled path rejects cached and initial-call approvals. + for (scope, prompt) in [ + ( + GuardianV2ReviewScope::Standard { + sandboxed_exec_commands: true, + }, + "review action", + ), + ( + GuardianV2ReviewScope::ComputerUseOnly, + r#"{"tool":"mcp_tool_call","server":"node_repl","connector_id":"node_repl","tool_name":"js"}"#, + ), + ] { + let mut config = (*thread_store + .get::() + .expect("Guardian configuration")) + .clone(); + config.review_scope = scope; + thread_store.insert(config); + thread_store + .get::() + .expect("score progress") + .js_executions + .store(/*val*/ 1, Ordering::Release); + assert_eq!( + fixture + .registry + .fast_approval_decision( + &fixture.session_store, + thread_store, + prompt, + /*extension_metrics*/ None + ) + .await, + (!thread_context_enabled).then_some(ReviewDecision::Approved) + ); + assert_eq!( + thread_store.remove::().as_deref(), + thread_context_enabled.then_some(&StrictReviewReason::IncompatibleCompaction) + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn contributor_counts_failed_thread_lookups_toward_score_lag() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2864,11 +2979,26 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<() #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<()> { + assert_parent_compaction_reuse(/*thread_context_enabled*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_contributor_reuses_the_latest_compatible_parent_compaction() -> Result<()> { + assert_parent_compaction_reuse(/*thread_context_enabled*/ false).await +} + +async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result<()> { skip_if_no_network!(Ok(())); let thread_server = responses::start_mock_server().await; let test = test_codex() - .with_config(|config| config.approvals_reviewer = ApprovalsReviewer::AutoReview) + .with_config(move |config| { + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .features + .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) + .expect("test context mode"); + }) .with_pre_build_hook(|home| { std::fs::write( home.join("config.toml"), @@ -2966,6 +3096,7 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< let conversation_history = TestRetainedHistory { retained, current: conversation_history, + compaction_model_hash: parent_model.comp_hash.clone(), }; thread_store.insert(parent_model); @@ -3026,6 +3157,8 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< }) .await?; assert_eq!(previous_score.scores.get("action_risk"), Some(&0.0)); + // Raw injection did not attach producer provenance to the live checkpoint. + // The sample's mock snapshot cannot make that live checkpoint safe for approval. assert_eq!( registry .fast_approval_decision( @@ -3035,7 +3168,7 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< /*extension_metrics*/ None, ) .await, - Some(ReviewDecision::Approved) + (!thread_context_enabled).then_some(ReviewDecision::Approved), ); let oversized_compaction = ResponseItem::Compaction { @@ -3054,10 +3187,13 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< tool_name: &tool_name, mcp_tool: None, payload: &tool_payload, - conversation_history: Arc::new(TestConversationHistory(vec![ - latest_compaction, - oversized_compaction, - ])), + conversation_history: Arc::new(TestRetainedHistory { + current: TestConversationHistory(vec![latest_compaction, oversized_compaction]), + retained: Vec::new(), + compaction_model_hash: thread_store + .get::() + .and_then(|model| model.comp_hash.clone()), + }), source: ToolCallSource::Direct, }) .await; @@ -3123,7 +3259,7 @@ async fn contributor_can_disable_parent_compaction_reuse() -> Result<()> { internal_chat_message_metadata_passthrough: None, }, ]; - let configuration = "[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n"; + let configuration = "[features]\nguardian_thread_context = true\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n"; let (request, test, _registry) = sample_configured_conversation_history( conversation_history, r#"{"path":"README.md"}"#, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs index f7f5aa411b..6a822cfd71 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs @@ -104,7 +104,7 @@ pub struct LunaSamplingRequest { pub images: Vec, /// Opaque parent compaction to reuse only for compatible model configurations. pub parent_compaction: Option, - /// Current parent model's encrypted-compaction compatibility hash. + /// Host-selected compatibility hash for the supplied parent checkpoint. pub parent_compaction_hash: Option, /// Reasoning budget explicitly selected for this request. pub reasoning_effort: ReasoningEffort, @@ -135,6 +135,9 @@ pub enum LunaSamplerError { /// A newer classification replaced this request when the pool was full. #[error("Luna request was superseded by a newer classification")] Superseded, + /// The supplied parent checkpoint cannot be consumed by this Luna configuration. + #[error("parent compaction is incompatible with Luna")] + IncompatibleCompaction, } struct PooledConnection { @@ -203,6 +206,15 @@ pub struct LunaSampler { } impl LunaSampler { + /// A checkpoint is reusable only when both models declare the same nonempty hash. + pub(super) fn supports_parent_compaction(&self, parent_hash: Option<&str>) -> bool { + parent_hash + .zip(self.config.luna_compaction_hash.as_deref()) + .is_some_and(|(parent_hash, luna_hash)| { + !parent_hash.is_empty() && parent_hash == luna_hash + }) + } + pub(super) fn new(config: LunaSamplerConfig) -> Self { Self { config, @@ -412,6 +424,7 @@ impl LunaSampler { | LunaSamplerError::MissingOutput | LunaSamplerError::OutputTooLarge | LunaSamplerError::Superseded + | LunaSamplerError::IncompatibleCompaction | LunaSamplerError::Api( ApiError::Transport(TransportError::Build(_)) | ApiError::ContextWindowExceeded @@ -432,6 +445,11 @@ impl LunaSampler { /// Sends one tool-less classification request on an exclusively leased WebSocket. pub async fn sample(&self, request: LunaSamplingRequest) -> Result { + if request.parent_compaction.is_some() + && !self.supports_parent_compaction(request.parent_compaction_hash.as_deref()) + { + return Err(LunaSamplerError::IncompatibleCompaction); + } // A classification is its own inference turn; retries keep that identity. let turn_id = Uuid::now_v7().to_string(); let parent_turn_id = request.parent_turn_id; @@ -452,15 +470,7 @@ impl LunaSampler { internal_chat_message_metadata_passthrough: None, }, ]; - if request - .parent_compaction_hash - .as_deref() - .zip(self.config.luna_compaction_hash.as_deref()) - .is_some_and(|(parent_hash, luna_hash)| { - !parent_hash.is_empty() && parent_hash == luna_hash - }) - && let Some(parent_compaction) = request.parent_compaction - { + if let Some(parent_compaction) = request.parent_compaction { input.push(parent_compaction); } if !request.trusted_review_evidence.is_empty() { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs index f797aad3c0..4750b3d7f8 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs @@ -39,6 +39,7 @@ use super::CLASSIFICATION_TOKEN_USAGE_METRIC; use super::INITIAL_WEBSOCKET_CONNECTIONS; use super::LunaSampler; use super::LunaSamplerConfig; +use super::LunaSamplerError; use super::LunaSamplingRequest; use super::MAX_SAMPLING_RETRIES; use super::MAX_WEBSOCKET_CONNECTIONS; @@ -570,7 +571,20 @@ async fn sampler_reuses_parent_compaction_only_for_matching_model_hashes() -> Re request.parent_compaction_hash = parent_hash.map(str::to_owned); request.trusted_review_evidence = vec!["trusted review".to_owned()]; - assert_eq!(sampler.sample(request).await?, "low"); + let result = sampler.sample(request).await; + if !should_reuse { + assert!(matches!( + result, + Err(LunaSamplerError::IncompatibleCompaction) + )); + assert!(server.connections().iter().all(Vec::is_empty)); + assert_eq!( + sampler.sample(sample_request("uncompacted-turn")).await?, + "low" + ); + continue; + } + assert_eq!(result?, "low"); let request = server .wait_for_request( @@ -582,31 +596,20 @@ async fn sampler_reuses_parent_compaction_only_for_matching_model_hashes() -> Re let input = request["input"].as_array().expect("input items"); assert_eq!(input[0]["type"], "additional_tools"); assert_eq!(input[1]["role"], "developer"); - if should_reuse { - assert_eq!(input.len(), 5); - assert_eq!(input[2], serde_json::to_value(&parent_compaction)?); - assert_eq!(input[3]["role"], "developer"); - assert_eq!(input[3]["content"][1]["text"], "trusted review"); - assert_eq!(input[4]["role"], "user"); + assert_eq!(input.len(), 5); + assert_eq!(input[2], serde_json::to_value(&parent_compaction)?); + assert_eq!(input[3]["role"], "developer"); + assert_eq!(input[3]["content"][1]["text"], "trusted review"); + assert_eq!(input[4]["role"], "user"); - let mut switched_request = sample_request("turn-2"); - switched_request.parent_compaction = Some(parent_compaction); - switched_request.parent_compaction_hash = Some("incompatible".to_owned()); - assert_eq!(sampler.sample(switched_request).await?, "low"); - let switched_request = server - .wait_for_request( - /*connection_index*/ INITIAL_WEBSOCKET_CONNECTIONS - 1, - /*request_index*/ 1, - ) - .await - .body_json(); - assert_eq!(switched_request["input"][2]["role"], "user"); - } else { - assert_eq!(input.len(), 4); - assert_eq!(input[2]["role"], "developer"); - assert_eq!(input[2]["content"][1]["text"], "trusted review"); - assert_eq!(input[3]["role"], "user"); - } + let mut switched_request = sample_request("turn-2"); + switched_request.parent_compaction = Some(parent_compaction); + switched_request.parent_compaction_hash = Some("incompatible".to_owned()); + assert!(matches!( + sampler.sample(switched_request).await, + Err(LunaSamplerError::IncompatibleCompaction) + )); + assert_eq!(server.connections().iter().map(Vec::len).sum::(), 1); } Ok(()) diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 74e5b01c0a..734ec8e673 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -58,6 +58,10 @@ pub struct CodexHarnessMetadata { /// Whether a response configuration update was created by the Codex harness itself. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub harness_authored_configuration: bool, + + /// Producer compatibility for an opaque compaction item, never the currently selected model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compaction_model_hash: Option, } impl ResponseItemEnvelope {