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> = Lazy::new(|| {
15    regex::Regex::new(r"[*?\[\]]").unwrap()
16});
17
18/// File operation type
19#[derive(Debug, Clone, PartialEq, Default)]
20pub enum FileOperationType {
21    #[default]
22    Read,
23    Write,
24    Create,
25}
26
27/// Path check result
28#[derive(Debug, Clone)]
29pub struct PathCheckResult {
30    pub allowed: bool,
31    pub decision_reason: Option<String>,
32}
33
34/// Resolved path check result
35#[derive(Debug, Clone)]
36pub struct ResolvedPathCheckResult {
37    pub allowed: bool,
38    pub decision_reason: Option<String>,
39    pub resolved_path: String,
40}
41
42/// Per-cmdlet parameter configuration
43#[derive(Debug, Clone)]
44pub struct CmdletPathConfig {
45    pub operation_type: FileOperationType,
46    pub path_params: Vec<String>,
47    pub known_switches: Vec<String>,
48    pub known_value_params: Vec<String>,
49    pub leaf_only_path_params: Option<Vec<String>>,
50    pub positional_skip: Option<usize>,
51    pub optional_write: bool,
52}
53
54impl Default for CmdletPathConfig {
55    fn default() -> Self {
56        Self {
57            operation_type: FileOperationType::Read,
58            path_params: Vec::new(),
59            known_switches: Vec::new(),
60            known_value_params: Vec::new(),
61            leaf_only_path_params: None,
62            positional_skip: None,
63            optional_write: false,
64        }
65    }
66}
67
68/// Cmdlet path configurations - maps cmdlet names to their path parameter configs
69static CMDLET_PATH_CONFIG: Lazy<HashMap<&'static str, CmdletPathConfig>> = Lazy::new(|| {
70    let mut map = HashMap::new();
71
72    // Write operations
73    map.insert("set-content", CmdletPathConfig {
74        operation_type: FileOperationType::Write,
75        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
76        known_switches: vec!["-passthru".to_string(), "-force".to_string(), "-whatif".to_string(), "-confirm".to_string(), "-nonewline".to_string()],
77        known_value_params: vec!["-value".to_string(), "-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-encoding".to_string()],
78        ..Default::default()
79    });
80
81    map.insert("add-content", CmdletPathConfig {
82        operation_type: FileOperationType::Write,
83        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
84        known_switches: vec!["-passthru".to_string(), "-force".to_string(), "-whatif".to_string(), "-confirm".to_string(), "-nonewline".to_string()],
85        known_value_params: vec!["-value".to_string(), "-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-encoding".to_string()],
86        ..Default::default()
87    });
88
89    map.insert("remove-item", CmdletPathConfig {
90        operation_type: FileOperationType::Write,
91        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
92        known_switches: vec!["-recurse".to_string(), "-force".to_string(), "-whatif".to_string(), "-confirm".to_string()],
93        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-stream".to_string()],
94        ..Default::default()
95    });
96
97    map.insert("clear-content", CmdletPathConfig {
98        operation_type: FileOperationType::Write,
99        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
100        known_switches: vec!["-force".to_string(), "-whatif".to_string(), "-confirm".to_string()],
101        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-stream".to_string()],
102        ..Default::default()
103    });
104
105    map.insert("out-file", CmdletPathConfig {
106        operation_type: FileOperationType::Write,
107        path_params: vec!["-filepath".to_string(), "-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
108        known_switches: vec!["-append".to_string(), "-force".to_string(), "-noclobber".to_string(), "-nonewline".to_string(), "-whatif".to_string(), "-confirm".to_string()],
109        known_value_params: vec!["-inputobject".to_string(), "-encoding".to_string(), "-width".to_string()],
110        ..Default::default()
111    });
112
113    map.insert("new-item", CmdletPathConfig {
114        operation_type: FileOperationType::Create,
115        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
116        leaf_only_path_params: Some(vec!["-name".to_string()]),
117        known_switches: vec!["-force".to_string(), "-whatif".to_string(), "-confirm".to_string()],
118        known_value_params: vec!["-itemtype".to_string(), "-value".to_string(), "-type".to_string()],
119        ..Default::default()
120    });
121
122    map.insert("copy-item", CmdletPathConfig {
123        operation_type: FileOperationType::Write,
124        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string(), "-destination".to_string()],
125        known_switches: vec!["-container".to_string(), "-force".to_string(), "-passthru".to_string(), "-recurse".to_string(), "-whatif".to_string(), "-confirm".to_string()],
126        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-fromsession".to_string(), "-tosession".to_string()],
127        ..Default::default()
128    });
129
130    map.insert("move-item", CmdletPathConfig {
131        operation_type: FileOperationType::Write,
132        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string(), "-destination".to_string()],
133        known_switches: vec!["-force".to_string(), "-passthru".to_string(), "-whatif".to_string(), "-confirm".to_string()],
134        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string()],
135        ..Default::default()
136    });
137
138    map.insert("rename-item", CmdletPathConfig {
139        operation_type: FileOperationType::Write,
140        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
141        known_switches: vec!["-force".to_string(), "-passthru".to_string(), "-whatif".to_string(), "-confirm".to_string()],
142        known_value_params: vec!["-newname".to_string(), "-credential".to_string(), "-filter".to_string(), "-include".to_string(), "-exclude".to_string()],
143        ..Default::default()
144    });
145
146    // Read operations
147    map.insert("get-content", CmdletPathConfig {
148        operation_type: FileOperationType::Read,
149        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
150        known_switches: vec!["-force".to_string(), "-wait".to_string(), "-raw".to_string(), "-asbytestream".to_string()],
151        known_value_params: vec!["-readcount".to_string(), "-totalcount".to_string(), "-tail".to_string(), "-first".to_string(), "-head".to_string(), "-last".to_string(), "-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-delimiter".to_string(), "-encoding".to_string(), "-stream".to_string()],
152        ..Default::default()
153    });
154
155    map.insert("get-childitem", CmdletPathConfig {
156        operation_type: FileOperationType::Read,
157        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
158        known_switches: vec!["-recurse".to_string(), "-force".to_string(), "-name".to_string(), "-directory".to_string(), "-file".to_string(), "-hidden".to_string(), "-readonly".to_string(), "-system".to_string()],
159        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-depth".to_string(), "-attributes".to_string()],
160        ..Default::default()
161    });
162
163    map.insert("get-item", CmdletPathConfig {
164        operation_type: FileOperationType::Read,
165        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
166        known_switches: vec!["-force".to_string()],
167        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-stream".to_string()],
168        ..Default::default()
169    });
170
171    map.insert("get-itemproperty", CmdletPathConfig {
172        operation_type: FileOperationType::Read,
173        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
174        known_switches: vec![],
175        known_value_params: vec!["-name".to_string(), "-filter".to_string(), "-include".to_string(), "-exclude".to_string()],
176        ..Default::default()
177    });
178
179    map.insert("test-path", CmdletPathConfig {
180        operation_type: FileOperationType::Read,
181        path_params: vec!["-path".to_string(), "-literalpath".to_string(), "-pspath".to_string(), "-lp".to_string()],
182        known_switches: vec!["-isvalid".to_string()],
183        known_value_params: vec!["-filter".to_string(), "-include".to_string(), "-exclude".to_string(), "-pathtype".to_string(), "-olderthan".to_string(), "-newerthan".to_string()],
184        ..Default::default()
185    });
186
187    map
188});
189
190/// Get cmdlet path config
191pub fn get_cmdlet_path_config(cmdlet_name: &str) -> Option<&'static CmdletPathConfig> {
192    // First try direct lookup
193    if let Some(config) = CMDLET_PATH_CONFIG.get(cmdlet_name) {
194        return Some(config);
195    }
196
197    // Try alias resolution
198    use super::read_only_validation::resolve_to_canonical;
199    let canonical = resolve_to_canonical(cmdlet_name);
200    CMDLET_PATH_CONFIG.get(canonical.as_str())
201}
202
203/// Check if path is dangerous for removal
204pub fn is_dangerous_removal_path(path: &str) -> bool {
205    let lower = path.to_lowercase();
206
207    // Check for critical system paths
208    let dangerous_paths = [
209        "/",
210        "/bin",
211        "/etc",
212        "/usr",
213        "/usr/bin",
214        "/usr/sbin",
215        "/var",
216        "/tmp",
217        "/home",
218        "/root",
219        "c:\\",
220        "c:\\windows",
221        "c:\\program files",
222        "c:\\program files (x86)",
223    ];
224
225    for dp in dangerous_paths.iter() {
226        if lower == *dp || lower.starts_with(&format!("{}/", dp)) || lower.starts_with(dp) {
227            return true;
228        }
229    }
230
231    false
232}
233
234/// Check path constraints
235pub fn check_path_constraints(
236    command: &str,
237    _allowed_paths: &[String],
238) -> PathCheckResult {
239    use super::read_only_validation::resolve_to_canonical;
240
241    let parts: Vec<&str> = command.split_whitespace().collect();
242    if parts.is_empty() {
243        return PathCheckResult {
244            allowed: true,
245            decision_reason: None,
246        };
247    }
248
249    // Get cmdlet name and resolve aliases
250    let cmdlet_name = resolve_to_canonical(parts[0]);
251
252    // Get config for this cmdlet
253    let config = match get_cmdlet_path_config(&cmdlet_name) {
254        Some(c) => c,
255        None => {
256            // No path config means we can't validate - ask
257            return PathCheckResult {
258                allowed: false,
259                decision_reason: Some("Cmdlet not in path validation config".to_string()),
260            };
261        }
262    };
263
264    // Check for write operations without path (optional write cmdlets like Invoke-WebRequest)
265    if config.optional_write && config.operation_type == FileOperationType::Write {
266        // Check if any path parameter is present
267        let has_path = parts.iter().any(|arg| {
268            config.path_params.iter().any(|p| arg.to_lowercase().starts_with(p))
269        });
270
271        if !has_path {
272            // No path = output goes to pipeline, not filesystem
273            return PathCheckResult {
274                allowed: true,
275                decision_reason: None,
276            };
277        }
278    }
279
280    // For write operations, check for dangerous paths
281    if config.operation_type == FileOperationType::Write || config.operation_type == FileOperationType::Create {
282        // Extract paths from arguments
283        for (i, arg) in parts.iter().enumerate() {
284            // Skip flags
285            if arg.starts_with('-') {
286                continue;
287            }
288
289            // Check if this could be a path parameter
290            let is_path_param = if i > 0 {
291                let prev = parts[i - 1].to_lowercase();
292                config.path_params.iter().any(|p| prev == *p)
293            } else {
294                false
295            };
296
297            if is_path_param || (!arg.starts_with('-') && i > 0) {
298                if is_dangerous_removal_path(arg) {
299                    return PathCheckResult {
300                        allowed: false,
301                        decision_reason: Some(format!("Path '{}' is a dangerous system path", arg)),
302                    };
303                }
304            }
305        }
306    }
307
308    PathCheckResult {
309        allowed: true,
310        decision_reason: None,
311    }
312}
313
314/// Dangerous removal deny check
315pub fn dangerous_removal_deny(path: &str) -> bool {
316    is_dangerous_removal_path(path)
317}
318
319/// Check if path is a dangerous raw path
320pub fn is_dangerous_removal_raw_path(path: &str) -> bool {
321    is_dangerous_removal_path(path)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_get_cmdlet_path_config() {
330        let config = get_cmdlet_path_config("set-content");
331        assert!(config.is_some());
332        assert_eq!(config.unwrap().operation_type, FileOperationType::Write);
333
334        let config = get_cmdlet_path_config("get-content");
335        assert!(config.is_some());
336        assert_eq!(config.unwrap().operation_type, FileOperationType::Read);
337    }
338
339    #[test]
340    fn test_is_dangerous_removal_path() {
341        assert!(is_dangerous_removal_path("/etc/passwd"));
342        assert!(is_dangerous_removal_path("/bin"));
343        // /home is in dangerous paths
344        assert!(is_dangerous_removal_path("/home/user/file.txt"));
345    }
346
347    #[test]
348    fn test_check_path_constraints() {
349        let result = check_path_constraints("Get-Content test.txt", &["/home/user".to_string()]);
350        assert!(result.allowed);
351
352        let result = check_path_constraints("Remove-Item /etc/passwd", &["/home/user".to_string()]);
353        assert!(!result.allowed);
354    }
355}