1use 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
20const 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
38fn validate_shell_override(shell: &str) -> Result<String> {
42 let trimmed = shell.trim();
43
44 for safe_shell in SAFE_SHELLS {
46 if trimmed == *safe_shell {
47 return Ok(trimmed.to_string());
48 }
49 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#[derive(Clone)]
67pub struct CommandTool {
68 workspace_root: PathBuf,
69 policy: CommandPolicyEvaluator,
70 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 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 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 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 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 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 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 validate_command(command, &self.workspace_root, &working_dir, confirm_ok).await?;
159 }
160 }
161
162 if risky_command && confirm_ok {
163 log_audit_for_command(&format_command(command), "Confirmed destructive operation by agent");
165 }
166
167 let resolved_invocation = if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') {
171 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 let shell = if let Some(ref shell_override) = input.shell {
181 if !shell_override.trim().is_empty() {
182 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 #[cfg(test)]
207 async fn validate_args(&self, input: &EnhancedTerminalInput) -> Result<()> {
208 self.prepare_invocation(input).await.map(|_| ())
209 }
210}
211
212#[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 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 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" }
256
257fn log_audit_for_command(_command: &str, _reason: &str) {
258 }
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 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 let env: HashMap<OsString, OsString> = std::env::vars_os().collect();
552
553 assert!(
555 env.contains_key(&OsString::from("PATH")),
556 "PATH environment variable must be inherited for command resolution"
557 );
558 }
559}