recursive/permissions/
auto_classifier.rs1use crate::error::Result;
13use crate::llm::LlmProvider;
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 pub consecutive: u32,
41 pub 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 LlmProvider>,
76 pub tracker: DenialTracker,
78}
79
80#[derive(serde::Deserialize)]
82struct ClassifierResponse {
83 block: bool,
84 #[allow(dead_code)]
85 reason: String,
86}
87
88impl AutoClassifier {
89 pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
91 Self {
92 provider,
93 tracker: DenialTracker::default(),
94 }
95 }
96
97 pub async fn classify(
113 &mut self,
114 tool_name: &str,
115 args_summary: &str,
116 transcript_snippet: &str,
117 ) -> Result<(bool, String)> {
118 if self.tracker.is_over_limit() {
120 return Ok((true, "denial limit reached".into()));
121 }
122
123 let prompt = CLASSIFIER_PROMPT
124 .replace("{tool_name}", tool_name)
125 .replace("{args_summary}", args_summary)
126 .replace("{transcript_snippet}", transcript_snippet);
127
128 let response = tokio::time::timeout(
130 std::time::Duration::from_secs(60),
131 self.provider.complete_simple(&prompt, 0.0),
132 )
133 .await
134 .map_err(|_| crate::error::Error::Config {
135 message: "auto classifier timeout".into(),
136 })??;
137
138 match serde_json::from_str::<ClassifierResponse>(&response) {
139 Ok(r) => {
140 if r.block {
141 self.tracker.record_denial();
142 } else {
143 self.tracker.record_allow();
144 }
145 Ok((r.block, r.reason))
146 }
147 Err(_) => {
148 Ok((false, "classifier parse error, defaulting to allow".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("run_shell", "{}", "").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_file", "{}", "").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_allow() {
239 let provider = Arc::new(mock_classifier("not valid json"));
240 let mut classifier = AutoClassifier::new(provider);
241 let (block, _reason) = classifier.classify("read_file", "{}", "").await.unwrap();
242 assert!(!block, "parse error should default to allow");
243 assert_eq!(classifier.tracker.consecutive, 0);
245 }
246
247 #[tokio::test]
248 async fn classifier_over_limit_skips_llm() {
249 let provider = MockProvider::new(vec![
251 Completion {
252 content: r#"{"block":true,"reason":"first"}"#.into(),
253 tool_calls: vec![],
254 finish_reason: Some("stop".into()),
255 usage: None,
256 reasoning_content: None,
257 },
258 Completion {
259 content: r#"{"block":true,"reason":"second"}"#.into(),
260 tool_calls: vec![],
261 finish_reason: Some("stop".into()),
262 usage: None,
263 reasoning_content: None,
264 },
265 Completion {
266 content: r#"{"block":true,"reason":"third"}"#.into(),
267 tool_calls: vec![],
268 finish_reason: Some("stop".into()),
269 usage: None,
270 reasoning_content: None,
271 },
272 ]);
273 let provider = Arc::new(provider);
274 let mut classifier = AutoClassifier::new(provider);
275
276 for _ in 0..3 {
278 let (block, _) = classifier.classify("run_shell", "{}", "").await.unwrap();
279 assert!(block);
280 }
281 assert!(classifier.tracker.is_over_limit());
282
283 let (block, reason) = classifier.classify("run_shell", "{}", "").await.unwrap();
285 assert!(block);
286 assert!(reason.contains("limit reached"));
287 }
288}