use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use crate::value::{VmClosure, VmError, VmValue};
thread_local! {
static APPROVAL_REVIEWER_STACK: RefCell<Vec<Arc<VmClosure>>> = const { RefCell::new(Vec::new()) };
static APPROVAL_REVIEWER_DEPTH: RefCell<usize> = const { RefCell::new(0) };
static APPROVAL_REVIEWER_DENIALS: RefCell<HashMap<String, BreakerCounts>> =
RefCell::new(HashMap::new());
}
pub const CONSECUTIVE_DENIAL_LIMIT: u32 = 3;
pub const PER_TURN_DENIAL_LIMIT: u32 = 10;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BreakerCounts {
pub consecutive: u32,
pub this_turn: u32,
}
impl BreakerCounts {
pub fn tripped(self) -> bool {
self.consecutive >= CONSECUTIVE_DENIAL_LIMIT || self.this_turn >= PER_TURN_DENIAL_LIMIT
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ApprovalReviewOutcome {
pub approved: bool,
pub reviewer_answered: bool,
pub rationale: String,
pub risk: Option<String>,
pub authorization: Option<String>,
pub unavailable_reason: Option<String>,
}
impl ApprovalReviewOutcome {
fn unavailable(reason: &str) -> Self {
Self {
approved: false,
reviewer_answered: false,
rationale: String::new(),
risk: None,
authorization: None,
unavailable_reason: Some(reason.to_string()),
}
}
}
pub fn push_approval_reviewer(reviewer: Arc<VmClosure>) {
APPROVAL_REVIEWER_STACK.with(|stack| stack.borrow_mut().push(reviewer));
}
pub fn pop_approval_reviewer() {
APPROVAL_REVIEWER_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
pub fn clear_approval_reviewers() {
APPROVAL_REVIEWER_STACK.with(|stack| stack.borrow_mut().clear());
APPROVAL_REVIEWER_DEPTH.with(|depth| *depth.borrow_mut() = 0);
APPROVAL_REVIEWER_DENIALS.with(|counts| counts.borrow_mut().clear());
}
pub fn current_approval_reviewer() -> Option<Arc<VmClosure>> {
APPROVAL_REVIEWER_STACK.with(|stack| stack.borrow().last().cloned())
}
pub fn approval_reviewer_active() -> bool {
APPROVAL_REVIEWER_STACK.with(|stack| !stack.borrow().is_empty())
}
pub(crate) fn swap_approval_reviewer_stack(next: Vec<Arc<VmClosure>>) -> Vec<Arc<VmClosure>> {
APPROVAL_REVIEWER_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
}
pub(crate) fn swap_approval_reviewer_depth(next: usize) -> usize {
APPROVAL_REVIEWER_DEPTH.with(|depth| std::mem::replace(&mut *depth.borrow_mut(), next))
}
pub fn approval_reviewer_breaker(session_id: &str) -> BreakerCounts {
APPROVAL_REVIEWER_DENIALS
.with(|counts| counts.borrow().get(session_id).copied())
.unwrap_or_default()
}
pub fn reset_approval_reviewer_turn(session_id: &str) {
APPROVAL_REVIEWER_DENIALS.with(|counts| {
if let Some(entry) = counts.borrow_mut().get_mut(session_id) {
entry.this_turn = 0;
}
});
}
fn record_verdict(session_id: &str, approved: bool) {
APPROVAL_REVIEWER_DENIALS.with(|counts| {
let mut counts = counts.borrow_mut();
let entry = counts.entry(session_id.to_string()).or_default();
if approved {
entry.consecutive = 0;
} else {
entry.consecutive = entry.consecutive.saturating_add(1);
entry.this_turn = entry.this_turn.saturating_add(1);
}
});
}
struct DepthGuard;
impl Drop for DepthGuard {
fn drop(&mut self) {
APPROVAL_REVIEWER_DEPTH.with(|depth| {
let mut depth = depth.borrow_mut();
*depth = depth.saturating_sub(1);
});
}
}
pub async fn run_approval_review(
ctx: Option<&crate::vm::AsyncBuiltinCtx>,
request: serde_json::Value,
session_id: &str,
) -> ApprovalReviewOutcome {
let Some(reviewer) = current_approval_reviewer() else {
return ApprovalReviewOutcome::unavailable("no_reviewer_installed");
};
if APPROVAL_REVIEWER_DEPTH.with(|depth| *depth.borrow()) > 0 {
return ApprovalReviewOutcome::unavailable("reviewer_reentrant");
}
if approval_reviewer_breaker(session_id).tripped() {
return ApprovalReviewOutcome::unavailable("breaker_tripped");
}
let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
return ApprovalReviewOutcome::unavailable("no_vm_context");
};
let arg = crate::stdlib::json_to_vm_value(&request);
APPROVAL_REVIEWER_DEPTH.with(|depth| *depth.borrow_mut() += 1);
let _guard = DepthGuard;
let outcome = match vm.call_closure_pub(&reviewer, &[arg]).await {
Ok(value) => parse_review_verdict(value),
Err(VmError::Runtime(message)) => {
ApprovalReviewOutcome::unavailable(&format!("reviewer_error: {message}"))
}
Err(_) => ApprovalReviewOutcome::unavailable("reviewer_error"),
};
if outcome.reviewer_answered {
record_verdict(session_id, outcome.approved);
}
outcome
}
fn parse_review_verdict(value: VmValue) -> ApprovalReviewOutcome {
let VmValue::Dict(map) = value else {
return ApprovalReviewOutcome::unavailable("reviewer_unparseable");
};
let approved = matches!(map.get("approved"), Some(VmValue::Bool(true)));
let answered = match map.get("reviewer_answered") {
Some(VmValue::Bool(flag)) => *flag,
_ => map.get("approved").is_some() || map.get("outcome").is_some(),
};
if !answered {
let reason = string_field(&map, "unavailable_reason")
.unwrap_or_else(|| "reviewer_did_not_answer".to_string());
return ApprovalReviewOutcome::unavailable(&reason);
}
ApprovalReviewOutcome {
approved,
reviewer_answered: true,
rationale: string_field(&map, "rationale").unwrap_or_default(),
risk: string_field(&map, "risk"),
authorization: string_field(&map, "authorization"),
unavailable_reason: None,
}
}
fn string_field(map: &crate::value::DictMap, key: &str) -> Option<String> {
match map.get(key) {
Some(VmValue::String(text)) if !text.is_empty() => Some(text.to_string()),
_ => None,
}
}
pub fn parse_approval_reviewer_value(
value: Option<&VmValue>,
label: &str,
) -> Result<Option<Arc<VmClosure>>, VmError> {
match value {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::Closure(closure)) => Ok(Some(closure.clone())),
Some(other) => Err(VmError::Runtime(format!(
"{label} must be a closure, got {}",
other.type_name()
))),
}
}
pub async fn maybe_grant_by_auto_review(
ctx: Option<&crate::vm::AsyncBuiltinCtx>,
decision: Option<&mut crate::orchestration::PolicyEvaluation>,
tool_name: &str,
tool_args: &serde_json::Value,
session_id: &str,
) -> bool {
if !approval_reviewer_active() {
return false;
}
let Some(decision) = decision else {
return false;
};
if !decision.is_deny() && !decision.is_ask() {
return false;
}
let request = serde_json::json!({
"tool": tool_name,
"arguments": tool_args,
"session_id": session_id,
"action": decision.action,
"reason": decision.reason,
"risk_labels": decision.risk_labels,
"policy_decision": decision.receipt,
});
let outcome = run_approval_review(ctx, request, session_id).await;
if !outcome.approved {
return false;
}
decision.grant_by_auto_review(&outcome.rationale);
true
}
#[cfg(test)]
mod tests {
use super::*;
fn dict(pairs: &[(&str, VmValue)]) -> VmValue {
let mut map = crate::value::DictMap::default();
for (key, value) in pairs {
map.insert(arcstr::ArcStr::from(*key), value.clone());
}
VmValue::Dict(std::sync::Arc::new(map))
}
fn text(value: &str) -> VmValue {
VmValue::String(arcstr::ArcStr::from(value))
}
#[test]
fn an_explicit_approval_is_an_approval() {
let outcome = parse_review_verdict(dict(&[
("approved", VmValue::Bool(true)),
("rationale", text("the goal names this file")),
]));
assert!(outcome.approved);
assert!(outcome.reviewer_answered);
assert_eq!(outcome.rationale, "the goal names this file");
}
#[test]
fn a_bare_true_is_not_an_approval() {
let outcome = parse_review_verdict(VmValue::Bool(true));
assert!(!outcome.approved);
assert!(!outcome.reviewer_answered);
assert_eq!(
outcome.unavailable_reason.as_deref(),
Some("reviewer_unparseable")
);
}
#[test]
fn a_missing_approved_field_is_not_an_approval() {
let outcome = parse_review_verdict(dict(&[("rationale", text("looks fine"))]));
assert!(!outcome.approved);
}
#[test]
fn an_answered_denial_is_not_an_unavailable() {
let outcome = parse_review_verdict(dict(&[
("approved", VmValue::Bool(false)),
("rationale", text("unrelated to the goal")),
]));
assert!(!outcome.approved);
assert!(outcome.reviewer_answered);
assert!(outcome.unavailable_reason.is_none());
}
#[test]
fn the_breaker_trips_on_consecutive_denials() {
let mut counts = BreakerCounts::default();
for _ in 0..CONSECUTIVE_DENIAL_LIMIT {
assert!(!counts.tripped());
counts.consecutive += 1;
counts.this_turn += 1;
}
assert!(counts.tripped());
}
#[test]
fn an_approval_clears_the_consecutive_run_but_not_the_turn() {
clear_approval_reviewers();
let session = "s-breaker";
record_verdict(session, false);
record_verdict(session, false);
assert_eq!(approval_reviewer_breaker(session).consecutive, 2);
record_verdict(session, true);
let counts = approval_reviewer_breaker(session);
assert_eq!(counts.consecutive, 0, "an approval breaks the run");
assert_eq!(
counts.this_turn, 2,
"but the turn total still remembers both denials"
);
clear_approval_reviewers();
}
#[test]
fn a_turn_reset_leaves_the_consecutive_run_standing() {
clear_approval_reviewers();
let session = "s-turn";
record_verdict(session, false);
record_verdict(session, false);
reset_approval_reviewer_turn(session);
let counts = approval_reviewer_breaker(session);
assert_eq!(counts.this_turn, 0);
assert_eq!(
counts.consecutive, 2,
"a reviewer denying across a turn boundary is the loop we are catching"
);
clear_approval_reviewers();
}
#[test]
fn an_unreviewed_session_reads_a_true_zero() {
clear_approval_reviewers();
let counts = approval_reviewer_breaker("never-seen");
assert_eq!(counts.consecutive, 0);
assert!(!counts.tripped());
}
}