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::ChatProvider;
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    consecutive: u32,
41    /// Total number of denials ever recorded.
42    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 ChatProvider>,
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    reason: String,
85}
86
87impl AutoClassifier {
88    /// Create a new classifier backed by the given provider.
89    pub fn new(provider: Arc<dyn ChatProvider>) -> Self {
90        Self {
91            provider,
92            tracker: DenialTracker::default(),
93        }
94    }
95
96    /// Classify whether a tool call should be blocked.
97    ///
98    /// # Arguments
99    /// * `tool_name` — name of the tool being called.
100    /// * `args_summary` — JSON string summary of the arguments.
101    /// * `transcript_snippet` — recent conversation context.
102    ///
103    /// # Returns
104    /// `Ok((block, reason))` — `block` is `true` if the tool should be denied.
105    ///
106    /// # Behaviour
107    /// * If the denial tracker is over limit, returns `(true, "...")` without
108    ///   calling the LLM.
109    /// * On JSON parse error, defaults to `(true, "classifier parse error...")`
110    ///   (fail-closed: err on the side of blocking).
111    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 over limit, skip the LLM call entirely.
118        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        // Call the LLM with a 60-second timeout.
128        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                // Parse failure — fail closed: deny rather than accidentally allow.
148                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// ── 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("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        // Parse error does not increment the denial tracker (it's not an LLM-decided denial).
245        assert_eq!(classifier.tracker.consecutive, 0);
246    }
247
248    #[tokio::test]
249    async fn classifier_over_limit_skips_llm() {
250        // Script 3 denial responses — the 4th call skips the LLM.
251        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        // Trigger 3 consecutive denials.
278        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        // Next call should skip LLM entirely (no mock completions needed).
285        let (block, reason) = classifier.classify("Bash", "{}", "").await.unwrap();
286        assert!(block);
287        assert!(reason.contains("limit reached"));
288    }
289}