Skip to main content

ai_agent/tools/powershell/
path_validation.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/tools/PowerShellTool/pathValidation.ts
2//! PowerShell-specific path validation for command arguments.
3//!
4//! Extracts file paths from PowerShell commands and validates they stay
5//! within allowed project directories.
6
7use once_cell::sync::Lazy;
8use std::collections::HashMap;
9
10/// Maximum directories to list
11const MAX_DIRS_TO_LIST: usize = 5;
12
13/// PowerShell wildcards
14static GLOB_PATTERN_REGEX: Lazy<regex::Regex> =
15    Lazy::new(|| regex::Regex::new(r"[*?\[\]]").unwrap());
16
17/// File operation type
18#[derive(Debug, Clone, PartialEq, Default)]
19pub enum FileOperationType {
20    #[default]
21    Read,
22    Write,
23    Create,
24}
25
26/// Path check result
27#[derive(Debug, Clone)]
28pub struct PathCheckResult {
29    pub allowed: bool,
30    pub decision_reason: Option<String>,
31}
32
33/// Resolved path check result
34#[derive(Debug, Clone)]
35pub struct ResolvedPathCheckResult {
36    pub allowed: bool,
37    pub decision_reason: Option<String>,
38    pub resolved_path: String,
39}
40
41/// Per-cmdlet parameter configuration
42#[derive(Debug, Clone)]
43pub struct CmdletPathConfig {
44    pub operation_type: FileOperationType,
45    pub path_params: Vec<String>,
46    pub known_switches: Vec<String>,
47    pub known_value_params: Vec<String>,
48    pub leaf_only_path_params: Option<Vec<String>>,
49    pub positional_skip: Option<usize>,
50    pub optional_write: bool,
51}
52
53impl Default for CmdletPathConfig {
54    fn default() -> Self {
55        Self {
56            operation_type: FileOperationType::Read,
57            path_params: Vec::new(),
58            known_switches: Vec::new(),
59            known_value_params: Vec::new(),
60            leaf_only_path_params: None,
61            positional_skip: None,
62            optional_write: false,
63        }
64    }
65}
66
67/// Cmdlet path configurations - maps cmdlet names to their path parameter configs
68static CMDLET_PATH_CONFIG: Lazy<HashMap<&'static str, CmdletPathConfig>> = Lazy::new(|| {
69    let mut map = HashMap::new();
70
71    // Write operations
72    map.insert(
73        "set-content",
74        CmdletPathConfig {
75            operation_type: FileOperationType::Write,
76            path_params: vec![
77                "-path".to_string(),
78                "-literalpath".to_string(),
79                "-pspath".to_string(),
80                "-lp".to_string(),
81            ],
82            known_switches: vec![
83                "-passthru".to_string(),
84                "-force".to_string(),
85                "-whatif".to_string(),
86                "-confirm".to_string(),
87                "-nonewline".to_string(),
88            ],
89            known_value_params: vec![
90                "-value".to_string(),
91                "-filter".to_string(),
92                "-include".to_string(),
93                "-exclude".to_string(),
94                "-encoding".to_string(),
95            ],
96            ..Default::default()
97        },
98    );
99
100    map.insert(
101        "add-content",
102        CmdletPathConfig {
103            operation_type: FileOperationType::Write,
104            path_params: vec![
105                "-path".to_string(),
106                "-literalpath".to_string(),
107                "-pspath".to_string(),
108                "-lp".to_string(),
109            ],
110            known_switches: vec![
111                "-passthru".to_string(),
112                "-force".to_string(),
113                "-whatif".to_string(),
114                "-confirm".to_string(),
115                "-nonewline".to_string(),
116            ],
117            known_value_params: vec![
118                "-value".to_string(),
119                "-filter".to_string(),
120                "-include".to_string(),
121                "-exclude".to_string(),
122                "-encoding".to_string(),
123            ],
124            ..Default::default()
125        },
126    );
127
128    map.insert(
129        "remove-item",
130        CmdletPathConfig {
131            operation_type: FileOperationType::Write,
132            path_params: vec![
133                "-path".to_string(),
134                "-literalpath".to_string(),
135                "-pspath".to_string(),
136                "-lp".to_string(),
137            ],
138            known_switches: vec![
139                "-recurse".to_string(),
140                "-force".to_string(),
141                "-whatif".to_string(),
142                "-confirm".to_string(),
143            ],
144            known_value_params: vec![
145                "-filter".to_string(),
146                "-include".to_string(),
147                "-exclude".to_string(),
148                "-stream".to_string(),
149            ],
150            ..Default::default()
151        },
152    );
153
154    map.insert(
155        "clear-content",
156        CmdletPathConfig {
157            operation_type: FileOperationType::Write,
158            path_params: vec![
159                "-path".to_string(),
160                "-literalpath".to_string(),
161                "-pspath".to_string(),
162                "-lp".to_string(),
163            ],
164            known_switches: vec![
165                "-force".to_string(),
166                "-whatif".to_string(),
167                "-confirm".to_string(),
168            ],
169            known_value_params: vec![
170                "-filter".to_string(),
171                "-include".to_string(),
172                "-exclude".to_string(),
173                "-stream".to_string(),
174            ],
175            ..Default::default()
176        },
177    );
178
179    map.insert(
180        "out-file",
181        CmdletPathConfig {
182            operation_type: FileOperationType::Write,
183            path_params: vec![
184                "-filepath".to_string(),
185                "-path".to_string(),
186                "-literalpath".to_string(),
187                "-pspath".to_string(),
188                "-lp".to_string(),
189            ],
190            known_switches: vec![
191                "-append".to_string(),
192                "-force".to_string(),
193                "-noclobber".to_string(),
194                "-nonewline".to_string(),
195                "-whatif".to_string(),
196                "-confirm".to_string(),
197            ],
198            known_value_params: vec![
199                "-inputobject".to_string(),
200                "-encoding".to_string(),
201                "-width".to_string(),
202            ],
203            ..Default::default()
204        },
205    );
206
207    map.insert(
208        "new-item",
209        CmdletPathConfig {
210            operation_type: FileOperationType::Create,
211            path_params: vec![
212                "-path".to_string(),
213                "-literalpath".to_string(),
214                "-pspath".to_string(),
215                "-lp".to_string(),
216            ],
217            leaf_only_path_params: Some(vec!["-name".to_string()]),
218            known_switches: vec![
219                "-force".to_string(),
220                "-whatif".to_string(),
221                "-confirm".to_string(),
222            ],
223            known_value_params: vec![
224                "-itemtype".to_string(),
225                "-value".to_string(),
226                "-type".to_string(),
227            ],
228            ..Default::default()
229        },
230    );
231
232    map.insert(
233        "copy-item",
234        CmdletPathConfig {
235            operation_type: FileOperationType::Write,
236            path_params: vec![
237                "-path".to_string(),
238                "-literalpath".to_string(),
239                "-pspath".to_string(),
240                "-lp".to_string(),
241                "-destination".to_string(),
242            ],
243            known_switches: vec![
244                "-container".to_string(),
245                "-force".to_string(),
246                "-passthru".to_string(),
247                "-recurse".to_string(),
248                "-whatif".to_string(),
249                "-confirm".to_string(),
250            ],
251            known_value_params: vec![
252                "-filter".to_string(),
253                "-include".to_string(),
254                "-exclude".to_string(),
255                "-fromsession".to_string(),
256                "-tosession".to_string(),
257            ],
258            ..Default::default()
259        },
260    );
261
262    map.insert(
263        "move-item",
264        CmdletPathConfig {
265            operation_type: FileOperationType::Write,
266            path_params: vec![
267                "-path".to_string(),
268                "-literalpath".to_string(),
269                "-pspath".to_string(),
270                "-lp".to_string(),
271                "-destination".to_string(),
272            ],
273            known_switches: vec![
274                "-force".to_string(),
275                "-passthru".to_string(),
276                "-whatif".to_string(),
277                "-confirm".to_string(),
278            ],
279            known_value_params: vec![
280                "-filter".to_string(),
281                "-include".to_string(),
282                "-exclude".to_string(),
283            ],
284            ..Default::default()
285        },
286    );
287
288    map.insert(
289        "rename-item",
290        CmdletPathConfig {
291            operation_type: FileOperationType::Write,
292            path_params: vec![
293                "-path".to_string(),
294                "-literalpath".to_string(),
295                "-pspath".to_string(),
296                "-lp".to_string(),
297            ],
298            known_switches: vec![
299                "-force".to_string(),
300                "-passthru".to_string(),
301                "-whatif".to_string(),
302                "-confirm".to_string(),
303            ],
304            known_value_params: vec![
305                "-newname".to_string(),
306                "-credential".to_string(),
307                "-filter".to_string(),
308                "-include".to_string(),
309                "-exclude".to_string(),
310            ],
311            ..Default::default()
312        },
313    );
314
315    // Read operations
316    map.insert(
317        "get-content",
318        CmdletPathConfig {
319            operation_type: FileOperationType::Read,
320            path_params: vec![
321                "-path".to_string(),
322                "-literalpath".to_string(),
323                "-pspath".to_string(),
324                "-lp".to_string(),
325            ],
326            known_switches: vec![
327                "-force".to_string(),
328                "-wait".to_string(),
329                "-raw".to_string(),
330                "-asbytestream".to_string(),
331            ],
332            known_value_params: vec![
333                "-readcount".to_string(),
334                "-totalcount".to_string(),
335                "-tail".to_string(),
336                "-first".to_string(),
337                "-head".to_string(),
338                "-last".to_string(),
339                "-filter".to_string(),
340                "-include".to_string(),
341                "-exclude".to_string(),
342                "-delimiter".to_string(),
343                "-encoding".to_string(),
344                "-stream".to_string(),
345            ],
346            ..Default::default()
347        },
348    );
349
350    map.insert(
351        "get-childitem",
352        CmdletPathConfig {
353            operation_type: FileOperationType::Read,
354            path_params: vec![
355                "-path".to_string(),
356                "-literalpath".to_string(),
357                "-pspath".to_string(),
358                "-lp".to_string(),
359            ],
360            known_switches: vec![
361                "-recurse".to_string(),
362                "-force".to_string(),
363                "-name".to_string(),
364                "-directory".to_string(),
365                "-file".to_string(),
366                "-hidden".to_string(),
367                "-readonly".to_string(),
368                "-system".to_string(),
369            ],
370            known_value_params: vec![
371                "-filter".to_string(),
372                "-include".to_string(),
373                "-exclude".to_string(),
374                "-depth".to_string(),
375                "-attributes".to_string(),
376            ],
377            ..Default::default()
378        },
379    );
380
381    map.insert(
382        "get-item",
383        CmdletPathConfig {
384            operation_type: FileOperationType::Read,
385            path_params: vec![
386                "-path".to_string(),
387                "-literalpath".to_string(),
388                "-pspath".to_string(),
389                "-lp".to_string(),
390            ],
391            known_switches: vec!["-force".to_string()],
392            known_value_params: vec![
393                "-filter".to_string(),
394                "-include".to_string(),
395                "-exclude".to_string(),
396                "-stream".to_string(),
397            ],
398            ..Default::default()
399        },
400    );
401
402    map.insert(
403        "get-itemproperty",
404        CmdletPathConfig {
405            operation_type: FileOperationType::Read,
406            path_params: vec![
407                "-path".to_string(),
408                "-literalpath".to_string(),
409                "-pspath".to_string(),
410                "-lp".to_string(),
411            ],
412            known_switches: vec![],
413            known_value_params: vec![
414                "-name".to_string(),
415                "-filter".to_string(),
416                "-include".to_string(),
417                "-exclude".to_string(),
418            ],
419            ..Default::default()
420        },
421    );
422
423    map.insert(
424        "test-path",
425        CmdletPathConfig {
426            operation_type: FileOperationType::Read,
427            path_params: vec![
428                "-path".to_string(),
429                "-literalpath".to_string(),
430                "-pspath".to_string(),
431                "-lp".to_string(),
432            ],
433            known_switches: vec!["-isvalid".to_string()],
434            known_value_params: vec![
435                "-filter".to_string(),
436                "-include".to_string(),
437                "-exclude".to_string(),
438                "-pathtype".to_string(),
439                "-olderthan".to_string(),
440                "-newerthan".to_string(),
441            ],
442            ..Default::default()
443        },
444    );
445
446    map
447});
448
449/// Get cmdlet path config
450pub fn get_cmdlet_path_config(cmdlet_name: &str) -> Option<&'static CmdletPathConfig> {
451    // First try direct lookup
452    if let Some(config) = CMDLET_PATH_CONFIG.get(cmdlet_name) {
453        return Some(config);
454    }
455
456    // Try alias resolution
457    use super::read_only_validation::resolve_to_canonical;
458    let canonical = resolve_to_canonical(cmdlet_name);
459    CMDLET_PATH_CONFIG.get(canonical.as_str())
460}
461
462/// Check if path is dangerous for removal
463pub fn is_dangerous_removal_path(path: &str) -> bool {
464    let lower = path.to_lowercase();
465
466    // Check for critical system paths
467    let dangerous_paths = [
468        "/",
469        "/bin",
470        "/etc",
471        "/usr",
472        "/usr/bin",
473        "/usr/sbin",
474        "/var",
475        "/tmp",
476        "/home",
477        "/root",
478        "c:\\",
479        "c:\\windows",
480        "c:\\program files",
481        "c:\\program files (x86)",
482    ];
483
484    for dp in dangerous_paths.iter() {
485        if lower == *dp || lower.starts_with(&format!("{}/", dp)) || lower.starts_with(dp) {
486            return true;
487        }
488    }
489
490    false
491}
492
493/// Check path constraints
494pub fn check_path_constraints(command: &str, _allowed_paths: &[String]) -> PathCheckResult {
495    use super::read_only_validation::resolve_to_canonical;
496
497    let parts: Vec<&str> = command.split_whitespace().collect();
498    if parts.is_empty() {
499        return PathCheckResult {
500            allowed: true,
501            decision_reason: None,
502        };
503    }
504
505    // Get cmdlet name and resolve aliases
506    let cmdlet_name = resolve_to_canonical(parts[0]);
507
508    // Get config for this cmdlet
509    let config = match get_cmdlet_path_config(&cmdlet_name) {
510        Some(c) => c,
511        None => {
512            // No path config means we can't validate - ask
513            return PathCheckResult {
514                allowed: false,
515                decision_reason: Some("Cmdlet not in path validation config".to_string()),
516            };
517        }
518    };
519
520    // Check for write operations without path (optional write cmdlets like Invoke-WebRequest)
521    if config.optional_write && config.operation_type == FileOperationType::Write {
522        // Check if any path parameter is present
523        let has_path = parts.iter().any(|arg| {
524            config
525                .path_params
526                .iter()
527                .any(|p| arg.to_lowercase().starts_with(p))
528        });
529
530        if !has_path {
531            // No path = output goes to pipeline, not filesystem
532            return PathCheckResult {
533                allowed: true,
534                decision_reason: None,
535            };
536        }
537    }
538
539    // For write operations, check for dangerous paths
540    if config.operation_type == FileOperationType::Write
541        || config.operation_type == FileOperationType::Create
542    {
543        // Extract paths from arguments
544        for (i, arg) in parts.iter().enumerate() {
545            // Skip flags
546            if arg.starts_with('-') {
547                continue;
548            }
549
550            // Check if this could be a path parameter
551            let is_path_param = if i > 0 {
552                let prev = parts[i - 1].to_lowercase();
553                config.path_params.iter().any(|p| prev == *p)
554            } else {
555                false
556            };
557
558            if is_path_param || (!arg.starts_with('-') && i > 0) {
559                if is_dangerous_removal_path(arg) {
560                    return PathCheckResult {
561                        allowed: false,
562                        decision_reason: Some(format!("Path '{}' is a dangerous system path", arg)),
563                    };
564                }
565            }
566        }
567    }
568
569    PathCheckResult {
570        allowed: true,
571        decision_reason: None,
572    }
573}
574
575/// Dangerous removal deny check
576pub fn dangerous_removal_deny(path: &str) -> bool {
577    is_dangerous_removal_path(path)
578}
579
580/// Check if path is a dangerous raw path
581pub fn is_dangerous_removal_raw_path(path: &str) -> bool {
582    is_dangerous_removal_path(path)
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn test_get_cmdlet_path_config() {
591        let config = get_cmdlet_path_config("set-content");
592        assert!(config.is_some());
593        assert_eq!(config.unwrap().operation_type, FileOperationType::Write);
594
595        let config = get_cmdlet_path_config("get-content");
596        assert!(config.is_some());
597        assert_eq!(config.unwrap().operation_type, FileOperationType::Read);
598    }
599
600    #[test]
601    fn test_is_dangerous_removal_path() {
602        assert!(is_dangerous_removal_path("/etc/passwd"));
603        assert!(is_dangerous_removal_path("/bin"));
604        // /home is in dangerous paths
605        assert!(is_dangerous_removal_path("/home/user/file.txt"));
606    }
607
608    #[test]
609    fn test_check_path_constraints() {
610        let result = check_path_constraints("Get-Content test.txt", &["/home/user".to_string()]);
611        assert!(result.allowed);
612
613        let result = check_path_constraints("Remove-Item /etc/passwd", &["/home/user".to_string()]);
614        assert!(!result.allowed);
615    }
616}