Skip to main content

vtcode_safety/exec_policy/command_validation/
mod.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
13mod git;
14
15use git::{
16    validate_git_add, validate_git_checkout, validate_git_commit, validate_git_merge, validate_git_read_only,
17    validate_git_reset, validate_git_stash,
18};
19
20/// Helper to validate flags in arguments (reduces duplication in 10+ validators)
21/// Returns error if any flag not in allowed_flags is found
22fn validate_allowed_flags(args: &[String], allowed_flags: &[&str], command_name: &str) -> Result<()> {
23    for arg in args {
24        if arg.starts_with('-') && !allowed_flags.contains(&arg.as_str()) {
25            return Err(anyhow!("unsupported {command_name} flag '{arg}'"));
26        }
27    }
28    Ok(())
29}
30
31/// Helper to validate that a command accepts no arguments
32fn validate_no_args(args: &[String], command_name: &str) -> Result<()> {
33    if args.is_empty() {
34        Ok(())
35    } else {
36        Err(anyhow!("{command_name} does not accept arguments"))
37    }
38}
39
40/// Validate whether a command is allowed to run under the execution policy.
41///
42/// The policy is inspired by the Codex execution policy and limits commands to
43/// a curated allow-list with argument validation to prevent workspace
44/// breakout or destructive actions.
45pub async fn validate_command(
46    command: &[String],
47    workspace_root: &Path,
48    working_dir: &Path,
49    confirm: bool,
50) -> Result<()> {
51    if command.is_empty() {
52        return Err(anyhow!("command cannot be empty"));
53    }
54
55    let (program, args) = command
56        .split_first()
57        .ok_or_else(|| anyhow!("command cannot be empty (unexpected)"))?;
58    let program = program.as_str();
59
60    match program {
61        "echo" => validate_echo(args),
62        "ls" => validate_ls(args, workspace_root, working_dir).await,
63        "cat" => validate_cat(args, workspace_root, working_dir).await,
64        "cp" => validate_cp(args, workspace_root, working_dir).await,
65        "head" => validate_head(args, workspace_root, working_dir).await,
66        "tail" => validate_tail(args, workspace_root, working_dir).await,
67        "printenv" => validate_printenv(args),
68        "pwd" => validate_pwd(args),
69        "rg" => validate_rg(args, workspace_root, working_dir).await,
70        "grep" => validate_grep(args, workspace_root, working_dir).await,
71        "sed" => validate_sed(args, workspace_root, working_dir).await,
72        "which" => validate_which(args),
73        "date" => validate_date(args),
74        "whoami" => validate_whoami(args),
75        "hostname" => validate_hostname(args),
76        "uname" => validate_uname(args),
77        "wc" => validate_wc(args, workspace_root, working_dir).await,
78        "git" => validate_git(args, workspace_root, working_dir, confirm).await,
79        "cargo" => validate_cargo(args, workspace_root, working_dir, confirm).await,
80        "python" | "python3" => validate_python(args, workspace_root, working_dir).await,
81        "npm" => validate_npm(args, workspace_root, working_dir).await,
82        "node" => validate_node(args, workspace_root, working_dir).await,
83        other => Err(anyhow!("command '{other}' is not permitted by the execution policy")),
84    }
85}
86
87/// Normalize a working directory relative to the workspace root.
88pub async fn sanitize_working_dir(workspace_root: &Path, working_dir: Option<&str>) -> Result<PathBuf> {
89    let normalized_root = normalize_workspace_root(workspace_root)?;
90    if let Some(dir) = working_dir {
91        if dir.trim().is_empty() {
92            return Ok(normalized_root);
93        }
94        let candidate = normalize_path(&normalized_root.join(dir));
95        if !candidate.starts_with(&normalized_root) {
96            return Err(anyhow!("working directory '{dir}' escapes the workspace root"));
97        }
98        ensure_within_workspace(&normalized_root, &candidate).await?;
99        Ok(candidate)
100    } else {
101        Ok(normalized_root)
102    }
103}
104
105fn validate_echo(args: &[String]) -> Result<()> {
106    validate_allowed_flags(args, &["-n", "-e", "-E"], "echo")
107}
108
109async fn validate_ls(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
110    let allowed_ls_flags = &["-1", "-a", "-l"];
111    for arg in args {
112        if arg.starts_with('-') {
113            if !allowed_ls_flags.contains(&arg.as_str()) {
114                return Err(anyhow!("unsupported ls flag '{arg}'"));
115            }
116        } else {
117            let path = resolve_path(workspace_root, working_dir, arg).await?;
118            ensure_path_exists(&path)?;
119        }
120    }
121    Ok(())
122}
123
124async fn validate_cat(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
125    let allowed_cat_flags = &["-b", "-n", "-t"];
126    let mut files = Vec::with_capacity(args.len());
127
128    for arg in args {
129        if arg.starts_with('-') {
130            if !allowed_cat_flags.contains(&arg.as_str()) {
131                return Err(anyhow!("unsupported cat flag '{arg}'"));
132            }
133        } else {
134            let path = resolve_path(workspace_root, working_dir, arg).await?;
135            ensure_is_file(&path).await?;
136            files.push(path);
137        }
138    }
139
140    if files.is_empty() {
141        return Err(anyhow!("cat requires at least one readable file"));
142    }
143
144    Ok(())
145}
146
147async fn validate_cp(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
148    let mut positional = Vec::new();
149    let mut allow_recursive = false;
150
151    for arg in args {
152        match arg.as_str() {
153            "-r" | "-R" | "--recursive" => {
154                allow_recursive = true;
155            }
156            value if value.starts_with('-') => {
157                return Err(anyhow!("unsupported cp flag '{value}'"));
158            }
159            value => positional.push(value.to_owned()),
160        }
161    }
162
163    if positional.len() < 2 {
164        return Err(anyhow!("cp requires a source and destination"));
165    }
166
167    let dest_raw = positional
168        .last()
169        .ok_or_else(|| anyhow!("cp command missing destination path"))?;
170    let sources = &positional[..positional.len() - 1];
171
172    for source in sources {
173        let path = resolve_path(workspace_root, working_dir, source).await?;
174        let metadata = fs::metadata(&path)
175            .await
176            .with_context(|| format!("failed to inspect source '{source}'"))?;
177        if metadata.is_dir() && !allow_recursive {
178            return Err(anyhow!("copying directories requires the recursive flag for '{source}'"));
179        }
180        if !metadata.is_file() && !metadata.is_dir() {
181            return Err(anyhow!("unsupported source type for '{source}'"));
182        }
183    }
184
185    let dest_path = resolve_path_allow_new(workspace_root, working_dir, dest_raw).await?;
186    if let Some(parent) = dest_path.parent()
187        && !fs::try_exists(parent).await.unwrap_or(false)
188    {
189        return Err(anyhow!("destination parent '{}' must exist", parent.display()));
190    }
191
192    Ok(())
193}
194
195async fn validate_head(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
196    let mut positional = Vec::new();
197    let mut index = 0;
198
199    while index < args.len() {
200        let current = &args[index];
201        match current.as_str() {
202            "-c" | "-n" => {
203                let value = args
204                    .get(index + 1)
205                    .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
206                let _parsed =
207                    parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
208                index += 2;
209            }
210            value if value.starts_with('-') => {
211                return Err(anyhow!("unsupported head flag '{value}'"));
212            }
213            value => {
214                positional.push(value);
215                index += 1;
216            }
217        }
218    }
219
220    if positional.is_empty() {
221        return Err(anyhow!("head requires at least one file"));
222    }
223
224    for file in positional {
225        let path = resolve_path(workspace_root, working_dir, file).await?;
226        ensure_is_file(&path).await?;
227    }
228
229    Ok(())
230}
231
232fn validate_printenv(args: &[String]) -> Result<()> {
233    match args.len() {
234        0 => Ok(()),
235        1 => {
236            let name = &args[0];
237            if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
238                return Err(anyhow!("invalid environment variable name '{name}'"));
239            }
240            Ok(())
241        }
242        _ => Err(anyhow!("printenv accepts zero or one argument")),
243    }
244}
245
246fn validate_pwd(args: &[String]) -> Result<()> {
247    validate_no_args(args, "pwd")
248}
249
250async fn validate_rg(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
251    let mut index = 0;
252    let mut allow_no_pattern = false;
253
254    while index < args.len() {
255        let current = &args[index];
256        if current == "--" {
257            index += 1;
258            break;
259        }
260
261        match current.as_str() {
262            // SECURITY: Block preprocessor flags that enable arbitrary command execution
263            "--pre" | "--pre-glob" => {
264                return Err(anyhow!(
265                    "ripgrep preprocessor flag '{current}' is not permitted for security reasons. \
266                     This flag enables arbitrary command execution."
267                ));
268            }
269            "-A" | "-B" | "-C" | "-d" | "--max-depth" | "-m" | "--max-count" => {
270                let value = args
271                    .get(index + 1)
272                    .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
273                let _parsed =
274                    parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
275                index += 2;
276            }
277            "-g" | "--glob" => {
278                let value = args
279                    .get(index + 1)
280                    .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
281                if value.is_empty() {
282                    return Err(anyhow!("glob value for '{current}' cannot be empty"));
283                }
284                index += 2;
285            }
286            "-n" | "-i" | "-l" | "--files" | "--files-with-matches" | "--files-without-match" => {
287                if matches!(current.as_str(), "--files" | "--files-with-matches" | "--files-without-match") {
288                    allow_no_pattern = true;
289                }
290                index += 1;
291            }
292            value if value.starts_with('-') => {
293                return Err(anyhow!("unsupported ripgrep flag '{value}'"));
294            }
295            _ => break,
296        }
297    }
298
299    let remaining = &args[index..];
300    if remaining.is_empty() && !allow_no_pattern {
301        return Err(anyhow!("ripgrep requires a pattern unless file listing flags are used"));
302    }
303
304    let mut rem_index = 0;
305    if !remaining.is_empty() {
306        let pattern = &remaining[0];
307        if pattern.is_empty() {
308            return Err(anyhow!("ripgrep pattern cannot be empty"));
309        }
310        rem_index = 1;
311    }
312
313    if remaining.len() > rem_index {
314        let search_root = &remaining[rem_index];
315        let path = resolve_path_allow_dir(workspace_root, working_dir, search_root).await?;
316        if !fs::try_exists(&path).await.unwrap_or(false) {
317            return Err(anyhow!("search path '{search_root}' does not exist"));
318        }
319        if remaining.len() > rem_index + 1 {
320            return Err(anyhow!("ripgrep accepts at most one search path"));
321        }
322    }
323
324    Ok(())
325}
326
327async fn validate_sed(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
328    let mut commands = Vec::new();
329    let mut files = Vec::new();
330    let mut index = 0;
331
332    while index < args.len() {
333        let current = &args[index];
334        match current.as_str() {
335            "-n" | "-u" => {
336                index += 1;
337            }
338            "-e" => {
339                let value = args.get(index + 1).ok_or_else(|| anyhow!("-e requires a sed command"))?;
340                ensure_safe_sed_command(value)?;
341                commands.push(value.clone());
342                index += 2;
343            }
344            value if value.starts_with('-') => {
345                return Err(anyhow!("unsupported sed flag '{value}'"));
346            }
347            value => {
348                if commands.is_empty() {
349                    ensure_safe_sed_command(value)?;
350                    commands.push(value.to_owned());
351                    index += 1;
352                } else {
353                    let path = resolve_path(workspace_root, working_dir, value).await?;
354                    ensure_is_file(&path).await?;
355                    files.push(path);
356                    index += 1;
357                }
358            }
359        }
360    }
361
362    if commands.is_empty() {
363        return Err(anyhow!("sed requires at least one command"));
364    }
365
366    if files.is_empty() {
367        return Err(anyhow!("sed requires at least one readable file"));
368    }
369
370    Ok(())
371}
372
373fn validate_which(args: &[String]) -> Result<()> {
374    if args.is_empty() {
375        return Err(anyhow!("which requires at least one program name"));
376    }
377
378    for arg in args {
379        match arg.as_str() {
380            "-a" | "-s" => continue,
381            value if value.starts_with('-') => {
382                return Err(anyhow!("unsupported which flag '{value}'"));
383            }
384            value => {
385                if value.is_empty() || value.contains('/') || value.chars().any(|ch| ch.is_whitespace()) {
386                    return Err(anyhow!("program name '{value}' contains unsupported characters"));
387                }
388            }
389        }
390    }
391
392    Ok(())
393}
394
395async fn validate_git(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
396    if args.is_empty() {
397        return Err(anyhow!("git requires a subcommand"));
398    }
399
400    let subcommand = args[0].as_str();
401    let subargs = &args[1..];
402
403    // Tier 1: Safe read-only operations (always allowed)
404    match subcommand {
405        // Status and history operations
406        "status" | "log" | "show" | "diff" | "branch" | "tag" | "remote" => {
407            // For tag command, add specific validation
408            if subcommand == "tag" && !subargs.is_empty() && !subargs[0].starts_with('-') {
409                // This condition was originally in the unreachable pattern
410                // but now it's handled within the general case for "tag"
411            }
412            validate_git_read_only(subcommand, subargs)
413        }
414
415        // Tree and object inspection
416        "ls-tree" | "ls-files" | "cat-file" | "rev-parse" | "describe" => validate_git_read_only(subcommand, subargs),
417
418        // Config inspection (read-only)
419        "config" if subargs.is_empty() || subargs.iter().all(|a| !a.starts_with("--")) => {
420            validate_git_read_only(subcommand, subargs)
421        }
422
423        // Additional inspection commands
424        "blame" | "grep" | "shortlog" | "format-patch" => validate_git_read_only(subcommand, subargs),
425
426        // Stash operations (safe list/show)
427        "stash" if matches!(subargs.first().map(|s| s.as_str()), Some("list" | "show" | "pop" | "apply" | "drop")) => {
428            validate_git_stash(subargs)
429        }
430
431        // Tier 2: Safe write operations (with validation)
432        "add" => validate_git_add(subargs, workspace_root, working_dir).await,
433        "commit" => validate_git_commit(subargs),
434        "reset" => validate_git_reset(subargs, confirm),
435        "checkout" | "switch" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
436        "restore" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
437        "merge" => validate_git_merge(subargs),
438
439        // Tier 3: Dangerous operations (always blocked)
440        "push" => {
441            // Check for force flags
442            if subargs.iter().any(|a| a.contains("force") || a == "-f" || a == "--no-verify") {
443                Err(anyhow!("git push with force flags is not permitted. Use safe push operations only."))
444            } else {
445                validate_git_read_only(subcommand, subargs)
446            }
447        }
448
449        "force-push" => Err(anyhow!("git force-push is not permitted by the execution policy")),
450
451        "clean" => {
452            Err(anyhow!("git clean is not permitted by the execution policy. Use explicit rm commands instead."))
453        }
454
455        "gc" if subargs.iter().any(|a| a.contains("aggressive")) => {
456            Err(anyhow!("git gc with aggressive flag is not permitted"))
457        }
458
459        "filter-branch" | "rebase" | "cherry-pick" => {
460            Err(anyhow!("git {subcommand} is not permitted - complex history operations require confirmation"))
461        }
462
463        other => Err(anyhow!("git subcommand '{other}' is not permitted by the execution policy")),
464    }
465}
466
467fn normalize_workspace_root(workspace_root: &Path) -> Result<PathBuf> {
468    if workspace_root.is_absolute() {
469        return Ok(normalize_path(workspace_root));
470    }
471
472    let cwd = env::current_dir().context("failed to resolve current working directory")?;
473    Ok(normalize_path(&cwd.join(workspace_root)))
474}
475
476fn ensure_path_exists(path: &Path) -> Result<()> {
477    if path.exists() {
478        Ok(())
479    } else {
480        Err(anyhow!("path '{}' does not exist", path.display()))
481    }
482}
483
484async fn ensure_is_file(path: &Path) -> Result<()> {
485    let metadata = fs::metadata(path)
486        .await
487        .with_context(|| format!("failed to inspect '{}'", path.display()))?;
488    if metadata.is_file() {
489        Ok(())
490    } else {
491        Err(anyhow!("'{}' is not a file", path.display()))
492    }
493}
494
495fn parse_positive_int(value: &str) -> Result<u64> {
496    let parsed: u64 = value.parse()?;
497    if parsed == 0 {
498        return Err(anyhow!("value must be greater than zero"));
499    }
500    Ok(parsed)
501}
502
503fn ensure_safe_sed_command(value: &str) -> Result<()> {
504    if value.trim().is_empty() {
505        return Err(anyhow!("sed command cannot be empty"));
506    }
507    if value.contains([';', '|', '&', '`']) {
508        return Err(anyhow!("sed command contains unsupported control characters"));
509    }
510
511    let mut chars = value.chars();
512    if chars.next() != Some('s') {
513        return Err(anyhow!("only sed substitution commands are supported"));
514    }
515    let delimiter = chars.next().ok_or_else(|| anyhow!("sed substitution is missing a delimiter"))?;
516    if delimiter.is_ascii_alphanumeric() || delimiter.is_ascii_whitespace() {
517        return Err(anyhow!("invalid sed delimiter"));
518    }
519
520    let mut pattern = String::new();
521    let mut replacement = String::new();
522    let mut flags = String::new();
523
524    parse_sed_section(&mut chars, delimiter, &mut pattern)?;
525    parse_sed_section(&mut chars, delimiter, &mut replacement)?;
526    collect_sed_flags(chars, &mut flags)?;
527
528    if flags.chars().any(|ch| matches!(ch, 'e' | 'E' | 'F' | 'f')) {
529        return Err(anyhow!("sed execution flags are not permitted in substitution"));
530    }
531
532    Ok(())
533}
534
535async fn ensure_within_workspace(normalized_root: &Path, candidate: &Path) -> Result<()> {
536    vtcode_commons::paths::ensure_path_within_workspace_resolved(candidate, normalized_root)
537        .await
538        .map(|_| ())
539}
540
541fn parse_sed_section(chars: &mut std::str::Chars<'_>, delimiter: char, target: &mut String) -> Result<()> {
542    let mut escaped = false;
543    for ch in chars.by_ref() {
544        if escaped {
545            target.push(ch);
546            escaped = false;
547            continue;
548        }
549        match ch {
550            '\\' => {
551                escaped = true;
552            }
553            value if value == delimiter => {
554                return Ok(());
555            }
556            other => target.push(other),
557        }
558    }
559    Err(anyhow!("sed command is missing a closing delimiter"))
560}
561
562fn collect_sed_flags(chars: std::str::Chars<'_>, target: &mut String) -> Result<()> {
563    for ch in chars {
564        if ch.is_ascii_alphabetic() {
565            target.push(ch);
566        } else {
567            return Err(anyhow!("sed flags contain unsupported characters"));
568        }
569    }
570    Ok(())
571}
572
573// Additional validators for common utilities
574async fn validate_tail(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
575    // tail is read-only, similar to head
576    for arg in args {
577        if !arg.starts_with('-') {
578            let path = normalize_path(&working_dir.join(arg));
579            ensure_within_workspace(workspace_root, &path).await?;
580        }
581    }
582    Ok(())
583}
584
585async fn validate_grep(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
586    // grep is read-only pattern search
587    let mut pattern_seen = false;
588    for arg in args {
589        if !arg.starts_with('-') && pattern_seen {
590            // Files come after pattern
591            let path = normalize_path(&working_dir.join(arg));
592            ensure_within_workspace(workspace_root, &path).await?;
593        } else if !arg.starts_with('-') {
594            pattern_seen = true;
595        }
596    }
597    Ok(())
598}
599
600fn validate_date(args: &[String]) -> Result<()> {
601    // date just displays current date/time, safe with format args
602    for arg in args {
603        if arg.starts_with('+') {
604            // Format string is safe
605            continue;
606        }
607    }
608    Ok(())
609}
610
611fn validate_whoami(_args: &[String]) -> Result<()> {
612    // whoami has no arguments, always safe
613    Ok(())
614}
615
616fn validate_hostname(_args: &[String]) -> Result<()> {
617    // hostname has no arguments, always safe
618    Ok(())
619}
620
621fn validate_uname(args: &[String]) -> Result<()> {
622    // uname only accepts specific flags
623    let safe_flags = ["-a", "-s", "-n", "-r", "-v", "-m"];
624    for arg in args {
625        if arg.starts_with('-') && !safe_flags.contains(&arg.as_str()) {
626            return Err(anyhow!("unsupported uname flag '{arg}'"));
627        }
628    }
629    Ok(())
630}
631
632async fn validate_wc(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
633    // wc is read-only, similar to head
634    for arg in args {
635        if !arg.starts_with('-') {
636            let path = normalize_path(&working_dir.join(arg));
637            ensure_within_workspace(workspace_root, &path).await?;
638        }
639    }
640    Ok(())
641}
642
643async fn validate_cargo(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
644    // Cargo commands - allow typical dev workflow operations
645    if args.is_empty() {
646        return Err(anyhow!("cargo requires a subcommand"));
647    }
648
649    let subcommand = args[0].as_str();
650    match subcommand {
651        // Safe read-only, build, and development operations
652        "build" | "check" | "test" | "doc" | "clippy" | "fmt" | "run" | "bench" | "expand" | "tree" | "metadata"
653        | "search" | "cache" => {
654            // These are generally safe - check working directory is in workspace
655            ensure_within_workspace(workspace_root, working_dir).await?;
656            Ok(())
657        }
658        // Operations that may be destructive or publish to remote registries
659        "clean" | "install" | "uninstall" | "publish" | "yank" => {
660            if confirm {
661                // Allow with explicit confirmation
662                ensure_within_workspace(workspace_root, working_dir).await?;
663                Ok(())
664            } else {
665                Err(anyhow!("cargo {subcommand} is potentially destructive; set `confirm=true` to proceed."))
666            }
667        }
668        other => Err(anyhow!("cargo subcommand '{other}' is not permitted by the execution policy")),
669    }
670}
671
672async fn validate_python(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
673    // Python allows running scripts and modules - safe for workspace
674    ensure_within_workspace(workspace_root, working_dir).await?;
675    if args.is_empty() {
676        return Ok(()); // python interactive is allowed
677    }
678
679    let first_arg = &args[0];
680
681    // Reject -c (execute code string) - arbitrary code execution risk
682    if first_arg == "-c" {
683        return Err(anyhow!(
684            "python -c is not permitted for security reasons: \
685             it enables arbitrary code execution outside the workspace sandbox"
686        ));
687    }
688
689    // Reject -e and -c variants that bypass file-based execution
690    if first_arg == "-e" || first_arg == "-exec" {
691        return Err(anyhow!("python {first_arg} is not permitted: it enables arbitrary code execution"));
692    }
693
694    if first_arg == "-m" || first_arg == "-W" {
695        // Allow -m (module), -W (warnings), but validate any file paths
696        if first_arg != "-m" && args.len() > 1 {
697            let path = normalize_path(&working_dir.join(&args[1]));
698            ensure_within_workspace(workspace_root, &path).await?;
699        }
700    } else if !first_arg.starts_with('-') {
701        // It's a script file - validate it exists in workspace
702        let path = normalize_path(&working_dir.join(first_arg));
703        ensure_within_workspace(workspace_root, &path).await?;
704    }
705    Ok(())
706}
707
708async fn validate_npm(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
709    // NPM commands - allow typical dev operations
710    ensure_within_workspace(workspace_root, working_dir).await?;
711    if args.is_empty() {
712        return Ok(());
713    }
714
715    let subcommand = args[0].as_str();
716    match subcommand {
717        // Dangerous operations
718        "publish" | "unpublish" => Err(anyhow!("npm {subcommand} is not permitted by the execution policy")),
719        // Allow safe and other commands by default, as npm is generally safe in workspace
720        _ => Ok(()),
721    }
722}
723
724async fn validate_node(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
725    // Node.js script execution - safe for workspace
726    ensure_within_workspace(workspace_root, working_dir).await?;
727    if args.is_empty() {
728        return Ok(()); // node interactive/REPL
729    }
730
731    let first_arg = &args[0];
732
733    // Reject -e/--eval (execute code string) - arbitrary code execution risk
734    if first_arg == "-e" || first_arg == "--eval" {
735        return Err(anyhow!(
736            "node {first_arg} is not permitted for security reasons: \
737             it enables arbitrary code execution outside the workspace sandbox"
738        ));
739    }
740
741    if !first_arg.starts_with('-') {
742        // It's a script file - validate it exists in workspace
743        let path = normalize_path(&working_dir.join(first_arg));
744        ensure_within_workspace(workspace_root, &path).await?;
745    }
746    Ok(())
747}
748
749async fn resolve_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
750    let base = build_candidate_path(workspace_root, working_dir, value).await?;
751    if !fs::try_exists(&base).await.unwrap_or(false) {
752        return Err(anyhow!("path '{value}' does not exist"));
753    }
754    if !base.starts_with(workspace_root) {
755        return Err(anyhow!("path '{value}' is outside the workspace root"));
756    }
757    Ok(base)
758}
759
760async fn resolve_path_allow_new(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 resolve_path_allow_dir(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
769    let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
770    if !candidate.starts_with(workspace_root) {
771        return Err(anyhow!("path '{value}' is outside the workspace root"));
772    }
773    Ok(candidate)
774}
775
776async fn build_candidate_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
777    let normalized_root = normalize_workspace_root(workspace_root)?;
778    let normalized_working = normalize_path(working_dir);
779    let raw_path = Path::new(value);
780    let candidate = if raw_path.is_absolute() {
781        normalize_path(raw_path)
782    } else {
783        normalize_path(&normalized_working.join(raw_path))
784    };
785
786    if !candidate.starts_with(&normalized_root) {
787        return Err(anyhow!("path '{value}' escapes the workspace root"));
788    }
789    ensure_within_workspace(&normalized_root, &candidate).await?;
790    Ok(candidate)
791}
792
793#[cfg(test)]
794mod tests;