1use std::env;
2use std::path::{Path, PathBuf};
3use tokio::fs;
4
5use anyhow::{Context, Result, anyhow};
6use vtcode_commons::paths::normalize_path;
7
8fn validate_allowed_flags(args: &[String], allowed_flags: &[&str], command_name: &str) -> Result<()> {
11 for arg in args {
12 if arg.starts_with('-') && !allowed_flags.contains(&arg.as_str()) {
13 return Err(anyhow!("unsupported {command_name} flag '{arg}'"));
14 }
15 }
16 Ok(())
17}
18
19fn validate_no_args(args: &[String], command_name: &str) -> Result<()> {
21 if args.is_empty() {
22 Ok(())
23 } else {
24 Err(anyhow!("{command_name} does not accept arguments"))
25 }
26}
27
28pub async fn validate_command(
34 command: &[String],
35 workspace_root: &Path,
36 working_dir: &Path,
37 confirm: bool,
38) -> Result<()> {
39 if command.is_empty() {
40 return Err(anyhow!("command cannot be empty"));
41 }
42
43 let (program, args) = command
44 .split_first()
45 .ok_or_else(|| anyhow!("command cannot be empty (unexpected)"))?;
46 let program = program.as_str();
47
48 match program {
49 "echo" => validate_echo(args),
50 "ls" => validate_ls(args, workspace_root, working_dir).await,
51 "cat" => validate_cat(args, workspace_root, working_dir).await,
52 "cp" => validate_cp(args, workspace_root, working_dir).await,
53 "head" => validate_head(args, workspace_root, working_dir).await,
54 "tail" => validate_tail(args, workspace_root, working_dir).await,
55 "printenv" => validate_printenv(args),
56 "pwd" => validate_pwd(args),
57 "rg" => validate_rg(args, workspace_root, working_dir).await,
58 "grep" => validate_grep(args, workspace_root, working_dir).await,
59 "sed" => validate_sed(args, workspace_root, working_dir).await,
60 "which" => validate_which(args),
61 "date" => validate_date(args),
62 "whoami" => validate_whoami(args),
63 "hostname" => validate_hostname(args),
64 "uname" => validate_uname(args),
65 "wc" => validate_wc(args, workspace_root, working_dir).await,
66 "git" => validate_git(args, workspace_root, working_dir, confirm).await,
67 "cargo" => validate_cargo(args, workspace_root, working_dir, confirm).await,
68 "python" | "python3" => validate_python(args, workspace_root, working_dir).await,
69 "npm" => validate_npm(args, workspace_root, working_dir).await,
70 "node" => validate_node(args, workspace_root, working_dir).await,
71 other => Err(anyhow!("command '{other}' is not permitted by the execution policy")),
72 }
73}
74
75pub async fn sanitize_working_dir(workspace_root: &Path, working_dir: Option<&str>) -> Result<PathBuf> {
77 let normalized_root = normalize_workspace_root(workspace_root)?;
78 if let Some(dir) = working_dir {
79 if dir.trim().is_empty() {
80 return Ok(normalized_root);
81 }
82 let candidate = normalize_path(&normalized_root.join(dir));
83 if !candidate.starts_with(&normalized_root) {
84 return Err(anyhow!("working directory '{dir}' escapes the workspace root"));
85 }
86 ensure_within_workspace(&normalized_root, &candidate).await?;
87 Ok(candidate)
88 } else {
89 Ok(normalized_root)
90 }
91}
92
93fn validate_echo(args: &[String]) -> Result<()> {
94 validate_allowed_flags(args, &["-n", "-e", "-E"], "echo")
95}
96
97async fn validate_ls(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
98 let allowed_ls_flags = &["-1", "-a", "-l"];
99 for arg in args {
100 if arg.starts_with('-') {
101 if !allowed_ls_flags.contains(&arg.as_str()) {
102 return Err(anyhow!("unsupported ls flag '{arg}'"));
103 }
104 } else {
105 let path = resolve_path(workspace_root, working_dir, arg).await?;
106 ensure_path_exists(&path)?;
107 }
108 }
109 Ok(())
110}
111
112async fn validate_cat(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
113 let allowed_cat_flags = &["-b", "-n", "-t"];
114 let mut files = Vec::with_capacity(args.len());
115
116 for arg in args {
117 if arg.starts_with('-') {
118 if !allowed_cat_flags.contains(&arg.as_str()) {
119 return Err(anyhow!("unsupported cat flag '{arg}'"));
120 }
121 } else {
122 let path = resolve_path(workspace_root, working_dir, arg).await?;
123 ensure_is_file(&path).await?;
124 files.push(path);
125 }
126 }
127
128 if files.is_empty() {
129 return Err(anyhow!("cat requires at least one readable file"));
130 }
131
132 Ok(())
133}
134
135async fn validate_cp(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
136 let mut positional = Vec::new();
137 let mut allow_recursive = false;
138
139 for arg in args {
140 match arg.as_str() {
141 "-r" | "-R" | "--recursive" => {
142 allow_recursive = true;
143 }
144 value if value.starts_with('-') => {
145 return Err(anyhow!("unsupported cp flag '{value}'"));
146 }
147 value => positional.push(value.to_owned()),
148 }
149 }
150
151 if positional.len() < 2 {
152 return Err(anyhow!("cp requires a source and destination"));
153 }
154
155 let dest_raw = positional
156 .last()
157 .ok_or_else(|| anyhow!("cp command missing destination path"))?;
158 let sources = &positional[..positional.len() - 1];
159
160 for source in sources {
161 let path = resolve_path(workspace_root, working_dir, source).await?;
162 let metadata = fs::metadata(&path)
163 .await
164 .with_context(|| format!("failed to inspect source '{source}'"))?;
165 if metadata.is_dir() && !allow_recursive {
166 return Err(anyhow!("copying directories requires the recursive flag for '{source}'"));
167 }
168 if !metadata.is_file() && !metadata.is_dir() {
169 return Err(anyhow!("unsupported source type for '{source}'"));
170 }
171 }
172
173 let dest_path = resolve_path_allow_new(workspace_root, working_dir, dest_raw).await?;
174 if let Some(parent) = dest_path.parent()
175 && !fs::try_exists(parent).await.unwrap_or(false)
176 {
177 return Err(anyhow!("destination parent '{}' must exist", parent.display()));
178 }
179
180 Ok(())
181}
182
183async fn validate_head(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
184 let mut positional = Vec::new();
185 let mut index = 0;
186
187 while index < args.len() {
188 let current = &args[index];
189 match current.as_str() {
190 "-c" | "-n" => {
191 let value = args
192 .get(index + 1)
193 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
194 parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
195 index += 2;
196 }
197 value if value.starts_with('-') => {
198 return Err(anyhow!("unsupported head flag '{value}'"));
199 }
200 value => {
201 positional.push(value);
202 index += 1;
203 }
204 }
205 }
206
207 if positional.is_empty() {
208 return Err(anyhow!("head requires at least one file"));
209 }
210
211 for file in positional {
212 let path = resolve_path(workspace_root, working_dir, file).await?;
213 ensure_is_file(&path).await?;
214 }
215
216 Ok(())
217}
218
219fn validate_printenv(args: &[String]) -> Result<()> {
220 match args.len() {
221 0 => Ok(()),
222 1 => {
223 let name = &args[0];
224 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
225 return Err(anyhow!("invalid environment variable name '{name}'"));
226 }
227 Ok(())
228 }
229 _ => Err(anyhow!("printenv accepts zero or one argument")),
230 }
231}
232
233fn validate_pwd(args: &[String]) -> Result<()> {
234 validate_no_args(args, "pwd")
235}
236
237async fn validate_rg(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
238 let mut index = 0;
239 let mut allow_no_pattern = false;
240
241 while index < args.len() {
242 let current = &args[index];
243 if current == "--" {
244 index += 1;
245 break;
246 }
247
248 match current.as_str() {
249 "--pre" | "--pre-glob" => {
251 return Err(anyhow!(
252 "ripgrep preprocessor flag '{current}' is not permitted for security reasons. \
253 This flag enables arbitrary command execution."
254 ));
255 }
256 "-A" | "-B" | "-C" | "-d" | "--max-depth" | "-m" | "--max-count" => {
257 let value = args
258 .get(index + 1)
259 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
260 parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
261 index += 2;
262 }
263 "-g" | "--glob" => {
264 let value = args
265 .get(index + 1)
266 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
267 if value.is_empty() {
268 return Err(anyhow!("glob value for '{current}' cannot be empty"));
269 }
270 index += 2;
271 }
272 "-n" | "-i" | "-l" | "--files" | "--files-with-matches" | "--files-without-match" => {
273 if matches!(current.as_str(), "--files" | "--files-with-matches" | "--files-without-match") {
274 allow_no_pattern = true;
275 }
276 index += 1;
277 }
278 value if value.starts_with('-') => {
279 return Err(anyhow!("unsupported ripgrep flag '{value}'"));
280 }
281 _ => break,
282 }
283 }
284
285 let remaining = &args[index..];
286 if remaining.is_empty() && !allow_no_pattern {
287 return Err(anyhow!("ripgrep requires a pattern unless file listing flags are used"));
288 }
289
290 let mut rem_index = 0;
291 if !remaining.is_empty() {
292 let pattern = &remaining[0];
293 if pattern.is_empty() {
294 return Err(anyhow!("ripgrep pattern cannot be empty"));
295 }
296 rem_index = 1;
297 }
298
299 if remaining.len() > rem_index {
300 let search_root = &remaining[rem_index];
301 let path = resolve_path_allow_dir(workspace_root, working_dir, search_root).await?;
302 if !fs::try_exists(&path).await.unwrap_or(false) {
303 return Err(anyhow!("search path '{search_root}' does not exist"));
304 }
305 if remaining.len() > rem_index + 1 {
306 return Err(anyhow!("ripgrep accepts at most one search path"));
307 }
308 }
309
310 Ok(())
311}
312
313async fn validate_sed(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
314 let mut commands = Vec::new();
315 let mut files = Vec::new();
316 let mut index = 0;
317
318 while index < args.len() {
319 let current = &args[index];
320 match current.as_str() {
321 "-n" | "-u" => {
322 index += 1;
323 }
324 "-e" => {
325 let value = args.get(index + 1).ok_or_else(|| anyhow!("-e requires a sed command"))?;
326 ensure_safe_sed_command(value)?;
327 commands.push(value.clone());
328 index += 2;
329 }
330 value if value.starts_with('-') => {
331 return Err(anyhow!("unsupported sed flag '{value}'"));
332 }
333 value => {
334 if commands.is_empty() {
335 ensure_safe_sed_command(value)?;
336 commands.push(value.to_owned());
337 index += 1;
338 } else {
339 let path = resolve_path(workspace_root, working_dir, value).await?;
340 ensure_is_file(&path).await?;
341 files.push(path);
342 index += 1;
343 }
344 }
345 }
346 }
347
348 if commands.is_empty() {
349 return Err(anyhow!("sed requires at least one command"));
350 }
351
352 if files.is_empty() {
353 return Err(anyhow!("sed requires at least one readable file"));
354 }
355
356 Ok(())
357}
358
359fn validate_which(args: &[String]) -> Result<()> {
360 if args.is_empty() {
361 return Err(anyhow!("which requires at least one program name"));
362 }
363
364 for arg in args {
365 match arg.as_str() {
366 "-a" | "-s" => continue,
367 value if value.starts_with('-') => {
368 return Err(anyhow!("unsupported which flag '{value}'"));
369 }
370 value => {
371 if value.is_empty() || value.contains('/') || value.chars().any(|ch| ch.is_whitespace()) {
372 return Err(anyhow!("program name '{value}' contains unsupported characters"));
373 }
374 }
375 }
376 }
377
378 Ok(())
379}
380
381async fn validate_git(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
382 if args.is_empty() {
383 return Err(anyhow!("git requires a subcommand"));
384 }
385
386 let subcommand = args[0].as_str();
387 let subargs = &args[1..];
388
389 match subcommand {
391 "status" | "log" | "show" | "diff" | "branch" | "tag" | "remote" => {
393 if subcommand == "tag" && !subargs.is_empty() && !subargs[0].starts_with('-') {
395 }
398 validate_git_read_only(subcommand, subargs)
399 }
400
401 "ls-tree" | "ls-files" | "cat-file" | "rev-parse" | "describe" => validate_git_read_only(subcommand, subargs),
403
404 "config" if subargs.is_empty() || subargs.iter().all(|a| !a.starts_with("--")) => {
406 validate_git_read_only(subcommand, subargs)
407 }
408
409 "blame" | "grep" | "shortlog" | "format-patch" => validate_git_read_only(subcommand, subargs),
411
412 "stash" if matches!(subargs.first().map(|s| s.as_str()), Some("list" | "show" | "pop" | "apply" | "drop")) => {
414 validate_git_stash(subargs)
415 }
416
417 "add" => validate_git_add(subargs, workspace_root, working_dir).await,
419 "commit" => validate_git_commit(subargs),
420 "reset" => validate_git_reset(subargs, confirm),
421 "checkout" | "switch" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
422 "restore" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
423 "merge" => validate_git_merge(subargs),
424
425 "push" => {
427 if subargs.iter().any(|a| a.contains("force") || a == "-f" || a == "--no-verify") {
429 Err(anyhow!("git push with force flags is not permitted. Use safe push operations only."))
430 } else {
431 validate_git_read_only(subcommand, subargs)
432 }
433 }
434
435 "force-push" => Err(anyhow!("git force-push is not permitted by the execution policy")),
436
437 "clean" => {
438 Err(anyhow!("git clean is not permitted by the execution policy. Use explicit rm commands instead."))
439 }
440
441 "gc" if subargs.iter().any(|a| a.contains("aggressive")) => {
442 Err(anyhow!("git gc with aggressive flag is not permitted"))
443 }
444
445 "filter-branch" | "rebase" | "cherry-pick" => {
446 Err(anyhow!("git {subcommand} is not permitted - complex history operations require confirmation"))
447 }
448
449 other => Err(anyhow!("git subcommand '{other}' is not permitted by the execution policy")),
450 }
451}
452
453fn validate_git_read_only(subcommand: &str, subargs: &[String]) -> Result<()> {
454 let dangerous_flags = ["-q", "--quiet", "--verbose", "-v"];
456
457 for arg in subargs {
458 if arg.starts_with("--") && arg.contains('=') {
459 let key = arg.split('=').next().unwrap_or("");
460 if key == "--format" {
461 continue;
463 }
464 }
465
466 if dangerous_flags.contains(&arg.as_str()) {
467 continue;
469 }
470
471 match subcommand {
473 "log" | "show" => {
474 if matches!(
475 arg.as_str(),
476 "-n" | "--oneline"
477 | "--graph"
478 | "--decorate"
479 | "--all"
480 | "--grep"
481 | "-S"
482 | "-p"
483 | "-U"
484 | "--stat"
485 | "--shortstat"
486 | "--name-status"
487 | "--name-only"
488 | "--author"
489 | "--since"
490 | "--until"
491 | "--date"
492 ) {
493 continue;
494 }
495 }
496 "diff" => {
497 if matches!(
498 arg.as_str(),
499 "-p" | "-U"
500 | "--stat"
501 | "--shortstat"
502 | "--name-status"
503 | "--name-only"
504 | "--no-index"
505 | "-w"
506 | "--ignore-all-space"
507 | "-b"
508 | "--ignore-space-change"
509 ) {
510 continue;
511 }
512 }
513 "branch" => {
514 if matches!(arg.as_str(), "-a" | "-r" | "-v" | "--verbose") {
515 continue;
516 }
517 }
518 _ => {
519 if !arg.starts_with('-') || arg.starts_with("--") {
521 continue;
522 }
523 }
524 }
525
526 if arg.contains(';') || arg.contains('|') || arg.contains('&') {
528 return Err(anyhow!("git argument contains suspicious shell metacharacters"));
529 }
530 }
531
532 Ok(())
533}
534
535async fn validate_git_add(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
536 if args.iter().any(|a| a == "-f" || a == "--force") {
538 return Err(anyhow!("git add --force is not permitted. Use regular add operations only."));
539 }
540
541 let mut index = 0;
543 while index < args.len() {
544 let arg = &args[index];
545 match arg.as_str() {
546 "-u" | "--update" | "-A" | "--all" | "." => {
547 index += 1;
549 }
550 "-p" | "--patch" | "-i" | "--interactive" => {
551 index += 1;
553 }
554 "-n" | "--dry-run" => {
555 index += 1;
556 }
557 value if value.starts_with('-') => {
558 return Err(anyhow!("unsupported git add flag '{value}'"));
559 }
560 path => {
561 let resolved = resolve_path(workspace_root, working_dir, path).await?;
563 ensure_within_workspace(workspace_root, &resolved).await?;
564 index += 1;
565 }
566 }
567 }
568
569 Ok(())
570}
571
572fn validate_git_commit(args: &[String]) -> Result<()> {
573 let mut index = 0;
574
575 while index < args.len() {
576 let arg = &args[index];
577 match arg.as_str() {
578 "-m" | "--message" => {
579 if index + 1 >= args.len() {
580 return Err(anyhow!("-m requires a commit message"));
581 }
582 index += 2;
583 }
584 "-F" | "--file" => {
585 if index + 1 >= args.len() {
586 return Err(anyhow!("-F requires a file path"));
587 }
588 index += 2;
589 }
590 "-a" | "--all" | "-p" | "--patch" | "--amend" | "--no-verify" | "-q" | "--quiet" => {
591 index += 1;
592 }
593 value if value.starts_with('-') => {
594 return Err(anyhow!("unsupported git commit flag '{value}'"));
595 }
596 _ => {
597 index += 1;
598 }
599 }
600 }
601
602 Ok(())
603}
604
605fn validate_git_reset(args: &[String], confirm: bool) -> Result<()> {
606 let is_destructive = args.iter().any(|a| a == "--hard" || a == "--merge" || a == "--keep");
608
609 if is_destructive && !confirm {
610 return Err(anyhow!(
611 "git reset with --hard, --merge, or --keep is potentially destructive. Set `confirm=true` to proceed."
612 ));
613 }
614
615 let safe_modes = ["--soft", "--mixed", "--unstage"];
617 let allowed_destructive: Vec<&str> = if confirm {
618 vec!["--hard", "--merge", "--keep"]
619 } else {
620 vec![]
621 };
622
623 for arg in args {
624 if arg.starts_with('-') {
625 let is_safe = safe_modes.iter().any(|m| arg.contains(m));
626 let is_allowed_destructive = allowed_destructive.iter().any(|m| arg.contains(m));
627 if !is_safe && !is_allowed_destructive {
628 match arg.as_str() {
629 "-q" | "--quiet" | "-p" | "--patch" => continue,
630 _ => {
631 return Err(anyhow!(
632 "unsupported git reset flag '{arg}'. Use --soft, --mixed, or --hard (with confirm) modes."
633 ));
634 }
635 }
636 }
637 }
638 }
639
640 Ok(())
641}
642
643async fn validate_git_checkout(
644 args: &[String],
645 workspace_root: &Path,
646 working_dir: &Path,
647 confirm: bool,
648) -> Result<()> {
649 if args.is_empty() {
650 return Ok(());
651 }
652
653 if args.iter().any(|a| a == "-f" || a == "--force") && !confirm {
655 return Err(anyhow!("git checkout --force is potentially destructive; set `confirm=true` to proceed."));
656 }
657
658 let mut paths_start = 0;
660 for (i, arg) in args.iter().enumerate() {
661 if arg == "--" {
662 paths_start = i + 1;
663 break;
664 }
665 if !arg.starts_with('-') {
666 paths_start = i;
668 break;
669 }
670 }
671
672 if paths_start > 0 {
673 for path_arg in &args[paths_start..] {
674 let resolved = resolve_path(workspace_root, working_dir, path_arg).await?;
676 ensure_within_workspace(workspace_root, &resolved).await?;
677 }
678 }
679
680 Ok(())
681}
682
683fn validate_git_stash(args: &[String]) -> Result<()> {
684 if args.is_empty() {
685 return Ok(());
686 }
687
688 let allowed_ops = ["list", "show", "pop", "apply", "drop", "clear", "create"];
689 let first = args[0].as_str();
690
691 if !allowed_ops.contains(&first) {
692 return Err(anyhow!("git stash operation '{first}' is not permitted"));
693 }
694
695 for arg in &args[1..] {
697 if arg.starts_with('-') {
698 match arg.as_str() {
699 "-q"
700 | "--quiet"
701 | "-p"
702 | "--patch"
703 | "-k"
704 | "--keep-index"
705 | "-u"
706 | "--include-untracked"
707 | "-a"
708 | "--all" => continue,
709 _ => return Err(anyhow!("unsupported git stash flag '{arg}'")),
710 }
711 }
712 }
713
714 Ok(())
715}
716
717fn validate_git_merge(args: &[String]) -> Result<()> {
718 if args.is_empty() {
720 return Err(anyhow!("git merge requires a branch"));
721 }
722
723 let dangerous_flags = ["--no-ff", "--squash"];
725 for arg in args {
726 if dangerous_flags.contains(&arg.as_str()) {
727 return Err(anyhow!("git merge with {arg} flag is not permitted; use simpler merge"));
728 }
729 }
730
731 Ok(())
732}
733
734async fn resolve_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
735 let base = build_candidate_path(workspace_root, working_dir, value).await?;
736 if !fs::try_exists(&base).await.unwrap_or(false) {
737 return Err(anyhow!("path '{value}' does not exist"));
738 }
739 if !base.starts_with(workspace_root) {
740 return Err(anyhow!("path '{value}' is outside the workspace root"));
741 }
742 Ok(base)
743}
744
745async fn resolve_path_allow_new(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
746 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
747 if !candidate.starts_with(workspace_root) {
748 return Err(anyhow!("path '{value}' is outside the workspace root"));
749 }
750 Ok(candidate)
751}
752
753async fn resolve_path_allow_dir(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
754 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
755 if !candidate.starts_with(workspace_root) {
756 return Err(anyhow!("path '{value}' is outside the workspace root"));
757 }
758 Ok(candidate)
759}
760
761async fn build_candidate_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
762 let normalized_root = normalize_workspace_root(workspace_root)?;
763 let normalized_working = normalize_path(working_dir);
764 let raw_path = Path::new(value);
765 let candidate = if raw_path.is_absolute() {
766 normalize_path(raw_path)
767 } else {
768 normalize_path(&normalized_working.join(raw_path))
769 };
770
771 if !candidate.starts_with(&normalized_root) {
772 return Err(anyhow!("path '{value}' escapes the workspace root"));
773 }
774 ensure_within_workspace(&normalized_root, &candidate).await?;
775 Ok(candidate)
776}
777
778fn normalize_workspace_root(workspace_root: &Path) -> Result<PathBuf> {
779 if workspace_root.is_absolute() {
780 return Ok(normalize_path(workspace_root));
781 }
782
783 let cwd = env::current_dir().context("failed to resolve current working directory")?;
784 Ok(normalize_path(&cwd.join(workspace_root)))
785}
786
787fn ensure_path_exists(path: &Path) -> Result<()> {
788 if path.exists() {
789 Ok(())
790 } else {
791 Err(anyhow!("path '{}' does not exist", path.display()))
792 }
793}
794
795async fn ensure_is_file(path: &Path) -> Result<()> {
796 let metadata = fs::metadata(path)
797 .await
798 .with_context(|| format!("failed to inspect '{}'", path.display()))?;
799 if metadata.is_file() {
800 Ok(())
801 } else {
802 Err(anyhow!("'{}' is not a file", path.display()))
803 }
804}
805
806fn parse_positive_int(value: &str) -> Result<u64> {
807 let parsed: u64 = value.parse()?;
808 if parsed == 0 {
809 return Err(anyhow!("value must be greater than zero"));
810 }
811 Ok(parsed)
812}
813
814fn ensure_safe_sed_command(value: &str) -> Result<()> {
815 if value.trim().is_empty() {
816 return Err(anyhow!("sed command cannot be empty"));
817 }
818 if value.contains([';', '|', '&', '`']) {
819 return Err(anyhow!("sed command contains unsupported control characters"));
820 }
821
822 let mut chars = value.chars();
823 if chars.next() != Some('s') {
824 return Err(anyhow!("only sed substitution commands are supported"));
825 }
826 let delimiter = chars.next().ok_or_else(|| anyhow!("sed substitution is missing a delimiter"))?;
827 if delimiter.is_ascii_alphanumeric() || delimiter.is_ascii_whitespace() {
828 return Err(anyhow!("invalid sed delimiter"));
829 }
830
831 let mut pattern = String::new();
832 let mut replacement = String::new();
833 let mut flags = String::new();
834
835 parse_sed_section(&mut chars, delimiter, &mut pattern)?;
836 parse_sed_section(&mut chars, delimiter, &mut replacement)?;
837 collect_sed_flags(chars, &mut flags)?;
838
839 if flags.chars().any(|ch| matches!(ch, 'e' | 'E' | 'F' | 'f')) {
840 return Err(anyhow!("sed execution flags are not permitted in substitution"));
841 }
842
843 Ok(())
844}
845
846async fn ensure_within_workspace(normalized_root: &Path, candidate: &Path) -> Result<()> {
847 vtcode_commons::paths::ensure_path_within_workspace_resolved(candidate, normalized_root)
848 .await
849 .map(|_| ())
850}
851
852fn parse_sed_section(chars: &mut std::str::Chars<'_>, delimiter: char, target: &mut String) -> Result<()> {
853 let mut escaped = false;
854 for ch in chars.by_ref() {
855 if escaped {
856 target.push(ch);
857 escaped = false;
858 continue;
859 }
860 match ch {
861 '\\' => {
862 escaped = true;
863 }
864 value if value == delimiter => {
865 return Ok(());
866 }
867 other => target.push(other),
868 }
869 }
870 Err(anyhow!("sed command is missing a closing delimiter"))
871}
872
873fn collect_sed_flags(chars: std::str::Chars<'_>, target: &mut String) -> Result<()> {
874 for ch in chars {
875 if ch.is_ascii_alphabetic() {
876 target.push(ch);
877 } else {
878 return Err(anyhow!("sed flags contain unsupported characters"));
879 }
880 }
881 Ok(())
882}
883
884async fn validate_tail(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
886 for arg in args {
888 if !arg.starts_with('-') {
889 let path = normalize_path(&working_dir.join(arg));
890 ensure_within_workspace(workspace_root, &path).await?;
891 }
892 }
893 Ok(())
894}
895
896async fn validate_grep(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
897 let mut pattern_seen = false;
899 for arg in args {
900 if !arg.starts_with('-') && pattern_seen {
901 let path = normalize_path(&working_dir.join(arg));
903 ensure_within_workspace(workspace_root, &path).await?;
904 } else if !arg.starts_with('-') {
905 pattern_seen = true;
906 }
907 }
908 Ok(())
909}
910
911fn validate_date(args: &[String]) -> Result<()> {
912 for arg in args {
914 if arg.starts_with('+') {
915 continue;
917 }
918 }
919 Ok(())
920}
921
922fn validate_whoami(_args: &[String]) -> Result<()> {
923 Ok(())
925}
926
927fn validate_hostname(_args: &[String]) -> Result<()> {
928 Ok(())
930}
931
932fn validate_uname(args: &[String]) -> Result<()> {
933 let safe_flags = ["-a", "-s", "-n", "-r", "-v", "-m"];
935 for arg in args {
936 if arg.starts_with('-') && !safe_flags.contains(&arg.as_str()) {
937 return Err(anyhow!("unsupported uname flag '{arg}'"));
938 }
939 }
940 Ok(())
941}
942
943async fn validate_wc(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
944 for arg in args {
946 if !arg.starts_with('-') {
947 let path = normalize_path(&working_dir.join(arg));
948 ensure_within_workspace(workspace_root, &path).await?;
949 }
950 }
951 Ok(())
952}
953
954async fn validate_cargo(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
955 if args.is_empty() {
957 return Err(anyhow!("cargo requires a subcommand"));
958 }
959
960 let subcommand = args[0].as_str();
961 match subcommand {
962 "build" | "check" | "test" | "doc" | "clippy" | "fmt" | "run" | "bench" | "expand" | "tree" | "metadata"
964 | "search" | "cache" => {
965 ensure_within_workspace(workspace_root, working_dir).await?;
967 Ok(())
968 }
969 "clean" | "install" | "uninstall" | "publish" | "yank" => {
971 if confirm {
972 ensure_within_workspace(workspace_root, working_dir).await?;
974 Ok(())
975 } else {
976 Err(anyhow!("cargo {subcommand} is potentially destructive; set `confirm=true` to proceed."))
977 }
978 }
979 other => Err(anyhow!("cargo subcommand '{other}' is not permitted by the execution policy")),
980 }
981}
982
983async fn validate_python(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
984 ensure_within_workspace(workspace_root, working_dir).await?;
986 if args.is_empty() {
987 return Ok(()); }
989
990 let first_arg = &args[0];
991
992 if first_arg == "-c" {
994 return Err(anyhow!(
995 "python -c is not permitted for security reasons: \
996 it enables arbitrary code execution outside the workspace sandbox"
997 ));
998 }
999
1000 if first_arg == "-e" || first_arg == "-exec" {
1002 return Err(anyhow!("python {first_arg} is not permitted: it enables arbitrary code execution"));
1003 }
1004
1005 if first_arg == "-m" || first_arg == "-W" {
1006 if first_arg != "-m" && args.len() > 1 {
1008 let path = normalize_path(&working_dir.join(&args[1]));
1009 ensure_within_workspace(workspace_root, &path).await?;
1010 }
1011 } else if !first_arg.starts_with('-') {
1012 let path = normalize_path(&working_dir.join(first_arg));
1014 ensure_within_workspace(workspace_root, &path).await?;
1015 }
1016 Ok(())
1017}
1018
1019async fn validate_npm(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1020 ensure_within_workspace(workspace_root, working_dir).await?;
1022 if args.is_empty() {
1023 return Ok(());
1024 }
1025
1026 let subcommand = args[0].as_str();
1027 match subcommand {
1028 "publish" | "unpublish" => Err(anyhow!("npm {subcommand} is not permitted by the execution policy")),
1030 _ => Ok(()),
1032 }
1033}
1034
1035async fn validate_node(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1036 ensure_within_workspace(workspace_root, working_dir).await?;
1038 if args.is_empty() {
1039 return Ok(()); }
1041
1042 let first_arg = &args[0];
1043
1044 if first_arg == "-e" || first_arg == "--eval" {
1046 return Err(anyhow!(
1047 "node {first_arg} is not permitted for security reasons: \
1048 it enables arbitrary code execution outside the workspace sandbox"
1049 ));
1050 }
1051
1052 if !first_arg.starts_with('-') {
1053 let path = normalize_path(&working_dir.join(first_arg));
1055 ensure_within_workspace(workspace_root, &path).await?;
1056 }
1057 Ok(())
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063
1064 #[test]
1065 fn test_validate_echo() {
1066 validate_echo(&[]).unwrap();
1067 validate_echo(&["hello".to_owned()]).unwrap();
1068 validate_echo(&["-n".to_owned(), "hello".to_owned()]).unwrap();
1069 validate_echo(&["-e".to_owned(), "test".to_owned()]).unwrap();
1070 assert!(validate_echo(&["--invalid".to_owned()]).is_err());
1071 }
1072
1073 #[test]
1074 fn test_validate_pwd() {
1075 validate_pwd(&[]).unwrap();
1076 assert!(validate_pwd(&["arg".to_owned()]).is_err());
1077 }
1078
1079 #[test]
1080 fn test_validate_printenv() {
1081 validate_printenv(&[]).unwrap();
1082 validate_printenv(&["PATH".to_owned()]).unwrap();
1083 validate_printenv(&["MY_VAR_123".to_owned()]).unwrap();
1084 assert!(validate_printenv(&["MY-VAR".to_owned()]).is_err());
1085 assert!(validate_printenv(&["MY VAR".to_owned()]).is_err());
1086 assert!(validate_printenv(&["VAR1".to_owned(), "VAR2".to_owned()]).is_err());
1087 }
1088
1089 #[tokio::test]
1090 async fn test_validate_git_read_only() {
1091 validate_git_read_only("status", &[]).unwrap();
1093 validate_git_read_only("log", &["--oneline".to_owned()]).unwrap();
1094 validate_git_read_only("diff", &["-p".to_owned()]).unwrap();
1095 validate_git_read_only("show", &["HEAD".to_owned()]).unwrap();
1096 validate_git_read_only("branch", &["-a".to_owned()]).unwrap();
1097
1098 assert!(validate_git_read_only("log", &["--format".to_owned(), "test;cat".to_owned()]).is_err());
1100 }
1101
1102 #[test]
1103 fn test_validate_git_commit() {
1104 validate_git_commit(&["-m".to_owned(), "fix: test".to_owned()]).unwrap();
1106 validate_git_commit(&["-a".to_owned()]).unwrap();
1107 validate_git_commit(&["--amend".to_owned()]).unwrap();
1108
1109 assert!(validate_git_commit(&["-m".to_owned()]).is_err()); assert!(validate_git_commit(&["--invalid-flag".to_owned()]).is_err());
1112 }
1113
1114 #[test]
1115 fn test_validate_git_reset() {
1116 validate_git_reset(&["--soft".to_owned()], false).unwrap();
1118 validate_git_reset(&["--mixed".to_owned()], false).unwrap();
1119 validate_git_reset(&["--unstage".to_owned()], false).unwrap();
1120 validate_git_reset(&[], false).unwrap();
1121
1122 assert!(validate_git_reset(&["--hard".to_owned()], false).is_err());
1124 assert!(validate_git_reset(&["--merge".to_owned()], false).is_err());
1125 assert!(validate_git_reset(&["--keep".to_owned()], false).is_err());
1126 }
1127
1128 #[test]
1129 fn test_validate_git_stash() {
1130 validate_git_stash(&["list".to_owned()]).unwrap();
1132 validate_git_stash(&["show".to_owned()]).unwrap();
1133 validate_git_stash(&["pop".to_owned()]).unwrap();
1134 validate_git_stash(&["apply".to_owned()]).unwrap();
1135 validate_git_stash(&["drop".to_owned()]).unwrap();
1136
1137 assert!(validate_git_stash(&["push".to_owned()]).is_err());
1139 assert!(validate_git_stash(&["save".to_owned()]).is_err());
1140 }
1141
1142 #[tokio::test]
1143 async fn test_validate_git_safe_operations() {
1144 let workspace = PathBuf::from("/tmp");
1145 let working = PathBuf::from("/tmp");
1146
1147 validate_git(&["status".to_owned()], &workspace, &working, false).await.unwrap();
1149 validate_git(&["log".to_owned(), "--oneline".to_owned()], &workspace, &working, false)
1150 .await
1151 .unwrap();
1152 validate_git(&["diff".to_owned()], &workspace, &working, false).await.unwrap();
1153 validate_git(&["show".to_owned(), "HEAD".to_owned()], &workspace, &working, false)
1154 .await
1155 .unwrap();
1156 }
1157
1158 #[tokio::test]
1159 async fn test_validate_git_dangerous_operations_blocked() {
1160 let workspace = PathBuf::from("/tmp");
1161 let working = PathBuf::from("/tmp");
1162
1163 assert!(
1165 validate_git(&["push".to_owned(), "--force".to_owned()], &workspace, &working, false)
1166 .await
1167 .is_err()
1168 );
1169 assert!(
1170 validate_git(&["push".to_owned(), "-f".to_owned()], &workspace, &working, false)
1171 .await
1172 .is_err()
1173 );
1174 assert!(validate_git(&["clean".to_owned()], &workspace, &working, false).await.is_err());
1175 assert!(
1176 validate_git(&["filter-branch".to_owned()], &workspace, &working, false)
1177 .await
1178 .is_err()
1179 );
1180 assert!(validate_git(&["rebase".to_owned()], &workspace, &working, false).await.is_err());
1181 assert!(
1182 validate_git(&["cherry-pick".to_owned()], &workspace, &working, false)
1183 .await
1184 .is_err()
1185 );
1186 }
1187
1188 #[test]
1189 fn test_validate_which() {
1190 validate_which(&["ls".to_owned()]).unwrap();
1191 validate_which(&["git".to_owned(), "-a".to_owned()]).unwrap();
1192 assert!(validate_which(&[]).is_err());
1193 assert!(validate_which(&["/usr/bin/ls".to_owned()]).is_err()); assert!(validate_which(&["ls git".to_owned()]).is_err()); }
1196}