diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8fd0997d06..c579921ff6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1441,6 +1441,10 @@ "minimum": 0.0, "type": "integer" }, + "persist_scores": { + "description": "Persist reviewed actions and risk scores to rollout files for debugging.", + "type": "boolean" + }, "reasoning_effort": { "$ref": "#/definitions/ReasoningEffort" }, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs index e635d2187f..17d8f8c0b3 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs @@ -33,6 +33,7 @@ pub(crate) enum GuardianV2ReviewScope { #[derive(Clone, Debug, PartialEq)] pub(crate) struct GuardianV2Config { local_overrides: GuardianV2ConfigToml, + pub(crate) persist_scores: bool, pub(crate) classifier_instructions: String, pub(crate) review_threshold: f64, pub(crate) max_tool_call_lag: usize, @@ -229,6 +230,7 @@ impl GuardianV2Config { Ok(Self { local_overrides: configured.clone(), + persist_scores: configured.persist_scores.unwrap_or(false), classifier_instructions: configured .classifier_instructions .unwrap_or_else(|| DEFAULT_CLASSIFIER_INSTRUCTIONS.to_owned()), 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 550c01ab6d..e1cf0efb3a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -804,7 +804,8 @@ impl GuardianV2Extension { .latest_scored_tool_call .fetch_max(tool_call_index, Ordering::Release); classification_finished_at = Some(Instant::now()); - if !config.ephemeral + if guardian_config.persist_scores + && !config.ephemeral && let Err(error) = thread .append_rollout_items(&[RolloutItem::SecurityRiskScore(score)]) .await 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 a4b6dfe048..f8fb1ed449 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 @@ -1770,26 +1770,16 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< ); assert!(score.sampled_at.is_some()); test.codex.ensure_rollout_materialized().await; - let persisted_score = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(persisted_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>(persisted_score); - } - tokio::task::yield_now().await; - } - }) - .await??; - assert_eq!(&persisted_score, score.as_ref()); + assert!( + !test + .codex + .load_history(/*include_archived*/ false) + .await? + .items + .into_iter() + .any(|item| matches!(item, RolloutItem::SecurityRiskScore(_))), + "risk scores should not be persisted unless explicitly enabled" + ); assert_eq!( registry .fast_approval_decision( @@ -1867,7 +1857,7 @@ async fn contributor_persists_nested_code_mode_action_with_score() -> Result<()> Vec::new(), r#"{"path":"README.md"}"#, Some(TEST_GUARDIAN_POLICY), - "", + "[features.guardianv2]\nenabled = true\npersist_scores = true\n", /*model_defaults*/ None, ToolCallSource::CodeMode { cell_id: "cell-1".to_owned(), diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index eb18554e36..7fa2ab6f09 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -129,6 +129,9 @@ pub struct GuardianV2ConfigToml { /// Route Guardian review and classification through the unmetered Codex endpoints. #[serde(skip_serializing_if = "Option::is_none")] pub free_guardian: Option, + /// Persist reviewed actions and risk scores to rollout files for debugging. + #[serde(skip_serializing_if = "Option::is_none")] + pub persist_scores: Option, #[serde(skip_serializing_if = "Option::is_none")] pub classifier_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 52a5d1f548..37760a4463 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -164,6 +164,7 @@ fn guardian_v2_feature_config_deserializes_classifier_and_transcript_settings() [guardianv2] enabled = true free_guardian = true +persist_scores = true classifier_instructions = "Review this action" review_threshold = 0.65 max_tool_call_lag = 2 @@ -194,6 +195,7 @@ max_recent_non_user_entries = 12 Some(FeatureToml::Config(crate::GuardianV2ConfigToml { enabled: Some(true), free_guardian: Some(true), + persist_scores: Some(true), classifier_instructions: Some("Review this action".to_owned()), review_threshold: Some(0.65), max_tool_call_lag: Some(2),