vtcode_core/tools/registry/
approval_recorder.rs1use super::justification::{ApprovalPattern, JustificationManager};
6use anyhow::Result;
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11#[derive(Clone)]
13pub struct ApprovalRecorder {
14 manager: Arc<RwLock<JustificationManager>>,
15}
16
17impl ApprovalRecorder {
18 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 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 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 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 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 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 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 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 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 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 let pattern = recorder.get_pattern("read_file").await;
156 assert!(pattern.is_some());
157 assert_eq!(pattern.unwrap().approval_count(), 2);
158
159 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 assert!(
170 recorder
171 .get_auto_approval_suggestion("read_file", "Read File")
172 .await
173 .is_none()
174 );
175
176 for _ in 0..5 {
178 let _ = recorder
179 .record_approval("read_file", Some("Read File"), true, None)
180 .await;
181 }
182
183 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 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 assert!(!recorder.should_auto_approve("run_command").await);
201
202 for _ in 0..3 {
204 let _ = recorder
205 .record_approval("run_command", Some("Run Command"), true, None)
206 .await;
207 }
208
209 assert!(recorder.should_auto_approve("run_command").await);
211
212 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 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 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}