1use std::env;
2use std::io;
3use std::path::{Path, PathBuf};
4use tokio::fs;
5
6use crate::utils::path::{canonicalize_workspace, normalize_path};
7use anyhow::{Context, Result, anyhow};
8
9fn validate_allowed_flags(
12 args: &[String],
13 allowed_flags: &[&str],
14 command_name: &str,
15) -> Result<()> {
16 for arg in args {
17 if arg.starts_with('-') && !allowed_flags.contains(&arg.as_str()) {
18 return Err(anyhow!("unsupported {} flag '{}'", command_name, 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!("{} does not accept arguments", command_name))
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!(
77 "command '{}' is not permitted by the execution policy",
78 other
79 )),
80 }
81}
82
83pub async fn sanitize_working_dir(
85 workspace_root: &Path,
86 working_dir: Option<&str>,
87) -> Result<PathBuf> {
88 let normalized_root = normalize_workspace_root(workspace_root)?;
89 if let Some(dir) = working_dir {
90 if dir.trim().is_empty() {
91 return Ok(normalized_root);
92 }
93 let candidate = normalize_path(&normalized_root.join(dir));
94 if !candidate.starts_with(&normalized_root) {
95 return Err(anyhow!(
96 "working directory '{}' escapes the workspace root",
97 dir
98 ));
99 }
100 ensure_within_workspace(&normalized_root, &candidate).await?;
101 Ok(candidate)
102 } else {
103 Ok(normalized_root)
104 }
105}
106
107fn validate_echo(args: &[String]) -> Result<()> {
108 validate_allowed_flags(args, &["-n", "-e", "-E"], "echo")
109}
110
111async fn validate_ls(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
112 let allowed_ls_flags = &["-1", "-a", "-l"];
113 for arg in args {
114 if arg.starts_with('-') {
115 if !allowed_ls_flags.contains(&arg.as_str()) {
116 return Err(anyhow!("unsupported ls flag '{}'", arg));
117 }
118 } else {
119 let path = resolve_path(workspace_root, working_dir, arg).await?;
120 ensure_path_exists(&path)?;
121 }
122 }
123 Ok(())
124}
125
126async fn validate_cat(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
127 let allowed_cat_flags = &["-b", "-n", "-t"];
128 let mut files = Vec::with_capacity(args.len());
129
130 for arg in args {
131 if arg.starts_with('-') {
132 if !allowed_cat_flags.contains(&arg.as_str()) {
133 return Err(anyhow!("unsupported cat flag '{}'", arg));
134 }
135 } else {
136 let path = resolve_path(workspace_root, working_dir, arg).await?;
137 ensure_is_file(&path).await?;
138 files.push(path);
139 }
140 }
141
142 if files.is_empty() {
143 return Err(anyhow!("cat requires at least one readable file"));
144 }
145
146 Ok(())
147}
148
149async fn validate_cp(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
150 let mut positional = Vec::new();
151 let mut allow_recursive = false;
152
153 for arg in args {
154 match arg.as_str() {
155 "-r" | "-R" | "--recursive" => {
156 allow_recursive = true;
157 }
158 value if value.starts_with('-') => {
159 return Err(anyhow!("unsupported cp flag '{}'", value));
160 }
161 value => positional.push(value.to_owned()),
162 }
163 }
164
165 if positional.len() < 2 {
166 return Err(anyhow!("cp requires a source and destination"));
167 }
168
169 let dest_raw = positional
170 .last()
171 .ok_or_else(|| anyhow!("cp command missing destination path"))?;
172 let sources = &positional[..positional.len() - 1];
173
174 for source in sources {
175 let path = resolve_path(workspace_root, working_dir, source).await?;
176 let metadata = fs::metadata(&path)
177 .await
178 .with_context(|| format!("failed to inspect source '{}'", source))?;
179 if metadata.is_dir() && !allow_recursive {
180 return Err(anyhow!(
181 "copying directories requires the recursive flag for '{}'",
182 source
183 ));
184 }
185 if !metadata.is_file() && !metadata.is_dir() {
186 return Err(anyhow!("unsupported source type for '{}'", source));
187 }
188 }
189
190 let dest_path = resolve_path_allow_new(workspace_root, working_dir, dest_raw).await?;
191 if let Some(parent) = dest_path.parent()
192 && !fs::try_exists(parent).await.unwrap_or(false)
193 {
194 return Err(anyhow!(
195 "destination parent '{}' must exist",
196 parent.display()
197 ));
198 }
199
200 Ok(())
201}
202
203async fn validate_head(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
204 let mut positional = Vec::new();
205 let mut index = 0;
206
207 while index < args.len() {
208 let current = &args[index];
209 match current.as_str() {
210 "-c" | "-n" => {
211 let value = args
212 .get(index + 1)
213 .ok_or_else(|| anyhow!("option '{}' requires a value", current))?;
214 parse_positive_int(value)
215 .with_context(|| format!("invalid value '{}' for '{}'", value, current))?;
216 index += 2;
217 }
218 value if value.starts_with('-') => {
219 return Err(anyhow!("unsupported head flag '{}'", value));
220 }
221 value => {
222 positional.push(value);
223 index += 1;
224 }
225 }
226 }
227
228 if positional.is_empty() {
229 return Err(anyhow!("head requires at least one file"));
230 }
231
232 for file in positional {
233 let path = resolve_path(workspace_root, working_dir, file).await?;
234 ensure_is_file(&path).await?;
235 }
236
237 Ok(())
238}
239
240fn validate_printenv(args: &[String]) -> Result<()> {
241 match args.len() {
242 0 => Ok(()),
243 1 => {
244 let name = &args[0];
245 if name.is_empty()
246 || !name
247 .chars()
248 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
249 {
250 return Err(anyhow!("invalid environment variable name '{}'", name));
251 }
252 Ok(())
253 }
254 _ => Err(anyhow!("printenv accepts zero or one argument")),
255 }
256}
257
258fn validate_pwd(args: &[String]) -> Result<()> {
259 validate_no_args(args, "pwd")
260}
261
262async fn validate_rg(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
263 let mut index = 0;
264 let mut allow_no_pattern = false;
265
266 while index < args.len() {
267 let current = &args[index];
268 if current == "--" {
269 index += 1;
270 break;
271 }
272
273 match current.as_str() {
274 "--pre" | "--pre-glob" => {
276 return Err(anyhow!(
277 "ripgrep preprocessor flag '{}' is not permitted for security reasons. \
278 This flag enables arbitrary command execution.",
279 current
280 ));
281 }
282 "-A" | "-B" | "-C" | "-d" | "--max-depth" | "-m" | "--max-count" => {
283 let value = args
284 .get(index + 1)
285 .ok_or_else(|| anyhow!("option '{}' requires a value", current))?;
286 parse_positive_int(value)
287 .with_context(|| format!("invalid value '{}' for '{}'", value, current))?;
288 index += 2;
289 }
290 "-g" | "--glob" => {
291 let value = args
292 .get(index + 1)
293 .ok_or_else(|| anyhow!("option '{}' requires a value", current))?;
294 if value.is_empty() {
295 return Err(anyhow!("glob value for '{}' cannot be empty", current));
296 }
297 index += 2;
298 }
299 "-n" | "-i" | "-l" | "--files" | "--files-with-matches" | "--files-without-match" => {
300 if matches!(
301 current.as_str(),
302 "--files" | "--files-with-matches" | "--files-without-match"
303 ) {
304 allow_no_pattern = true;
305 }
306 index += 1;
307 }
308 value if value.starts_with('-') => {
309 return Err(anyhow!("unsupported ripgrep flag '{}'", value));
310 }
311 _ => break,
312 }
313 }
314
315 let remaining = &args[index..];
316 if remaining.is_empty() && !allow_no_pattern {
317 return Err(anyhow!(
318 "ripgrep requires a pattern unless file listing flags are used"
319 ));
320 }
321
322 let mut rem_index = 0;
323 if !remaining.is_empty() {
324 let pattern = &remaining[0];
325 if pattern.is_empty() {
326 return Err(anyhow!("ripgrep pattern cannot be empty"));
327 }
328 rem_index = 1;
329 }
330
331 if remaining.len() > rem_index {
332 let search_root = &remaining[rem_index];
333 let path = resolve_path_allow_dir(workspace_root, working_dir, search_root).await?;
334 if !fs::try_exists(&path).await.unwrap_or(false) {
335 return Err(anyhow!("search path '{}' does not exist", search_root));
336 }
337 if remaining.len() > rem_index + 1 {
338 return Err(anyhow!("ripgrep accepts at most one search path"));
339 }
340 }
341
342 Ok(())
343}
344
345async fn validate_sed(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
346 let mut commands = Vec::new();
347 let mut files = Vec::new();
348 let mut index = 0;
349
350 while index < args.len() {
351 let current = &args[index];
352 match current.as_str() {
353 "-n" | "-u" => {
354 index += 1;
355 }
356 "-e" => {
357 let value = args
358 .get(index + 1)
359 .ok_or_else(|| anyhow!("-e requires a sed command"))?;
360 ensure_safe_sed_command(value)?;
361 commands.push(value.clone());
362 index += 2;
363 }
364 value if value.starts_with('-') => {
365 return Err(anyhow!("unsupported sed flag '{}'", value));
366 }
367 value => {
368 if commands.is_empty() {
369 ensure_safe_sed_command(value)?;
370 commands.push(value.to_owned());
371 index += 1;
372 } else {
373 let path = resolve_path(workspace_root, working_dir, value).await?;
374 ensure_is_file(&path).await?;
375 files.push(path);
376 index += 1;
377 }
378 }
379 }
380 }
381
382 if commands.is_empty() {
383 return Err(anyhow!("sed requires at least one command"));
384 }
385
386 if files.is_empty() {
387 return Err(anyhow!("sed requires at least one readable file"));
388 }
389
390 Ok(())
391}
392
393fn validate_which(args: &[String]) -> Result<()> {
394 if args.is_empty() {
395 return Err(anyhow!("which requires at least one program name"));
396 }
397
398 for arg in args {
399 match arg.as_str() {
400 "-a" | "-s" => continue,
401 value if value.starts_with('-') => {
402 return Err(anyhow!("unsupported which flag '{}'", value));
403 }
404 value => {
405 if value.is_empty()
406 || value.contains('/')
407 || value.chars().any(|ch| ch.is_whitespace())
408 {
409 return Err(anyhow!(
410 "program name '{}' contains unsupported characters",
411 value
412 ));
413 }
414 }
415 }
416 }
417
418 Ok(())
419}
420
421async fn validate_git(
422 args: &[String],
423 workspace_root: &Path,
424 working_dir: &Path,
425 confirm: bool,
426) -> Result<()> {
427 if args.is_empty() {
428 return Err(anyhow!("git requires a subcommand"));
429 }
430
431 let subcommand = args[0].as_str();
432 let subargs = &args[1..];
433
434 match subcommand {
436 "status" | "log" | "show" | "diff" | "branch" | "tag" | "remote" => {
438 if subcommand == "tag" && !subargs.is_empty() && !subargs[0].starts_with('-') {
440 }
443 validate_git_read_only(subcommand, subargs)
444 }
445
446 "ls-tree" | "ls-files" | "cat-file" | "rev-parse" | "describe" => {
448 validate_git_read_only(subcommand, subargs)
449 }
450
451 "config" if subargs.is_empty() || subargs.iter().all(|a| !a.starts_with("--")) => {
453 validate_git_read_only(subcommand, subargs)
454 }
455
456 "blame" | "grep" | "shortlog" | "format-patch" => {
458 validate_git_read_only(subcommand, subargs)
459 }
460
461 "stash"
463 if matches!(
464 subargs.first().map(|s| s.as_str()),
465 Some("list" | "show" | "pop" | "apply" | "drop")
466 ) =>
467 {
468 validate_git_stash(subargs)
469 }
470
471 "add" => validate_git_add(subargs, workspace_root, working_dir).await,
473 "commit" => validate_git_commit(subargs),
474 "reset" => validate_git_reset(subargs, confirm),
475 "checkout" | "switch" => {
476 validate_git_checkout(subargs, workspace_root, working_dir, confirm).await
477 }
478 "restore" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
479 "merge" => validate_git_merge(subargs),
480
481 "push" => {
483 if subargs
485 .iter()
486 .any(|a| a.contains("force") || a == "-f" || a == "--no-verify")
487 {
488 Err(anyhow!(
489 "git push with force flags is not permitted. Use safe push operations only."
490 ))
491 } else {
492 validate_git_read_only(subcommand, subargs)
493 }
494 }
495
496 "force-push" => Err(anyhow!(
497 "git force-push is not permitted by the execution policy"
498 )),
499
500 "clean" => Err(anyhow!(
501 "git clean is not permitted by the execution policy. Use explicit rm commands instead."
502 )),
503
504 "gc" if subargs.iter().any(|a| a.contains("aggressive")) => {
505 Err(anyhow!("git gc with aggressive flag is not permitted"))
506 }
507
508 "filter-branch" | "rebase" | "cherry-pick" => Err(anyhow!(
509 "git {} is not permitted - complex history operations require confirmation",
510 subcommand
511 )),
512
513 other => Err(anyhow!(
514 "git subcommand '{}' is not permitted by the execution policy",
515 other
516 )),
517 }
518}
519
520fn validate_git_read_only(subcommand: &str, subargs: &[String]) -> Result<()> {
521 let dangerous_flags = ["-q", "--quiet", "--verbose", "-v"];
523
524 for arg in subargs {
525 if arg.starts_with("--") && arg.contains('=') {
526 let key = arg.split('=').next().unwrap_or("");
527 if key == "--format" {
528 continue;
530 }
531 }
532
533 if dangerous_flags.contains(&arg.as_str()) {
534 continue;
536 }
537
538 match subcommand {
540 "log" | "show" => {
541 if matches!(
542 arg.as_str(),
543 "-n" | "--oneline"
544 | "--graph"
545 | "--decorate"
546 | "--all"
547 | "--grep"
548 | "-S"
549 | "-p"
550 | "-U"
551 | "--stat"
552 | "--shortstat"
553 | "--name-status"
554 | "--name-only"
555 | "--author"
556 | "--since"
557 | "--until"
558 | "--date"
559 ) {
560 continue;
561 }
562 }
563 "diff" => {
564 if matches!(
565 arg.as_str(),
566 "-p" | "-U"
567 | "--stat"
568 | "--shortstat"
569 | "--name-status"
570 | "--name-only"
571 | "--no-index"
572 | "-w"
573 | "--ignore-all-space"
574 | "-b"
575 | "--ignore-space-change"
576 ) {
577 continue;
578 }
579 }
580 "branch" => {
581 if matches!(arg.as_str(), "-a" | "-r" | "-v" | "--verbose") {
582 continue;
583 }
584 }
585 _ => {
586 if !arg.starts_with('-') || arg.starts_with("--") {
588 continue;
589 }
590 }
591 }
592
593 if arg.contains(';') || arg.contains('|') || arg.contains('&') {
595 return Err(anyhow!(
596 "git argument contains suspicious shell metacharacters"
597 ));
598 }
599 }
600
601 Ok(())
602}
603
604async fn validate_git_add(
605 args: &[String],
606 workspace_root: &Path,
607 working_dir: &Path,
608) -> Result<()> {
609 if args.iter().any(|a| a == "-f" || a == "--force") {
611 return Err(anyhow!(
612 "git add --force is not permitted. Use regular add operations only."
613 ));
614 }
615
616 let mut index = 0;
618 while index < args.len() {
619 let arg = &args[index];
620 match arg.as_str() {
621 "-u" | "--update" | "-A" | "--all" | "." => {
622 index += 1;
624 }
625 "-p" | "--patch" | "-i" | "--interactive" => {
626 index += 1;
628 }
629 "-n" | "--dry-run" => {
630 index += 1;
631 }
632 value if value.starts_with('-') => {
633 return Err(anyhow!("unsupported git add flag '{}'", value));
634 }
635 path => {
636 let resolved = resolve_path(workspace_root, working_dir, path).await?;
638 ensure_within_workspace(workspace_root, &resolved).await?;
639 index += 1;
640 }
641 }
642 }
643
644 Ok(())
645}
646
647fn validate_git_commit(args: &[String]) -> Result<()> {
648 let mut index = 0;
649
650 while index < args.len() {
651 let arg = &args[index];
652 match arg.as_str() {
653 "-m" | "--message" => {
654 if index + 1 >= args.len() {
655 return Err(anyhow!("-m requires a commit message"));
656 }
657 index += 2;
658 }
659 "-F" | "--file" => {
660 if index + 1 >= args.len() {
661 return Err(anyhow!("-F requires a file path"));
662 }
663 index += 2;
664 }
665 "-a" | "--all" | "-p" | "--patch" | "--amend" | "--no-verify" | "-q" | "--quiet" => {
666 index += 1;
667 }
668 value if value.starts_with('-') => {
669 return Err(anyhow!("unsupported git commit flag '{}'", value));
670 }
671 _ => {
672 index += 1;
673 }
674 }
675 }
676
677 Ok(())
678}
679
680fn validate_git_reset(args: &[String], confirm: bool) -> Result<()> {
681 let is_destructive = args
683 .iter()
684 .any(|a| a == "--hard" || a == "--merge" || a == "--keep");
685
686 if is_destructive && !confirm {
687 return Err(anyhow!(
688 "git reset with --hard, --merge, or --keep is potentially destructive. Set `confirm=true` to proceed."
689 ));
690 }
691
692 let safe_modes = ["--soft", "--mixed", "--unstage"];
694 let allowed_destructive: Vec<&str> = if confirm {
695 vec!["--hard", "--merge", "--keep"]
696 } else {
697 vec![]
698 };
699
700 for arg in args {
701 if arg.starts_with('-') {
702 let is_safe = safe_modes.iter().any(|m| arg.contains(m));
703 let is_allowed_destructive = allowed_destructive.iter().any(|m| arg.contains(m));
704 if !is_safe && !is_allowed_destructive {
705 match arg.as_str() {
706 "-q" | "--quiet" | "-p" | "--patch" => continue,
707 _ => {
708 return Err(anyhow!(
709 "unsupported git reset flag '{}'. Use --soft, --mixed, or --hard (with confirm) modes.",
710 arg
711 ));
712 }
713 }
714 }
715 }
716 }
717
718 Ok(())
719}
720
721async fn validate_git_checkout(
722 args: &[String],
723 workspace_root: &Path,
724 working_dir: &Path,
725 confirm: bool,
726) -> Result<()> {
727 if args.is_empty() {
728 return Ok(());
729 }
730
731 if args.iter().any(|a| a == "-f" || a == "--force") && !confirm {
733 return Err(anyhow!(
734 "git checkout --force is potentially destructive; set `confirm=true` to proceed."
735 ));
736 }
737
738 let mut paths_start = 0;
740 for (i, arg) in args.iter().enumerate() {
741 if arg == "--" {
742 paths_start = i + 1;
743 break;
744 }
745 if !arg.starts_with('-') {
746 paths_start = i;
748 break;
749 }
750 }
751
752 if paths_start > 0 {
753 for path_arg in &args[paths_start..] {
754 let resolved = resolve_path(workspace_root, working_dir, path_arg).await?;
756 ensure_within_workspace(workspace_root, &resolved).await?;
757 }
758 }
759
760 Ok(())
761}
762
763fn validate_git_stash(args: &[String]) -> Result<()> {
764 if args.is_empty() {
765 return Ok(());
766 }
767
768 let allowed_ops = ["list", "show", "pop", "apply", "drop", "clear", "create"];
769 let first = args[0].as_str();
770
771 if !allowed_ops.contains(&first) {
772 return Err(anyhow!("git stash operation '{}' is not permitted", first));
773 }
774
775 for arg in &args[1..] {
777 if arg.starts_with('-') {
778 match arg.as_str() {
779 "-q"
780 | "--quiet"
781 | "-p"
782 | "--patch"
783 | "-k"
784 | "--keep-index"
785 | "-u"
786 | "--include-untracked"
787 | "-a"
788 | "--all" => continue,
789 _ => return Err(anyhow!("unsupported git stash flag '{}'", arg)),
790 }
791 }
792 }
793
794 Ok(())
795}
796
797fn validate_git_merge(args: &[String]) -> Result<()> {
798 if args.is_empty() {
800 return Err(anyhow!("git merge requires a branch"));
801 }
802
803 let dangerous_flags = ["--no-ff", "--squash"];
805 for arg in args {
806 if dangerous_flags.contains(&arg.as_str()) {
807 return Err(anyhow!(
808 "git merge with {} flag is not permitted; use simpler merge",
809 arg
810 ));
811 }
812 }
813
814 Ok(())
815}
816
817async fn resolve_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
818 let base = build_candidate_path(workspace_root, working_dir, value).await?;
819 if !fs::try_exists(&base).await.unwrap_or(false) {
820 return Err(anyhow!("path '{}' does not exist", value));
821 }
822 if !base.starts_with(workspace_root) {
823 return Err(anyhow!("path '{}' is outside the workspace root", value));
824 }
825 Ok(base)
826}
827
828async fn resolve_path_allow_new(
829 workspace_root: &Path,
830 working_dir: &Path,
831 value: &str,
832) -> Result<PathBuf> {
833 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
834 if !candidate.starts_with(workspace_root) {
835 return Err(anyhow!("path '{}' is outside the workspace root", value));
836 }
837 Ok(candidate)
838}
839
840async fn resolve_path_allow_dir(
841 workspace_root: &Path,
842 working_dir: &Path,
843 value: &str,
844) -> Result<PathBuf> {
845 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
846 if !candidate.starts_with(workspace_root) {
847 return Err(anyhow!("path '{}' is outside the workspace root", value));
848 }
849 Ok(candidate)
850}
851
852async fn build_candidate_path(
853 workspace_root: &Path,
854 working_dir: &Path,
855 value: &str,
856) -> Result<PathBuf> {
857 let normalized_root = normalize_workspace_root(workspace_root)?;
858 let normalized_working = normalize_path(working_dir);
859 let raw_path = Path::new(value);
860 let candidate = if raw_path.is_absolute() {
861 normalize_path(raw_path)
862 } else {
863 normalize_path(&normalized_working.join(raw_path))
864 };
865
866 if !candidate.starts_with(&normalized_root) {
867 return Err(anyhow!("path '{}' escapes the workspace root", value));
868 }
869 ensure_within_workspace(&normalized_root, &candidate).await?;
870 Ok(candidate)
871}
872
873fn normalize_workspace_root(workspace_root: &Path) -> Result<PathBuf> {
874 if workspace_root.is_absolute() {
875 return Ok(normalize_path(workspace_root));
876 }
877
878 let cwd = env::current_dir().context("failed to resolve current working directory")?;
879 Ok(normalize_path(&cwd.join(workspace_root)))
880}
881
882fn ensure_path_exists(path: &Path) -> Result<()> {
883 if path.exists() {
884 Ok(())
885 } else {
886 Err(anyhow!("path '{}' does not exist", path.display()))
887 }
888}
889
890async fn ensure_is_file(path: &Path) -> Result<()> {
891 let metadata = fs::metadata(path)
892 .await
893 .with_context(|| format!("failed to inspect '{}'", path.display()))?;
894 if metadata.is_file() {
895 Ok(())
896 } else {
897 Err(anyhow!("'{}' is not a file", path.display()))
898 }
899}
900
901fn parse_positive_int(value: &str) -> Result<u64> {
902 let parsed: u64 = value.parse()?;
903 if parsed == 0 {
904 return Err(anyhow!("value must be greater than zero"));
905 }
906 Ok(parsed)
907}
908
909fn ensure_safe_sed_command(value: &str) -> Result<()> {
910 if value.trim().is_empty() {
911 return Err(anyhow!("sed command cannot be empty"));
912 }
913 if value.contains([';', '|', '&', '`']) {
914 return Err(anyhow!(
915 "sed command contains unsupported control characters"
916 ));
917 }
918
919 let mut chars = value.chars();
920 if chars.next() != Some('s') {
921 return Err(anyhow!("only sed substitution commands are supported"));
922 }
923 let delimiter = chars
924 .next()
925 .ok_or_else(|| anyhow!("sed substitution is missing a delimiter"))?;
926 if delimiter.is_ascii_alphanumeric() || delimiter.is_ascii_whitespace() {
927 return Err(anyhow!("invalid sed delimiter"));
928 }
929
930 let mut pattern = String::new();
931 let mut replacement = String::new();
932 let mut flags = String::new();
933
934 parse_sed_section(&mut chars, delimiter, &mut pattern)?;
935 parse_sed_section(&mut chars, delimiter, &mut replacement)?;
936 collect_sed_flags(chars, &mut flags)?;
937
938 if flags.chars().any(|ch| matches!(ch, 'e' | 'E' | 'F' | 'f')) {
939 return Err(anyhow!(
940 "sed execution flags are not permitted in substitution"
941 ));
942 }
943
944 Ok(())
945}
946
947async fn ensure_within_workspace(normalized_root: &Path, candidate: &Path) -> Result<()> {
948 let canonical_root = tokio::task::spawn_blocking({
949 let root = normalized_root.to_path_buf();
950 move || canonicalize_workspace(&root)
951 })
952 .await
953 .context("failed to spawn canonicalization task")?;
954
955 if normalized_root == candidate {
956 return Ok(());
957 }
958
959 let relative = candidate
960 .strip_prefix(normalized_root)
961 .map_err(|_| anyhow!("path '{}' escapes the workspace root", candidate.display()))?;
962
963 let mut prefix = normalized_root.to_path_buf();
964 let mut components = relative.components().peekable();
965
966 while let Some(component) = components.next() {
967 prefix.push(component.as_os_str());
968
969 let metadata = match fs::symlink_metadata(&prefix).await {
970 Ok(metadata) => metadata,
971 Err(error) => {
972 if error.kind() == io::ErrorKind::NotFound {
973 break;
974 }
975 return Err(error).with_context(|| {
976 format!("failed to inspect path component '{}'", prefix.display())
977 });
978 }
979 };
980
981 if metadata.file_type().is_symlink() {
982 let resolved = fs::canonicalize(&prefix).await.with_context(|| {
983 format!(
984 "failed to canonicalize path component '{}'",
985 prefix.display()
986 )
987 })?;
988 if !resolved.starts_with(&canonical_root) {
989 return Err(anyhow!(
990 "path '{}' escapes the workspace root via symlink '{}'",
991 candidate.display(),
992 prefix.display()
993 ));
994 }
995 } else {
996 let resolved = fs::canonicalize(&prefix).await.with_context(|| {
997 format!(
998 "failed to canonicalize path component '{}'",
999 prefix.display()
1000 )
1001 })?;
1002 if !resolved.starts_with(&canonical_root) {
1003 return Err(anyhow!(
1004 "path '{}' escapes the workspace root via component '{}'",
1005 candidate.display(),
1006 prefix.display()
1007 ));
1008 }
1009
1010 if metadata.is_file() && components.peek().is_some() {
1011 return Err(anyhow!(
1012 "path '{}' traverses through file component '{}'",
1013 candidate.display(),
1014 prefix.display()
1015 ));
1016 }
1017 }
1018 }
1019
1020 Ok(())
1021}
1022
1023fn parse_sed_section(
1024 chars: &mut std::str::Chars<'_>,
1025 delimiter: char,
1026 target: &mut String,
1027) -> Result<()> {
1028 let mut escaped = false;
1029 for ch in chars.by_ref() {
1030 if escaped {
1031 target.push(ch);
1032 escaped = false;
1033 continue;
1034 }
1035 match ch {
1036 '\\' => {
1037 escaped = true;
1038 }
1039 value if value == delimiter => {
1040 return Ok(());
1041 }
1042 other => target.push(other),
1043 }
1044 }
1045 Err(anyhow!("sed command is missing a closing delimiter"))
1046}
1047
1048fn collect_sed_flags(chars: std::str::Chars<'_>, target: &mut String) -> Result<()> {
1049 for ch in chars {
1050 if ch.is_ascii_alphabetic() {
1051 target.push(ch);
1052 } else {
1053 return Err(anyhow!("sed flags contain unsupported characters"));
1054 }
1055 }
1056 Ok(())
1057}
1058
1059async fn validate_tail(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1061 for arg in args {
1063 if !arg.starts_with('-') {
1064 let path = normalize_path(&working_dir.join(arg));
1065 ensure_within_workspace(workspace_root, &path).await?;
1066 }
1067 }
1068 Ok(())
1069}
1070
1071async fn validate_grep(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1072 let mut pattern_seen = false;
1074 for arg in args {
1075 if !arg.starts_with('-') && pattern_seen {
1076 let path = normalize_path(&working_dir.join(arg));
1078 ensure_within_workspace(workspace_root, &path).await?;
1079 } else if !arg.starts_with('-') {
1080 pattern_seen = true;
1081 }
1082 }
1083 Ok(())
1084}
1085
1086fn validate_date(args: &[String]) -> Result<()> {
1087 for arg in args {
1089 if arg.starts_with('+') {
1090 continue;
1092 }
1093 }
1094 Ok(())
1095}
1096
1097fn validate_whoami(_args: &[String]) -> Result<()> {
1098 Ok(())
1100}
1101
1102fn validate_hostname(_args: &[String]) -> Result<()> {
1103 Ok(())
1105}
1106
1107fn validate_uname(args: &[String]) -> Result<()> {
1108 let safe_flags = ["-a", "-s", "-n", "-r", "-v", "-m"];
1110 for arg in args {
1111 if arg.starts_with('-') && !safe_flags.contains(&arg.as_str()) {
1112 return Err(anyhow!("unsupported uname flag '{}'", arg));
1113 }
1114 }
1115 Ok(())
1116}
1117
1118async fn validate_wc(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1119 for arg in args {
1121 if !arg.starts_with('-') {
1122 let path = normalize_path(&working_dir.join(arg));
1123 ensure_within_workspace(workspace_root, &path).await?;
1124 }
1125 }
1126 Ok(())
1127}
1128
1129async fn validate_cargo(
1130 args: &[String],
1131 workspace_root: &Path,
1132 working_dir: &Path,
1133 confirm: bool,
1134) -> Result<()> {
1135 if args.is_empty() {
1137 return Err(anyhow!("cargo requires a subcommand"));
1138 }
1139
1140 let subcommand = args[0].as_str();
1141 match subcommand {
1142 "build" | "check" | "test" | "doc" | "clippy" | "fmt" | "run" | "bench" | "expand"
1144 | "tree" | "metadata" | "search" | "cache" => {
1145 ensure_within_workspace(workspace_root, working_dir).await?;
1147 Ok(())
1148 }
1149 "clean" | "install" | "uninstall" | "publish" | "yank" => {
1151 if confirm {
1152 ensure_within_workspace(workspace_root, working_dir).await?;
1154 Ok(())
1155 } else {
1156 Err(anyhow!(
1157 "cargo {} is potentially destructive; set `confirm=true` to proceed.",
1158 subcommand
1159 ))
1160 }
1161 }
1162 other => Err(anyhow!(
1163 "cargo subcommand '{}' is not permitted by the execution policy",
1164 other
1165 )),
1166 }
1167}
1168
1169async fn validate_python(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1170 ensure_within_workspace(workspace_root, working_dir).await?;
1172 if args.is_empty() {
1174 return Ok(()); }
1176
1177 let first_arg = &args[0];
1178 if first_arg == "-c" || first_arg == "-m" || first_arg == "-W" {
1179 if first_arg != "-m" && args.len() > 1 {
1181 let path = normalize_path(&working_dir.join(&args[1]));
1182 ensure_within_workspace(workspace_root, &path).await?;
1183 }
1184 } else if !first_arg.starts_with('-') {
1185 let path = normalize_path(&working_dir.join(first_arg));
1187 ensure_within_workspace(workspace_root, &path).await?;
1188 }
1189 Ok(())
1190}
1191
1192async fn validate_npm(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1193 ensure_within_workspace(workspace_root, working_dir).await?;
1195 if args.is_empty() {
1196 return Ok(());
1197 }
1198
1199 let subcommand = args[0].as_str();
1200 match subcommand {
1201 "publish" | "unpublish" => Err(anyhow!(
1203 "npm {} is not permitted by the execution policy",
1204 subcommand
1205 )),
1206 _ => Ok(()),
1208 }
1209}
1210
1211async fn validate_node(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1212 ensure_within_workspace(workspace_root, working_dir).await?;
1214 if args.is_empty() {
1215 return Ok(()); }
1217
1218 let first_arg = &args[0];
1219 if !first_arg.starts_with('-') {
1220 let path = normalize_path(&working_dir.join(first_arg));
1222 ensure_within_workspace(workspace_root, &path).await?;
1223 }
1224 Ok(())
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230
1231 #[test]
1232 fn test_validate_echo() {
1233 validate_echo(&[]).unwrap();
1234 validate_echo(&["hello".to_owned()]).unwrap();
1235 validate_echo(&["-n".to_owned(), "hello".to_owned()]).unwrap();
1236 validate_echo(&["-e".to_owned(), "test".to_owned()]).unwrap();
1237 assert!(validate_echo(&["--invalid".to_owned()]).is_err());
1238 }
1239
1240 #[test]
1241 fn test_validate_pwd() {
1242 validate_pwd(&[]).unwrap();
1243 assert!(validate_pwd(&["arg".to_owned()]).is_err());
1244 }
1245
1246 #[test]
1247 fn test_validate_printenv() {
1248 validate_printenv(&[]).unwrap();
1249 validate_printenv(&["PATH".to_owned()]).unwrap();
1250 validate_printenv(&["MY_VAR_123".to_owned()]).unwrap();
1251 assert!(validate_printenv(&["MY-VAR".to_owned()]).is_err());
1252 assert!(validate_printenv(&["MY VAR".to_owned()]).is_err());
1253 assert!(validate_printenv(&["VAR1".to_owned(), "VAR2".to_owned()]).is_err());
1254 }
1255
1256 #[tokio::test]
1257 async fn test_validate_git_read_only() {
1258 validate_git_read_only("status", &[]).unwrap();
1260 validate_git_read_only("log", &["--oneline".to_owned()]).unwrap();
1261 validate_git_read_only("diff", &["-p".to_owned()]).unwrap();
1262 validate_git_read_only("show", &["HEAD".to_owned()]).unwrap();
1263 validate_git_read_only("branch", &["-a".to_owned()]).unwrap();
1264
1265 assert!(
1267 validate_git_read_only("log", &["--format".to_owned(), "test;cat".to_owned()]).is_err()
1268 );
1269 }
1270
1271 #[test]
1272 fn test_validate_git_commit() {
1273 validate_git_commit(&["-m".to_owned(), "fix: test".to_owned()]).unwrap();
1275 validate_git_commit(&["-a".to_owned()]).unwrap();
1276 validate_git_commit(&["--amend".to_owned()]).unwrap();
1277
1278 assert!(validate_git_commit(&["-m".to_owned()]).is_err()); assert!(validate_git_commit(&["--invalid-flag".to_owned()]).is_err());
1281 }
1282
1283 #[test]
1284 fn test_validate_git_reset() {
1285 validate_git_reset(&["--soft".to_owned()], false).unwrap();
1287 validate_git_reset(&["--mixed".to_owned()], false).unwrap();
1288 validate_git_reset(&["--unstage".to_owned()], false).unwrap();
1289 validate_git_reset(&[], false).unwrap();
1290
1291 assert!(validate_git_reset(&["--hard".to_owned()], false).is_err());
1293 assert!(validate_git_reset(&["--merge".to_owned()], false).is_err());
1294 assert!(validate_git_reset(&["--keep".to_owned()], false).is_err());
1295 }
1296
1297 #[test]
1298 fn test_validate_git_stash() {
1299 validate_git_stash(&["list".to_owned()]).unwrap();
1301 validate_git_stash(&["show".to_owned()]).unwrap();
1302 validate_git_stash(&["pop".to_owned()]).unwrap();
1303 validate_git_stash(&["apply".to_owned()]).unwrap();
1304 validate_git_stash(&["drop".to_owned()]).unwrap();
1305
1306 assert!(validate_git_stash(&["push".to_owned()]).is_err());
1308 assert!(validate_git_stash(&["save".to_owned()]).is_err());
1309 }
1310
1311 #[tokio::test]
1312 async fn test_validate_git_safe_operations() {
1313 let workspace = PathBuf::from("/tmp");
1314 let working = PathBuf::from("/tmp");
1315
1316 validate_git(&["status".to_owned()], &workspace, &working, false)
1318 .await
1319 .unwrap();
1320 validate_git(
1321 &["log".to_owned(), "--oneline".to_owned()],
1322 &workspace,
1323 &working,
1324 false,
1325 )
1326 .await
1327 .unwrap();
1328 validate_git(&["diff".to_owned()], &workspace, &working, false)
1329 .await
1330 .unwrap();
1331 validate_git(
1332 &["show".to_owned(), "HEAD".to_owned()],
1333 &workspace,
1334 &working,
1335 false,
1336 )
1337 .await
1338 .unwrap();
1339 }
1340
1341 #[tokio::test]
1342 async fn test_validate_git_dangerous_operations_blocked() {
1343 let workspace = PathBuf::from("/tmp");
1344 let working = PathBuf::from("/tmp");
1345
1346 assert!(
1348 validate_git(
1349 &["push".to_owned(), "--force".to_owned()],
1350 &workspace,
1351 &working,
1352 false
1353 )
1354 .await
1355 .is_err()
1356 );
1357 assert!(
1358 validate_git(
1359 &["push".to_owned(), "-f".to_owned()],
1360 &workspace,
1361 &working,
1362 false
1363 )
1364 .await
1365 .is_err()
1366 );
1367 assert!(
1368 validate_git(&["clean".to_owned()], &workspace, &working, false)
1369 .await
1370 .is_err()
1371 );
1372 assert!(
1373 validate_git(&["filter-branch".to_owned()], &workspace, &working, false)
1374 .await
1375 .is_err()
1376 );
1377 assert!(
1378 validate_git(&["rebase".to_owned()], &workspace, &working, false)
1379 .await
1380 .is_err()
1381 );
1382 assert!(
1383 validate_git(&["cherry-pick".to_owned()], &workspace, &working, false)
1384 .await
1385 .is_err()
1386 );
1387 }
1388
1389 #[test]
1390 fn test_validate_which() {
1391 validate_which(&["ls".to_owned()]).unwrap();
1392 validate_which(&["git".to_owned(), "-a".to_owned()]).unwrap();
1393 assert!(validate_which(&[]).is_err());
1394 assert!(validate_which(&["/usr/bin/ls".to_owned()]).is_err()); assert!(validate_which(&["ls git".to_owned()]).is_err()); }
1397}