ai_agent/tools/powershell/
read_only_validation.rs1use once_cell::sync::Lazy;
7use regex::Regex;
8use std::collections::HashSet;
9
10#[derive(Debug, Clone, Default)]
12pub struct CommandConfig {
13 pub safe_flags: Option<Vec<String>>,
15 pub allow_all_flags: bool,
17 pub regex: Option<Regex>,
19}
20
21static CMDLET_ALLOWLIST: Lazy<HashSet<&'static str>> = Lazy::new(|| {
23 let mut set = HashSet::new();
24 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 set.insert("set-location");
35 set.insert("push-location");
36 set.insert("pop-location");
37 set.insert("select-string");
39 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 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 set.insert("convert-path");
55 set.insert("join-path");
56 set.insert("split-path");
57 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 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 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 set.insert("get-eventlog");
99 set.insert("get-winevent");
100 set.insert("get-cimclass");
102 set.insert("git");
104 set.insert("gh");
105 set.insert("docker");
106 set.insert("dotnet");
107 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 set.insert("file");
121 set.insert("tree");
122 set.insert("findstr");
123 set
124});
125
126static SAFE_OUTPUT_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
128 let mut set = HashSet::new();
129 set.insert("out-null");
130 set
131});
132
133static 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
150static SAFE_EXTERNAL_EXES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
152 let mut set = HashSet::new();
153 set.insert("where.exe");
154 set
155});
156
157static WINDOWS_PATHEXT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.(exe|cmd|bat|com)$").unwrap());
159
160static COMMON_ALIASES: Lazy<std::collections::HashMap<&'static str, &'static str>> =
162 Lazy::new(|| {
163 let mut map = std::collections::HashMap::new();
164 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 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 map.insert("select", "select-string");
198 map.insert("find", "findstr");
199 map.insert("echo", "write-output");
201 map.insert("write", "write-output");
202 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
220static 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
230static 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
243static 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
258static DANGEROUS_GIT_SHORT_FLAGS_ATTACHED: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["-c", "-C"]);
260
261pub fn resolve_to_canonical(name: &str) -> String {
263 let mut lower = name.to_lowercase();
264
265 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
276pub 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
285pub 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
291pub 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
297pub fn has_sync_security_concerns(command: &str) -> bool {
299 let trimmed = command.trim();
300 if trimmed.is_empty() {
301 return false;
302 }
303
304 if trimmed.contains("$(") {
306 return true;
307 }
308
309 if Regex::new(r"(?:^|[^\w.])@\w+").unwrap().is_match(trimmed) {
311 return true;
312 }
313
314 if Regex::new(r"\.\w+\s*\(").unwrap().is_match(trimmed) {
316 return true;
317 }
318
319 if Regex::new(r"\$\w+\s*[+\-*/]?=").unwrap().is_match(trimmed) {
321 return true;
322 }
323
324 if trimmed.contains("--%") {
326 return true;
327 }
328
329 if trimmed.contains("\\\\") {
331 return true;
332 }
333 if trimmed.contains("//") && !trimmed.contains("://") {
335 return true;
336 }
337
338 if trimmed.contains("::") {
340 return true;
341 }
342
343 false
344}
345
346pub 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_sync_security_concerns(trimmed_command) {
355 return false;
356 }
357
358 let first_word = trimmed_command.split_whitespace().next().unwrap_or("");
360 let canonical = resolve_to_canonical(first_word);
361
362 if !CMDLET_ALLOWLIST.contains(canonical.as_str()) {
364 return false;
365 }
366
367 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 if trimmed_command.contains(">")
393 && !trimmed_command.contains("> $null")
394 && !trimmed_command.contains(">|")
395 {
396 return false;
397 }
398
399 true
400}
401
402pub fn arg_leaks_value(arg: &str) -> bool {
404 if arg.contains('$') || arg.contains("@{") || arg.contains("$(") || arg.contains("@(") {
406 return true;
407 }
408 false
409}
410
411fn validate_flags(args: &[String], safe_flags: &[&str]) -> bool {
413 for arg in args {
414 if !arg.starts_with('-') && !arg.starts_with('/') {
416 continue;
417 }
418
419 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 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
441pub fn is_git_safe(args: &[String]) -> bool {
443 if args.is_empty() {
444 return true;
445 }
446
447 for arg in args {
449 if arg.contains('$') {
450 return false;
451 }
452 }
453
454 let mut idx = 0;
456 while idx < args.len() {
457 let arg = &args[idx];
458 if !arg.starts_with('-') {
459 break;
460 }
461
462 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 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 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 let subcmd = args[idx].to_lowercase();
495
496 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 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
542pub fn is_docker_safe(args: &[String]) -> bool {
544 if args.is_empty() {
545 return true;
546 }
547
548 for arg in args {
550 if arg.contains('$') {
551 return false;
552 }
553 }
554
555 let subcmd = args[0].to_lowercase();
556
557 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
582pub fn is_dotnet_safe(args: &[String]) -> bool {
584 if args.is_empty() {
585 return false;
586 }
587
588 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
598pub 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 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 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}