guardian: move denial tracking into turn extension state

This commit is contained in:
Dylan Hurd
2026-06-12 12:10:48 -07:00
parent 70c858fc28
commit c7645ca96e
12 changed files with 188 additions and 185 deletions
+3 -1
View File
@@ -7,7 +7,6 @@ version.workspace = true
[lib]
name = "codex_guardian"
path = "src/lib.rs"
test = false
doctest = false
[lints]
@@ -22,3 +21,6 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-output-truncation = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
@@ -0,0 +1,58 @@
use std::collections::VecDeque;
pub const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
pub const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10;
pub const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50;
/// Per-turn denial history used to stop repeated unsafe approval requests.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GuardianRejectionCircuitBreaker {
consecutive_denials: u32,
recent_denials: VecDeque<bool>,
interrupt_triggered: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GuardianRejectionCircuitBreakerAction {
Continue,
InterruptTurn {
consecutive_denials: u32,
recent_denials: u32,
},
}
impl GuardianRejectionCircuitBreaker {
pub fn record_denial(&mut self) -> GuardianRejectionCircuitBreakerAction {
self.consecutive_denials = self.consecutive_denials.saturating_add(1);
self.record_recent_review(/*denied*/ true);
let recent_denials = self.recent_denials.iter().filter(|denied| **denied).count() as u32;
if !self.interrupt_triggered
&& (self.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN
|| recent_denials >= MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN)
{
self.interrupt_triggered = true;
GuardianRejectionCircuitBreakerAction::InterruptTurn {
consecutive_denials: self.consecutive_denials,
recent_denials,
}
} else {
GuardianRejectionCircuitBreakerAction::Continue
}
}
pub fn record_non_denial(&mut self) {
self.consecutive_denials = 0;
self.record_recent_review(/*denied*/ false);
}
fn record_recent_review(&mut self, denied: bool) {
self.recent_denials.push_back(denied);
if self.recent_denials.len() > AUTO_REVIEW_DENIAL_WINDOW_SIZE {
self.recent_denials.pop_front();
}
}
}
#[cfg(test)]
#[path = "circuit_breaker_tests.rs"]
mod tests;
@@ -0,0 +1,100 @@
use std::collections::VecDeque;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn interrupts_once_after_three_consecutive_denials() {
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::InterruptTurn {
consecutive_denials: 3,
recent_denials: 3,
}
);
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker,
GuardianRejectionCircuitBreaker {
consecutive_denials: 4,
recent_denials: VecDeque::from([true, true, true, true]),
interrupt_triggered: true,
}
);
}
#[test]
fn non_denial_resets_consecutive_denials() {
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
circuit_breaker.record_non_denial();
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::InterruptTurn {
consecutive_denials: 3,
recent_denials: 4,
}
);
}
#[test]
fn interrupts_after_ten_recent_denials() {
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
for _ in 0..9 {
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
circuit_breaker.record_non_denial();
}
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::InterruptTurn {
consecutive_denials: 1,
recent_denials: 10,
}
);
}
#[test]
fn forgets_denials_outside_recent_review_window() {
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
for _ in 0..9 {
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
circuit_breaker.record_non_denial();
}
for _ in 0..(AUTO_REVIEW_DENIAL_WINDOW_SIZE - 18) {
circuit_breaker.record_non_denial();
}
assert_eq!(
circuit_breaker.record_denial(),
GuardianRejectionCircuitBreakerAction::Continue
);
}
+6
View File
@@ -14,6 +14,7 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
mod approval_request;
mod circuit_breaker;
mod truncation;
pub use approval_request::FormattedGuardianAction;
@@ -23,6 +24,11 @@ pub use approval_request::guardian_assessment_action;
pub use approval_request::guardian_request_target_item_id;
pub use approval_request::guardian_request_turn_id;
pub use approval_request::guardian_reviewed_action;
pub use circuit_breaker::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
pub use circuit_breaker::GuardianRejectionCircuitBreaker;
pub use circuit_breaker::GuardianRejectionCircuitBreakerAction;
pub use circuit_breaker::MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN;
pub use circuit_breaker::MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN;
pub use truncation::guardian_truncate_text;
/// Guardian extension dependencies supplied by the host at construction time.