Persist verified user answers in Guardian thread context (#42579)

## Why

Guardian authorization reviews need host-verified `request_user_input` answers to
remain aligned with thread history across compaction, resume, and rollback.

## What changed

- Record verified question-and-answer pairs in retained thread context when
  `GuardianThreadContext` is enabled, and source both local and delegated Guardian
  review evidence from that context.
- Preserve complete answer records within the evidence budget. Mark authorization
  context incomplete when records are unavailable or oversized, and defer cached
  fast approvals in that case.
- Keep the existing runtime-only answer path for threads that do not enable the
  thread-owned context.

## Testing

- Cover answer retention through compaction and resume, removal after rollback,
  source-call eviction, oversized answers, delegated review evidence, and legacy
  behavior.

GitOrigin-RevId: 01f6f4541515434a0d00fdde318ff59f2f8bd2eb
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-09-03 16:41:11 +00:00
1 parent 7a7c188682
commit 1d74c3ba1e
22 files changed
+960 -397

No files matched your search

@@ -209,7 +209,10 @@ impl ThreadLifecycleContributor<Config> for GuardianV2Extension {
metrics: input.extension_metrics.clone(),
..Default::default()
});
input.thread_store.insert(GuardianReviewEvidence::default());
// Preserve the answer path selected by the host for this thread.
input
.thread_store
.get_or_init(GuardianReviewEvidence::default);
input
.thread_store
.insert(TrustedSkillRoots::from_config(input.config));
@@ -337,6 +340,18 @@ impl ApprovalReviewContributor for GuardianV2Extension {
return None;
};
let current_authorization = ScoreAuthorization::current(&thread).await;
if !current_authorization.local.retained_context_complete
|| current_authorization
.root
.is_some_and(|root| !root.retained_context_complete)
{
record_fast_decision(
extension_metrics.as_deref(),
"deferred",
"incomplete_authorization",
);
return None;
}
let scored_authorization = score_progress
.authorization
.lock()
@@ -592,10 +607,10 @@ impl GuardianV2Extension {
.thread_store
.get_or_init(GuardianReviewEvidence::default);
let sync_reviews = guardian_evidence.snapshot();
let authorization_version =
guardian_evidence.authorization_version(input.conversation_history.as_ref());
let trusted_user_inputs =
guardian_evidence.user_input_fragments(input.conversation_history.as_ref());
let codex_core::context::GuardianUserInputSnapshot {
fragments: trusted_user_inputs,
authorization_version,
} = guardian_evidence.user_input_snapshot(input.conversation_history.as_ref());
let history = Arc::clone(&input.conversation_history);
let local_trusted_skill_paths = guardian_evidence.trusted_skill_paths(input.turn_id);
let node_repl_images = if guardian_config.transcript.include_images {
@@ -77,9 +77,9 @@ pub(super) fn build(
};
let trusted_user_answers = input
.thread_store
.get::<GuardianReviewEvidence>()
.map(|evidence| evidence.user_input_fragments(input.conversation_history.as_ref()))
.unwrap_or_default();
.get_or_init(GuardianReviewEvidence::default)
.user_input_snapshot(input.conversation_history.as_ref())
.fragments;
let node_repl_inputs = input
.thread_store
.get::<NodeReplReviewEvidence>()
@@ -104,6 +104,7 @@ async fn prompt_preserves_root_authorization_reasons_and_denied_reads() -> Resul
authorization_version: GuardianAuthorizationVersion {
user_message_revision: 1,
user_input_response_count: 0,
retained_context_complete: true,
},
messages: vec![
GuardianRootMessage::User("inspect only public files".to_string()),