Skip to main content

vtcode_safety/exec_policy/
command_validation.rs

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