Skip to main content

vtcode_safety/exec_policy/
command_validation.rs

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