Skip to main content

st/mcp/
permissions.rs

1//! Permission-based tool gating system for MCP
2//!
3//! This module implements a smart permission checking system that:
4//! 1. Requires digest/verification before other tools can be used
5//! 2. Only exposes tools that are relevant based on permissions
6//! 3. Saves context by hiding unavailable operations
7//! 4. Provides helpful comments about why tools are unavailable
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16/// Permission state for a path
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PathPermissions {
19    /// Path that was checked
20    pub path: PathBuf,
21    /// Whether the path exists
22    pub exists: bool,
23    /// Whether we can read the path
24    pub readable: bool,
25    /// Whether we can write to the path
26    pub writable: bool,
27    /// Whether it's a directory
28    pub is_directory: bool,
29    /// Whether it's a file
30    pub is_file: bool,
31    /// When this was last verified
32    pub verified_at: SystemTime,
33    /// Any error messages
34    pub error: Option<String>,
35}
36
37/// Permission cache for verified paths
38#[derive(Debug, Default)]
39pub struct PermissionCache {
40    /// Cached permissions by path
41    permissions: HashMap<PathBuf, PathPermissions>,
42    /// How long to cache permissions (default: 5 minutes)
43    cache_duration: Duration,
44}
45
46impl PermissionCache {
47    pub fn new() -> Self {
48        Self {
49            permissions: HashMap::new(),
50            cache_duration: Duration::from_secs(300), // 5 minutes
51        }
52    }
53
54    /// Check if a path has been verified recently
55    pub fn is_verified(&self, path: &Path) -> bool {
56        if let Some(perms) = self.permissions.get(path) {
57            if let Ok(elapsed) = perms.verified_at.elapsed() {
58                return elapsed < self.cache_duration;
59            }
60        }
61        false
62    }
63
64    /// Get cached permissions for a path
65    pub fn get(&self, path: &Path) -> Option<&PathPermissions> {
66        self.permissions
67            .get(path)
68            .filter(|p| p.verified_at.elapsed().unwrap_or(Duration::MAX) < self.cache_duration)
69    }
70
71    /// Verify and cache permissions for a path
72    pub fn verify(&mut self, path: &Path) -> Result<PathPermissions> {
73        // Check if path exists
74        let exists = path.exists();
75        if !exists {
76            let perms = PathPermissions {
77                path: path.to_path_buf(),
78                exists: false,
79                readable: false,
80                writable: false,
81                is_directory: false,
82                is_file: false,
83                verified_at: SystemTime::now(),
84                error: Some("Path does not exist".to_string()),
85            };
86            self.permissions.insert(path.to_path_buf(), perms.clone());
87            return Ok(perms);
88        }
89
90        // Get metadata
91        let metadata = match fs::metadata(path) {
92            Ok(m) => m,
93            Err(e) => {
94                let perms = PathPermissions {
95                    path: path.to_path_buf(),
96                    exists: true,
97                    readable: false,
98                    writable: false,
99                    is_directory: false,
100                    is_file: false,
101                    verified_at: SystemTime::now(),
102                    error: Some(format!("Cannot read metadata: {}", e)),
103                };
104                self.permissions.insert(path.to_path_buf(), perms.clone());
105                return Ok(perms);
106            }
107        };
108
109        let is_directory = metadata.is_dir();
110        let is_file = metadata.is_file();
111
112        // Check read permission
113        let readable = if is_directory {
114            fs::read_dir(path).is_ok()
115        } else {
116            fs::File::open(path).is_ok()
117        };
118
119        // Check write permission
120        let writable = !metadata.permissions().readonly();
121
122        let perms = PathPermissions {
123            path: path.to_path_buf(),
124            exists,
125            readable,
126            writable,
127            is_directory,
128            is_file,
129            verified_at: SystemTime::now(),
130            error: None,
131        };
132
133        self.permissions.insert(path.to_path_buf(), perms.clone());
134        Ok(perms)
135    }
136
137    /// Clear expired entries
138    pub fn cleanup(&mut self) {
139        self.permissions
140            .retain(|_, p| p.verified_at.elapsed().unwrap_or(Duration::MAX) < self.cache_duration);
141    }
142}
143
144/// Tool availability based on permissions
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ToolAvailability {
147    /// Tool name
148    pub name: String,
149    /// Whether the tool is available
150    pub available: bool,
151    /// Reason why the tool is unavailable (if applicable)
152    pub reason: Option<String>,
153    /// Required permissions for this tool
154    pub requires: Vec<String>,
155}
156
157/// Get available tools based on path permissions
158pub fn get_available_tools(perms: &PathPermissions) -> Vec<ToolAvailability> {
159    let mut tools = vec![];
160
161    // Always available tools (verification tools)
162    tools.push(ToolAvailability {
163        name: "get_digest".to_string(),
164        available: true,
165        reason: None,
166        requires: vec![],
167    });
168
169    tools.push(ToolAvailability {
170        name: "server_info".to_string(),
171        available: true,
172        reason: None,
173        requires: vec![],
174    });
175
176    // Read-only tools
177    if perms.readable {
178        tools.extend(vec![
179            ToolAvailability {
180                name: "analyze_directory".to_string(),
181                available: perms.is_directory,
182                reason: if !perms.is_directory {
183                    Some("Path is not a directory".to_string())
184                } else {
185                    None
186                },
187                requires: vec!["read".to_string(), "directory".to_string()],
188            },
189            ToolAvailability {
190                name: "quick_tree".to_string(),
191                available: perms.is_directory,
192                reason: if !perms.is_directory {
193                    Some("Path is not a directory".to_string())
194                } else {
195                    None
196                },
197                requires: vec!["read".to_string(), "directory".to_string()],
198            },
199            ToolAvailability {
200                name: "find_files".to_string(),
201                available: perms.is_directory,
202                reason: if !perms.is_directory {
203                    Some("Path is not a directory".to_string())
204                } else {
205                    None
206                },
207                requires: vec!["read".to_string(), "directory".to_string()],
208            },
209            ToolAvailability {
210                name: "search_in_files".to_string(),
211                available: perms.is_directory,
212                reason: if !perms.is_directory {
213                    Some("Path is not a directory".to_string())
214                } else {
215                    None
216                },
217                requires: vec!["read".to_string(), "directory".to_string()],
218            },
219            ToolAvailability {
220                name: "get_statistics".to_string(),
221                available: perms.is_directory,
222                reason: if !perms.is_directory {
223                    Some("Path is not a directory".to_string())
224                } else {
225                    None
226                },
227                requires: vec!["read".to_string(), "directory".to_string()],
228            },
229            ToolAvailability {
230                name: "get_function_tree".to_string(),
231                available: perms.is_file,
232                reason: if !perms.is_file {
233                    Some("Path is not a file".to_string())
234                } else {
235                    None
236                },
237                requires: vec!["read".to_string(), "file".to_string()],
238            },
239        ]);
240    } else {
241        // Add read tools as unavailable with reason
242        tools.extend(vec![
243            ToolAvailability {
244                name: "analyze_directory".to_string(),
245                available: false,
246                reason: Some("No read permission for this path".to_string()),
247                requires: vec!["read".to_string()],
248            },
249            ToolAvailability {
250                name: "quick_tree".to_string(),
251                available: false,
252                reason: Some("No read permission for this path".to_string()),
253                requires: vec!["read".to_string()],
254            },
255        ]);
256    }
257
258    // Write tools
259    if perms.writable && perms.readable {
260        tools.extend(vec![
261            ToolAvailability {
262                name: "smart_edit".to_string(),
263                available: perms.is_file,
264                reason: if !perms.is_file {
265                    Some("Can only edit files, not directories".to_string())
266                } else {
267                    None
268                },
269                requires: vec!["read".to_string(), "write".to_string(), "file".to_string()],
270            },
271            ToolAvailability {
272                name: "insert_function".to_string(),
273                available: perms.is_file,
274                reason: if !perms.is_file {
275                    Some("Can only edit files, not directories".to_string())
276                } else {
277                    None
278                },
279                requires: vec!["read".to_string(), "write".to_string(), "file".to_string()],
280            },
281            ToolAvailability {
282                name: "remove_function".to_string(),
283                available: perms.is_file,
284                reason: if !perms.is_file {
285                    Some("Can only edit files, not directories".to_string())
286                } else {
287                    None
288                },
289                requires: vec!["read".to_string(), "write".to_string(), "file".to_string()],
290            },
291            ToolAvailability {
292                name: "track_file_operation".to_string(),
293                available: perms.is_file,
294                reason: if !perms.is_file {
295                    Some("Can only track operations on files".to_string())
296                } else {
297                    None
298                },
299                requires: vec!["read".to_string(), "write".to_string(), "file".to_string()],
300            },
301        ]);
302    } else if !perms.writable && perms.readable {
303        // Add write tools as unavailable
304        tools.extend(vec![
305            ToolAvailability {
306                name: "smart_edit".to_string(),
307                available: false,
308                reason: Some("File is read-only - no write permission".to_string()),
309                requires: vec!["write".to_string()],
310            },
311            ToolAvailability {
312                name: "insert_function".to_string(),
313                available: false,
314                reason: Some("File is read-only - no write permission".to_string()),
315                requires: vec!["write".to_string()],
316            },
317            ToolAvailability {
318                name: "remove_function".to_string(),
319                available: false,
320                reason: Some("File is read-only - no write permission".to_string()),
321                requires: vec!["write".to_string()],
322            },
323        ]);
324    }
325
326    tools
327}
328
329/// Check if a specific tool is available for a path
330pub fn _is_tool_available(tool_name: &str, perms: &PathPermissions) -> (bool, Option<String>) {
331    let tools = get_available_tools(perms);
332    for tool in tools {
333        if tool.name == tool_name {
334            return (tool.available, tool.reason);
335        }
336    }
337    // Tool not found in permission system - might be always available
338    (true, None)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    use tempfile::TempDir;
346
347    #[test]
348    fn test_permission_cache() {
349        let mut cache = PermissionCache::new();
350        let temp_dir = TempDir::new().unwrap();
351        let path = temp_dir.path();
352
353        // Verify directory permissions
354        let perms = cache.verify(path).unwrap();
355        assert!(perms.exists);
356        assert!(perms.readable);
357        assert!(perms.is_directory);
358        assert!(!perms.is_file);
359
360        // Check caching
361        assert!(cache.is_verified(path));
362    }
363
364    #[test]
365    fn test_tool_availability() {
366        // Test with readable directory
367        let dir_perms = PathPermissions {
368            path: PathBuf::from("/test"),
369            exists: true,
370            readable: true,
371            writable: true,
372            is_directory: true,
373            is_file: false,
374            verified_at: SystemTime::now(),
375            error: None,
376        };
377
378        let tools = get_available_tools(&dir_perms);
379
380        // Check that directory tools are available
381        let analyze = tools
382            .iter()
383            .find(|t| t.name == "analyze_directory")
384            .unwrap();
385        assert!(analyze.available);
386
387        // Check that file tools are not available for directories
388        let edit = tools.iter().find(|t| t.name == "smart_edit").unwrap();
389        assert!(!edit.available);
390        assert_eq!(
391            edit.reason,
392            Some("Can only edit files, not directories".to_string())
393        );
394
395        // Test with read-only file
396        let ro_file_perms = PathPermissions {
397            path: PathBuf::from("/test.txt"),
398            exists: true,
399            readable: true,
400            writable: false,
401            is_directory: false,
402            is_file: true,
403            verified_at: SystemTime::now(),
404            error: None,
405        };
406
407        let tools = get_available_tools(&ro_file_perms);
408
409        // Check that edit tools are unavailable
410        let edit = tools.iter().find(|t| t.name == "smart_edit").unwrap();
411        assert!(!edit.available);
412        assert_eq!(
413            edit.reason,
414            Some("File is read-only - no write permission".to_string())
415        );
416    }
417}