mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
e2eb7c30fe
## Why Guardian review selection was hard-coded in `core`, which worked for the default OpenAI path but did not give provider implementations a way to choose backend-specific reviewer model IDs. That matters for Amazon Bedrock: guardian review should run through the Bedrock/Mantle provider using Bedrock's `openai.gpt-5.4` model ID, instead of accidentally selecting a reviewer model that implies the OpenAI backend. ## What Changed - Added provider-owned approval review model selection via `ModelProvider::approval_review_model_selection`. - Moved the existing default selection policy into the provider abstraction: prefer the requested reviewer model when it is available, otherwise fall back to the active turn model, preferring `Low` reasoning when supported. - Added an Amazon Bedrock override that pins guardian review to `openai.gpt-5.4` with `Low` reasoning.
171 lines
6.3 KiB
Rust
171 lines
6.3 KiB
Rust
//! Guardian review decides whether an `on-request` approval should be granted
|
|
//! automatically instead of shown to the user.
|
|
//!
|
|
//! High-level approach:
|
|
//! 1. Reconstruct a compact transcript that preserves user intent plus the most
|
|
//! relevant recent assistant and tool context.
|
|
//! 2. Ask a dedicated guardian review session to assess the exact planned
|
|
//! action and return strict JSON.
|
|
//! The guardian clones the parent config, so it inherits any managed
|
|
//! network proxy / allowlist that the parent turn already had.
|
|
//! 3. Fail closed on timeout, execution failure, or malformed output.
|
|
//! 4. Apply the guardian's explicit allow/deny outcome.
|
|
|
|
mod approval_request;
|
|
mod prompt;
|
|
mod review;
|
|
mod review_session;
|
|
|
|
use std::time::Duration;
|
|
|
|
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
|
|
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
|
|
pub(crate) use approval_request::GuardianApprovalRequest;
|
|
pub(crate) use approval_request::GuardianMcpAnnotations;
|
|
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
|
|
pub(crate) use approval_request::guardian_approval_request_to_json;
|
|
pub(crate) use review::guardian_rejection_message;
|
|
pub(crate) use review::guardian_timeout_message;
|
|
pub(crate) use review::is_guardian_reviewer_source;
|
|
pub(crate) use review::new_guardian_review_id;
|
|
#[cfg(test)]
|
|
pub(crate) use review::record_guardian_denial_for_test;
|
|
pub(crate) use review::review_approval_request;
|
|
#[cfg(test)]
|
|
pub(crate) use review::review_approval_request_with_cancel;
|
|
pub(crate) use review::routes_approval_to_guardian;
|
|
pub(crate) use review::spawn_approval_request_review;
|
|
pub(crate) use review_session::GuardianReviewSessionManager;
|
|
|
|
pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90);
|
|
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
|
|
pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
|
|
pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10;
|
|
pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50;
|
|
pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str =
|
|
"The user has manually approved a specific action that was previously `Rejected`.";
|
|
const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000;
|
|
const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000;
|
|
const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000;
|
|
const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000;
|
|
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
|
|
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
|
|
const TRUNCATION_TAG: &str = "truncated";
|
|
|
|
/// Structured output contract that the guardian reviewer must satisfy.
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub(crate) struct GuardianAssessment {
|
|
pub(crate) risk_level: codex_protocol::protocol::GuardianRiskLevel,
|
|
pub(crate) user_authorization: codex_protocol::protocol::GuardianUserAuthorization,
|
|
pub(crate) outcome: GuardianAssessmentOutcome,
|
|
pub(crate) rationale: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct GuardianRejection {
|
|
pub(crate) rationale: String,
|
|
pub(crate) source: GuardianAssessmentDecisionSource,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct GuardianRejectionCircuitBreaker {
|
|
turns: std::collections::HashMap<String, GuardianRejectionCircuitBreakerTurn>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct GuardianRejectionCircuitBreakerTurn {
|
|
consecutive_denials: u32,
|
|
recent_denials: std::collections::VecDeque<bool>,
|
|
interrupt_triggered: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum GuardianRejectionCircuitBreakerAction {
|
|
Continue,
|
|
InterruptTurn {
|
|
consecutive_denials: u32,
|
|
recent_denials: u32,
|
|
},
|
|
}
|
|
|
|
impl GuardianRejectionCircuitBreaker {
|
|
pub(crate) fn clear_turn(&mut self, turn_id: &str) {
|
|
self.turns.remove(turn_id);
|
|
}
|
|
|
|
pub(crate) fn record_denial(&mut self, turn_id: &str) -> GuardianRejectionCircuitBreakerAction {
|
|
let turn = self.turns.entry(turn_id.to_string()).or_default();
|
|
turn.consecutive_denials = turn.consecutive_denials.saturating_add(1);
|
|
Self::record_recent_review(turn, /*denied*/ true);
|
|
let recent_denials = turn.recent_denials.iter().filter(|denied| **denied).count() as u32;
|
|
if !turn.interrupt_triggered
|
|
&& (turn.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN
|
|
|| recent_denials >= MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN)
|
|
{
|
|
turn.interrupt_triggered = true;
|
|
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
|
consecutive_denials: turn.consecutive_denials,
|
|
recent_denials,
|
|
}
|
|
} else {
|
|
GuardianRejectionCircuitBreakerAction::Continue
|
|
}
|
|
}
|
|
|
|
pub(crate) fn record_non_denial(&mut self, turn_id: &str) {
|
|
let turn = self.turns.entry(turn_id.to_string()).or_default();
|
|
turn.consecutive_denials = 0;
|
|
Self::record_recent_review(turn, /*denied*/ false);
|
|
}
|
|
|
|
fn record_recent_review(turn: &mut GuardianRejectionCircuitBreakerTurn, denied: bool) {
|
|
turn.recent_denials.push_back(denied);
|
|
if turn.recent_denials.len() > AUTO_REVIEW_DENIAL_WINDOW_SIZE {
|
|
turn.recent_denials.pop_front();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
use approval_request::format_guardian_action_pretty;
|
|
#[cfg(test)]
|
|
use approval_request::guardian_assessment_action;
|
|
#[cfg(test)]
|
|
use approval_request::guardian_request_turn_id;
|
|
#[cfg(test)]
|
|
use prompt::GuardianPromptMode;
|
|
#[cfg(test)]
|
|
use prompt::GuardianTranscriptCursor;
|
|
#[cfg(test)]
|
|
use prompt::GuardianTranscriptEntry;
|
|
#[cfg(test)]
|
|
use prompt::GuardianTranscriptEntryKind;
|
|
#[cfg(test)]
|
|
use prompt::build_guardian_prompt_items;
|
|
#[cfg(test)]
|
|
use prompt::collect_guardian_transcript_entries;
|
|
#[cfg(test)]
|
|
use prompt::guardian_output_schema;
|
|
#[cfg(test)]
|
|
pub(crate) use prompt::guardian_policy_prompt;
|
|
#[cfg(test)]
|
|
pub(crate) use prompt::guardian_policy_prompt_with_config;
|
|
#[cfg(test)]
|
|
use prompt::guardian_truncate_text;
|
|
#[cfg(test)]
|
|
use prompt::parse_guardian_assessment;
|
|
#[cfg(test)]
|
|
use prompt::render_guardian_transcript_entries;
|
|
#[cfg(test)]
|
|
use review::GuardianReviewOutcome;
|
|
#[cfg(test)]
|
|
use review::run_guardian_review_session as run_guardian_review_session_for_test;
|
|
#[cfg(test)]
|
|
use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|