Skip to main content

vtcode_bash_runner/
runner.rs

1use crate::executor::{CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind};
2use crate::policy::CommandPolicy;
3use anyhow::{Context, Result, anyhow, bail};
4use path_clean::PathClean;
5use shell_escape::escape;
6use std::fs;
7use std::path::{Path, PathBuf};
8use vtcode_commons::{WorkspacePaths, canonicalize};
9
10pub struct BashRunner<E, P> {
11    executor: E,
12    policy: P,
13    workspace_root: PathBuf,
14    working_dir: PathBuf,
15    shell_kind: ShellKind,
16}
17
18impl<E, P> BashRunner<E, P>
19where
20    E: CommandExecutor,
21    P: CommandPolicy,
22{
23    pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
24        if !workspace_root.exists() {
25            bail!("workspace root `{}` does not exist", workspace_root.display());
26        }
27
28        let canonical_root = canonicalize(&workspace_root)
29            .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
30
31        Ok(Self {
32            executor,
33            policy,
34            workspace_root: canonical_root.clone(),
35            working_dir: canonical_root,
36            shell_kind: default_shell_kind(),
37        })
38    }
39
40    pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
41    where
42        W: WorkspacePaths,
43    {
44        Self::new(paths.workspace_root().to_path_buf(), executor, policy)
45    }
46
47    pub fn workspace_root(&self) -> &Path {
48        &self.workspace_root
49    }
50
51    fn working_dir(&self) -> &Path {
52        &self.working_dir
53    }
54
55    pub fn shell_kind(&self) -> ShellKind {
56        self.shell_kind
57    }
58
59    /// Authorization must resolve the current filesystem, never a cached symlink target.
60    fn resolve_canonical_path(&self, path: &Path) -> Result<PathBuf> {
61        canonicalize(path).with_context(|| format!("failed to canonicalize `{}`", path.display()))
62    }
63
64    pub fn cd(&mut self, path: &str) -> Result<()> {
65        let candidate = self.resolve_path(path)?;
66        if !candidate.exists() {
67            bail!("directory `{}` does not exist", candidate.display());
68        }
69        if !candidate.is_dir() {
70            bail!("path `{}` is not a directory", candidate.display());
71        }
72
73        let canonical = self.resolve_canonical_path(&candidate)?;
74
75        self.ensure_within_workspace(&canonical)?;
76
77        let invocation = CommandInvocation::new(
78            self.shell_kind,
79            format!("cd {}", format_path(self.shell_kind, &canonical)),
80            CommandCategory::ChangeDirectory,
81            canonical.clone(),
82        )
83        .with_paths(vec![canonical.clone()]);
84
85        self.policy.check(&invocation)?;
86        self.working_dir = canonical;
87        Ok(())
88    }
89
90    pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
91        let target = path
92            .map(|p| self.resolve_existing_path(p))
93            .transpose()?
94            .unwrap_or_else(|| self.working_dir.clone());
95
96        let command = match self.shell_kind {
97            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
98                .verb("ls")
99                .flag(if show_hidden { "la" } else { "l" })
100                .value(format_path(ShellKind::Unix, &target))
101                .build(),
102            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
103                .verb("Get-ChildItem")
104                .flag_if(show_hidden, "Force")
105                .named("Path", format_path(ShellKind::Windows, &target))
106                .build(),
107        };
108
109        let invocation =
110            CommandInvocation::new(self.shell_kind, command, CommandCategory::ListDirectory, self.working_dir.clone())
111                .with_paths(vec![target]);
112
113        let output = self.expect_success(invocation)?;
114        Ok(output.stdout)
115    }
116
117    pub fn pwd(&self) -> Result<String> {
118        let command = match self.shell_kind {
119            ShellKind::Unix => ShellCommand::new(ShellKind::Unix).verb("pwd").build(),
120            ShellKind::Windows => ShellCommand::new(ShellKind::Windows).verb("Get-Location").build(),
121        };
122        let invocation =
123            CommandInvocation::new(self.shell_kind, command, CommandCategory::PrintDirectory, self.working_dir.clone());
124        self.policy.check(&invocation)?;
125        Ok(self.working_dir.to_string_lossy().into_owned())
126    }
127
128    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
129        let target = self.resolve_path(path)?;
130        self.ensure_mutation_target_within_workspace(&target)?;
131
132        let command = match self.shell_kind {
133            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
134                .verb("mkdir")
135                .flag_if(parents, "p")
136                .value(format_path(ShellKind::Unix, &target))
137                .build(),
138            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
139                .verb("New-Item")
140                .flag("ItemType")
141                .value("Directory")
142                .flag_if(parents, "Force")
143                .named("Path", format_path(ShellKind::Windows, &target))
144                .build(),
145        };
146
147        let invocation = CommandInvocation::new(
148            self.shell_kind,
149            command,
150            CommandCategory::CreateDirectory,
151            self.working_dir.clone(),
152        )
153        .with_paths(vec![target]);
154
155        self.expect_success(invocation).map(|_| ())
156    }
157
158    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
159        let target = self.resolve_path(path)?;
160        // rm replaces its target itself, so the target must not be (or
161        // resolve through a symlink to) the workspace root. The canonical
162        // comparison closes the traversal (`rm ..` from a subdirectory),
163        // alias (`/tmp` vs `/private/tmp`) and symlink-to-root cases.
164        let target_canonical = if target.exists() {
165            Some(self.resolve_canonical_path(&target)?)
166        } else {
167            None
168        };
169        if let Some(canonical) = &target_canonical {
170            self.ensure_not_workspace_root(canonical)?;
171        }
172        self.ensure_mutation_target_within_workspace(&target)?;
173
174        let command = match self.shell_kind {
175            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
176                .verb("rm")
177                .flag_if(recursive, "r")
178                .flag_if(force, "f")
179                .value(format_path(ShellKind::Unix, &target))
180                .build(),
181            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
182                .verb("Remove-Item")
183                .flag_if(recursive, "Recurse")
184                .flag_if(force, "Force")
185                .named("Path", format_path(ShellKind::Windows, &target))
186                .build(),
187        };
188
189        let invocation =
190            CommandInvocation::new(self.shell_kind, command, CommandCategory::Remove, self.working_dir.clone())
191                .with_paths(vec![target]);
192
193        self.expect_success(invocation).map(|_| ())
194    }
195
196    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
197        let source_path = self.resolve_existing_path(source)?;
198        let dest_path = self.resolve_path(dest)?;
199        self.ensure_mutation_target_within_workspace(&dest_path)?;
200
201        let command = match self.shell_kind {
202            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
203                .verb("cp")
204                .flag_if(recursive, "r")
205                .value(format_path(ShellKind::Unix, &source_path))
206                .value(format_path(ShellKind::Unix, &dest_path))
207                .build(),
208            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
209                .verb("Copy-Item")
210                .named("Path", format_path(ShellKind::Windows, &source_path))
211                .named("Destination", format_path(ShellKind::Windows, &dest_path))
212                .flag_if(recursive, "Recurse")
213                .build(),
214        };
215
216        let invocation =
217            CommandInvocation::new(self.shell_kind, command, CommandCategory::Copy, self.working_dir.clone())
218                .with_paths(vec![source_path, dest_path]);
219
220        self.expect_success(invocation).map(|_| ())
221    }
222
223    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
224        let source_path = self.resolve_existing_path(source)?;
225        let dest_path = self.resolve_path(dest)?;
226        self.ensure_mutation_target_within_workspace(&dest_path)?;
227
228        let command = match self.shell_kind {
229            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
230                .verb("mv")
231                .value(format_path(ShellKind::Unix, &source_path))
232                .value(format_path(ShellKind::Unix, &dest_path))
233                .build(),
234            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
235                .verb("Move-Item")
236                .named("Path", format_path(ShellKind::Windows, &source_path))
237                .named("Destination", format_path(ShellKind::Windows, &dest_path))
238                .build(),
239        };
240
241        let invocation =
242            CommandInvocation::new(self.shell_kind, command, CommandCategory::Move, self.working_dir.clone())
243                .with_paths(vec![source_path, dest_path]);
244
245        self.expect_success(invocation).map(|_| ())
246    }
247
248    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
249        let target = path
250            .map(|p| self.resolve_existing_path(p))
251            .transpose()?
252            .unwrap_or_else(|| self.working_dir.clone());
253
254        let command = match self.shell_kind {
255            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
256                .verb("grep")
257                .flag("n")
258                .flag_if(recursive, "r")
259                .value(format_pattern(ShellKind::Unix, pattern))
260                .value(format_path(ShellKind::Unix, &target))
261                .build(),
262            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
263                .verb("Select-String")
264                .named("Pattern", format_pattern(ShellKind::Windows, pattern))
265                .named("Path", format_path(ShellKind::Windows, &target))
266                .value("-SimpleMatch")
267                .flag_if(recursive, "Recurse")
268                .build(),
269        };
270
271        let invocation =
272            CommandInvocation::new(self.shell_kind, command, CommandCategory::Search, self.working_dir.clone())
273                .with_paths(vec![target]);
274
275        let output = self.execute_invocation(invocation)?;
276        if output.status.success() {
277            return Ok(output.stdout);
278        }
279
280        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
281            Ok(String::new())
282        } else {
283            Err(anyhow!(
284                "search command failed: {}",
285                if output.stderr.trim().is_empty() {
286                    output.stdout
287                } else {
288                    output.stderr
289                }
290            ))
291        }
292    }
293
294    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
295        self.policy.check(&invocation)?;
296        self.executor.execute(&invocation)
297    }
298
299    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
300        let output = self.execute_invocation(invocation.clone())?;
301        if output.status.success() {
302            Ok(output)
303        } else {
304            Err(anyhow!(
305                "command `{}` failed: {}",
306                invocation.command,
307                if output.stderr.trim().is_empty() {
308                    output.stdout
309                } else {
310                    output.stderr
311                }
312            ))
313        }
314    }
315
316    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
317        let path = self.resolve_path(raw)?;
318        if !path.exists() {
319            bail!("path `{}` does not exist", path.display());
320        }
321
322        let canonical = self.resolve_canonical_path(&path)?;
323
324        self.ensure_within_workspace(&canonical)?;
325        Ok(canonical)
326    }
327
328    fn resolve_path(&self, raw: &str) -> Result<PathBuf> {
329        // An empty/whitespace path joins to the working directory itself
330        // (`PathBuf::join("")` returns the base), turning `rm -r -f ""` into
331        // a recursive delete of the workspace root. Reject it at the entry
332        // point shared by cd/mkdir/rm/cp/mv.
333        if raw.trim().is_empty() {
334            bail!("path must not be empty");
335        }
336        let candidate = Path::new(raw);
337        let joined = if candidate.is_absolute() {
338            candidate.to_path_buf()
339        } else {
340            self.working_dir.join(candidate)
341        };
342        Ok(joined.clean())
343    }
344
345    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
346        if let Ok(metadata) = fs::symlink_metadata(candidate)
347            && metadata.file_type().is_symlink()
348        {
349            let canonical = self.resolve_canonical_path(candidate)?;
350            return self.ensure_within_workspace(&canonical);
351        }
352
353        if candidate.exists() {
354            let canonical = self.resolve_canonical_path(candidate)?;
355            self.ensure_within_workspace(&canonical)
356        } else {
357            let parent = self.canonicalize_existing_parent(candidate)?;
358            self.ensure_within_workspace(&parent)
359        }
360    }
361
362    /// Refuse to operate on the workspace root itself. A recursive delete of
363    /// the root erases the entire workspace; the root can be reached
364    /// lexically (`rm -r .` from the root, `rm -r ..` from a subdirectory)
365    /// or through a symlink/absolute alias, so the check compares the
366    /// canonicalized target against the canonical workspace root.
367    fn ensure_not_workspace_root(&self, canonical_candidate: &Path) -> Result<()> {
368        if canonical_candidate == self.workspace_root {
369            bail!("refusing to operate on the workspace root itself (`{}`)", canonical_candidate.display());
370        }
371        Ok(())
372    }
373
374    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
375        let mut current = candidate.parent();
376        while let Some(path) = current {
377            if path.exists() {
378                return self.resolve_canonical_path(path);
379            }
380            current = path.parent();
381        }
382
383        Ok(self.working_dir.clone())
384    }
385
386    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
387        // `workspace_root` is canonicalized in the constructor and candidates
388        // arrive canonicalized, so the lexical check is sufficient here.
389        vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root).map_err(|error| {
390            error.context(format!(
391                "path `{}` escapes workspace root `{}`",
392                candidate.display(),
393                self.workspace_root.display()
394            ))
395        })?;
396        Ok(())
397    }
398}
399
400fn default_shell_kind() -> ShellKind {
401    if cfg!(windows) {
402        ShellKind::Windows
403    } else {
404        ShellKind::Unix
405    }
406}
407
408fn join_command(parts: Vec<String>) -> String {
409    parts.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" ")
410}
411
412fn format_path(shell: ShellKind, path: &Path) -> String {
413    match shell {
414        ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
415        ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
416    }
417}
418
419fn format_pattern(shell: ShellKind, pattern: &str) -> String {
420    match shell {
421        ShellKind::Unix => escape(pattern.into()).into_owned(),
422        ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
423    }
424}
425
426/// Fluent builder for shell-aware command strings.
427///
428/// `ShellKind::Unix` follows POSIX conventions (flags prefixed with `-`,
429/// arguments are positional). `ShellKind::Windows` targets PowerShell,
430/// which uses named switches in the form `-Name value`.
431struct ShellCommand {
432    shell: ShellKind,
433    parts: Vec<String>,
434}
435
436impl ShellCommand {
437    fn new(shell: ShellKind) -> Self {
438        Self { shell, parts: Vec::new() }
439    }
440
441    /// Append the command verb (first token).
442    fn verb(mut self, name: &str) -> Self {
443        self.parts.push(name.to_string());
444        self
445    }
446
447    /// Append a `-Name` flag unconditionally.
448    fn flag(mut self, name: &str) -> Self {
449        self.parts.push(format!("-{name}"));
450        self
451    }
452
453    /// Append a `-Name` flag only if `condition` holds.
454    fn flag_if(mut self, condition: bool, name: &str) -> Self {
455        if condition {
456            self.parts.push(format!("-{name}"));
457        }
458        self
459    }
460
461    /// Append a named parameter with a value. On Unix, the `name` is ignored
462    /// and the value is added as a positional argument. On Windows, the
463    /// pair is rendered as `-Name value`.
464    fn named(mut self, name: &str, value: impl Into<String>) -> Self {
465        let v = value.into();
466        let token = match self.shell {
467            ShellKind::Unix => v,
468            ShellKind::Windows => format!("-{name} {v}"),
469        };
470        self.parts.push(token);
471        self
472    }
473
474    /// Append a positional value rendered the same way on both shells.
475    fn value(mut self, value: impl Into<String>) -> Self {
476        self.parts.push(value.into());
477        self
478    }
479
480    fn build(self) -> String {
481        join_command(self.parts)
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
489    use crate::policy::AllowAllPolicy;
490    use assert_fs::TempDir;
491    use std::sync::{Arc, Mutex};
492
493    #[derive(Clone, Default)]
494    struct RecordingExecutor {
495        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
496    }
497
498    impl CommandExecutor for RecordingExecutor {
499        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
500            self.invocations
501                .lock()
502                .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
503                .push(invocation.clone());
504            Ok(CommandOutput {
505                status: CommandStatus::new(true, Some(0)),
506                stdout: String::new(),
507                stderr: String::new(),
508            })
509        }
510    }
511
512    #[test]
513    fn cd_updates_working_directory() -> Result<()> {
514        let dir = TempDir::new()?;
515        let nested = dir.path().join("nested");
516        fs::create_dir(&nested)?;
517        let runner = BashRunner::new(dir.path().to_path_buf(), RecordingExecutor::default(), AllowAllPolicy);
518        let mut runner = runner?;
519        runner.cd("nested")?;
520        // Canonicalize expected path to match runner's canonical working_dir
521        let expected = canonicalize(&nested)?;
522        assert_eq!(runner.working_dir(), expected);
523        Ok(())
524    }
525
526    #[test]
527    fn rm_rejects_empty_path_instead_of_targeting_workspace_root() -> Result<()> {
528        let dir = TempDir::new()?;
529        let executor = RecordingExecutor::default();
530        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
531
532        for empty in ["", "   ", "."] {
533            let result = runner.rm(empty, true, true);
534            assert!(result.is_err(), "rm({empty:?}) must be rejected");
535        }
536        assert!(
537            executor.invocations.lock().expect("invocations lock").is_empty(),
538            "no command must be built for empty paths"
539        );
540        Ok(())
541    }
542
543    #[test]
544    fn rm_rejects_workspace_root_via_parent_traversal_and_absolute_alias() -> Result<()> {
545        let dir = TempDir::new()?;
546        let executor = RecordingExecutor::default();
547        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
548        let mut runner = runner;
549        let canonical_root = runner.working_dir().to_path_buf();
550
551        // rm .. from a subdirectory resolves to the workspace root.
552        fs::create_dir_all(runner.working_dir().join("sub"))?;
553        runner.cd("sub")?;
554        assert!(runner.rm("..", true, true).is_err(), "rm('..') from sub must be rejected");
555        // Absolute path written through the non-canonical alias of the root.
556        let alias = dir.path().to_path_buf();
557        if alias != canonical_root {
558            assert!(
559                runner.rm(&alias.to_string_lossy(), true, true).is_err(),
560                "rm via non-canonical absolute alias must be rejected"
561            );
562        }
563        // The canonical root itself (rm is run from a subdirectory on purpose).
564        assert!(
565            runner.rm(&canonical_root.to_string_lossy(), true, true).is_err(),
566            "rm on the canonical root must be rejected"
567        );
568        // A symlink to the workspace root placed inside it.
569        let link = runner.working_dir().join("root-link");
570        #[cfg(unix)]
571        std::os::unix::fs::symlink(&canonical_root, &link).expect("create root symlink");
572        #[cfg(unix)]
573        assert!(
574            runner.rm(&link.to_string_lossy(), true, true).is_err(),
575            "rm through a symlink to the root must be rejected"
576        );
577        #[cfg(not(unix))]
578        let _ = &link;
579        assert!(
580            executor.invocations.lock().expect("invocations lock").is_empty(),
581            "no command must be built for root targets"
582        );
583        Ok(())
584    }
585
586    #[test]
587    fn mkdir_records_invocation() -> Result<()> {
588        let dir = TempDir::new()?;
589        let executor = RecordingExecutor::default();
590        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
591        runner?.mkdir("new_dir", true)?;
592        let invocations = executor
593            .invocations
594            .lock()
595            .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
596        assert_eq!(invocations.len(), 1);
597        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
598        Ok(())
599    }
600    #[cfg(unix)]
601    #[test]
602    fn symlink_retargeting_cannot_reuse_an_earlier_authorization() -> Result<()> {
603        let root = TempDir::new()?;
604        let outside = TempDir::new()?;
605        let inside = root.path().join("inside");
606        fs::create_dir(&inside)?;
607        let link = root.path().join("link");
608        std::os::unix::fs::symlink(&inside, &link)?;
609        let executor = RecordingExecutor::default();
610        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
611        runner.ls(Some("link"), false)?;
612        fs::remove_file(&link)?;
613        std::os::unix::fs::symlink(outside.path(), &link)?;
614        assert!(runner.ls(Some("link"), false).is_err());
615        assert!(runner.mkdir("link/new-directory", false).is_err());
616        assert_eq!(executor.invocations.lock().expect("invocations lock").len(), 1);
617        Ok(())
618    }
619
620    #[cfg(unix)]
621    #[test]
622    fn shell_command_keeps_metacharacters_inside_a_single_literal_argument() -> Result<()> {
623        let root = TempDir::new()?;
624        let executor = RecordingExecutor::default();
625        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
626        let filename = "literal;$(echo injected)'file";
627        runner.mkdir(filename, false)?;
628        let invocations = executor.invocations.lock().expect("invocations lock");
629        let command = &invocations[0].command;
630        let output = std::process::Command::new("sh").arg("-c").arg(command).output()?;
631        assert!(output.status.success());
632        assert!(runner.workspace_root().join(filename).is_dir());
633        Ok(())
634    }
635}