ai_agent/utils/permissions/
filesystem.rs1#![allow(dead_code)]
3
4use crate::types::permissions::{
10 PermissionDecision, PermissionDecisionReason, PermissionRule, PermissionUpdate,
11 PermissionUpdateDestination, ToolPermissionContext,
12};
13use std::path::{MAIN_SEPARATOR, Path, PathBuf};
14
15pub 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
30pub const DANGEROUS_DIRECTORIES: &[&str] = &[".git", ".vscode", ".idea", ".claude"];
32
33pub fn normalize_case_for_comparison(path: &str) -> String {
37 path.to_lowercase()
38}
39
40pub 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 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
105pub 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
115pub 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
129pub fn to_posix_path(path: &str) -> String {
131 if cfg!(windows) {
132 path.replace('\\', "/")
133 } else {
134 path.to_string()
135 }
136}
137
138pub 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
149pub 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
159pub 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
175pub 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 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
205pub 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
212pub fn has_suspicious_windows_path_pattern(path: &str) -> bool {
214 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 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 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 if path.ends_with(|c: char| c == '.' || c.is_whitespace()) {
241 return true;
242 }
243
244 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 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
259fn 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 if path.starts_with("\\\\") || path.starts_with("//") {
267 return true;
268 }
269
270 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 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 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
307pub 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 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 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 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
350pub enum PathSafetyResult {
352 Safe,
353 Unsafe {
354 message: String,
355 classifier_approvable: bool,
356 },
357}
358
359pub 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
404pub 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
421pub 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 PathCheckResult {
430 allowed: false,
431 decision_reason: None,
432 }
433}
434
435#[derive(Clone, Copy, PartialEq, Eq)]
437pub enum FileOperationType {
438 Read,
439 Write,
440 Create,
441}
442
443pub struct PathCheckResult {
445 pub allowed: bool,
446 pub decision_reason: Option<PermissionDecisionReason>,
447}
448
449pub 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
459pub fn get_session_memory_path() -> String {
461 format!("{}summary.md", get_session_memory_dir())
462}
463
464fn 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
470pub fn check_editable_internal_path(_path: &str, _input: &serde_json::Value) -> InternalPathResult {
472 InternalPathResult::Passthrough
473}
474
475pub fn check_readable_internal_path(_path: &str, _input: &serde_json::Value) -> InternalPathResult {
477 InternalPathResult::Passthrough
478}
479
480pub enum InternalPathResult {
482 Allow {
483 decision_reason: PermissionDecisionReason,
484 },
485 Passthrough,
486}
487
488pub 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
501pub 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
511pub 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
521pub 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
536pub 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}