use crate::error::Result;
use crate::llm::LlmProvider;
use std::sync::Arc;
const CLASSIFIER_PROMPT: &str = "\
You are a security classifier for an AI coding agent.
Current tool call:
tool: {tool_name}
args: {args_summary}
Recent conversation context (last 10 turns):
{transcript_snippet}
Is this action safe to auto-approve?
Respond ONLY with JSON (no markdown): { \"block\": true|false, \"reason\": \"...\" }";
#[derive(Debug, Default)]
pub struct DenialTracker {
pub consecutive: u32,
pub total: u32,
}
const MAX_CONSECUTIVE: u32 = 3;
const MAX_TOTAL: u32 = 10;
impl DenialTracker {
pub fn record_denial(&mut self) {
self.consecutive += 1;
self.total += 1;
}
pub fn record_allow(&mut self) {
self.consecutive = 0;
}
pub fn is_over_limit(&self) -> bool {
self.consecutive >= MAX_CONSECUTIVE || self.total >= MAX_TOTAL
}
}
pub struct AutoClassifier {
provider: Arc<dyn LlmProvider>,
pub tracker: DenialTracker,
}
#[derive(serde::Deserialize)]
struct ClassifierResponse {
block: bool,
#[allow(dead_code)]
reason: String,
}
impl AutoClassifier {
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
Self {
provider,
tracker: DenialTracker::default(),
}
}
pub async fn classify(
&mut self,
tool_name: &str,
args_summary: &str,
transcript_snippet: &str,
) -> Result<(bool, String)> {
if self.tracker.is_over_limit() {
return Ok((true, "denial limit reached".into()));
}
let prompt = CLASSIFIER_PROMPT
.replace("{tool_name}", tool_name)
.replace("{args_summary}", args_summary)
.replace("{transcript_snippet}", transcript_snippet);
let response = tokio::time::timeout(
std::time::Duration::from_secs(60),
self.provider.complete_simple(&prompt, 0.0),
)
.await
.map_err(|_| crate::error::Error::Config {
message: "auto classifier timeout".into(),
})??;
match serde_json::from_str::<ClassifierResponse>(&response) {
Ok(r) => {
if r.block {
self.tracker.record_denial();
} else {
self.tracker.record_allow();
}
Ok((r.block, r.reason))
}
Err(_) => {
Ok((false, "classifier parse error, defaulting to allow".into()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::mock::MockProvider;
use crate::llm::Completion;
#[test]
fn denial_tracker_consecutive_limit() {
let mut tracker = DenialTracker::default();
assert!(!tracker.is_over_limit());
tracker.record_denial();
tracker.record_denial();
assert!(!tracker.is_over_limit());
tracker.record_denial(); assert!(tracker.is_over_limit());
assert_eq!(tracker.consecutive, 3);
assert_eq!(tracker.total, 3);
}
#[test]
fn denial_tracker_reset_on_allow() {
let mut tracker = DenialTracker::default();
tracker.record_denial();
tracker.record_denial(); assert!(!tracker.is_over_limit());
tracker.record_allow(); assert_eq!(tracker.consecutive, 0);
tracker.record_denial(); assert_eq!(tracker.consecutive, 1);
assert!(!tracker.is_over_limit());
}
#[test]
fn denial_tracker_total_limit() {
let mut tracker = DenialTracker::default();
for _ in 0..9 {
tracker.record_denial();
tracker.record_allow(); }
assert!(!tracker.is_over_limit()); tracker.record_denial(); assert!(tracker.is_over_limit());
assert_eq!(tracker.total, 10);
}
fn mock_classifier(content: &str) -> MockProvider {
MockProvider::new(vec![Completion {
content: content.to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}])
}
#[tokio::test]
async fn classifier_parse_block_true() {
let provider = Arc::new(mock_classifier(r#"{"block":true,"reason":"unsafe"}"#));
let mut classifier = AutoClassifier::new(provider);
let (block, reason) = classifier.classify("run_shell", "{}", "").await.unwrap();
assert!(block);
assert_eq!(reason, "unsafe");
assert_eq!(classifier.tracker.consecutive, 1);
}
#[tokio::test]
async fn classifier_parse_allow() {
let provider = Arc::new(mock_classifier(r#"{"block":false,"reason":"ok"}"#));
let mut classifier = AutoClassifier::new(provider);
let (block, reason) = classifier.classify("read_file", "{}", "").await.unwrap();
assert!(!block);
assert_eq!(reason, "ok");
assert_eq!(classifier.tracker.consecutive, 0);
}
#[tokio::test]
async fn classifier_parse_error_defaults_allow() {
let provider = Arc::new(mock_classifier("not valid json"));
let mut classifier = AutoClassifier::new(provider);
let (block, _reason) = classifier.classify("read_file", "{}", "").await.unwrap();
assert!(!block, "parse error should default to allow");
assert_eq!(classifier.tracker.consecutive, 0);
}
#[tokio::test]
async fn classifier_over_limit_skips_llm() {
let provider = MockProvider::new(vec![
Completion {
content: r#"{"block":true,"reason":"first"}"#.into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: r#"{"block":true,"reason":"second"}"#.into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: r#"{"block":true,"reason":"third"}"#.into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
]);
let provider = Arc::new(provider);
let mut classifier = AutoClassifier::new(provider);
for _ in 0..3 {
let (block, _) = classifier.classify("run_shell", "{}", "").await.unwrap();
assert!(block);
}
assert!(classifier.tracker.is_over_limit());
let (block, reason) = classifier.classify("run_shell", "{}", "").await.unwrap();
assert!(block);
assert!(reason.contains("limit reached"));
}
}