Skip to main content

vtcode_core/tools/registry/
approval_recorder.rs

1/// Approval Decision Recording and Learning
2///
3/// Records user approval decisions for high-risk tools and enables pattern learning
4/// to reduce approval friction over time.
5use super::justification::{ApprovalPattern, JustificationManager};
6use anyhow::Result;
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11/// Records tool approval decisions for learning
12#[derive(Clone)]
13pub struct ApprovalRecorder {
14    manager: Arc<RwLock<JustificationManager>>,
15}
16
17impl ApprovalRecorder {
18    /// Create a new approval recorder
19    pub fn new(cache_dir: PathBuf) -> Self {
20        let manager = JustificationManager::new(cache_dir);
21        Self {
22            manager: Arc::new(RwLock::new(manager)),
23        }
24    }
25}
26
27impl Default for ApprovalRecorder {
28    fn default() -> Self {
29        // This default implementation creates a temporary directory for the cache.
30        // In a real application, you might want a more robust default path or
31        // to make `new` take an optional `cache_dir`.
32        let temp_dir =
33            std::env::temp_dir().join(format!("approval_recorder_default_{}", std::process::id()));
34        Self::new(temp_dir)
35    }
36}
37
38impl ApprovalRecorder {
39    /// Record a user's approval decision for a learned approval key
40    pub async fn record_approval(
41        &self,
42        approval_key: &str,
43        display_name: Option<&str>,
44        approved: bool,
45        reason: Option<String>,
46    ) -> Result<()> {
47        let manager = self.manager.write().await;
48        manager.record_decision(approval_key, display_name, approved, reason);
49        Ok(())
50    }
51
52    /// Get the approval pattern for a learned approval key
53    pub async fn get_pattern(&self, approval_key: &str) -> Option<ApprovalPattern> {
54        let manager = self.manager.read().await;
55        manager.get_pattern(approval_key)
56    }
57
58    /// Check if a key has high approval rate from history
59    pub async fn has_high_approval_rate(&self, approval_key: &str) -> bool {
60        let manager = self.manager.read().await;
61        if let Some(pattern) = manager.get_pattern(approval_key) {
62            pattern.has_high_approval_rate()
63        } else {
64            false
65        }
66    }
67
68    /// Get learning summary for a learned approval key
69    pub async fn get_learning_summary(&self, approval_key: &str) -> Option<String> {
70        let manager = self.manager.read().await;
71        manager.get_learning_summary(approval_key)
72    }
73
74    /// Get approval count for a learned approval key
75    pub async fn get_approval_count(&self, approval_key: &str) -> u32 {
76        let manager = self.manager.read().await;
77        if let Some(pattern) = manager.get_pattern(approval_key) {
78            pattern.approval_count()
79        } else {
80            0
81        }
82    }
83
84    /// Should auto-approve based on approval pattern
85    /// Rules:
86    /// - At least 3 approvals
87    /// - Approval rate > 80%
88    ///
89    /// Refreshes the in-memory pattern map from disk first so we observe
90    /// approvals recorded by concurrent sessions (e.g. another running vtcode
91    /// instance sharing the same `~/.vtcode/cache/approval_patterns.json`).
92    pub async fn should_auto_approve(&self, approval_key: &str) -> bool {
93        let manager = self.manager.write().await;
94        if let Err(err) = manager.refresh_patterns() {
95            tracing::debug!(
96                approval_key = %approval_key,
97                error = %err,
98                "Failed to refresh approval patterns before auto-approve check"
99            );
100        }
101        if let Some(pattern) = manager.get_pattern(approval_key) {
102            pattern.has_high_approval_rate()
103        } else {
104            false
105        }
106    }
107
108    /// Suggest auto-approval message if user has approved this target many times
109    pub async fn get_auto_approval_suggestion(
110        &self,
111        approval_key: &str,
112        fallback_display_name: &str,
113    ) -> Option<String> {
114        let manager = self.manager.read().await;
115        if let Some(pattern) = manager.get_pattern(approval_key) {
116            let rate = pattern.approval_rate();
117            if pattern.approval_count() >= 5 {
118                let display_name = pattern.display_name(fallback_display_name);
119                return Some(format!(
120                    "You've approved {} {} times ({:.0}% approval rate)",
121                    display_name,
122                    pattern.approval_count(),
123                    rate * 100.0
124                ));
125            }
126        }
127        None
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[tokio::test]
136    async fn test_approval_recording() {
137        let temp_dir = std::env::temp_dir().join(format!("vtcode_test_{}", std::process::id()));
138        let recorder = ApprovalRecorder::new(temp_dir.clone());
139
140        // Record some approvals
141        recorder
142            .record_approval("read_file", Some("Read File"), true, None)
143            .await
144            .unwrap();
145        recorder
146            .record_approval("read_file", Some("Read File"), true, None)
147            .await
148            .unwrap();
149        recorder
150            .record_approval("read_file", Some("Read File"), false, None)
151            .await
152            .unwrap();
153
154        // Check pattern
155        let pattern = recorder.get_pattern("read_file").await;
156        assert!(pattern.is_some());
157        assert_eq!(pattern.unwrap().approval_count(), 2);
158
159        // Cleanup
160        let _ = std::fs::remove_dir_all(&temp_dir);
161    }
162
163    #[tokio::test]
164    async fn test_auto_approval_suggestion() {
165        let temp_dir = std::env::temp_dir().join(format!("vtcode_test_{}", std::process::id()));
166        let recorder = ApprovalRecorder::new(temp_dir.clone());
167
168        // Not enough approvals initially
169        assert!(
170            recorder
171                .get_auto_approval_suggestion("read_file", "Read File")
172                .await
173                .is_none()
174        );
175
176        // Add 5 approvals
177        for _ in 0..5 {
178            let _ = recorder
179                .record_approval("read_file", Some("Read File"), true, None)
180                .await;
181        }
182
183        // Now we should get a suggestion
184        let suggestion = recorder
185            .get_auto_approval_suggestion("read_file", "Read File")
186            .await;
187        assert!(suggestion.is_some());
188        assert!(suggestion.unwrap().contains("100%"));
189
190        // Cleanup
191        let _ = std::fs::remove_dir_all(&temp_dir);
192    }
193
194    #[tokio::test]
195    async fn test_should_auto_approve() {
196        let temp_dir = std::env::temp_dir().join(format!("vtcode_test_{}", std::process::id()));
197        let recorder = ApprovalRecorder::new(temp_dir.clone());
198
199        // Not approved initially
200        assert!(!recorder.should_auto_approve("run_command").await);
201
202        // Add 3 approvals (minimum threshold)
203        for _ in 0..3 {
204            let _ = recorder
205                .record_approval("run_command", Some("Run Command"), true, None)
206                .await;
207        }
208
209        // Now should auto-approve
210        assert!(recorder.should_auto_approve("run_command").await);
211
212        // Cleanup
213        let _ = std::fs::remove_dir_all(&temp_dir);
214    }
215
216    #[tokio::test]
217    async fn test_auto_approval_suggestion_uses_display_name() {
218        let temp_dir = std::env::temp_dir().join(format!("vtcode_test_{}", std::process::id()));
219        let recorder = ApprovalRecorder::new(temp_dir.clone());
220
221        for _ in 0..5 {
222            let _ = recorder
223                .record_approval(
224                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
225                    Some("commands starting with `cargo test`"),
226                    true,
227                    None,
228                )
229                .await;
230        }
231
232        let suggestion = recorder
233            .get_auto_approval_suggestion(
234                "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
235                "fallback label",
236            )
237            .await
238            .expect("suggestion");
239        assert!(suggestion.contains("commands starting with `cargo test`"));
240
241        let _ = std::fs::remove_dir_all(&temp_dir);
242    }
243
244    #[tokio::test]
245    async fn test_should_auto_approve_refreshes_patterns_from_disk() {
246        // Simulates a second vtcode session: one ApprovalRecorder records
247        // approvals to disk, then a separately constructed recorder must
248        // observe them on the next auto-approve check without restart.
249        let temp_dir = std::env::temp_dir().join(format!(
250            "vtcode_test_refresh_{}_{}",
251            std::process::id(),
252            std::time::SystemTime::now()
253                .duration_since(std::time::UNIX_EPOCH)
254                .map(|d| d.as_nanos())
255                .unwrap_or_default()
256        ));
257        let _ = std::fs::remove_dir_all(&temp_dir);
258
259        let key = "find src -type f -name '*.rs' '|' sort|sandbox_permissions=\"use_default\"|additional_permissions=null";
260
261        let reader = ApprovalRecorder::new(temp_dir.clone());
262        assert!(!reader.should_auto_approve(key).await);
263
264        let writer = ApprovalRecorder::new(temp_dir.clone());
265        for _ in 0..3 {
266            writer
267                .record_approval(key, Some("find src"), true, None)
268                .await
269                .unwrap();
270        }
271
272        // Without the disk refresh in should_auto_approve, the reader's
273        // in-memory map would still be empty and this assertion would fail.
274        assert!(reader.should_auto_approve(key).await);
275
276        let _ = std::fs::remove_dir_all(&temp_dir);
277    }
278
279    #[tokio::test]
280    async fn test_shell_scoped_history_does_not_reuse_tool_level_key() {
281        let temp_dir = std::env::temp_dir().join(format!("vtcode_test_{}", std::process::id()));
282        let recorder = ApprovalRecorder::new(temp_dir.clone());
283
284        for _ in 0..5 {
285            let _ = recorder
286                .record_approval("unified_exec", Some("Unified Exec"), true, None)
287                .await;
288        }
289
290        assert_eq!(
291            recorder
292                .get_approval_count(
293                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null"
294                )
295                .await,
296            0
297        );
298        assert!(
299            recorder
300                .get_auto_approval_suggestion(
301                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
302                    "commands starting with `cargo test`",
303                )
304                .await
305                .is_none()
306        );
307
308        let _ = std::fs::remove_dir_all(&temp_dir);
309    }
310}