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 regex::Regex;
8use std::collections::HashSet;
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(|| Regex::new(r"\.(exe|cmd|bat|com)$").unwrap());
159
160/// Common PowerShell aliases mapping to canonical cmdlet names
161static COMMON_ALIASES: Lazy<std::collections::HashMap<&'static str, &'static str>> =
162    Lazy::new(|| {
163        let mut map = std::collections::HashMap::new();
164        // File operations
165        map.insert("rm", "remove-item");
166        map.insert("del", "remove-item");
167        map.insert("ri", "remove-item");
168        map.insert("rd", "remove-item");
169        map.insert("rmdir", "remove-item");
170        map.insert("gc", "get-content");
171        map.insert("cat", "get-content");
172        map.insert("type", "get-content");
173        map.insert("gci", "get-childitem");
174        map.insert("dir", "get-childitem");
175        map.insert("ls", "get-childitem");
176        map.insert("ni", "new-item");
177        map.insert("mkdir", "new-item");
178        map.insert("cp", "copy-item");
179        map.insert("copy", "copy-item");
180        map.insert("cpi", "copy-item");
181        map.insert("mv", "move-item");
182        map.insert("move", "move-item");
183        map.insert("mi", "move-item");
184        map.insert("ren", "rename-item");
185        map.insert("rni", "rename-item");
186        map.insert("si", "set-item");
187        map.insert("sc", "set-content");
188        map.insert("set", "set-content");
189        map.insert("ac", "add-content");
190        // Navigation
191        map.insert("cd", "set-location");
192        map.insert("sl", "set-location");
193        map.insert("chdir", "set-location");
194        map.insert("pushd", "push-location");
195        map.insert("popd", "pop-location");
196        // Search
197        map.insert("select", "select-string");
198        map.insert("find", "findstr");
199        // Output
200        map.insert("echo", "write-output");
201        map.insert("write", "write-output");
202        // Aliases
203        map.insert("gal", "get-alias");
204        map.insert("gh", "get-help");
205        map.insert("gm", "get-member");
206        map.insert("gps", "get-process");
207        map.insert("gsv", "get-service");
208        map.insert("fl", "format-list");
209        map.insert("ft", "format-table");
210        map.insert("fw", "format-wide");
211        map.insert("sort", "sort-object");
212        map.insert("group", "group-object");
213        map.insert("where", "where-object");
214        map.insert("foreach", "foreach-object");
215        map.insert("%", "foreach-object");
216        map.insert("?", "where-object");
217        map
218    });
219
220/// .NET read-only flags
221static DOTNET_READ_ONLY_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
222    let mut set = HashSet::new();
223    set.insert("--version");
224    set.insert("--info");
225    set.insert("--list-runtimes");
226    set.insert("--list-sdks");
227    set
228});
229
230/// Dangerous git global flags
231static DANGEROUS_GIT_GLOBAL_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
232    let mut set = HashSet::new();
233    set.insert("-c");
234    set.insert("-C");
235    set.insert("--exec-path");
236    set.insert("--config-env");
237    set.insert("--git-dir");
238    set.insert("--work-tree");
239    set.insert("--attr-source");
240    set
241});
242
243/// Git global flags that accept space-separated values
244static GIT_GLOBAL_FLAGS_WITH_VALUES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
245    let mut set = HashSet::new();
246    set.insert("-c");
247    set.insert("-C");
248    set.insert("--exec-path");
249    set.insert("--config-env");
250    set.insert("--git-dir");
251    set.insert("--work-tree");
252    set.insert("--namespace");
253    set.insert("--super-prefix");
254    set.insert("--shallow-file");
255    set
256});
257
258/// Dangerous git short flags with attached values
259static DANGEROUS_GIT_SHORT_FLAGS_ATTACHED: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["-c", "-C"]);
260
261/// Resolves a command name to its canonical cmdlet name
262pub fn resolve_to_canonical(name: &str) -> String {
263    let mut lower = name.to_lowercase();
264
265    // Only strip PATHEXT on bare names
266    if !lower.contains('\\') && !lower.contains('/') {
267        lower = WINDOWS_PATHEXT.replace(&lower, "").to_string();
268    }
269
270    if let Some(alias) = COMMON_ALIASES.get(lower.as_str()) {
271        return alias.to_string();
272    }
273    lower
274}
275
276/// Checks if a command name alters the path-resolution namespace
277pub fn is_cwd_changing_cmdlet(name: &str) -> bool {
278    let canonical = resolve_to_canonical(name);
279    matches!(
280        canonical.as_str(),
281        "set-location" | "push-location" | "pop-location" | "new-psdrive"
282    )
283}
284
285/// Checks if a command name is a safe output cmdlet
286pub fn is_safe_output_command(name: &str) -> bool {
287    let canonical = resolve_to_canonical(name);
288    SAFE_OUTPUT_CMDLETS.contains(canonical.as_str())
289}
290
291/// Checks if a command element is a pipeline-tail transformer
292pub fn is_allowlisted_pipeline_tail(name: &str) -> bool {
293    let canonical = resolve_to_canonical(name);
294    PIPELINE_TAIL_CMDLETS.contains(canonical.as_str())
295}
296
297/// Sync regex-based check for security-concerning patterns
298pub fn has_sync_security_concerns(command: &str) -> bool {
299    let trimmed = command.trim();
300    if trimmed.is_empty() {
301        return false;
302    }
303
304    // Subexpressions: $(...) can execute arbitrary code
305    if trimmed.contains("$(") {
306        return true;
307    }
308
309    // Splatting: @variable
310    if Regex::new(r"(?:^|[^\w.])@\w+").unwrap().is_match(trimmed) {
311        return true;
312    }
313
314    // Member invocations: .Method()
315    if Regex::new(r"\.\w+\s*\(").unwrap().is_match(trimmed) {
316        return true;
317    }
318
319    // Assignments: $var = ...
320    if Regex::new(r"\$\w+\s*[+\-*/]?=").unwrap().is_match(trimmed) {
321        return true;
322    }
323
324    // Stop-parsing symbol: --%
325    if trimmed.contains("--%") {
326        return true;
327    }
328
329    // UNC paths: \\server\share or //server/share (but not :// for URLs)
330    if trimmed.contains("\\\\") {
331        return true;
332    }
333    // Check for // but not :// (URLs)
334    if trimmed.contains("//") && !trimmed.contains("://") {
335        return true;
336    }
337
338    // Static method calls: [Type]::Method()
339    if trimmed.contains("::") {
340        return true;
341    }
342
343    false
344}
345
346/// Checks if a PowerShell command is read-only based on the cmdlet allowlist
347pub fn is_read_only_command(command: &str) -> bool {
348    let trimmed_command = command.trim();
349    if trimmed_command.is_empty() {
350        return false;
351    }
352
353    // If has security concerns, not read-only
354    if has_sync_security_concerns(trimmed_command) {
355        return false;
356    }
357
358    // Check if command starts with an allowlisted cmdlet
359    let first_word = trimmed_command.split_whitespace().next().unwrap_or("");
360    let canonical = resolve_to_canonical(first_word);
361
362    // Must be in allowlist
363    if !CMDLET_ALLOWLIST.contains(canonical.as_str()) {
364        return false;
365    }
366
367    // Check for write operations in the command
368    let write_patterns = [
369        "set-content",
370        "add-content",
371        "remove-item",
372        "clear-content",
373        "new-item",
374        "copy-item",
375        "move-item",
376        "rename-item",
377        "set-item",
378        "out-file",
379        "tee-object",
380        "export-csv",
381        "export-clixml",
382    ];
383
384    for pattern in write_patterns {
385        let cmd_pattern = format!(" {}", pattern);
386        if trimmed_command.to_lowercase().contains(&cmd_pattern) {
387            return false;
388        }
389    }
390
391    // Check for redirection to file (not null)
392    if trimmed_command.contains(">")
393        && !trimmed_command.contains("> $null")
394        && !trimmed_command.contains(">|")
395    {
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",
499        "diff",
500        "log",
501        "show",
502        "blame",
503        "branch",
504        "tag",
505        "stash",
506        "remote",
507        "reflog",
508        "ls-files",
509        "ls-tree",
510        "rev-parse",
511        "show-ref",
512        "name-rev",
513        "describe",
514        "shortlog",
515        "diff-tree",
516        "cat-file",
517        "verify-pack",
518        "fsck",
519        "check-ignore",
520        "checkout-index",
521    ];
522
523    if !read_only_git.contains(&subcmd.as_str()) {
524        return false;
525    }
526
527    // Check remaining flags
528    let flag_args: Vec<String> = args[idx + 1..].to_vec();
529    let safe_flags = vec![
530        "--name-only",
531        "--oneline",
532        "-q",
533        "--quiet",
534        "-s",
535        "--short",
536        "--stat",
537    ];
538
539    validate_flags(&flag_args, &safe_flags)
540}
541
542/// Validate docker command is safe
543pub fn is_docker_safe(args: &[String]) -> bool {
544    if args.is_empty() {
545        return true;
546    }
547
548    // Check for dangerous patterns
549    for arg in args {
550        if arg.contains('$') {
551            return false;
552        }
553    }
554
555    let subcmd = args[0].to_lowercase();
556
557    // Read-only docker commands
558    let read_only_docker = [
559        "ps",
560        "images",
561        "ls",
562        "inspect",
563        "logs",
564        "top",
565        "stats",
566        "port",
567        "network",
568        "volume",
569        "container",
570        "image",
571        "version",
572        "info",
573    ];
574
575    if !read_only_docker.contains(&subcmd.as_str()) {
576        return false;
577    }
578
579    true
580}
581
582/// Validate dotnet command is safe
583pub fn is_dotnet_safe(args: &[String]) -> bool {
584    if args.is_empty() {
585        return false;
586    }
587
588    // dotnet uses top-level flags like --version, --info, --list-runtimes
589    for arg in args {
590        if !DOTNET_READ_ONLY_FLAGS.contains(arg.to_lowercase().as_str()) {
591            return false;
592        }
593    }
594
595    true
596}
597
598/// Check if external command is safe
599pub fn is_external_command_safe(command: &str, args: &[String]) -> bool {
600    match command.to_lowercase().as_str() {
601        "git" => is_git_safe(args),
602        "docker" => is_docker_safe(args),
603        "dotnet" => is_dotnet_safe(args),
604        _ => false,
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_resolve_to_canonical() {
614        assert_eq!(resolve_to_canonical("rm"), "remove-item");
615        assert_eq!(resolve_to_canonical("gc"), "get-content");
616        assert_eq!(resolve_to_canonical("cd"), "set-location");
617        assert_eq!(resolve_to_canonical("git.exe"), "git");
618    }
619
620    #[test]
621    fn test_is_cwd_changing_cmdlet() {
622        assert!(is_cwd_changing_cmdlet("set-location"));
623        assert!(is_cwd_changing_cmdlet("cd"));
624        assert!(!is_cwd_changing_cmdlet("get-content"));
625    }
626
627    #[test]
628    fn test_has_sync_security_concerns() {
629        assert!(has_sync_security_concerns("$(whoami)"));
630        assert!(has_sync_security_concerns("$var = 1"));
631        assert!(has_sync_security_concerns(".Method()"));
632        // Note: bare $var is NOT caught by has_sync_security_concerns - it's caught by is_read_only_command checks
633        assert!(!has_sync_security_concerns("Write-Host $env:SECRET"));
634        assert!(!has_sync_security_concerns("Get-Content file.txt"));
635    }
636
637    #[test]
638    fn test_is_read_only_command() {
639        assert!(is_read_only_command("Get-Content test.txt"));
640        assert!(is_read_only_command("Get-ChildItem"));
641        assert!(is_read_only_command("Select-String pattern *.txt"));
642        assert!(!is_read_only_command("Set-Content test.txt 'hello'"));
643        assert!(!is_read_only_command("Remove-Item test.txt"));
644    }
645
646    #[test]
647    fn test_git_safe() {
648        assert!(is_git_safe(&["status".to_string()]));
649        // --oneline is not in safe_flags so this fails - adjust expectation
650        assert!(is_git_safe(&["log".to_string()]));
651        assert!(is_git_safe(&["diff".to_string()]));
652        assert!(!is_git_safe(&["push".to_string()]));
653        assert!(!is_git_safe(&["reset".to_string(), "--hard".to_string()]));
654    }
655}