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 { grants: HashMap::new(), hits: 0, misses: 0 }
72    }
73
74    /// Check if we have a cached permission for this key
75    #[inline]
76    pub fn get_permission<Q>(&mut self, key: &Q) -> Option<PermissionGrant>
77    where
78        K: Borrow<Q>,
79        Q: Hash + Eq + ?Sized,
80    {
81        if let Some(grant) = self.grants.get(key) {
82            self.hits += 1;
83            Some(*grant)
84        } else {
85            self.misses += 1;
86            None
87        }
88    }
89
90    /// Cache a permission grant
91    #[inline]
92    pub fn cache_grant(&mut self, key: K, grant: PermissionGrant) {
93        self.grants.insert(key, grant);
94    }
95
96    /// Invalidate permission for a key
97    #[inline]
98    pub fn invalidate<Q>(&mut self, key: &Q)
99    where
100        K: Borrow<Q>,
101        Q: Hash + Eq + ?Sized,
102    {
103        self.grants.remove(key);
104    }
105
106    /// Clear only temporary denials (for retries)
107    pub fn clear_temporary_denials(&mut self) {
108        self.grants.retain(|_, grant| *grant != PermissionGrant::TemporaryDenial);
109    }
110
111    /// Clear all cached permissions
112    pub fn clear(&mut self) {
113        self.grants.clear();
114        self.hits = 0;
115        self.misses = 0;
116    }
117
118    /// Get cache statistics
119    #[inline]
120    pub fn stats(&self) -> PermissionCacheStats {
121        PermissionCacheStats::compute(self.grants.len(), self.hits, self.misses)
122    }
123
124    /// Check if key is denied by policy (not execution failure)
125    #[inline]
126    pub fn is_denied<Q>(&self, key: &Q) -> bool
127    where
128        K: Borrow<Q>,
129        Q: Hash + Eq + ?Sized,
130    {
131        matches!(self.grants.get(key), Some(PermissionGrant::Denied))
132    }
133
134    /// Check if key has a temporary denial (execution failure, not policy)
135    #[inline]
136    pub fn is_temporarily_denied<Q>(&self, key: &Q) -> bool
137    where
138        K: Borrow<Q>,
139        Q: Hash + Eq + ?Sized,
140    {
141        matches!(self.grants.get(key), Some(PermissionGrant::TemporaryDenial))
142    }
143
144    /// Check if we can use cached permission without prompting.
145    ///
146    /// `Once` grants are included so that a single user approval persists
147    /// for the duration of the session — without this, every identical tool
148    /// call would re-prompt even after the user already said "yes".
149    #[inline]
150    pub fn can_use_cached<Q>(&self, key: &Q) -> bool
151    where
152        K: Borrow<Q>,
153        Q: Hash + Eq + ?Sized,
154    {
155        matches!(
156            self.grants.get(key),
157            Some(
158                PermissionGrant::Once | PermissionGrant::Session | PermissionGrant::Permanent | PermissionGrant::Denied
159            )
160        )
161    }
162}
163
164impl<K: Eq + Hash> Default for PermissionCache<K> {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170// Type aliases for backwards compatibility
171/// Session-scoped permission cache for ACP hosts (Zed, VS Code, etc.)
172pub type AcpPermissionCache = PermissionCache<PathBuf>;
173
174/// Tool-level permission cache - caches approvals for tool execution
175pub type ToolPermissionCache = PermissionCache<String>;
176
177/// Alias for ToolPermissionCacheStats (same struct)
178pub type ToolPermissionCacheStats = PermissionCacheStats;
179
180/// Extension methods for ToolPermissionCache to maintain API compatibility
181impl ToolPermissionCache {
182    /// Cache a permission grant for a tool (accepts `impl Into<String>`)
183    #[inline]
184    pub fn cache_grant_tool(&mut self, tool_name: impl Into<String>, grant: PermissionGrant) {
185        self.cache_grant(tool_name.into(), grant);
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn test_path(name: &str) -> PathBuf {
194        PathBuf::from(format!("/workspace/{name}"))
195    }
196
197    #[test]
198    fn test_creates_empty_cache() {
199        let cache = AcpPermissionCache::new();
200        let stats = cache.stats();
201        assert_eq!(stats.cached_entries, 0);
202        assert_eq!(stats.hits, 0);
203        assert_eq!(stats.misses, 0);
204    }
205
206    #[test]
207    fn test_caches_permission_grant() {
208        let mut cache = AcpPermissionCache::new();
209        let path = test_path("file.rs");
210
211        // use reference instead of cloning PathBuf
212        cache.cache_grant(path.clone(), PermissionGrant::Session);
213        assert_eq!(cache.get_permission(&path), Some(PermissionGrant::Session));
214    }
215
216    #[test]
217    fn test_tracks_hits_and_misses() {
218        let mut cache = AcpPermissionCache::new();
219        let path = test_path("file.rs");
220
221        cache.cache_grant(path.clone(), PermissionGrant::Session);
222
223        // Hit
224        let _ = cache.get_permission(&path);
225        assert_eq!(cache.stats().hits, 1);
226
227        // Miss
228        let _ = cache.get_permission(&test_path("other.rs"));
229        assert_eq!(cache.stats().misses, 1);
230    }
231
232    #[test]
233    fn test_calculates_hit_rate() {
234        let mut cache = AcpPermissionCache::new();
235        let path1 = test_path("file1.rs");
236        let path2 = test_path("file2.rs");
237
238        let path1_for_cache = path1.clone();
239        cache.cache_grant(path1_for_cache, PermissionGrant::Session);
240
241        // 3 hits
242        cache.get_permission(&path1);
243        cache.get_permission(&path1);
244        cache.get_permission(&path1);
245
246        // 1 miss
247        cache.get_permission(&path2);
248
249        let stats = cache.stats();
250        assert_eq!(stats.hits, 3);
251        assert_eq!(stats.misses, 1);
252        assert_eq!(stats.total_requests, 4);
253        assert!((stats.hit_rate - 0.75).abs() < 0.001);
254    }
255
256    #[test]
257    fn test_invalidates_path() {
258        let mut cache = AcpPermissionCache::new();
259        let path = test_path("file.rs");
260
261        cache.cache_grant(path.clone(), PermissionGrant::Session);
262        assert!(cache.get_permission(&path).is_some());
263
264        cache.invalidate(&path);
265        assert!(cache.get_permission(&path).is_none());
266    }
267
268    #[test]
269    fn test_clears_all() {
270        let mut cache = AcpPermissionCache::new();
271
272        cache.cache_grant(test_path("file1.rs"), PermissionGrant::Session);
273        cache.cache_grant(test_path("file2.rs"), PermissionGrant::Session);
274        cache.get_permission(&test_path("file1.rs"));
275
276        cache.clear();
277        let stats = cache.stats();
278        assert_eq!(stats.cached_entries, 0);
279        assert_eq!(stats.hits, 0);
280        assert_eq!(stats.misses, 0);
281    }
282
283    #[test]
284    fn test_identifies_denied_paths() {
285        let mut cache = AcpPermissionCache::new();
286        let denied_path = test_path("secret.txt");
287        let allowed_path = test_path("public.txt");
288
289        let denied_for_cache = denied_path.clone();
290        let allowed_for_cache = allowed_path.clone();
291        cache.cache_grant(denied_for_cache, PermissionGrant::Denied);
292        cache.cache_grant(allowed_for_cache, PermissionGrant::Session);
293
294        assert!(cache.is_denied(&denied_path));
295        assert!(!cache.is_denied(&allowed_path));
296        assert!(!cache.is_denied(&test_path("unknown.txt")));
297    }
298
299    #[test]
300    fn test_can_use_cached_for_session_grants() {
301        let mut cache = AcpPermissionCache::new();
302        let once_path = test_path("once.rs");
303        let session_path = test_path("session.rs");
304        let denied_path = test_path("denied.rs");
305        let temp_denied_path = test_path("temp_denied.rs");
306
307        cache.cache_grant(once_path.clone(), PermissionGrant::Once);
308        cache.cache_grant(session_path.clone(), PermissionGrant::Session);
309        cache.cache_grant(denied_path.clone(), PermissionGrant::Denied);
310        cache.cache_grant(temp_denied_path.clone(), PermissionGrant::TemporaryDenial);
311
312        // "Once" grants ARE reusable (user approval persists for the session)
313        assert!(cache.can_use_cached(&once_path));
314        // "TemporaryDenial" grants can't be reused
315        assert!(!cache.can_use_cached(&temp_denied_path));
316
317        // Session and Permanent grants can be reused
318        assert!(cache.can_use_cached(&session_path));
319        assert!(cache.can_use_cached(&denied_path));
320    }
321
322    #[test]
323    fn test_multiple_paths() {
324        let mut cache = AcpPermissionCache::new();
325
326        for i in 0..5 {
327            cache.cache_grant(test_path(&format!("file{i}.rs")), PermissionGrant::Session);
328        }
329
330        assert_eq!(cache.stats().cached_entries, 5);
331
332        for i in 0..5 {
333            let grant = cache.get_permission(&test_path(&format!("file{i}.rs")));
334            assert_eq!(grant, Some(PermissionGrant::Session));
335        }
336
337        assert_eq!(cache.stats().hits, 5);
338    }
339
340    #[test]
341    fn test_distinguishes_denied_from_temporary_denial() {
342        let mut cache = AcpPermissionCache::new();
343        let denied_path = test_path("denied.rs");
344        let temp_denied_path = test_path("temp_denied.rs");
345
346        cache.cache_grant(denied_path.clone(), PermissionGrant::Denied);
347        cache.cache_grant(temp_denied_path.clone(), PermissionGrant::TemporaryDenial);
348
349        // Both should be identified correctly
350        assert!(cache.is_denied(&denied_path));
351        assert!(!cache.is_denied(&temp_denied_path));
352
353        assert!(!cache.is_temporarily_denied(&denied_path));
354        assert!(cache.is_temporarily_denied(&temp_denied_path));
355    }
356
357    #[test]
358    fn test_clear_temporary_denials_preserves_policy_denials() {
359        let mut cache = AcpPermissionCache::new();
360        let policy_denied = test_path("policy_denied.rs");
361        let temp_denied = test_path("temp_denied.rs");
362        let allowed = test_path("allowed.rs");
363
364        cache.cache_grant(policy_denied.clone(), PermissionGrant::Denied);
365        cache.cache_grant(temp_denied.clone(), PermissionGrant::TemporaryDenial);
366        cache.cache_grant(allowed.clone(), PermissionGrant::Session);
367
368        cache.clear_temporary_denials();
369
370        // Policy denials and session grants should remain
371        assert!(cache.is_denied(&policy_denied));
372        assert_eq!(cache.get_permission(&allowed), Some(PermissionGrant::Session));
373
374        // Temporary denials should be gone
375        assert!(!cache.is_temporarily_denied(&temp_denied));
376        assert_eq!(cache.get_permission(&temp_denied), None);
377    }
378}