ai_agent/tools/powershell/
read_only_validation.rs1use once_cell::sync::Lazy;
7use std::collections::HashSet;
8use regex::Regex;
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(|| {
159 Regex::new(r"\.(exe|cmd|bat|com)$").unwrap()
160});
161
162static COMMON_ALIASES: Lazy<std::collections::HashMap<&'static str, &'static str>> = Lazy::new(|| {
164 let mut map = std::collections::HashMap::new();
165 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 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 map.insert("select", "select-string");
199 map.insert("find", "findstr");
200 map.insert("echo", "write-output");
202 map.insert("write", "write-output");
203 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
221static 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
231static 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
244static 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
259static DANGEROUS_GIT_SHORT_FLAGS_ATTACHED: Lazy<Vec<&'static str>> = Lazy::new(|| {
261 vec!["-c", "-C"]
262});
263
264pub fn resolve_to_canonical(name: &str) -> String {
266 let mut lower = name.to_lowercase();
267
268 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
279pub 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
288pub 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
294pub 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
300pub fn has_sync_security_concerns(command: &str) -> bool {
302 let trimmed = command.trim();
303 if trimmed.is_empty() {
304 return false;
305 }
306
307 if trimmed.contains("$(") {
309 return true;
310 }
311
312 if Regex::new(r"(?:^|[^\w.])@\w+").unwrap().is_match(trimmed) {
314 return true;
315 }
316
317 if Regex::new(r"\.\w+\s*\(").unwrap().is_match(trimmed) {
319 return true;
320 }
321
322 if Regex::new(r"\$\w+\s*[+\-*/]?=").unwrap().is_match(trimmed) {
324 return true;
325 }
326
327 if trimmed.contains("--%") {
329 return true;
330 }
331
332 if trimmed.contains("\\\\") {
334 return true;
335 }
336 if trimmed.contains("//") && !trimmed.contains("://") {
338 return true;
339 }
340
341 if trimmed.contains("::") {
343 return true;
344 }
345
346 false
347}
348
349pub 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_sync_security_concerns(trimmed_command) {
358 return false;
359 }
360
361 let first_word = trimmed_command.split_whitespace().next().unwrap_or("");
363 let canonical = resolve_to_canonical(first_word);
364
365 if !CMDLET_ALLOWLIST.contains(canonical.as_str()) {
367 return false;
368 }
369
370 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 if trimmed_command.contains(">") && !trimmed_command.contains("> $null") && !trimmed_command.contains(">|") {
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", "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 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
515pub fn is_docker_safe(args: &[String]) -> bool {
517 if args.is_empty() {
518 return true;
519 }
520
521 for arg in args {
523 if arg.contains('$') {
524 return false;
525 }
526 }
527
528 let subcmd = args[0].to_lowercase();
529
530 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
543pub fn is_dotnet_safe(args: &[String]) -> bool {
545 if args.is_empty() {
546 return false;
547 }
548
549 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
559pub 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 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 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}