Skip to main content

vtcode_core/acp/
permission_cache.rs

1use hashbrown::HashMap;
2/// ACP Permission caching - avoid re-prompting for same file in same session
3///
4/// Caches file-level permission grants so the agent doesn't repeatedly
5/// ask the user for access to the same file during a single session.
6///
7/// This module uses a generic `PermissionCache<K>` to eliminate duplicate code
8/// between file-based and tool-based permission caching.
9use std::borrow::Borrow;
10use std::hash::Hash;
11use std::path::PathBuf;
12
13/// Permission grant decision
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PermissionGrant {
16    /// Allow for this operation only
17    Once,
18    /// Allow for remainder of session
19    Session,
20    /// Allow permanently (stored to disk)
21    Permanent,
22    /// Explicitly denied by policy
23    Denied,
24    /// Temporary denial from execution failure (not policy-based)
25    /// Should be invalidated on retry
26    TemporaryDenial,
27}
28
29/// Cache statistics (shared between all permission cache types)
30#[derive(Debug, Clone, Default)]
31pub struct PermissionCacheStats {
32    pub cached_entries: usize,
33    pub hits: usize,
34    pub misses: usize,
35    pub total_requests: usize,
36    pub hit_rate: f64,
37}
38
39impl PermissionCacheStats {
40    #[inline]
41    fn compute(entries: usize, hits: usize, misses: usize) -> Self {
42        let total_requests = hits + misses;
43        let hit_rate = if total_requests > 0 {
44            (hits as f64) / (total_requests as f64)
45        } else {
46            0.0
47        };
48        Self {
49            cached_entries: entries,
50            hits,
51            misses,
52            total_requests,
53            hit_rate,
54        }
55    }
56}
57
58/// Generic permission cache that works for any hashable key type.
59/// Eliminates duplication between file-based and tool-based caches.
60#[derive(Debug)]
61pub struct PermissionCache<K: Eq + Hash> {
62    grants: HashMap<K, PermissionGrant>,
63    hits: usize,
64    misses: usize,
65}
66
67impl<K: Eq + Hash> PermissionCache<K> {
68    /// Create new permission cache
69    #[inline]
70    pub fn new() -> Self {
71        Self {
72            grants: HashMap::new(),
73            hits: 0,
74            misses: 0,
75        }
76    }
77
78    /// Check if we have a cached permission for this key
79    #[inline]
80    pub fn get_permission<Q>(&mut self, key: &Q) -> Option<PermissionGrant>
81    where
82        K: Borrow<Q>,
83        Q: Hash + Eq + ?Sized,
84    {
85        if let Some(grant) = self.grants.get(key) {
86            self.hits += 1;
87            Some(*grant)
88        } else {
89            self.misses += 1;
90            None
91        }
92    }
93
94    /// Cache a permission grant
95    #[inline]
96    pub fn cache_grant(&mut self, key: K, grant: PermissionGrant) {
97        self.grants.insert(key, grant);
98    }
99
100    /// Invalidate permission for a key
101    #[inline]
102    pub fn invalidate<Q>(&mut self, key: &Q)
103    where
104        K: Borrow<Q>,
105        Q: Hash + Eq + ?Sized,
106    {
107        self.grants.remove(key);
108    }
109
110    /// Clear only temporary denials (for retries)
111    pub fn clear_temporary_denials(&mut self) {
112        self.grants
113            .retain(|_, grant| *grant != PermissionGrant::TemporaryDenial);
114    }
115
116    /// Clear all cached permissions
117    pub fn clear(&mut self) {
118        self.grants.clear();
119        self.hits = 0;
120        self.misses = 0;
121    }
122
123    /// Get cache statistics
124    #[inline]
125    pub fn stats(&self) -> PermissionCacheStats {
126        PermissionCacheStats::compute(self.grants.len(), self.hits, self.misses)
127    }
128
129    /// Check if key is denied by policy (not execution failure)
130    #[inline]
131    pub fn is_denied<Q>(&self, key: &Q) -> bool
132    where
133        K: Borrow<Q>,
134        Q: Hash + Eq + ?Sized,
135    {
136        matches!(self.grants.get(key), Some(PermissionGrant::Denied))
137    }
138
139    /// Check if key has a temporary denial (execution failure, not policy)
140    #[inline]
141    pub fn is_temporarily_denied<Q>(&self, key: &Q) -> bool
142    where
143        K: Borrow<Q>,
144        Q: Hash + Eq + ?Sized,
145    {
146        matches!(self.grants.get(key), Some(PermissionGrant::TemporaryDenial))
147    }
148
149    /// Check if we can use cached permission without prompting
150    #[inline]
151    pub fn can_use_cached<Q>(&self, key: &Q) -> bool
152    where
153        K: Borrow<Q>,
154        Q: Hash + Eq + ?Sized,
155    {
156        matches!(
157            self.grants.get(key),
158            Some(PermissionGrant::Session | PermissionGrant::Permanent | PermissionGrant::Denied)
159        )
160    }
161}
162
163impl<K: Eq + Hash> Default for PermissionCache<K> {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169// Type aliases for backwards compatibility
170/// Session-scoped permission cache for ACP hosts (Zed, VS Code, etc.)
171pub type AcpPermissionCache = PermissionCache<PathBuf>;
172
173/// Tool-level permission cache - caches approvals for tool execution
174pub type ToolPermissionCache = PermissionCache<String>;
175
176/// Alias for ToolPermissionCacheStats (same struct)
177pub type ToolPermissionCacheStats = PermissionCacheStats;
178
179/// Extension methods for ToolPermissionCache to maintain API compatibility
180impl ToolPermissionCache {
181    /// Cache a permission grant for a tool (accepts `impl Into<String>`)
182    #[inline]
183    pub fn cache_grant_tool(&mut self, tool_name: impl Into<String>, grant: PermissionGrant) {
184        self.cache_grant(tool_name.into(), grant);
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn test_path(name: &str) -> PathBuf {
193        PathBuf::from(format!("/workspace/{}", name))
194    }
195
196    #[test]
197    fn test_creates_empty_cache() {
198        let cache = AcpPermissionCache::new();
199        let stats = cache.stats();
200        assert_eq!(stats.cached_entries, 0);
201        assert_eq!(stats.hits, 0);
202        assert_eq!(stats.misses, 0);
203    }
204
205    #[test]
206    fn test_caches_permission_grant() {
207        let mut cache = AcpPermissionCache::new();
208        let path = test_path("file.rs");
209
210        // use reference instead of cloning PathBuf
211        cache.cache_grant(path.clone(), PermissionGrant::Session);
212        assert_eq!(cache.get_permission(&path), Some(PermissionGrant::Session));
213    }
214
215    #[test]
216    fn test_tracks_hits_and_misses() {
217        let mut cache = AcpPermissionCache::new();
218        let path = test_path("file.rs");
219
220        cache.cache_grant(path.clone(), PermissionGrant::Session);
221
222        // Hit
223        let _ = cache.get_permission(&path);
224        assert_eq!(cache.stats().hits, 1);
225
226        // Miss
227        let _ = cache.get_permission(&test_path("other.rs"));
228        assert_eq!(cache.stats().misses, 1);
229    }
230
231    #[test]
232    fn test_calculates_hit_rate() {
233        let mut cache = AcpPermissionCache::new();
234        let path1 = test_path("file1.rs");
235        let path2 = test_path("file2.rs");
236
237        let path1_for_cache = path1.clone();
238        cache.cache_grant(path1_for_cache, PermissionGrant::Session);
239
240        // 3 hits
241        cache.get_permission(&path1);
242        cache.get_permission(&path1);
243        cache.get_permission(&path1);
244
245        // 1 miss
246        cache.get_permission(&path2);
247
248        let stats = cache.stats();
249        assert_eq!(stats.hits, 3);
250        assert_eq!(stats.misses, 1);
251        assert_eq!(stats.total_requests, 4);
252        assert!((stats.hit_rate - 0.75).abs() < 0.001);
253    }
254
255    #[test]
256    fn test_invalidates_path() {
257        let mut cache = AcpPermissionCache::new();
258        let path = test_path("file.rs");
259
260        cache.cache_grant(path.clone(), PermissionGrant::Session);
261        assert!(cache.get_permission(&path).is_some());
262
263        cache.invalidate(&path);
264        assert!(cache.get_permission(&path).is_none());
265    }
266
267    #[test]
268    fn test_clears_all() {
269        let mut cache = AcpPermissionCache::new();
270
271        cache.cache_grant(test_path("file1.rs"), PermissionGrant::Session);
272        cache.cache_grant(test_path("file2.rs"), PermissionGrant::Session);
273        cache.get_permission(&test_path("file1.rs"));
274
275        cache.clear();
276        let stats = cache.stats();
277        assert_eq!(stats.cached_entries, 0);
278        assert_eq!(stats.hits, 0);
279        assert_eq!(stats.misses, 0);
280    }
281
282    #[test]
283    fn test_identifies_denied_paths() {
284        let mut cache = AcpPermissionCache::new();
285        let denied_path = test_path("secret.txt");
286        let allowed_path = test_path("public.txt");
287
288        let denied_for_cache = denied_path.clone();
289        let allowed_for_cache = allowed_path.clone();
290        cache.cache_grant(denied_for_cache, PermissionGrant::Denied);
291        cache.cache_grant(allowed_for_cache, PermissionGrant::Session);
292
293        assert!(cache.is_denied(&denied_path));
294        assert!(!cache.is_denied(&allowed_path));
295        assert!(!cache.is_denied(&test_path("unknown.txt")));
296    }
297
298    #[test]
299    fn test_can_use_cached_for_session_grants() {
300        let mut cache = AcpPermissionCache::new();
301        let once_path = test_path("once.rs");
302        let session_path = test_path("session.rs");
303        let denied_path = test_path("denied.rs");
304        let temp_denied_path = test_path("temp_denied.rs");
305
306        cache.cache_grant(once_path.clone(), PermissionGrant::Once);
307        cache.cache_grant(session_path.clone(), PermissionGrant::Session);
308        cache.cache_grant(denied_path.clone(), PermissionGrant::Denied);
309        cache.cache_grant(temp_denied_path.clone(), PermissionGrant::TemporaryDenial);
310
311        // "Once" and "TemporaryDenial" grants can't be reused
312        assert!(!cache.can_use_cached(&once_path));
313        assert!(!cache.can_use_cached(&temp_denied_path));
314
315        // Session and Permanent grants can be reused
316        assert!(cache.can_use_cached(&session_path));
317        assert!(cache.can_use_cached(&denied_path));
318    }
319
320    #[test]
321    fn test_multiple_paths() {
322        let mut cache = AcpPermissionCache::new();
323
324        for i in 0..5 {
325            cache.cache_grant(
326                test_path(&format!("file{}.rs", i)),
327                PermissionGrant::Session,
328            );
329        }
330
331        assert_eq!(cache.stats().cached_entries, 5);
332
333        for i in 0..5 {
334            let grant = cache.get_permission(&test_path(&format!("file{}.rs", i)));
335            assert_eq!(grant, Some(PermissionGrant::Session));
336        }
337
338        assert_eq!(cache.stats().hits, 5);
339    }
340
341    #[test]
342    fn test_distinguishes_denied_from_temporary_denial() {
343        let mut cache = AcpPermissionCache::new();
344        let denied_path = test_path("denied.rs");
345        let temp_denied_path = test_path("temp_denied.rs");
346
347        cache.cache_grant(denied_path.clone(), PermissionGrant::Denied);
348        cache.cache_grant(temp_denied_path.clone(), PermissionGrant::TemporaryDenial);
349
350        // Both should be identified correctly
351        assert!(cache.is_denied(&denied_path));
352        assert!(!cache.is_denied(&temp_denied_path));
353
354        assert!(!cache.is_temporarily_denied(&denied_path));
355        assert!(cache.is_temporarily_denied(&temp_denied_path));
356    }
357
358    #[test]
359    fn test_clear_temporary_denials_preserves_policy_denials() {
360        let mut cache = AcpPermissionCache::new();
361        let policy_denied = test_path("policy_denied.rs");
362        let temp_denied = test_path("temp_denied.rs");
363        let allowed = test_path("allowed.rs");
364
365        cache.cache_grant(policy_denied.clone(), PermissionGrant::Denied);
366        cache.cache_grant(temp_denied.clone(), PermissionGrant::TemporaryDenial);
367        cache.cache_grant(allowed.clone(), PermissionGrant::Session);
368
369        cache.clear_temporary_denials();
370
371        // Policy denials and session grants should remain
372        assert!(cache.is_denied(&policy_denied));
373        assert_eq!(
374            cache.get_permission(&allowed),
375            Some(PermissionGrant::Session)
376        );
377
378        // Temporary denials should be gone
379        assert!(!cache.is_temporarily_denied(&temp_denied));
380        assert_eq!(cache.get_permission(&temp_denied), None);
381    }
382}