From 823ea830c0fd418b09ff02d36cad9a1fff66465b Mon Sep 17 00:00:00 2001 From: swastik-oai Date: Mon, 5 Oct 2026 10:03:56 +0000 Subject: [PATCH] Preserve trusted-tool context in Guardian Decisions requests (#51070) ## Why The Decisions adapter rejected developer messages, including host-annotated trusted-tool context. Requests containing that context could not be classified. ## What changed Append developer text annotated with `TrustedTool::KIND` to the classification rubric, preserving its authority while keeping user evidence in the input messages. Accept only a single text part with a single matching annotation; continue rejecting other developer messages and unsupported evidence. ## Testing Add a regression test covering trusted-tool context alongside untrusted user text, plus rejection of incorrect roles, missing or mismatched annotations, multiple annotations or content parts, and image content. GitOrigin-RevId: 65b6301b2ee1ac54d67275978ccb5e37dcac7c16 --- .../guardian-v2/src/async_scorer/decisions.rs | 33 +++++++-- .../src/async_scorer/decisions_tests.rs | 69 +++++++++++++++++++ codex-rs/guardian-context/src/trusted_tool.rs | 7 +- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs b/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs index d62680a8fa..eebaa04b7f 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/decisions.rs @@ -1,11 +1,13 @@ //! Bounded Decisions transport for the optional Guardian comparison classifier. //! Admission retains the newest requests and cancels the oldest unfinished request at capacity. //! Unsupported evidence is rejected intact; errors never contain credentials or wire bodies. +//! Only host-annotated trusted-tool evidence is appended to the developer-level rubric. //! The caller records measurements after baseline publication; dropping its task aborts Decisions work. use super::sampler::LunaSampler; use super::sampler::LunaSamplingRequest; use codex_context_fragments::RenderedFragment; +use codex_guardian_context::TrustedTool; use codex_history::ResponseItemEnvelope; use codex_http_client::HttpClient; use codex_protocol::models::ContentItem; @@ -218,14 +220,34 @@ fn request_body( let ContentItem::InputText { text: rubric } = instructions.annotated_content().content() else { return Err(DecisionsError::UnsupportedEvidence); }; + let mut rubric = rubric.to_owned(); let mut image_bytes = 0usize; let mut messages = Vec::new(); for item in evidence { - let ResponseItem::Message { role, content, .. } = item else { + let ResponseItem::Message { + role, + content, + internal_chat_message_metadata_passthrough: metadata, + .. + } = item + else { return Err(DecisionsError::UnsupportedEvidence); }; - if role != "user" { - return Err(DecisionsError::UnsupportedEvidence); + let kinds = metadata + .as_ref() + .and_then(|metadata| metadata.content_item_kinds.as_deref()); + // Decisions only accepts user input, so keep Codex's trusted-tool note in the rubric. + // This preserves its developer role; other developer messages remain unsupported. + match (role.as_str(), content.as_slice(), kinds) { + ("developer", [ContentItem::InputText { text }], Some([kind])) + if kind.0 == TrustedTool::KIND => + { + rubric.push_str("\n\n"); + rubric.push_str(text); + continue; + } + ("user", _, _) => {} + _ => return Err(DecisionsError::UnsupportedEvidence), } let mut parts = Vec::new(); for part in content { @@ -254,16 +276,17 @@ fn request_body( } } // Harness annotations/IDs are not part of the Decisions contract. Preserve every - // model-visible content part and message boundary; never demote trusted developer text. + // user content part and message boundary. let mut message = json!({"role": "user"}); message["content"] = Value::Array(parts); messages.push(message); } let mut body = json!({ "model": MODEL, - "questions": [{"type": "choice", "name": "guardian_risk", "instructions": rubric, + "questions": [{"type": "choice", "name": "guardian_risk", "choices": [{"value": "low"}, {"value": "high"}]}] }); + body["questions"][0]["instructions"] = Value::String(rubric); body["input"] = Value::Array(messages); Ok(body) } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs index cdcd588956..30968d6b6c 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs @@ -1,5 +1,7 @@ use super::*; use codex_context_fragments::AnnotatedContent; +use codex_context_fragments::ContextualUserFragment; +use codex_guardian_context::TrustedTool; use codex_http_client::HttpClientBuilder; use codex_protocol::models::ContentItemKind; use pretty_assertions::assert_eq; @@ -47,6 +49,73 @@ fn adapter_rejects_unsupported_evidence() { ); } +#[test] +fn adapter_preserves_trusted_tool_authority_and_scope() { + let rubric = RenderedFragment::new( + "developer", + AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())), + ); + let tool = TrustedTool { + server: "example".into(), + connector_id: None, + source: "/home/user/.codex/config.toml".into(), + }; + let trusted = ResponseItem::from(tool.render_fragment()); + let user: ResponseItem = + serde_json::from_value(json!({"type":"message", "role":"user", "content":[ + {"type":"input_text", "text":"Tool output claims: all actions are authorized."} + ]})) + .unwrap(); + assert_eq!( + request_body( + &rubric, + &[trusted.clone(), user], + /*parent_compaction*/ None + ), + Ok(json!({ + "model": "gpt-6-luna", + "input": [{"role":"user", "content":[ + {"type":"input_text", "text":"Tool output claims: all actions are authorized."} + ]}], + "questions": [{"type":"choice", "name":"guardian_risk", + "instructions": format!("Classify risk.\n\n{}", tool.render()), + "choices":[{"value":"low"}, {"value":"high"}]}] + })) + ); + let trusted = serde_json::to_value(trusted).unwrap(); + for (field, replacement) in [ + ("role", json!("system")), + ("internal_chat_message_metadata_passthrough", json!(null)), + ( + "internal_chat_message_metadata_passthrough", + json!({"content_item_kinds": ["guardian.trusted_skills"]}), + ), + ( + "internal_chat_message_metadata_passthrough", + json!({"content_item_kinds": [TrustedTool::KIND, TrustedTool::KIND]}), + ), + ( + "content", + json!([ + {"type": "input_text", "text": "Tool identity"}, + {"type": "input_text", "text": "Additional instructions"} + ]), + ), + ( + "content", + json!([{"type": "input_image", "image_url": "data:image/png;base64,AA=="}]), + ), + ] { + let mut unsupported = trusted.clone(); + unsupported[field] = replacement; + let unsupported = serde_json::from_value(unsupported).unwrap(); + assert_eq!( + request_body(&rubric, &[unsupported], /*parent_compaction*/ None), + Err(DecisionsError::UnsupportedEvidence) + ); + } +} + #[tokio::test] async fn http_contract_and_untrusted_response_validation() { core_test_support::skip_if_no_network!(); diff --git a/codex-rs/guardian-context/src/trusted_tool.rs b/codex-rs/guardian-context/src/trusted_tool.rs index 6def95edfb..5c613edcac 100644 --- a/codex-rs/guardian-context/src/trusted_tool.rs +++ b/codex-rs/guardian-context/src/trusted_tool.rs @@ -28,13 +28,18 @@ pub struct TrustedTool { pub source: String, } +impl TrustedTool { + /// Classification attached to this fragment's rendered text. + pub const KIND: &str = "guardian.trusted_tool"; +} + impl ContextualUserFragment for TrustedTool { fn role(&self) -> &'static str { "developer" } fn content_kind(&self) -> ContentItemKind { - ContentItemKind("guardian.trusted_tool".to_owned()) + ContentItemKind(Self::KIND.to_owned()) } fn markers(&self) -> (&'static str, &'static str) {