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