Skip to main content

ai_agent/utils/permissions/
filesystem.rs

1// Source: ~/claudecode/openclaudecode/src/utils/permissions/filesystem.ts
2#![allow(dead_code)]
3
4//! Filesystem-related permission utilities.
5//!
6//! Handles path validation, dangerous file detection, auto-edit safety checks,
7//! and working directory permission validation.
8
9use crate::types::permissions::{
10    PermissionDecision, PermissionDecisionReason, PermissionRule, PermissionUpdate,
11    PermissionUpdateDestination, ToolPermissionContext,
12};
13use std::path::{MAIN_SEPARATOR, Path, PathBuf};
14
15/// Dangerous files that should be protected from auto-editing.
16/// These files can be used for code execution or data exfiltration.
17pub const DANGEROUS_FILES: &[&str] = &[
18    ".gitconfig",
19    ".gitmodules",
20    ".bashrc",
21    ".bash_profile",
22    ".zshrc",
23    ".zprofile",
24    ".profile",
25    ".ripgreprc",
26    ".mcp.json",
27    ".claude.json",
28];
29
30/// Dangerous directories that should be protected from auto-editing.
31pub const DANGEROUS_DIRECTORIES: &[&str] = &[".git", ".vscode", ".idea", ".claude"];
32
33/// Normalizes a path for case-insensitive comparison.
34/// Prevents bypassing security checks using mixed-case paths
35/// on case-insensitive filesystems (macOS/Windows).
36pub fn normalize_case_for_comparison(path: &str) -> String {
37    path.to_lowercase()
38}
39
40/// If file_path is inside a .claude/skills/{name}/ directory (project or global),
41/// return the skill name and a session-allow pattern scoped to just that skill.
42pub fn get_claude_skill_scope(file_path: &str) -> Option<(String, String)> {
43    let absolute_path = expand_path(file_path);
44    let absolute_path_lower = normalize_case_for_comparison(&absolute_path);
45
46    let cwd = std::env::current_dir().ok()?;
47    let home = dirs::home_dir()?;
48
49    let bases = [
50        (
51            cwd.join(".claude").join("skills"),
52            "/.claude/skills/".to_string(),
53        ),
54        (
55            home.join(".claude").join("skills"),
56            "~/.claude/skills/".to_string(),
57        ),
58    ];
59
60    for (dir, prefix) in &bases {
61        let dir_lower = normalize_case_for_comparison(&dir.to_string_lossy());
62        for sep_char in [MAIN_SEPARATOR, '/'] {
63            let sep_lower = sep_char.to_lowercase().to_string();
64            if absolute_path_lower.starts_with(&format!("{}{}", dir_lower, sep_lower)) {
65                let dir_str = dir.to_string_lossy();
66                let rest = &absolute_path[dir_str.len() + 1..];
67                let slash = rest.find('/');
68                let bslash = if MAIN_SEPARATOR == '\\' {
69                    rest.find('\\')
70                } else {
71                    None
72                };
73                let cut = match (slash, bslash) {
74                    (None, None) => return None,
75                    (Some(s), None) => s,
76                    (None, Some(b)) => b,
77                    (Some(s), Some(b)) => s.min(b),
78                };
79                if cut == 0 {
80                    return None;
81                }
82                let skill_name = &rest[..cut];
83                if skill_name.is_empty() || skill_name == "." || skill_name.contains("..") {
84                    return None;
85                }
86                // Reject glob metacharacters
87                if skill_name.contains('*')
88                    || skill_name.contains('?')
89                    || skill_name.contains('[')
90                    || skill_name.contains(']')
91                {
92                    return None;
93                }
94                return Some((
95                    skill_name.to_string(),
96                    format!("{}{}/**", prefix, skill_name),
97                ));
98            }
99        }
100    }
101
102    None
103}
104
105/// Expands tilde (~) at the start of a path to the user's home directory.
106pub fn expand_tilde(path: &str) -> String {
107    if path == "~" || path.starts_with("~/") || (cfg!(windows) && path.starts_with("~\\")) {
108        if let Some(home) = dirs::home_dir() {
109            return format!("{}{}", home.to_string_lossy(), &path[1..]);
110        }
111    }
112    path.to_string()
113}
114
115/// Expands a path, resolving tilde and making absolute.
116pub fn expand_path(path: &str) -> String {
117    let expanded = expand_tilde(path);
118    let p = Path::new(&expanded);
119    if p.is_absolute() {
120        p.to_string_lossy().to_string()
121    } else {
122        std::env::current_dir()
123            .ok()
124            .map(|cwd| cwd.join(p).to_string_lossy().to_string())
125            .unwrap_or(expanded)
126    }
127}
128
129/// Converts a path to POSIX format for pattern matching.
130pub fn to_posix_path(path: &str) -> String {
131    if cfg!(windows) {
132        path.replace('\\', "/")
133    } else {
134        path.to_string()
135    }
136}
137
138/// Calculates a relative path using POSIX separators.
139pub fn relative_path(from: &str, to: &str) -> String {
140    let from_path = Path::new(from);
141    let to_path = Path::new(to);
142    if let Ok(rel) = to_path.strip_prefix(from_path) {
143        to_posix_path(&rel.to_string_lossy())
144    } else {
145        to.to_string()
146    }
147}
148
149/// Checks if the file path is a Claude settings path.
150pub fn is_claude_settings_path(file_path: &str) -> bool {
151    let expanded = expand_path(file_path);
152    let normalized = normalize_case_for_comparison(&expanded);
153    let sep = MAIN_SEPARATOR.to_string();
154
155    normalized.ends_with(&format!("{}{}claude{}settings.json", sep, sep, sep))
156        || normalized.ends_with(&format!("{}{}claude{}settings.local.json", sep, sep, sep))
157}
158
159/// Checks if the file path is a Claude config file path.
160pub fn is_claude_config_file_path(file_path: &str) -> bool {
161    if is_claude_settings_path(file_path) {
162        return true;
163    }
164
165    let cwd = std::env::current_dir().ok().unwrap_or_default();
166    let commands_dir = cwd.join(".claude").join("commands");
167    let agents_dir = cwd.join(".claude").join("agents");
168    let skills_dir = cwd.join(".claude").join("skills");
169
170    path_in_working_path(file_path, &commands_dir.to_string_lossy())
171        || path_in_working_path(file_path, &agents_dir.to_string_lossy())
172        || path_in_working_path(file_path, &skills_dir.to_string_lossy())
173}
174
175/// Checks if a path is within a working path.
176pub fn path_in_working_path(path: &str, working_path: &str) -> bool {
177    let absolute_path = expand_path(path);
178    let absolute_working_path = expand_path(working_path);
179
180    // Handle macOS symlink issues
181    let normalized_path = absolute_path
182        .replace("/private/var/", "/var/")
183        .replace("/private/tmp/", "/tmp/")
184        .replace("/private/tmp", "/tmp");
185    let normalized_working_path = absolute_working_path
186        .replace("/private/var/", "/var/")
187        .replace("/private/tmp/", "/tmp/")
188        .replace("/private/tmp", "/tmp");
189
190    let case_normalized_path = normalize_case_for_comparison(&normalized_path);
191    let case_normalized_working_path = normalize_case_for_comparison(&normalized_working_path);
192
193    let relative = relative_path(&case_normalized_working_path, &case_normalized_path);
194    if relative.is_empty() {
195        return true;
196    }
197
198    if contains_path_traversal(&relative) {
199        return false;
200    }
201
202    !Path::new(&relative).is_absolute()
203}
204
205/// Checks if a path contains traversal sequences.
206pub fn contains_path_traversal(path: &str) -> bool {
207    path.split(MAIN_SEPARATOR).any(|c| c == "..")
208        || path.split('/').any(|c| c == "..")
209        || path.split('\\').any(|c| c == "..")
210}
211
212/// Checks if a path has suspicious Windows patterns.
213pub fn has_suspicious_windows_path_pattern(path: &str) -> bool {
214    // Check for NTFS Alternate Data Streams (Windows/WSL only)
215    if cfg!(windows) || std::env::var("WSL_DISTRO_NAME").is_ok() {
216        let colon_index = path[2..].find(':');
217        if colon_index.is_some() {
218            return true;
219        }
220    }
221
222    // Check for 8.3 short names
223    if path.contains("~") {
224        let re = regex::Regex::new(r"~\d").unwrap();
225        if re.is_match(path) {
226            return true;
227        }
228    }
229
230    // Check for long path prefixes
231    if path.starts_with(r"\\?\")
232        || path.starts_with(r"\\.\")
233        || path.starts_with("//?/")
234        || path.starts_with("//./")
235    {
236        return true;
237    }
238
239    // Check for trailing dots and spaces
240    if path.ends_with(|c: char| c == '.' || c.is_whitespace()) {
241        return true;
242    }
243
244    // Check for DOS device names
245    let dos_device_re = regex::Regex::new(r"\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$").unwrap();
246    if dos_device_re.is_match(path) {
247        return true;
248    }
249
250    // Check for three or more consecutive dots as path component
251    let dots_re = regex::Regex::new(r"(^|/|\\)\.{3,}(/|\\|$)").unwrap();
252    if dots_re.is_match(path) {
253        return true;
254    }
255
256    false
257}
258
259/// Checks if a file path is dangerous for auto-edit.
260fn is_dangerous_file_path_to_auto_edit(path: &str) -> bool {
261    let absolute_path = expand_path(path);
262    let path_segments: Vec<&str> = absolute_path.split(MAIN_SEPARATOR).collect();
263    let file_name = path_segments.last().copied().unwrap_or("");
264
265    // Block UNC paths
266    if path.starts_with("\\\\") || path.starts_with("//") {
267        return true;
268    }
269
270    // Check dangerous directories
271    for segment in &path_segments {
272        let normalized_segment = normalize_case_for_comparison(segment);
273        for dir in DANGEROUS_DIRECTORIES {
274            if normalized_segment == normalize_case_for_comparison(dir) {
275                // Special case: .claude/worktrees/ is not dangerous
276                if *dir == ".claude" {
277                    let idx = path_segments
278                        .iter()
279                        .position(|&s| s == *segment)
280                        .unwrap_or(0);
281                    if idx + 1 < path_segments.len() {
282                        let next = path_segments[idx + 1];
283                        if normalize_case_for_comparison(next) == "worktrees" {
284                            continue;
285                        }
286                    }
287                }
288                return true;
289            }
290        }
291    }
292
293    // Check dangerous files
294    if !file_name.is_empty() {
295        let normalized_file_name = normalize_case_for_comparison(file_name);
296        if DANGEROUS_FILES
297            .iter()
298            .any(|df| normalize_case_for_comparison(df) == normalized_file_name)
299        {
300            return true;
301        }
302    }
303
304    false
305}
306
307/// Checks if a path is safe for auto-editing.
308pub fn check_path_safety_for_auto_edit(
309    path: &str,
310    _precomputed_paths_to_check: Option<&[String]>,
311) -> PathSafetyResult {
312    let path_to_check = path.to_string();
313
314    // Check for suspicious Windows path patterns
315    if has_suspicious_windows_path_pattern(&path_to_check) {
316        return PathSafetyResult::Unsafe {
317            message: format!(
318                "Claude requested permissions to write to {}, which contains a suspicious Windows path pattern that requires manual approval.",
319                path
320            ),
321            classifier_approvable: false,
322        };
323    }
324
325    // Check for Claude config files
326    if is_claude_config_file_path(&path_to_check) {
327        return PathSafetyResult::Unsafe {
328            message: format!(
329                "Claude requested permissions to write to {}, but you haven't granted it yet.",
330                path
331            ),
332            classifier_approvable: true,
333        };
334    }
335
336    // Check for dangerous files
337    if is_dangerous_file_path_to_auto_edit(&path_to_check) {
338        return PathSafetyResult::Unsafe {
339            message: format!(
340                "Claude requested permissions to edit {} which is a sensitive file.",
341                path
342            ),
343            classifier_approvable: true,
344        };
345    }
346
347    PathSafetyResult::Safe
348}
349
350/// Result of a path safety check.
351pub enum PathSafetyResult {
352    Safe,
353    Unsafe {
354        message: String,
355        classifier_approvable: bool,
356    },
357}
358
359/// Checks if a resolved path is dangerous for removal operations.
360pub fn is_dangerous_removal_path(resolved_path: &str) -> bool {
361    let forward_slashed = resolved_path.replace(&['\\', '/'][..], "/");
362
363    if forward_slashed == "*" || forward_slashed.ends_with("/*") {
364        return true;
365    }
366
367    let normalized_path = if forward_slashed == "/" {
368        forward_slashed.clone()
369    } else {
370        forward_slashed.trim_end_matches('/').to_string()
371    };
372
373    if normalized_path == "/" {
374        return true;
375    }
376
377    let drive_root_re = regex::Regex::new(r"^[A-Za-z]:/?$").unwrap();
378    if drive_root_re.is_match(&normalized_path) {
379        return true;
380    }
381
382    if let Some(home) = dirs::home_dir() {
383        let normalized_home = home.to_string_lossy().replace('\\', "/");
384        if normalized_path == normalized_home {
385            return true;
386        }
387    }
388
389    let parent = Path::new(&normalized_path)
390        .parent()
391        .map(|p| p.to_string_lossy().to_string());
392    if parent.as_deref() == Some("/") {
393        return true;
394    }
395
396    let drive_child_re = regex::Regex::new(r"^[A-Za-z]:/[^/]+$").unwrap();
397    if drive_child_re.is_match(&normalized_path) {
398        return true;
399    }
400
401    false
402}
403
404/// Validates a glob pattern by checking its base directory.
405pub fn get_glob_base_directory(path: &str) -> String {
406    let glob_pattern_re = regex::Regex::new(r"[*?\[\]{}]").unwrap();
407    if let Some(m) = glob_pattern_re.find(path) {
408        let before_glob = &path[..m.start()];
409        let last_sep = before_glob.rfind('/');
410        if let Some(idx) = last_sep {
411            if idx == 0 {
412                return "/".to_string();
413            }
414            return before_glob[..idx].to_string();
415        }
416        return ".".to_string();
417    }
418    path.to_string()
419}
420
421/// Checks if a resolved path is allowed for the given operation type.
422pub fn is_path_allowed(
423    resolved_path: &str,
424    _context: &ToolPermissionContext,
425    _operation_type: FileOperationType,
426    _precomputed_paths_to_check: Option<&[String]>,
427) -> PathCheckResult {
428    // Simplified implementation — full implementation requires tool context integration
429    PathCheckResult {
430        allowed: false,
431        decision_reason: None,
432    }
433}
434
435/// Type of file operation.
436#[derive(Clone, Copy, PartialEq, Eq)]
437pub enum FileOperationType {
438    Read,
439    Write,
440    Create,
441}
442
443/// Result of a path check.
444pub struct PathCheckResult {
445    pub allowed: bool,
446    pub decision_reason: Option<PermissionDecisionReason>,
447}
448
449/// Session memory directory path.
450pub fn get_session_memory_dir() -> String {
451    let project_dir = std::env::current_dir()
452        .ok()
453        .unwrap_or_default()
454        .to_string_lossy()
455        .to_string();
456    format!("{}/session-memory/", project_dir)
457}
458
459/// Session memory file path.
460pub fn get_session_memory_path() -> String {
461    format!("{}summary.md", get_session_memory_dir())
462}
463
464/// Checks if path is within the session memory directory.
465fn is_session_memory_path(absolute_path: &str) -> bool {
466    let normalized = Path::new(absolute_path).to_string_lossy().to_string();
467    normalized.starts_with(&get_session_memory_dir())
468}
469
470/// Checks if a path is an internal editable path.
471pub fn check_editable_internal_path(_path: &str, _input: &serde_json::Value) -> InternalPathResult {
472    InternalPathResult::Passthrough
473}
474
475/// Checks if a path is an internal readable path.
476pub fn check_readable_internal_path(_path: &str, _input: &serde_json::Value) -> InternalPathResult {
477    InternalPathResult::Passthrough
478}
479
480/// Result of internal path check.
481pub enum InternalPathResult {
482    Allow {
483        decision_reason: PermissionDecisionReason,
484    },
485    Passthrough,
486}
487
488/// Gets all working directories from context.
489pub fn all_working_directories(context: &ToolPermissionContext) -> Vec<String> {
490    let mut dirs = vec![
491        std::env::current_dir()
492            .ok()
493            .unwrap_or_default()
494            .to_string_lossy()
495            .to_string(),
496    ];
497    dirs.extend(context.additional_working_directories.keys().cloned());
498    dirs
499}
500
501/// Generates permission suggestions for a path.
502pub fn generate_suggestions(
503    _file_path: &str,
504    _operation_type: &str,
505    _tool_permission_context: &ToolPermissionContext,
506    _paths_to_check: Option<&[String]>,
507) -> Vec<PermissionUpdate> {
508    vec![]
509}
510
511/// Matching rule for input path.
512pub fn matching_rule_for_input(
513    _path: &str,
514    _tool_permission_context: &ToolPermissionContext,
515    _tool_type: &str,
516    _behavior: &str,
517) -> Option<PermissionRule> {
518    None
519}
520
521/// Checks if path is in allowed working path.
522pub fn path_in_allowed_working_path(
523    path: &str,
524    tool_permission_context: &ToolPermissionContext,
525    _precomputed_paths_to_check: Option<&[String]>,
526) -> bool {
527    let working_paths = all_working_directories(tool_permission_context);
528    for working_path in &working_paths {
529        if path_in_working_path(path, working_path) {
530            return true;
531        }
532    }
533    false
534}
535
536/// Formats directory list for display.
537pub fn format_directory_list(directories: &[String]) -> String {
538    const MAX_DIRS: usize = 5;
539    let dir_count = directories.len();
540
541    if dir_count <= MAX_DIRS {
542        return directories
543            .iter()
544            .map(|d| format!("'{}'", d))
545            .collect::<Vec<_>>()
546            .join(", ");
547    }
548
549    let first_dirs = directories[..MAX_DIRS]
550        .iter()
551        .map(|d| format!("'{}'", d))
552        .collect::<Vec<_>>()
553        .join(", ");
554
555    format!("{}, and {} more", first_dirs, dir_count - MAX_DIRS)
556}