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