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    use tempfile::TempDir;
135
136    fn temp_cache_dir() -> TempDir {
137        TempDir::new().expect("temp approval cache")
138    }
139
140    #[tokio::test]
141    async fn test_approval_recording() {
142        let temp_dir = temp_cache_dir();
143        let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
144
145        // Record some approvals
146        recorder
147            .record_approval("read_file", Some("Read File"), true, None)
148            .await
149            .unwrap();
150        recorder
151            .record_approval("read_file", Some("Read File"), true, None)
152            .await
153            .unwrap();
154        recorder
155            .record_approval("read_file", Some("Read File"), false, None)
156            .await
157            .unwrap();
158
159        // Check pattern
160        let pattern = recorder.get_pattern("read_file").await;
161        assert!(pattern.is_some());
162        assert_eq!(pattern.unwrap().approval_count(), 2);
163    }
164
165    #[tokio::test]
166    async fn test_auto_approval_suggestion() {
167        let temp_dir = temp_cache_dir();
168        let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
169
170        // Not enough approvals initially
171        assert!(
172            recorder
173                .get_auto_approval_suggestion("read_file", "Read File")
174                .await
175                .is_none()
176        );
177
178        // Add 5 approvals
179        for _ in 0..5 {
180            let _ = recorder
181                .record_approval("read_file", Some("Read File"), true, None)
182                .await;
183        }
184
185        // Now we should get a suggestion
186        let suggestion = recorder
187            .get_auto_approval_suggestion("read_file", "Read File")
188            .await;
189        assert!(suggestion.is_some());
190        assert!(suggestion.unwrap().contains("100%"));
191    }
192
193    #[tokio::test]
194    async fn test_should_auto_approve() {
195        let temp_dir = temp_cache_dir();
196        let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
197
198        // Not approved initially
199        assert!(!recorder.should_auto_approve("run_command").await);
200
201        // Add 3 approvals (minimum threshold)
202        for _ in 0..3 {
203            let _ = recorder
204                .record_approval("run_command", Some("Run Command"), true, None)
205                .await;
206        }
207
208        // Now should auto-approve
209        assert!(recorder.should_auto_approve("run_command").await);
210    }
211
212    #[tokio::test]
213    async fn test_auto_approval_suggestion_uses_display_name() {
214        let temp_dir = temp_cache_dir();
215        let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
216
217        for _ in 0..5 {
218            let _ = recorder
219                .record_approval(
220                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
221                    Some("commands starting with `cargo test`"),
222                    true,
223                    None,
224                )
225                .await;
226        }
227
228        let suggestion = recorder
229            .get_auto_approval_suggestion(
230                "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
231                "fallback label",
232            )
233            .await
234            .expect("suggestion");
235        assert!(suggestion.contains("commands starting with `cargo test`"));
236    }
237
238    #[tokio::test]
239    async fn test_should_auto_approve_refreshes_patterns_from_disk() {
240        // Simulates a second vtcode session: one ApprovalRecorder records
241        // approvals to disk, then a separately constructed recorder must
242        // observe them on the next auto-approve check without restart.
243        let temp_dir = temp_cache_dir();
244
245        let key = "find src -type f -name '*.rs' '|' sort|sandbox_permissions=\"use_default\"|additional_permissions=null";
246
247        let reader = ApprovalRecorder::new(temp_dir.path().to_path_buf());
248        assert!(!reader.should_auto_approve(key).await);
249
250        let writer = ApprovalRecorder::new(temp_dir.path().to_path_buf());
251        for _ in 0..3 {
252            writer
253                .record_approval(key, Some("find src"), true, None)
254                .await
255                .unwrap();
256        }
257
258        // Without the disk refresh in should_auto_approve, the reader's
259        // in-memory map would still be empty and this assertion would fail.
260        assert!(reader.should_auto_approve(key).await);
261    }
262
263    #[tokio::test]
264    async fn test_shell_scoped_history_does_not_reuse_tool_level_key() {
265        let temp_dir = temp_cache_dir();
266        let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
267
268        for _ in 0..5 {
269            let _ = recorder
270                .record_approval("unified_exec", Some("Unified Exec"), true, None)
271                .await;
272        }
273
274        assert_eq!(
275            recorder
276                .get_approval_count(
277                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null"
278                )
279                .await,
280            0
281        );
282        assert!(
283            recorder
284                .get_auto_approval_suggestion(
285                    "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
286                    "commands starting with `cargo test`",
287                )
288                .await
289                .is_none()
290        );
291    }
292}