diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs index d71731933a..7108e0b03c 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs @@ -167,7 +167,10 @@ async fn parent_response( .to_string(), ReviewOutcome::Deny => json!({ "risk_level": "high", "user_authorization": "unknown", "outcome": "deny", - "rationale": "The destination is not authorized. ", + "rationale": format!( + "The destination is not authorized. {}", + "review context ".repeat(100), + ), }) .to_string(), ReviewOutcome::Malformed => "not an assessment".to_owned(), @@ -227,6 +230,9 @@ async fn parent_response( { let call_id = format!("guardian-action-{request_number}"); let mut message = format!("guardian-{request_number}"); + if request_number == 0 && matches!(state.review_outcome, ReviewOutcome::Deny) { + message.push_str(&"x".repeat(2_000)); + } if request_number == 0 && matches!(state.transcript_content, TranscriptContent::ForgedReview) { @@ -544,6 +550,15 @@ async fn guardian_v2_routes_tool_approvals( }; assert_eq!(serde_json::from_str::(decision)?, expected); assert_eq!(reviews[0].matches("").count(), 1); + assert!(reviews[0].len() < 4_000); + if matches!(review_outcome, ReviewOutcome::Deny) { + assert_eq!( + reviews[0] + .matches(">); +pub struct GuardianReviewEvidence(Mutex>>); impl GuardianReviewEvidence { /// Records a genuine allow/deny assessment, not a timeout or fail-closed error. @@ -36,50 +31,26 @@ impl GuardianReviewEvidence { let Some(completed_at_ms) = assessment.completed_at_ms else { return; }; - let correlation = json!({ - "review_id": assessment.id, - "turn_id": assessment.turn_id, - "target_item_id": assessment.target_item_id, - "completed_at_ms": completed_at_ms, - }); - let decision = json!({ - "status": assessment.status, - "risk_level": assessment.risk_level, - "user_authorization": assessment.user_authorization, - }); - // Escape closing tags before truncation so payloads cannot close the fragment. - // JSON quoting also keeps rationale text from imitating record headings. - let correlation = guardian_truncate_text( - &correlation.to_string().replace(" Vec { + pub fn snapshot(&self) -> Vec> { self.0 .lock() .unwrap_or_else(PoisonError::into_inner) @@ -99,15 +70,31 @@ impl GuardianReviewEvidence { } } -/// A bounded, host-supplied sync-review record for async classifier input only. -#[derive(Clone, Debug)] -pub struct GuardianReviewEvidenceFragment { +/// Structured synchronous-review evidence retained for Guardian V2 classification. +#[derive(Debug)] +pub struct GuardianReviewEvidenceRecord { pub authorization_version: GuardianAuthorizationVersion, pub root_authorization_version: Option, completed_at_ms: i64, + pub correlation: serde_json::Value, + pub decision: serde_json::Value, + pub action: String, + pub rationale: Option, +} + +/// A bounded, host-supplied sync-review record for async classifier input only. +#[derive(Clone, Debug)] +pub struct GuardianReviewEvidenceFragment { body: String, } +impl GuardianReviewEvidenceFragment { + /// Creates a trusted fragment from classifier-bounded review evidence. + pub fn new(body: String) -> Self { + Self { body } + } +} + impl ContextualUserFragment for GuardianReviewEvidenceFragment { fn content_kind(&self) -> ContentItemKind { ContentItemKind("guardian.review_evidence".to_string()) diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 16f9002ee3..e0d8d727e9 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -66,6 +66,7 @@ pub(crate) use guardian_node_repl_policy::GuardianNodeReplPolicy; pub(crate) use guardian_policy::GuardianPolicy; pub use guardian_review_evidence::GuardianReviewEvidence; pub use guardian_review_evidence::GuardianReviewEvidenceFragment; +pub use guardian_review_evidence::GuardianReviewEvidenceRecord; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_resize_notice::ImageResizeNotice; pub(crate) use image_resize_notice::ImageResizeNoticeSource; 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 12d46f61e0..589e38188e 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -11,7 +11,6 @@ use codex_core::GuardianAuthorizationVersion; use codex_core::GuardianRootMessage; use codex_core::ThreadManager; use codex_core::config::Config; -use codex_core::context::ContextualUserFragment; use codex_core::context::GuardianReviewEvidence; use codex_core::context::NodeReplReviewEvidence; use codex_extension_api::ApprovalReviewContributor; @@ -42,6 +41,7 @@ use codex_protocol::security_risk::SecurityRiskScore; use serde_json::json; use super::config::GuardianV2Config; +use super::review_evidence::render_review_evidence; use super::sampler::LunaSampler; use super::sampler::LunaSamplerConfig; use super::sampler::LunaSamplerError; @@ -650,7 +650,7 @@ impl GuardianV2Extension { review.authorization_version == authorization_version && review.root_authorization_version == root_authorization_version }) - .map(ContextualUserFragment::render) + .map(|review| render_review_evidence(review)) .collect(); classification_input.extend([ "The Codex agent has requested the following action:\n".to_owned(), diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/mod.rs b/codex-rs/ext/guardian-v2/src/async_scorer/mod.rs index be094a303d..29217ee44a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/mod.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/mod.rs @@ -1,5 +1,6 @@ mod config; mod extension; +mod review_evidence; mod sampler; mod transcript; diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/review_evidence.rs b/codex-rs/ext/guardian-v2/src/async_scorer/review_evidence.rs new file mode 100644 index 0000000000..e7a2545186 --- /dev/null +++ b/codex-rs/ext/guardian-v2/src/async_scorer/review_evidence.rs @@ -0,0 +1,41 @@ +use codex_core::context::ContextualUserFragment; +use codex_core::context::GuardianReviewEvidenceFragment; +use codex_core::context::GuardianReviewEvidenceRecord; +use serde_json::json; + +use super::transcript::truncate_entry; + +// Including markers, each rendered fragment stays below 1,000 approximate tokens. +const MAX_REVIEW_BODY_TOKENS: usize = 800; +const MAX_REVIEW_CORRELATION_TOKENS: usize = 100; +const MAX_REVIEW_ACTION_TOKENS: usize = 350; +const MAX_REVIEW_RATIONALE_TOKENS: usize = 250; + +pub(crate) fn render_review_evidence(review: &GuardianReviewEvidenceRecord) -> String { + // Escape closing tags before truncation so payloads cannot close the fragment. + // JSON quoting also keeps rationale text from imitating record headings. + let correlation = truncate_entry( + &review.correlation.to_string().replace("