Skip to main content

vtcode_core/exec_policy/
command_validation.rs

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
9/// Helper to validate flags in arguments (reduces duplication in 10+ validators)
10/// Returns error if any flag not in allowed_flags is found
11fn 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
24/// Helper to validate that a command accepts no arguments
25fn 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
33/// Validate whether a command is allowed to run under the execution policy.
34///
35/// The policy is inspired by the Codex execution policy and limits commands to
36/// a curated allow-list with argument validation to prevent workspace
37/// breakout or destructive actions.
38pub 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
83/// Normalize a working directory relative to the workspace root.
84pub 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            // SECURITY: Block preprocessor flags that enable arbitrary command execution
275            "--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    // Tier 1: Safe read-only operations (always allowed)
435    match subcommand {
436        // Status and history operations
437        "status" | "log" | "show" | "diff" | "branch" | "tag" | "remote" => {
438            // For tag command, add specific validation
439            if subcommand == "tag" && !subargs.is_empty() && !subargs[0].starts_with('-') {
440                // This condition was originally in the unreachable pattern
441                // but now it's handled within the general case for "tag"
442            }
443            validate_git_read_only(subcommand, subargs)
444        }
445
446        // Tree and object inspection
447        "ls-tree" | "ls-files" | "cat-file" | "rev-parse" | "describe" => {
448            validate_git_read_only(subcommand, subargs)
449        }
450
451        // Config inspection (read-only)
452        "config" if subargs.is_empty() || subargs.iter().all(|a| !a.starts_with("--")) => {
453            validate_git_read_only(subcommand, subargs)
454        }
455
456        // Additional inspection commands
457        "blame" | "grep" | "shortlog" | "format-patch" => {
458            validate_git_read_only(subcommand, subargs)
459        }
460
461        // Stash operations (safe list/show)
462        "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        // Tier 2: Safe write operations (with validation)
472        "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        // Tier 3: Dangerous operations (always blocked)
482        "push" => {
483            // Check for force flags
484            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    // Block dangerous flags even in read-only commands
522    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                // Allow custom formats for output
529                continue;
530            }
531        }
532
533        if dangerous_flags.contains(&arg.as_str()) {
534            // Benign flags, allow them
535            continue;
536        }
537
538        // Allow common flags per subcommand
539        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                // For other read-only commands, allow most flags
587                if !arg.starts_with('-') || arg.starts_with("--") {
588                    continue;
589                }
590            }
591        }
592
593        // Block any suspicious patterns
594        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    // Block dangerous flags
610    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    // Validate file paths if provided
617    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                // These are safe - they add all tracked or current directory
623                index += 1;
624            }
625            "-p" | "--patch" | "-i" | "--interactive" => {
626                // Interactive mode is fine
627                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                // Validate the file path
637                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    // Block destructive reset modes
682    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    // Allow safe flags: --soft, --mixed, --unstage, and destructive ones with confirmation
693    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    // Block forced checkout unless explicit confirm
732    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    // Validate paths if provided
739    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            // Could be a branch or path
747            paths_start = i;
748            break;
749        }
750    }
751
752    if paths_start > 0 {
753        for path_arg in &args[paths_start..] {
754            // Validate file paths
755            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    // Allow flags for these operations
776    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    // git merge is allowed for typical workflow
799    if args.is_empty() {
800        return Err(anyhow!("git merge requires a branch"));
801    }
802
803    // Block dangerous flags
804    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
1059// Additional validators for common utilities
1060async fn validate_tail(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1061    // tail is read-only, similar to head
1062    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    // grep is read-only pattern search
1073    let mut pattern_seen = false;
1074    for arg in args {
1075        if !arg.starts_with('-') && pattern_seen {
1076            // Files come after pattern
1077            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    // date just displays current date/time, safe with format args
1088    for arg in args {
1089        if arg.starts_with('+') {
1090            // Format string is safe
1091            continue;
1092        }
1093    }
1094    Ok(())
1095}
1096
1097fn validate_whoami(_args: &[String]) -> Result<()> {
1098    // whoami has no arguments, always safe
1099    Ok(())
1100}
1101
1102fn validate_hostname(_args: &[String]) -> Result<()> {
1103    // hostname has no arguments, always safe
1104    Ok(())
1105}
1106
1107fn validate_uname(args: &[String]) -> Result<()> {
1108    // uname only accepts specific flags
1109    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    // wc is read-only, similar to head
1120    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    // Cargo commands - allow typical dev workflow operations
1136    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        // Safe read-only, build, and development operations
1143        "build" | "check" | "test" | "doc" | "clippy" | "fmt" | "run" | "bench" | "expand"
1144        | "tree" | "metadata" | "search" | "cache" => {
1145            // These are generally safe - check working directory is in workspace
1146            ensure_within_workspace(workspace_root, working_dir).await?;
1147            Ok(())
1148        }
1149        // Operations that may be destructive or publish to remote registries
1150        "clean" | "install" | "uninstall" | "publish" | "yank" => {
1151            if confirm {
1152                // Allow with explicit confirmation
1153                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    // Python allows running scripts and modules - safe for workspace
1171    ensure_within_workspace(workspace_root, working_dir).await?;
1172    // Don't allow -c or eval-like flags that could be dangerous - only file/module execution
1173    if args.is_empty() {
1174        return Ok(()); // python interactive is allowed
1175    }
1176
1177    let first_arg = &args[0];
1178    if first_arg == "-c" || first_arg == "-m" || first_arg == "-W" {
1179        // Allow -m (module), -W (warnings), but validate any file paths
1180        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        // It's a script file - validate it exists in workspace
1186        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    // NPM commands - allow typical dev operations
1194    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        // Dangerous operations
1202        "publish" | "unpublish" => Err(anyhow!(
1203            "npm {} is not permitted by the execution policy",
1204            subcommand
1205        )),
1206        // Allow safe and other commands by default, as npm is generally safe in workspace
1207        _ => Ok(()),
1208    }
1209}
1210
1211async fn validate_node(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
1212    // Node.js script execution - safe for workspace
1213    ensure_within_workspace(workspace_root, working_dir).await?;
1214    if args.is_empty() {
1215        return Ok(()); // node interactive/REPL
1216    }
1217
1218    let first_arg = &args[0];
1219    if !first_arg.starts_with('-') {
1220        // It's a script file - validate it exists in workspace
1221        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        // Safe read-only operations
1259        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        // Dangerous patterns blocked
1266        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        // Valid commits
1274        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        // Invalid commits
1279        assert!(validate_git_commit(&["-m".to_owned()]).is_err()); // Missing message
1280        assert!(validate_git_commit(&["--invalid-flag".to_owned()]).is_err());
1281    }
1282
1283    #[test]
1284    fn test_validate_git_reset() {
1285        // Safe reset modes
1286        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        // Dangerous reset modes
1292        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        // Safe stash operations
1300        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        // Dangerous operations
1307        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        // Safe read-only operations should be allowed
1317        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        // Dangerous operations should be blocked
1347        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()); // Contains /
1395        assert!(validate_which(&["ls git".to_owned()]).is_err()); // Contains space
1396    }
1397}