recursive/permissions/
auto_classifier.rs1use crate::error::Result;
13use crate::llm::ChatProvider;
14use std::sync::Arc;
15
16const CLASSIFIER_PROMPT: &str = "\
19You are a security classifier for an AI coding agent.
20Current tool call:
21 tool: {tool_name}
22 args: {args_summary}
23
24Recent conversation context (last 10 turns):
25{transcript_snippet}
26
27Is this action safe to auto-approve?
28Respond ONLY with JSON (no markdown): { \"block\": true|false, \"reason\": \"...\" }";
29
30#[derive(Debug, Default)]
38pub struct DenialTracker {
39 consecutive: u32,
41 total: u32,
43}
44
45const MAX_CONSECUTIVE: u32 = 3;
47
48const MAX_TOTAL: u32 = 10;
50
51impl DenialTracker {
52 pub fn record_denial(&mut self) {
54 self.consecutive += 1;
55 self.total += 1;
56 }
57
58 pub fn record_allow(&mut self) {
60 self.consecutive = 0;
61 }
62
63 pub fn is_over_limit(&self) -> bool {
65 self.consecutive >= MAX_CONSECUTIVE || self.total >= MAX_TOTAL
66 }
67}
68
69pub struct AutoClassifier {
74 provider: Arc<dyn ChatProvider>,
76 pub tracker: DenialTracker,
78}
79
80#[derive(serde::Deserialize)]
82struct ClassifierResponse {
83 block: bool,
84 reason: String,
85}
86
87impl AutoClassifier {
88 pub fn new(provider: Arc<dyn ChatProvider>) -> Self {
90 Self {
91 provider,
92 tracker: DenialTracker::default(),
93 }
94 }
95
96 pub async fn classify(
112 &mut self,
113 tool_name: &str,
114 args_summary: &str,
115 transcript_snippet: &str,
116 ) -> Result<(bool, String)> {
117 if self.tracker.is_over_limit() {
119 return Ok((true, "denial limit reached".into()));
120 }
121
122 let prompt = CLASSIFIER_PROMPT
123 .replace("{tool_name}", tool_name)
124 .replace("{args_summary}", args_summary)
125 .replace("{transcript_snippet}", transcript_snippet);
126
127 let response = tokio::time::timeout(
129 std::time::Duration::from_secs(60),
130 self.provider.complete_simple(&prompt, 0.0),
131 )
132 .await
133 .map_err(|_| crate::error::Error::Config {
134 message: "auto classifier timeout".into(),
135 })??;
136
137 match serde_json::from_str::<ClassifierResponse>(&response) {
138 Ok(r) => {
139 if r.block {
140 self.tracker.record_denial();
141 } else {
142 self.tracker.record_allow();
143 }
144 Ok((r.block, r.reason))
145 }
146 Err(e) => {
147 tracing::warn!(target: "recursive::permissions", "classifier JSON parse error: {e}; defaulting to block");
149 Ok((true, "classifier parse error, defaulting to block".into()))
150 }
151 }
152 }
153}
154
155#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::llm::mock::MockProvider;
161 use crate::llm::Completion;
162
163 #[test]
166 fn denial_tracker_consecutive_limit() {
167 let mut tracker = DenialTracker::default();
168 assert!(!tracker.is_over_limit());
169 tracker.record_denial();
170 tracker.record_denial();
171 assert!(!tracker.is_over_limit());
172 tracker.record_denial(); assert!(tracker.is_over_limit());
174 assert_eq!(tracker.consecutive, 3);
175 assert_eq!(tracker.total, 3);
176 }
177
178 #[test]
179 fn denial_tracker_reset_on_allow() {
180 let mut tracker = DenialTracker::default();
181 tracker.record_denial();
182 tracker.record_denial(); assert!(!tracker.is_over_limit());
184 tracker.record_allow(); assert_eq!(tracker.consecutive, 0);
186 tracker.record_denial(); assert_eq!(tracker.consecutive, 1);
188 assert!(!tracker.is_over_limit());
189 }
190
191 #[test]
192 fn denial_tracker_total_limit() {
193 let mut tracker = DenialTracker::default();
194 for _ in 0..9 {
195 tracker.record_denial();
196 tracker.record_allow(); }
198 assert!(!tracker.is_over_limit()); tracker.record_denial(); assert!(tracker.is_over_limit());
201 assert_eq!(tracker.total, 10);
202 }
203
204 fn mock_classifier(content: &str) -> MockProvider {
208 MockProvider::new(vec![Completion {
209 content: content.to_string(),
210 tool_calls: vec![],
211 finish_reason: Some("stop".into()),
212 usage: None,
213 reasoning_content: None,
214 }])
215 }
216
217 #[tokio::test]
218 async fn classifier_parse_block_true() {
219 let provider = Arc::new(mock_classifier(r#"{"block":true,"reason":"unsafe"}"#));
220 let mut classifier = AutoClassifier::new(provider);
221 let (block, reason) = classifier.classify("Bash", "{}", "").await.unwrap();
222 assert!(block);
223 assert_eq!(reason, "unsafe");
224 assert_eq!(classifier.tracker.consecutive, 1);
225 }
226
227 #[tokio::test]
228 async fn classifier_parse_allow() {
229 let provider = Arc::new(mock_classifier(r#"{"block":false,"reason":"ok"}"#));
230 let mut classifier = AutoClassifier::new(provider);
231 let (block, reason) = classifier.classify("Read", "{}", "").await.unwrap();
232 assert!(!block);
233 assert_eq!(reason, "ok");
234 assert_eq!(classifier.tracker.consecutive, 0);
235 }
236
237 #[tokio::test]
238 async fn classifier_parse_error_defaults_block() {
239 let provider = Arc::new(mock_classifier("not valid json"));
240 let mut classifier = AutoClassifier::new(provider);
241 let (block, reason) = classifier.classify("Read", "{}", "").await.unwrap();
242 assert!(block, "parse error should default to block (fail-closed)");
243 assert!(reason.contains("defaulting to block"));
244 assert_eq!(classifier.tracker.consecutive, 0);
246 }
247
248 #[tokio::test]
249 async fn classifier_over_limit_skips_llm() {
250 let provider = MockProvider::new(vec![
252 Completion {
253 content: r#"{"block":true,"reason":"first"}"#.into(),
254 tool_calls: vec![],
255 finish_reason: Some("stop".into()),
256 usage: None,
257 reasoning_content: None,
258 },
259 Completion {
260 content: r#"{"block":true,"reason":"second"}"#.into(),
261 tool_calls: vec![],
262 finish_reason: Some("stop".into()),
263 usage: None,
264 reasoning_content: None,
265 },
266 Completion {
267 content: r#"{"block":true,"reason":"third"}"#.into(),
268 tool_calls: vec![],
269 finish_reason: Some("stop".into()),
270 usage: None,
271 reasoning_content: None,
272 },
273 ]);
274 let provider = Arc::new(provider);
275 let mut classifier = AutoClassifier::new(provider);
276
277 for _ in 0..3 {
279 let (block, _) = classifier.classify("Bash", "{}", "").await.unwrap();
280 assert!(block);
281 }
282 assert!(classifier.tracker.is_over_limit());
283
284 let (block, reason) = classifier.classify("Bash", "{}", "").await.unwrap();
286 assert!(block);
287 assert!(reason.contains("limit reached"));
288 }
289}