Skip to main content

vtcode_core/tools/
command.rs

1//! Command execution tool
2
3use super::types::*;
4use crate::command_safety::UnifiedCommandEvaluator;
5use crate::command_safety::command_might_be_dangerous;
6use crate::command_safety::unified::EvaluationReason;
7use crate::config::CommandsConfig;
8use crate::exec_policy::command_validation::{sanitize_working_dir, validate_command};
9use crate::tools::command_policy::CommandPolicyEvaluator;
10use crate::tools::path_env;
11use crate::tools::shell::resolve_fallback_shell;
12use anyhow::{Result, anyhow};
13#[cfg(test)]
14use hashbrown::HashMap;
15#[cfg(test)]
16use std::ffi::OsString;
17use std::path::PathBuf;
18use vtcode_commons::validation::NonEmptySlice;
19
20/// Known-safe shell binaries that can be used as shell overrides.
21/// This list is intentionally restrictive to prevent prompt-injected LLMs
22/// from specifying arbitrary executables as the shell.
23const SAFE_SHELLS: &[&str] = &[
24    "/bin/sh",
25    "/bin/bash",
26    "/bin/zsh",
27    "/usr/bin/sh",
28    "/usr/bin/bash",
29    "/usr/bin/zsh",
30    "/bin/dash",
31    "/usr/bin/dash",
32    "sh",
33    "bash",
34    "zsh",
35    "dash",
36];
37
38/// Validate that a shell override is one of the known-safe shells.
39///
40/// Returns `Ok(shell)` if the shell is safe, or an error if it's not in the allowed list.
41fn validate_shell_override(shell: &str) -> Result<String> {
42    let trimmed = shell.trim();
43
44    // Check if it's in the safe list (exact match or basename match)
45    for safe_shell in SAFE_SHELLS {
46        if trimmed == *safe_shell {
47            return Ok(trimmed.to_string());
48        }
49        // Also match basename (e.g., "/usr/local/bin/bash" matches "bash")
50        if let Some(basename) = PathBuf::from(trimmed).file_name().and_then(|n| n.to_str()) {
51            if basename == *safe_shell {
52                return Ok(trimmed.to_string());
53            }
54        }
55    }
56
57    Err(anyhow!(
58        "shell '{}' is not in the allowed list. \
59         Allowed shells: {}",
60        trimmed,
61        SAFE_SHELLS.join(", ")
62    ))
63}
64
65/// Command execution tool for non-PTY process handling with policy enforcement
66#[derive(Clone)]
67pub struct CommandTool {
68    workspace_root: PathBuf,
69    policy: CommandPolicyEvaluator,
70    /// Unified command evaluator combining policy and safety rules
71    unified_evaluator: UnifiedCommandEvaluator,
72    extra_path_entries: Vec<PathBuf>,
73}
74
75impl CommandTool {
76    pub fn new(workspace_root: PathBuf) -> Self {
77        Self::with_commands_config(workspace_root, CommandsConfig::default())
78    }
79
80    pub fn with_commands_config(workspace_root: PathBuf, commands_config: CommandsConfig) -> Self {
81        // Note: We use the workspace_root directly here. Full validation happens
82        // in prepare_invocation which is async.
83        let policy = CommandPolicyEvaluator::from_config(&commands_config);
84        let unified_evaluator = UnifiedCommandEvaluator::new();
85        let extra_path_entries =
86            path_env::compute_extra_search_paths(&commands_config.extra_path_entries, &workspace_root);
87        Self {
88            workspace_root,
89            policy,
90            unified_evaluator,
91            extra_path_entries,
92        }
93    }
94
95    pub fn update_commands_config(&mut self, commands_config: &CommandsConfig) {
96        self.policy = CommandPolicyEvaluator::from_config(commands_config);
97        self.unified_evaluator = UnifiedCommandEvaluator::new();
98        self.extra_path_entries =
99            path_env::compute_extra_search_paths(&commands_config.extra_path_entries, &self.workspace_root);
100    }
101
102    /// Check the configured command policy for an argv request.
103    pub fn policy_allows(&self, command: &[String]) -> bool {
104        self.policy.allows(command)
105    }
106
107    #[cfg_attr(
108        not(test),
109        expect(
110            dead_code,
111            reason = "Legacy invocation preparation remains available to test-only compatibility paths."
112        )
113    )]
114    async fn prepare_invocation(&self, input: &EnhancedTerminalInput) -> Result<CommandInvocation> {
115        let command = &input.command;
116        let parsed = NonEmptySlice::from_slice(command).ok_or_else(|| anyhow!("Command cannot be empty"))?;
117
118        let program = parsed.first();
119        // Validate that the executable is non-empty after trimming
120        if program.trim().is_empty() {
121            return Err(anyhow!("Command executable cannot be empty"));
122        }
123        if program.contains(char::is_whitespace) {
124            return Err(anyhow!("Program name cannot contain whitespace: {program}"));
125        }
126
127        let working_dir = sanitize_working_dir(&self.workspace_root, input.working_dir.as_deref()).await?;
128
129        // Unified command evaluation: combines safety rules + policy rules
130        let confirm_ok = input.confirm.unwrap_or(false);
131        let risky_command = is_risky_command(command);
132        if risky_command && !confirm_ok {
133            return Err(anyhow!(
134                "Command appears destructive. Do not self-approve: surface it to the operator, and only retry with `confirm: true` after the operator explicitly approves this exact command."
135            ));
136        }
137
138        let policy_allowed = self.policy.allows(command);
139
140        // Use unified evaluator with policy layer
141        let eval_result = self
142            .unified_evaluator
143            .evaluate_with_policy(command, policy_allowed, "config policy")
144            .await?;
145
146        if !eval_result.allowed {
147            if !policy_allowed {
148                return Err(anyhow!("command '{program}' is not permitted by the execution policy"));
149            }
150            // If unified evaluator denied, still allow explicitly confirmed risky commands
151            // when they are permitted by the configured policy.
152            let allow_confirmed_risky = risky_command
153                && confirm_ok
154                && policy_allowed
155                && matches!(eval_result.primary_reason, EvaluationReason::DangerousCommand(_));
156            if !allow_confirmed_risky {
157                // If unified evaluator denied, forward to validator for custom checks
158                validate_command(command, &self.workspace_root, &working_dir, confirm_ok).await?;
159            }
160        }
161
162        if risky_command && confirm_ok {
163            // Record audit for the explicitly confirmed destructive command
164            log_audit_for_command(&format_command(command), "Confirmed destructive operation by agent");
165        }
166
167        // If the program name includes a path separator or is absolute, execute it directly as provided
168        // (unless the caller explicitly requested a shell override). Otherwise, always use the
169        // user's login shell in `-lc` mode so PATH and environment are initialized consistently.
170        let resolved_invocation = if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') {
171            // Program provided as absolute/relative path: run directly
172            CommandInvocation {
173                program: program.to_owned(),
174                args: parsed.rest().to_vec(),
175                display: input.raw_command.clone().unwrap_or_else(|| format_command(command)),
176            }
177        } else {
178            // Honor explicit shell override provided in the input. If the caller set `login` to
179            // false, use `-c` (no login). Otherwise use `-lc` to force login shell semantics.
180            let shell = if let Some(ref shell_override) = input.shell {
181                if !shell_override.trim().is_empty() {
182                    // Validate the shell override against the safe list
183                    validate_shell_override(shell_override)?
184                } else {
185                    resolve_fallback_shell()
186                }
187            } else {
188                resolve_fallback_shell()
189            };
190            let use_login = input.login.unwrap_or(true);
191            let full_command = format_command(command);
192            CommandInvocation {
193                program: shell,
194                args: vec![
195                    if use_login { "-lc".to_owned() } else { "-c".to_owned() },
196                    full_command.clone(),
197                ],
198                display: full_command,
199            }
200        };
201
202        Ok(resolved_invocation)
203    }
204
205    /// Validate command arguments without executing them (test/helper)
206    #[cfg(test)]
207    async fn validate_args(&self, input: &EnhancedTerminalInput) -> Result<()> {
208        self.prepare_invocation(input).await.map(|_| ())
209    }
210}
211
212// NOTE: Tool and ModeTool trait implementations removed since CommandTool
213// is no longer registered as a public tool (RUN_COMMAND was deprecated).
214// CommandTool is kept for internal command preparation in the PTY system.
215
216#[derive(Debug, Clone)]
217#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
218pub(crate) struct CommandInvocation {
219    pub(crate) program: String,
220    pub(crate) args: Vec<String>,
221    pub(crate) display: String,
222}
223
224fn format_command(command: &[String]) -> String {
225    command
226        .iter()
227        .map(|part| quote_argument_posix(part))
228        .collect::<Vec<_>>()
229        .join(" ")
230}
231
232fn is_risky_command(command: &[String]) -> bool {
233    let Some(parsed) = NonEmptySlice::from_slice(command) else {
234        return false;
235    };
236
237    // Centralized detection for dangerous command patterns (git/rm/mkfs/dd/etc.).
238    if command_might_be_dangerous(command) {
239        return true;
240    }
241
242    let program = parsed.first().as_str();
243    let args = parsed.rest();
244
245    // Supplemental checks outside centralized detection coverage.
246    if program == "rm" && args.iter().any(|a| a == "/") {
247        return true;
248    }
249
250    if program == "docker" && args.iter().any(|a| a == "run" && args.iter().any(|b| b == "--privileged")) {
251        return true;
252    }
253
254    program == "kubectl" // kubectl operations can be destructive; require confirmation
255}
256
257fn log_audit_for_command(_command: &str, _reason: &str) {
258    // Audit logging removed - kept as no-op for backwards compatibility
259}
260
261fn quote_argument_posix(arg: &str) -> String {
262    if arg.is_empty() {
263        return "''".to_owned();
264    }
265
266    if arg.chars().all(|ch| ch.is_ascii_alphanumeric() || "-_./:@".contains(ch)) {
267        return arg.to_owned();
268    }
269
270    let mut quoted = String::from("'");
271    for ch in arg.chars() {
272        if ch == '\'' {
273            quoted.push_str("'\"'\"'");
274        } else {
275            quoted.push(ch);
276        }
277    }
278    quoted.push('\'');
279    quoted
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::tools::path_env;
286    use tempfile::tempdir;
287
288    fn make_tool() -> CommandTool {
289        let cwd = std::env::current_dir().expect("current dir");
290        CommandTool::new(cwd)
291    }
292
293    fn make_input(command: Vec<&str>) -> EnhancedTerminalInput {
294        EnhancedTerminalInput {
295            command: command.into_iter().map(String::from).collect(),
296            working_dir: None,
297            timeout_secs: None,
298            mode: None,
299            response_format: None,
300            raw_command: None,
301            shell: None,
302            login: None,
303            confirm: None,
304            max_tokens: None,
305        }
306    }
307
308    #[test]
309    fn formats_command_for_display() {
310        let parts = vec!["echo".to_string(), "hello world".to_string()];
311        assert_eq!(format_command(&parts), "echo 'hello world'");
312    }
313
314    #[tokio::test]
315    async fn prepare_invocation_allows_policy_command() {
316        let tool = make_tool();
317        let input = make_input(vec!["ls"]);
318        let invocation = tool.prepare_invocation(&input).await.expect("invocation");
319        let shell = resolve_fallback_shell();
320        assert_eq!(invocation.program, shell);
321        assert_eq!(invocation.args, vec!["-lc".to_owned(), "ls".to_owned()]);
322        assert_eq!(invocation.display, "ls");
323    }
324
325    #[tokio::test]
326    async fn prepare_invocation_allows_cargo_via_policy() {
327        let tool = make_tool();
328        let input = make_input(vec!["cargo", "check"]);
329        let invocation = tool.prepare_invocation(&input).await.expect("cargo check should be allowed");
330        let shell = resolve_fallback_shell();
331        assert_eq!(invocation.program, shell);
332        assert_eq!(invocation.args, vec!["-lc".to_owned(), "cargo check".to_owned()]);
333        assert_eq!(invocation.display, "cargo check");
334    }
335
336    #[tokio::test]
337    async fn prepare_invocation_rejects_command_not_in_policy() {
338        let tool = make_tool();
339        let input = make_input(vec!["custom-tool"]);
340        let error = tool
341            .prepare_invocation(&input)
342            .await
343            .expect_err("custom-tool should be blocked");
344        assert!(error.to_string().contains("is not permitted by the execution policy"));
345    }
346
347    #[tokio::test]
348    async fn prepare_invocation_requires_confirm_for_git_reset_hard() {
349        let tool = make_tool();
350        let input = make_input(vec!["git", "reset", "--hard"]);
351        // No explicit confirm set - should error
352        let error = tool
353            .prepare_invocation(&input)
354            .await
355            .expect_err("git reset --hard should require confirmation");
356        assert!(error.to_string().contains("Do not self-approve"));
357    }
358
359    #[tokio::test]
360    async fn prepare_invocation_allows_git_reset_with_confirm() {
361        let tool = make_tool();
362        let mut input = make_input(vec!["git", "reset", "--hard"]);
363        input.confirm = Some(true);
364        let invocation = tool
365            .prepare_invocation(&input)
366            .await
367            .expect("git reset --hard should be allowed when confirm=true");
368        assert!(invocation.display.contains("git reset"));
369    }
370
371    #[tokio::test]
372    async fn prepare_invocation_respects_custom_allow_list() {
373        let cwd = std::env::current_dir().expect("current dir");
374        let mut config = CommandsConfig::default();
375        config.allow_list.push("my-build".to_owned());
376        let tool = CommandTool::with_commands_config(cwd, config);
377        let input = make_input(vec!["my-build"]);
378        let invocation = tool
379            .prepare_invocation(&input)
380            .await
381            .expect("custom allow list should enable command");
382        let shell = resolve_fallback_shell();
383        assert_eq!(invocation.program, shell);
384        assert_eq!(invocation.args, vec!["-lc".to_owned(), "my-build".to_owned()]);
385    }
386
387    #[tokio::test]
388    async fn prepare_invocation_respects_shell_override_and_login_false() {
389        let cwd = std::env::current_dir().expect("current dir");
390        let tool = CommandTool::new(cwd);
391        let mut input = make_input(vec!["ls"]);
392        input.shell = Some("/bin/sh".to_string());
393        input.login = Some(false);
394        let invocation = tool.prepare_invocation(&input).await.expect("invocation");
395        assert_eq!(invocation.program, "/bin/sh".to_owned());
396        assert_eq!(invocation.args, vec!["-c".to_owned(), "ls".to_owned()]);
397    }
398
399    #[test]
400    fn resolve_program_path_respects_os_path_separator() {
401        let noise_dir = tempdir().expect("noise tempdir");
402        let target_dir = tempdir().expect("target tempdir");
403        let fake_tool_path = target_dir.path().join("fake-tool");
404        std::fs::write(&fake_tool_path, b"#!/bin/sh\n").expect("write fake tool");
405
406        #[cfg(unix)]
407        {
408            use std::os::unix::fs::PermissionsExt;
409            let mut perms = std::fs::metadata(&fake_tool_path).expect("metadata").permissions();
410            perms.set_mode(0o755);
411            std::fs::set_permissions(&fake_tool_path, perms).expect("set perms");
412        }
413
414        let custom_paths = vec![noise_dir.path().to_path_buf(), target_dir.path().to_path_buf()];
415        let resolved = path_env::resolve_program_path_from_paths("fake-tool", custom_paths.into_iter());
416        let expected = fake_tool_path.to_string_lossy().into_owned();
417        assert_eq!(resolved, Some(expected));
418    }
419
420    #[tokio::test]
421    async fn prepare_invocation_respects_custom_deny_list() {
422        let cwd = std::env::current_dir().expect("current dir");
423        let mut config = CommandsConfig::default();
424        config.deny_list.push("cargo".to_string());
425        let tool = CommandTool::with_commands_config(cwd, config);
426        let input = make_input(vec!["cargo", "check"]);
427        let error = tool.prepare_invocation(&input).await.expect_err("deny list should block cargo");
428        assert!(error.to_string().contains("is not permitted"));
429    }
430
431    #[tokio::test]
432    async fn prepare_invocation_uses_shell_for_command_execution() {
433        let tool = make_tool();
434        let input = make_input(vec!["cargo", "check"]);
435        let invocation = tool.prepare_invocation(&input).await.expect("invocation");
436        let shell = resolve_fallback_shell();
437        assert_eq!(invocation.program, shell);
438        assert_eq!(invocation.args, vec!["-lc".to_owned(), "cargo check".to_owned()]);
439        assert_eq!(invocation.display, "cargo check");
440    }
441
442    #[tokio::test]
443    async fn prepare_invocation_uses_extra_path_entries() {
444        let cwd = std::env::current_dir().expect("current dir");
445        let temp_dir = tempdir().expect("tempdir");
446        let binary_path = temp_dir.path().join("fake-extra");
447        std::fs::write(&binary_path, b"#!/bin/sh\n").expect("write fake binary");
448        #[cfg(unix)]
449        {
450            use std::os::unix::fs::PermissionsExt;
451            let mut perms = std::fs::metadata(&binary_path).expect("metadata").permissions();
452            perms.set_mode(0o755);
453            std::fs::set_permissions(&binary_path, perms).expect("set perms");
454        }
455
456        let mut config = CommandsConfig::default();
457        config.allow_list.push("fake-extra".to_owned());
458        config.extra_path_entries = vec![binary_path.parent().expect("parent").to_string_lossy().into_owned()];
459
460        let tool = CommandTool::with_commands_config(cwd, config);
461        let input = make_input(vec!["fake-extra"]);
462        let invocation = tool.prepare_invocation(&input).await.expect("extra path should allow command");
463        let shell = resolve_fallback_shell();
464        assert_eq!(invocation.program, shell);
465        assert_eq!(invocation.args, vec!["-lc".to_owned(), "fake-extra".to_owned()]);
466        assert_eq!(tool.extra_path_entries, vec![binary_path.parent().expect("parent").to_path_buf()]);
467    }
468
469    #[tokio::test]
470    async fn working_dir_escape_is_rejected() {
471        let tool = make_tool();
472        let mut input = make_input(vec!["ls"]);
473        input.working_dir = Some("../".into());
474        let error = tool
475            .prepare_invocation(&input)
476            .await
477            .expect_err("working dir escape should fail");
478        assert!(error.to_string().contains("working directory '../' escapes the workspace root"));
479    }
480
481    #[tokio::test]
482    async fn prepare_invocation_rejects_empty_command() {
483        let tool = make_tool();
484        let input = make_input(vec![]);
485        let error = tool
486            .prepare_invocation(&input)
487            .await
488            .expect_err("empty command should be rejected");
489        assert!(error.to_string().contains("Command cannot be empty"));
490    }
491
492    #[tokio::test]
493    async fn prepare_invocation_rejects_empty_executable() {
494        let tool = make_tool();
495        let input = make_input(vec!["", "arg1"]);
496        let error = tool
497            .prepare_invocation(&input)
498            .await
499            .expect_err("empty executable should be rejected");
500        assert!(error.to_string().contains("Command executable cannot be empty"));
501    }
502
503    #[tokio::test]
504    async fn prepare_invocation_rejects_whitespace_only_executable() {
505        let tool = make_tool();
506        let input = make_input(vec!["   ", "arg1"]);
507        let error = tool
508            .prepare_invocation(&input)
509            .await
510            .expect_err("whitespace-only executable should be rejected");
511        assert!(error.to_string().contains("Command executable cannot be empty"));
512    }
513
514    #[tokio::test]
515    async fn validate_args_rejects_empty_command() {
516        let tool = make_tool();
517        let args = make_input(vec![]);
518        let error = tool
519            .validate_args(&args)
520            .await
521            .expect_err("empty command should fail validation");
522        assert!(error.to_string().contains("Command cannot be empty"));
523    }
524
525    #[tokio::test]
526    async fn validate_args_rejects_empty_executable() {
527        let tool = make_tool();
528        let args = make_input(vec!["", "arg1"]);
529        let error = tool
530            .validate_args(&args)
531            .await
532            .expect_err("empty executable should fail validation");
533        assert!(error.to_string().contains("Command executable cannot be empty"));
534    }
535
536    #[tokio::test]
537    async fn validate_args_accepts_valid_command() {
538        let tool = make_tool();
539        let args = make_input(vec!["ls", "-la"]);
540        tool.validate_args(&args).await.expect("valid command should pass validation");
541    }
542
543    #[test]
544    fn environment_variables_are_inherited_from_parent() {
545        // Verify that the environment setup includes inherited parent process variables.
546        // This test documents the fix for the cargo fmt issue where PATH and other
547        // critical environment variables were not being passed to subprocesses.
548        // See: crates/codegen/vtcode-core/src/tools/command.rs:execute_terminal_command()
549
550        // The fix uses std::env::vars_os().collect() which inherits all parent variables
551        let env: HashMap<OsString, OsString> = std::env::vars_os().collect();
552
553        // Verify critical system variables are present
554        assert!(
555            env.contains_key(&OsString::from("PATH")),
556            "PATH environment variable must be inherited for command resolution"
557        );
558    }
559}