Skip to main content

ai_agent/tools/powershell/
read_only_validation.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/tools/PowerShellTool/readOnlyValidation.ts
2//! PowerShell read-only command validation.
3//!
4//! Cmdlets are case-insensitive; all matching is done in lowercase.
5
6use once_cell::sync::Lazy;
7use std::collections::HashSet;
8use regex::Regex;
9
10/// Command configuration for allowlist
11#[derive(Debug, Clone, Default)]
12pub struct CommandConfig {
13    /// Safe subcommands or flags for this command
14    pub safe_flags: Option<Vec<String>>,
15    /// When true, all flags are allowed regardless of safeFlags
16    pub allow_all_flags: bool,
17    /// Regex constraint on the original command
18    pub regex: Option<Regex>,
19}
20
21/// PowerShell cmdlet allowlist - maps canonical cmdlet names to their safe configuration
22static CMDLET_ALLOWLIST: Lazy<HashSet<&'static str>> = Lazy::new(|| {
23    let mut set = HashSet::new();
24    // PowerShell Cmdlets - Filesystem (read-only)
25    set.insert("get-childitem");
26    set.insert("get-content");
27    set.insert("get-item");
28    set.insert("get-itemproperty");
29    set.insert("test-path");
30    set.insert("resolve-path");
31    set.insert("get-filehash");
32    set.insert("get-acl");
33    // PowerShell Cmdlets - Navigation
34    set.insert("set-location");
35    set.insert("push-location");
36    set.insert("pop-location");
37    // PowerShell Cmdlets - Text searching/filtering
38    set.insert("select-string");
39    // PowerShell Cmdlets - Data conversion
40    set.insert("convertto-json");
41    set.insert("convertfrom-json");
42    set.insert("convertto-csv");
43    set.insert("convertfrom-csv");
44    set.insert("convertto-xml");
45    set.insert("convertto-html");
46    set.insert("format-hex");
47    // PowerShell Cmdlets - Object inspection
48    set.insert("get-member");
49    set.insert("get-unique");
50    set.insert("compare-object");
51    set.insert("join-string");
52    set.insert("get-random");
53    // PowerShell Cmdlets - Path utilities
54    set.insert("convert-path");
55    set.insert("join-path");
56    set.insert("split-path");
57    // PowerShell Cmdlets - System info
58    set.insert("get-hotfix");
59    set.insert("get-itempropertyvalue");
60    set.insert("get-psprovider");
61    set.insert("get-process");
62    set.insert("get-service");
63    set.insert("get-computerinfo");
64    set.insert("get-host");
65    set.insert("get-date");
66    set.insert("get-location");
67    set.insert("get-psdrive");
68    set.insert("get-module");
69    set.insert("get-alias");
70    set.insert("get-history");
71    set.insert("get-culture");
72    set.insert("get-uiculture");
73    set.insert("get-timezone");
74    set.insert("get-uptime");
75    // PowerShell Cmdlets - Output & misc
76    set.insert("write-output");
77    set.insert("write-host");
78    set.insert("start-sleep");
79    set.insert("format-table");
80    set.insert("format-list");
81    set.insert("format-wide");
82    set.insert("format-custom");
83    set.insert("measure-object");
84    set.insert("select-object");
85    set.insert("sort-object");
86    set.insert("group-object");
87    set.insert("where-object");
88    set.insert("out-string");
89    set.insert("out-host");
90    // PowerShell Cmdlets - Network info
91    set.insert("get-netadapter");
92    set.insert("get-netipaddress");
93    set.insert("get-netipconfiguration");
94    set.insert("get-netroute");
95    set.insert("get-dnsclientcache");
96    set.insert("get-dnsclient");
97    // PowerShell Cmdlets - Event log
98    set.insert("get-eventlog");
99    set.insert("get-winevent");
100    // PowerShell Cmdlets - WMI/CIM
101    set.insert("get-cimclass");
102    // External commands
103    set.insert("git");
104    set.insert("gh");
105    set.insert("docker");
106    set.insert("dotnet");
107    // Windows-specific
108    set.insert("ipconfig");
109    set.insert("netstat");
110    set.insert("systeminfo");
111    set.insert("tasklist");
112    set.insert("where.exe");
113    set.insert("hostname");
114    set.insert("whoami");
115    set.insert("ver");
116    set.insert("arp");
117    set.insert("route");
118    set.insert("getmac");
119    // Cross-platform CLI
120    set.insert("file");
121    set.insert("tree");
122    set.insert("findstr");
123    set
124});
125
126/// Safe output cmdlets that can receive piped input
127static SAFE_OUTPUT_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
128    let mut set = HashSet::new();
129    set.insert("out-null");
130    set
131});
132
133/// Pipeline tail cmdlets moved from SAFE_OUTPUT_CMDLETS
134static PIPELINE_TAIL_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
135    let mut set = HashSet::new();
136    set.insert("format-table");
137    set.insert("format-list");
138    set.insert("format-wide");
139    set.insert("format-custom");
140    set.insert("measure-object");
141    set.insert("select-object");
142    set.insert("sort-object");
143    set.insert("group-object");
144    set.insert("where-object");
145    set.insert("out-string");
146    set.insert("out-host");
147    set
148});
149
150/// Safe external .exe names
151static SAFE_EXTERNAL_EXES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
152    let mut set = HashSet::new();
153    set.insert("where.exe");
154    set
155});
156
157/// Windows PATHEXT extensions
158static WINDOWS_PATHEXT: Lazy<Regex> = Lazy::new(|| {
159    Regex::new(r"\.(exe|cmd|bat|com)$").unwrap()
160});
161
162/// Common PowerShell aliases mapping to canonical cmdlet names
163static COMMON_ALIASES: Lazy<std::collections::HashMap<&'static str, &'static str>> = Lazy::new(|| {
164    let mut map = std::collections::HashMap::new();
165    // File operations
166    map.insert("rm", "remove-item");
167    map.insert("del", "remove-item");
168    map.insert("ri", "remove-item");
169    map.insert("rd", "remove-item");
170    map.insert("rmdir", "remove-item");
171    map.insert("gc", "get-content");
172    map.insert("cat", "get-content");
173    map.insert("type", "get-content");
174    map.insert("gci", "get-childitem");
175    map.insert("dir", "get-childitem");
176    map.insert("ls", "get-childitem");
177    map.insert("ni", "new-item");
178    map.insert("mkdir", "new-item");
179    map.insert("cp", "copy-item");
180    map.insert("copy", "copy-item");
181    map.insert("cpi", "copy-item");
182    map.insert("mv", "move-item");
183    map.insert("move", "move-item");
184    map.insert("mi", "move-item");
185    map.insert("ren", "rename-item");
186    map.insert("rni", "rename-item");
187    map.insert("si", "set-item");
188    map.insert("sc", "set-content");
189    map.insert("set", "set-content");
190    map.insert("ac", "add-content");
191    // Navigation
192    map.insert("cd", "set-location");
193    map.insert("sl", "set-location");
194    map.insert("chdir", "set-location");
195    map.insert("pushd", "push-location");
196    map.insert("popd", "pop-location");
197    // Search
198    map.insert("select", "select-string");
199    map.insert("find", "findstr");
200    // Output
201    map.insert("echo", "write-output");
202    map.insert("write", "write-output");
203    // Aliases
204    map.insert("gal", "get-alias");
205    map.insert("gh", "get-help");
206    map.insert("gm", "get-member");
207    map.insert("gps", "get-process");
208    map.insert("gsv", "get-service");
209    map.insert("fl", "format-list");
210    map.insert("ft", "format-table");
211    map.insert("fw", "format-wide");
212    map.insert("sort", "sort-object");
213    map.insert("group", "group-object");
214    map.insert("where", "where-object");
215    map.insert("foreach", "foreach-object");
216    map.insert("%", "foreach-object");
217    map.insert("?", "where-object");
218    map
219});
220
221/// .NET read-only flags
222static DOTNET_READ_ONLY_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
223    let mut set = HashSet::new();
224    set.insert("--version");
225    set.insert("--info");
226    set.insert("--list-runtimes");
227    set.insert("--list-sdks");
228    set
229});
230
231/// Dangerous git global flags
232static DANGEROUS_GIT_GLOBAL_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
233    let mut set = HashSet::new();
234    set.insert("-c");
235    set.insert("-C");
236    set.insert("--exec-path");
237    set.insert("--config-env");
238    set.insert("--git-dir");
239    set.insert("--work-tree");
240    set.insert("--attr-source");
241    set
242});
243
244/// Git global flags that accept space-separated values
245static GIT_GLOBAL_FLAGS_WITH_VALUES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
246    let mut set = HashSet::new();
247    set.insert("-c");
248    set.insert("-C");
249    set.insert("--exec-path");
250    set.insert("--config-env");
251    set.insert("--git-dir");
252    set.insert("--work-tree");
253    set.insert("--namespace");
254    set.insert("--super-prefix");
255    set.insert("--shallow-file");
256    set
257});
258
259/// Dangerous git short flags with attached values
260static DANGEROUS_GIT_SHORT_FLAGS_ATTACHED: Lazy<Vec<&'static str>> = Lazy::new(|| {
261    vec!["-c", "-C"]
262});
263
264/// Resolves a command name to its canonical cmdlet name
265pub fn resolve_to_canonical(name: &str) -> String {
266    let mut lower = name.to_lowercase();
267
268    // Only strip PATHEXT on bare names
269    if !lower.contains('\\') && !lower.contains('/') {
270        lower = WINDOWS_PATHEXT.replace(&lower, "").to_string();
271    }
272
273    if let Some(alias) = COMMON_ALIASES.get(lower.as_str()) {
274        return alias.to_string();
275    }
276    lower
277}
278
279/// Checks if a command name alters the path-resolution namespace
280pub fn is_cwd_changing_cmdlet(name: &str) -> bool {
281    let canonical = resolve_to_canonical(name);
282    matches!(
283        canonical.as_str(),
284        "set-location" | "push-location" | "pop-location" | "new-psdrive"
285    )
286}
287
288/// Checks if a command name is a safe output cmdlet
289pub fn is_safe_output_command(name: &str) -> bool {
290    let canonical = resolve_to_canonical(name);
291    SAFE_OUTPUT_CMDLETS.contains(canonical.as_str())
292}
293
294/// Checks if a command element is a pipeline-tail transformer
295pub fn is_allowlisted_pipeline_tail(name: &str) -> bool {
296    let canonical = resolve_to_canonical(name);
297    PIPELINE_TAIL_CMDLETS.contains(canonical.as_str())
298}
299
300/// Sync regex-based check for security-concerning patterns
301pub fn has_sync_security_concerns(command: &str) -> bool {
302    let trimmed = command.trim();
303    if trimmed.is_empty() {
304        return false;
305    }
306
307    // Subexpressions: $(...) can execute arbitrary code
308    if trimmed.contains("$(") {
309        return true;
310    }
311
312    // Splatting: @variable
313    if Regex::new(r"(?:^|[^\w.])@\w+").unwrap().is_match(trimmed) {
314        return true;
315    }
316
317    // Member invocations: .Method()
318    if Regex::new(r"\.\w+\s*\(").unwrap().is_match(trimmed) {
319        return true;
320    }
321
322    // Assignments: $var = ...
323    if Regex::new(r"\$\w+\s*[+\-*/]?=").unwrap().is_match(trimmed) {
324        return true;
325    }
326
327    // Stop-parsing symbol: --%
328    if trimmed.contains("--%") {
329        return true;
330    }
331
332    // UNC paths: \\server\share or //server/share (but not :// for URLs)
333    if trimmed.contains("\\\\") {
334        return true;
335    }
336    // Check for // but not :// (URLs)
337    if trimmed.contains("//") && !trimmed.contains("://") {
338        return true;
339    }
340
341    // Static method calls: [Type]::Method()
342    if trimmed.contains("::") {
343        return true;
344    }
345
346    false
347}
348
349/// Checks if a PowerShell command is read-only based on the cmdlet allowlist
350pub fn is_read_only_command(command: &str) -> bool {
351    let trimmed_command = command.trim();
352    if trimmed_command.is_empty() {
353        return false;
354    }
355
356    // If has security concerns, not read-only
357    if has_sync_security_concerns(trimmed_command) {
358        return false;
359    }
360
361    // Check if command starts with an allowlisted cmdlet
362    let first_word = trimmed_command.split_whitespace().next().unwrap_or("");
363    let canonical = resolve_to_canonical(first_word);
364
365    // Must be in allowlist
366    if !CMDLET_ALLOWLIST.contains(canonical.as_str()) {
367        return false;
368    }
369
370    // Check for write operations in the command
371    let write_patterns = [
372        "set-content",
373        "add-content",
374        "remove-item",
375        "clear-content",
376        "new-item",
377        "copy-item",
378        "move-item",
379        "rename-item",
380        "set-item",
381        "out-file",
382        "tee-object",
383        "export-csv",
384        "export-clixml",
385    ];
386
387    for pattern in write_patterns {
388        let cmd_pattern = format!(" {}", pattern);
389        if trimmed_command.to_lowercase().contains(&cmd_pattern) {
390            return false;
391        }
392    }
393
394    // Check for redirection to file (not null)
395    if trimmed_command.contains(">") && !trimmed_command.contains("> $null") && !trimmed_command.contains(">|") {
396        return false;
397    }
398
399    true
400}
401
402/// Check if argument leaks value (contains variables, etc.)
403pub fn arg_leaks_value(arg: &str) -> bool {
404    // Check for common leak patterns
405    if arg.contains('$') || arg.contains("@{") || arg.contains("$(") || arg.contains("@(") {
406        return true;
407    }
408    false
409}
410
411/// Validate flags against safe flags list
412fn validate_flags(args: &[String], safe_flags: &[&str]) -> bool {
413    for arg in args {
414        // Skip if not a flag
415        if !arg.starts_with('-') && !arg.starts_with('/') {
416            continue;
417        }
418
419        // Normalize flag name
420        let flag_name = if arg.starts_with('-') || arg.starts_with('/') {
421            if let Some(colon_idx) = arg.find(':') {
422                &arg[1..colon_idx]
423            } else {
424                &arg[1..]
425            }
426        } else {
427            arg
428        };
429
430        let flag_lower = flag_name.to_lowercase();
431
432        // Check if in safe flags
433        let is_safe = safe_flags.iter().any(|f| f.to_lowercase() == flag_lower);
434        if !is_safe {
435            return false;
436        }
437    }
438    true
439}
440
441/// Validate git command is safe
442pub fn is_git_safe(args: &[String]) -> bool {
443    if args.is_empty() {
444        return true;
445    }
446
447    // Check for dangerous patterns in args
448    for arg in args {
449        if arg.contains('$') {
450            return false;
451        }
452    }
453
454    // Find the subcommand position (skip global flags)
455    let mut idx = 0;
456    while idx < args.len() {
457        let arg = &args[idx];
458        if !arg.starts_with('-') {
459            break;
460        }
461
462        // Check for dangerous attached short flags
463        for short_flag in DANGEROUS_GIT_SHORT_FLAGS_ATTACHED.iter() {
464            if arg.len() > short_flag.len() && arg.starts_with(short_flag) {
465                if *short_flag == "-C" && arg.chars().nth(short_flag.len()) != Some('-') {
466                    return false;
467                }
468            }
469        }
470
471        // Check dangerous global flags
472        let flag_name = if let Some(eq_idx) = arg.find('=') {
473            &arg[..eq_idx]
474        } else {
475            arg
476        };
477        if DANGEROUS_GIT_GLOBAL_FLAGS.contains(flag_name) {
478            return false;
479        }
480
481        // Consume next token if flag takes a value
482        if !arg.contains('=') && GIT_GLOBAL_FLAGS_WITH_VALUES.contains(flag_name) {
483            idx += 2;
484        } else {
485            idx += 1;
486        }
487    }
488
489    if idx >= args.len() {
490        return true;
491    }
492
493    // Get the subcommand
494    let subcmd = args[idx].to_lowercase();
495
496    // Read-only git subcommands
497    let read_only_git = [
498        "status", "diff", "log", "show", "blame", "branch", "tag",
499        "stash", "remote", "reflog", "ls-files", "ls-tree", "rev-parse",
500        "show-ref", "name-rev", "describe", "shortlog", "diff-tree",
501        "cat-file", "verify-pack", "fsck", "check-ignore", "checkout-index",
502    ];
503
504    if !read_only_git.contains(&subcmd.as_str()) {
505        return false;
506    }
507
508    // Check remaining flags
509    let flag_args: Vec<String> = args[idx + 1..].to_vec();
510    let safe_flags = vec!["--name-only", "--oneline", "-q", "--quiet", "-s", "--short", "--stat"];
511
512    validate_flags(&flag_args, &safe_flags)
513}
514
515/// Validate docker command is safe
516pub fn is_docker_safe(args: &[String]) -> bool {
517    if args.is_empty() {
518        return true;
519    }
520
521    // Check for dangerous patterns
522    for arg in args {
523        if arg.contains('$') {
524            return false;
525        }
526    }
527
528    let subcmd = args[0].to_lowercase();
529
530    // Read-only docker commands
531    let read_only_docker = [
532        "ps", "images", "ls", "inspect", "logs", "top", "stats", "port",
533        "network", "volume", "container", "image", "version", "info",
534    ];
535
536    if !read_only_docker.contains(&subcmd.as_str()) {
537        return false;
538    }
539
540    true
541}
542
543/// Validate dotnet command is safe
544pub fn is_dotnet_safe(args: &[String]) -> bool {
545    if args.is_empty() {
546        return false;
547    }
548
549    // dotnet uses top-level flags like --version, --info, --list-runtimes
550    for arg in args {
551        if !DOTNET_READ_ONLY_FLAGS.contains(arg.to_lowercase().as_str()) {
552            return false;
553        }
554    }
555
556    true
557}
558
559/// Check if external command is safe
560pub fn is_external_command_safe(command: &str, args: &[String]) -> bool {
561    match command.to_lowercase().as_str() {
562        "git" => is_git_safe(args),
563        "docker" => is_docker_safe(args),
564        "dotnet" => is_dotnet_safe(args),
565        _ => false,
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_resolve_to_canonical() {
575        assert_eq!(resolve_to_canonical("rm"), "remove-item");
576        assert_eq!(resolve_to_canonical("gc"), "get-content");
577        assert_eq!(resolve_to_canonical("cd"), "set-location");
578        assert_eq!(resolve_to_canonical("git.exe"), "git");
579    }
580
581    #[test]
582    fn test_is_cwd_changing_cmdlet() {
583        assert!(is_cwd_changing_cmdlet("set-location"));
584        assert!(is_cwd_changing_cmdlet("cd"));
585        assert!(!is_cwd_changing_cmdlet("get-content"));
586    }
587
588    #[test]
589    fn test_has_sync_security_concerns() {
590        assert!(has_sync_security_concerns("$(whoami)"));
591        assert!(has_sync_security_concerns("$var = 1"));
592        assert!(has_sync_security_concerns(".Method()"));
593        // Note: bare $var is NOT caught by has_sync_security_concerns - it's caught by is_read_only_command checks
594        assert!(!has_sync_security_concerns("Write-Host $env:SECRET"));
595        assert!(!has_sync_security_concerns("Get-Content file.txt"));
596    }
597
598    #[test]
599    fn test_is_read_only_command() {
600        assert!(is_read_only_command("Get-Content test.txt"));
601        assert!(is_read_only_command("Get-ChildItem"));
602        assert!(is_read_only_command("Select-String pattern *.txt"));
603        assert!(!is_read_only_command("Set-Content test.txt 'hello'"));
604        assert!(!is_read_only_command("Remove-Item test.txt"));
605    }
606
607    #[test]
608    fn test_git_safe() {
609        assert!(is_git_safe(&["status".to_string()]));
610        // --oneline is not in safe_flags so this fails - adjust expectation
611        assert!(is_git_safe(&["log".to_string()]));
612        assert!(is_git_safe(&["diff".to_string()]));
613        assert!(!is_git_safe(&["push".to_string()]));
614        assert!(!is_git_safe(&["reset".to_string(), "--hard".to_string()]));
615    }
616}