use async_trait::async_trait;
use clawft_types::agent_chat::GateDenialRecord;
use super::effects::EffectVector;
pub const GATE_DENIAL_ESCALATION_LIMIT: u32 = 3;
pub fn gate_denial_reason(result_json: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(result_json).ok()?;
let obj = value.as_object()?;
if !obj.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
return None;
}
let reason = obj
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("policy")
.to_string();
Some(reason)
}
pub fn gate_deferral_reason(result_json: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(result_json).ok()?;
let obj = value.as_object()?;
if !obj
.get("deferred")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
let reason = obj
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("policy review")
.to_string();
Some(reason)
}
pub fn record_gate_denial_streak(
count: &mut u32,
denials: &mut Vec<GateDenialRecord>,
tool: &str,
result_json: &str,
limit: u32,
) -> bool {
match gate_denial_reason(result_json) {
Some(reason) => {
denials.push(GateDenialRecord {
tool: tool.to_string(),
reason,
});
*count = count.saturating_add(1);
*count >= limit
}
None => {
*count = 0;
denials.clear();
false
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum GateDecision {
Permit {
token: String,
},
Defer {
reason: String,
},
Deny {
reason: String,
},
}
impl GateDecision {
pub fn is_permit(&self) -> bool {
matches!(self, GateDecision::Permit { .. })
}
}
#[async_trait]
pub trait EffectGate: Send + Sync + 'static {
async fn check(&self, agent_id: &str, action: &str, effect: &EffectVector) -> GateDecision;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGate;
#[async_trait]
impl EffectGate for NoopGate {
async fn check(&self, _agent_id: &str, _action: &str, _effect: &EffectVector) -> GateDecision {
GateDecision::Permit {
token: "noop".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
fn _coerce(_gate: &dyn EffectGate) {}
#[tokio::test]
async fn noop_gate_permits_everything() {
let gate = NoopGate;
let ev = EffectVector {
risk: 0.99,
security: 0.99,
..Default::default()
};
let decision = gate.check("agent-1", "tool.exec", &ev).await;
assert!(decision.is_permit());
match decision {
GateDecision::Permit { token } => assert_eq!(token, "noop"),
other => panic!("expected Permit, got {other:?}"),
}
}
#[tokio::test]
async fn noop_gate_permits_with_zero_effect() {
let gate = NoopGate;
let decision = gate
.check("agent-1", "tool.read_file", &EffectVector::default())
.await;
assert!(decision.is_permit());
}
#[test]
fn is_permit_matches_only_permit() {
assert!(GateDecision::Permit { token: "x".into() }.is_permit());
assert!(!GateDecision::Defer { reason: "x".into() }.is_permit());
assert!(!GateDecision::Deny { reason: "x".into() }.is_permit());
}
#[test]
fn gate_denial_reason_parses_structured_denied() {
let body = r#"{"denied":true,"reason":"write blocked"}"#;
assert_eq!(
gate_denial_reason(body).as_deref(),
Some("write blocked")
);
}
#[test]
fn gate_denial_reason_ignores_error_and_deferred() {
assert!(gate_denial_reason(r#"{"error":"sandbox denied: x"}"#).is_none());
assert!(gate_denial_reason(r#"{"deferred":true,"reason":"review"}"#).is_none());
assert!(gate_denial_reason(r#"{"ok":true}"#).is_none());
assert!(gate_denial_reason("not-json").is_none());
}
#[test]
fn gate_deferral_reason_parses_structured_deferred() {
let body = r#"{"deferred":true,"reason":"policy review pending"}"#;
assert_eq!(
gate_deferral_reason(body).as_deref(),
Some("policy review pending")
);
}
#[test]
fn gate_deferral_reason_ignores_denied_and_error() {
assert!(gate_deferral_reason(r#"{"denied":true,"reason":"no"}"#).is_none());
assert!(gate_deferral_reason(r#"{"error":"sandbox denied: x"}"#).is_none());
assert!(gate_deferral_reason(r#"{"ok":true}"#).is_none());
assert!(gate_deferral_reason("not-json").is_none());
}
#[test]
fn record_gate_denial_streak_trips_at_limit() {
let mut count = 0;
let mut denials = Vec::new();
let deny = r#"{"denied":true,"reason":"no"}"#;
assert!(!record_gate_denial_streak(
&mut count,
&mut denials,
"a",
deny,
GATE_DENIAL_ESCALATION_LIMIT
));
assert_eq!(count, 1);
assert!(!record_gate_denial_streak(
&mut count,
&mut denials,
"b",
deny,
GATE_DENIAL_ESCALATION_LIMIT
));
assert_eq!(count, 2);
assert!(record_gate_denial_streak(
&mut count,
&mut denials,
"c",
deny,
GATE_DENIAL_ESCALATION_LIMIT
));
assert_eq!(count, 3);
assert_eq!(denials.len(), 3);
assert_eq!(denials[2].tool, "c");
}
#[test]
fn record_gate_denial_streak_resets_on_non_deny() {
let mut count = 0;
let mut denials = Vec::new();
let deny = r#"{"denied":true,"reason":"no"}"#;
assert!(!record_gate_denial_streak(
&mut count,
&mut denials,
"a",
deny,
GATE_DENIAL_ESCALATION_LIMIT
));
assert!(!record_gate_denial_streak(
&mut count,
&mut denials,
"a",
r#"{"ok":true}"#,
GATE_DENIAL_ESCALATION_LIMIT
));
assert_eq!(count, 0);
assert!(denials.is_empty());
}
}