Skip to main content

recursive/permissions/
auto_classifier.rs

1//! LLM-based permission classifier for `PermissionMode::Auto`.
2//!
3//! When the agent is in Auto mode, each tool call is sent to a small
4//! classifier prompt running against the same LLM provider. The classifier
5//! returns `{"block": true/false, "reason": "..."}` and the result is
6//! enforced before the tool executes.
7//!
8//! A [`DenialTracker`] prevents runaway denial loops: after 3 consecutive
9//! or 10 total denials, the classifier stops calling the LLM and blocks
10//! everything, triggering a [`FinishReason::PermissionDenialLimit`].
11
12use crate::error::Result;
13use crate::llm::LlmProvider;
14use std::sync::Arc;
15
16// ── Classifier prompt ──────────────────────────────────────────────────────
17
18const 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// ── Denial tracker ─────────────────────────────────────────────────────────
31
32/// Tracks classifier denial counts to enforce safety limits.
33///
34/// After `MAX_CONSECUTIVE` consecutive denials or `MAX_TOTAL` total
35/// denials, `is_over_limit()` returns `true` and the classifier stops
36/// calling the LLM (all subsequent tool calls are denied).
37#[derive(Debug, Default)]
38pub struct DenialTracker {
39    /// Number of consecutive denials without an intervening allow.
40    pub consecutive: u32,
41    /// Total number of denials ever recorded.
42    pub total: u32,
43}
44
45/// Maximum number of consecutive denials before limit is hit.
46const MAX_CONSECUTIVE: u32 = 3;
47
48/// Maximum total number of denials before limit is hit.
49const MAX_TOTAL: u32 = 10;
50
51impl DenialTracker {
52    /// Record a denial — increments both counters.
53    pub fn record_denial(&mut self) {
54        self.consecutive += 1;
55        self.total += 1;
56    }
57
58    /// Record an allow — resets the consecutive counter.
59    pub fn record_allow(&mut self) {
60        self.consecutive = 0;
61    }
62
63    /// Returns `true` if either limit has been reached.
64    pub fn is_over_limit(&self) -> bool {
65        self.consecutive >= MAX_CONSECUTIVE || self.total >= MAX_TOTAL
66    }
67}
68
69// ── Auto classifier ────────────────────────────────────────────────────────
70
71/// LLM-based classifier that decides whether a tool call should be
72/// auto-approved in [`PermissionMode::Auto`](crate::permissions::PermissionMode::Auto).
73pub struct AutoClassifier {
74    /// The LLM provider used for classification calls.
75    provider: Arc<dyn LlmProvider>,
76    /// Denial tracker for safety limits.
77    pub tracker: DenialTracker,
78}
79
80/// JSON response expected from the classifier LLM.
81#[derive(serde::Deserialize)]
82struct ClassifierResponse {
83    block: bool,
84    #[allow(dead_code)]
85    reason: String,
86}
87
88impl AutoClassifier {
89    /// Create a new classifier backed by the given provider.
90    pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
91        Self {
92            provider,
93            tracker: DenialTracker::default(),
94        }
95    }
96
97    /// Classify whether a tool call should be blocked.
98    ///
99    /// # Arguments
100    /// * `tool_name` — name of the tool being called.
101    /// * `args_summary` — JSON string summary of the arguments.
102    /// * `transcript_snippet` — recent conversation context.
103    ///
104    /// # Returns
105    /// `Ok((block, reason))` — `block` is `true` if the tool should be denied.
106    ///
107    /// # Behaviour
108    /// * If the denial tracker is over limit, returns `(true, "...")` without
109    ///   calling the LLM.
110    /// * On JSON parse error, defaults to `(false, "classifier parse error...")`
111    ///   (conservative: err on the side of allowing).
112    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 over limit, skip the LLM call entirely.
119        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        // Call the LLM with a 60-second timeout.
129        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                // Parse failure — conservative: allow the tool
149                Ok((false, "classifier parse error, defaulting to allow".into()))
150            }
151        }
152    }
153}
154
155// ── Tests ──────────────────────────────────────────────────────────────────
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::llm::mock::MockProvider;
161    use crate::llm::Completion;
162
163    // ── DenialTracker tests ──────────────────────────────────────────────
164
165    #[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(); // 3rd consecutive
173        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(); // consecutive = 2
183        assert!(!tracker.is_over_limit());
184        tracker.record_allow(); // consecutive resets to 0
185        assert_eq!(tracker.consecutive, 0);
186        tracker.record_denial(); // consecutive = 1
187        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(); // reset consecutive
197        }
198        assert!(!tracker.is_over_limit()); // total = 9
199        tracker.record_denial(); // total = 10
200        assert!(tracker.is_over_limit());
201        assert_eq!(tracker.total, 10);
202    }
203
204    // ── AutoClassifier tests with mock provider ──────────────────────────
205
206    /// Build a mock provider whose `complete_simple` returns the given content.
207    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        // Consecutive should still be 0 (parse error is not a denial).
244        assert_eq!(classifier.tracker.consecutive, 0);
245    }
246
247    #[tokio::test]
248    async fn classifier_over_limit_skips_llm() {
249        // Script 3 denial responses — the 4th call skips the LLM.
250        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        // Trigger 3 consecutive denials.
277        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        // Next call should skip LLM entirely (no mock completions needed).
284        let (block, reason) = classifier.classify("run_shell", "{}", "").await.unwrap();
285        assert!(block);
286        assert!(reason.contains("limit reached"));
287    }
288}