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)]
217pub(crate) struct CommandInvocation {
218 #[allow(
219 dead_code,
220 reason = "Internal command-invocation record kept for PTY preparation and Debug output."
221 )]
222 pub(crate) program: String,
223 #[allow(
224 dead_code,
225 reason = "Internal command-invocation record kept for PTY preparation and Debug output."
226 )]
227 pub(crate) args: Vec<String>,
228 #[allow(
229 dead_code,
230 reason = "Internal command-invocation record kept for PTY preparation and Debug output."
231 )]
232 pub(crate) display: String,
233}
234
235fn format_command(command: &[String]) -> String {
236 command
237 .iter()
238 .map(|part| quote_argument_posix(part))
239 .collect::<Vec<_>>()
240 .join(" ")
241}
242
243fn is_risky_command(command: &[String]) -> bool {
244 let Some(parsed) = NonEmptySlice::from_slice(command) else {
245 return false;
246 };
247
248 if command_might_be_dangerous(command) {
250 return true;
251 }
252
253 let program = parsed.first().as_str();
254 let args = parsed.rest();
255
256 if program == "rm" && args.iter().any(|a| a == "/") {
258 return true;
259 }
260
261 if program == "docker" && args.iter().any(|a| a == "run" && args.iter().any(|b| b == "--privileged")) {
262 return true;
263 }
264
265 program == "kubectl" }
267
268fn log_audit_for_command(_command: &str, _reason: &str) {
269 }
271
272fn quote_argument_posix(arg: &str) -> String {
273 if arg.is_empty() {
274 return "''".to_owned();
275 }
276
277 if arg.chars().all(|ch| ch.is_ascii_alphanumeric() || "-_./:@".contains(ch)) {
278 return arg.to_owned();
279 }
280
281 let mut quoted = String::from("'");
282 for ch in arg.chars() {
283 if ch == '\'' {
284 quoted.push_str("'\"'\"'");
285 } else {
286 quoted.push(ch);
287 }
288 }
289 quoted.push('\'');
290 quoted
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::tools::path_env;
297 use tempfile::tempdir;
298
299 fn make_tool() -> CommandTool {
300 let cwd = std::env::current_dir().expect("current dir");
301 CommandTool::new(cwd)
302 }
303
304 fn make_input(command: Vec<&str>) -> EnhancedTerminalInput {
305 EnhancedTerminalInput {
306 command: command.into_iter().map(String::from).collect(),
307 working_dir: None,
308 timeout_secs: None,
309 mode: None,
310 response_format: None,
311 raw_command: None,
312 shell: None,
313 login: None,
314 confirm: None,
315 max_tokens: None,
316 }
317 }
318
319 #[test]
320 fn formats_command_for_display() {
321 let parts = vec!["echo".to_string(), "hello world".to_string()];
322 assert_eq!(format_command(&parts), "echo 'hello world'");
323 }
324
325 #[tokio::test]
326 async fn prepare_invocation_allows_policy_command() {
327 let tool = make_tool();
328 let input = make_input(vec!["ls"]);
329 let invocation = tool.prepare_invocation(&input).await.expect("invocation");
330 let shell = resolve_fallback_shell();
331 assert_eq!(invocation.program, shell);
332 assert_eq!(invocation.args, vec!["-lc".to_owned(), "ls".to_owned()]);
333 assert_eq!(invocation.display, "ls");
334 }
335
336 #[tokio::test]
337 async fn prepare_invocation_allows_cargo_via_policy() {
338 let tool = make_tool();
339 let input = make_input(vec!["cargo", "check"]);
340 let invocation = tool.prepare_invocation(&input).await.expect("cargo check should be allowed");
341 let shell = resolve_fallback_shell();
342 assert_eq!(invocation.program, shell);
343 assert_eq!(invocation.args, vec!["-lc".to_owned(), "cargo check".to_owned()]);
344 assert_eq!(invocation.display, "cargo check");
345 }
346
347 #[tokio::test]
348 async fn prepare_invocation_rejects_command_not_in_policy() {
349 let tool = make_tool();
350 let input = make_input(vec!["custom-tool"]);
351 let error = tool
352 .prepare_invocation(&input)
353 .await
354 .expect_err("custom-tool should be blocked");
355 assert!(error.to_string().contains("is not permitted by the execution policy"));
356 }
357
358 #[tokio::test]
359 async fn prepare_invocation_requires_confirm_for_git_reset_hard() {
360 let tool = make_tool();
361 let input = make_input(vec!["git", "reset", "--hard"]);
362 let error = tool
364 .prepare_invocation(&input)
365 .await
366 .expect_err("git reset --hard should require confirmation");
367 assert!(error.to_string().contains("Do not self-approve"));
368 }
369
370 #[tokio::test]
371 async fn prepare_invocation_allows_git_reset_with_confirm() {
372 let tool = make_tool();
373 let mut input = make_input(vec!["git", "reset", "--hard"]);
374 input.confirm = Some(true);
375 let invocation = tool
376 .prepare_invocation(&input)
377 .await
378 .expect("git reset --hard should be allowed when confirm=true");
379 assert!(invocation.display.contains("git reset"));
380 }
381
382 #[tokio::test]
383 async fn prepare_invocation_respects_custom_allow_list() {
384 let cwd = std::env::current_dir().expect("current dir");
385 let mut config = CommandsConfig::default();
386 config.allow_list.push("my-build".to_owned());
387 let tool = CommandTool::with_commands_config(cwd, config);
388 let input = make_input(vec!["my-build"]);
389 let invocation = tool
390 .prepare_invocation(&input)
391 .await
392 .expect("custom allow list should enable command");
393 let shell = resolve_fallback_shell();
394 assert_eq!(invocation.program, shell);
395 assert_eq!(invocation.args, vec!["-lc".to_owned(), "my-build".to_owned()]);
396 }
397
398 #[tokio::test]
399 async fn prepare_invocation_respects_shell_override_and_login_false() {
400 let cwd = std::env::current_dir().expect("current dir");
401 let tool = CommandTool::new(cwd);
402 let mut input = make_input(vec!["ls"]);
403 input.shell = Some("/bin/sh".to_string());
404 input.login = Some(false);
405 let invocation = tool.prepare_invocation(&input).await.expect("invocation");
406 assert_eq!(invocation.program, "/bin/sh".to_owned());
407 assert_eq!(invocation.args, vec!["-c".to_owned(), "ls".to_owned()]);
408 }
409
410 #[test]
411 fn resolve_program_path_respects_os_path_separator() {
412 let noise_dir = tempdir().expect("noise tempdir");
413 let target_dir = tempdir().expect("target tempdir");
414 let fake_tool_path = target_dir.path().join("fake-tool");
415 std::fs::write(&fake_tool_path, b"#!/bin/sh\n").expect("write fake tool");
416
417 #[cfg(unix)]
418 {
419 use std::os::unix::fs::PermissionsExt;
420 let mut perms = std::fs::metadata(&fake_tool_path).expect("metadata").permissions();
421 perms.set_mode(0o755);
422 std::fs::set_permissions(&fake_tool_path, perms).expect("set perms");
423 }
424
425 let custom_paths = vec![noise_dir.path().to_path_buf(), target_dir.path().to_path_buf()];
426 let resolved = path_env::resolve_program_path_from_paths("fake-tool", custom_paths.into_iter());
427 let expected = fake_tool_path.to_string_lossy().into_owned();
428 assert_eq!(resolved, Some(expected));
429 }
430
431 #[tokio::test]
432 async fn prepare_invocation_respects_custom_deny_list() {
433 let cwd = std::env::current_dir().expect("current dir");
434 let mut config = CommandsConfig::default();
435 config.deny_list.push("cargo".to_string());
436 let tool = CommandTool::with_commands_config(cwd, config);
437 let input = make_input(vec!["cargo", "check"]);
438 let error = tool.prepare_invocation(&input).await.expect_err("deny list should block cargo");
439 assert!(error.to_string().contains("is not permitted"));
440 }
441
442 #[tokio::test]
443 async fn prepare_invocation_uses_shell_for_command_execution() {
444 let tool = make_tool();
445 let input = make_input(vec!["cargo", "check"]);
446 let invocation = tool.prepare_invocation(&input).await.expect("invocation");
447 let shell = resolve_fallback_shell();
448 assert_eq!(invocation.program, shell);
449 assert_eq!(invocation.args, vec!["-lc".to_owned(), "cargo check".to_owned()]);
450 assert_eq!(invocation.display, "cargo check");
451 }
452
453 #[tokio::test]
454 async fn prepare_invocation_uses_extra_path_entries() {
455 let cwd = std::env::current_dir().expect("current dir");
456 let temp_dir = tempdir().expect("tempdir");
457 let binary_path = temp_dir.path().join("fake-extra");
458 std::fs::write(&binary_path, b"#!/bin/sh\n").expect("write fake binary");
459 #[cfg(unix)]
460 {
461 use std::os::unix::fs::PermissionsExt;
462 let mut perms = std::fs::metadata(&binary_path).expect("metadata").permissions();
463 perms.set_mode(0o755);
464 std::fs::set_permissions(&binary_path, perms).expect("set perms");
465 }
466
467 let mut config = CommandsConfig::default();
468 config.allow_list.push("fake-extra".to_owned());
469 config.extra_path_entries = vec![binary_path.parent().expect("parent").to_string_lossy().into_owned()];
470
471 let tool = CommandTool::with_commands_config(cwd, config);
472 let input = make_input(vec!["fake-extra"]);
473 let invocation = tool.prepare_invocation(&input).await.expect("extra path should allow command");
474 let shell = resolve_fallback_shell();
475 assert_eq!(invocation.program, shell);
476 assert_eq!(invocation.args, vec!["-lc".to_owned(), "fake-extra".to_owned()]);
477 assert_eq!(tool.extra_path_entries, vec![binary_path.parent().expect("parent").to_path_buf()]);
478 }
479
480 #[tokio::test]
481 async fn working_dir_escape_is_rejected() {
482 let tool = make_tool();
483 let mut input = make_input(vec!["ls"]);
484 input.working_dir = Some("../".into());
485 let error = tool
486 .prepare_invocation(&input)
487 .await
488 .expect_err("working dir escape should fail");
489 assert!(error.to_string().contains("working directory '../' escapes the workspace root"));
490 }
491
492 #[tokio::test]
493 async fn prepare_invocation_rejects_empty_command() {
494 let tool = make_tool();
495 let input = make_input(vec![]);
496 let error = tool
497 .prepare_invocation(&input)
498 .await
499 .expect_err("empty command should be rejected");
500 assert!(error.to_string().contains("Command cannot be empty"));
501 }
502
503 #[tokio::test]
504 async fn prepare_invocation_rejects_empty_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("empty executable should be rejected");
511 assert!(error.to_string().contains("Command executable cannot be empty"));
512 }
513
514 #[tokio::test]
515 async fn prepare_invocation_rejects_whitespace_only_executable() {
516 let tool = make_tool();
517 let input = make_input(vec![" ", "arg1"]);
518 let error = tool
519 .prepare_invocation(&input)
520 .await
521 .expect_err("whitespace-only executable should be rejected");
522 assert!(error.to_string().contains("Command executable cannot be empty"));
523 }
524
525 #[tokio::test]
526 async fn validate_args_rejects_empty_command() {
527 let tool = make_tool();
528 let args = make_input(vec![]);
529 let error = tool
530 .validate_args(&args)
531 .await
532 .expect_err("empty command should fail validation");
533 assert!(error.to_string().contains("Command cannot be empty"));
534 }
535
536 #[tokio::test]
537 async fn validate_args_rejects_empty_executable() {
538 let tool = make_tool();
539 let args = make_input(vec!["", "arg1"]);
540 let error = tool
541 .validate_args(&args)
542 .await
543 .expect_err("empty executable should fail validation");
544 assert!(error.to_string().contains("Command executable cannot be empty"));
545 }
546
547 #[tokio::test]
548 async fn validate_args_accepts_valid_command() {
549 let tool = make_tool();
550 let args = make_input(vec!["ls", "-la"]);
551 tool.validate_args(&args).await.expect("valid command should pass validation");
552 }
553
554 #[test]
555 fn environment_variables_are_inherited_from_parent() {
556 let env: HashMap<OsString, OsString> = std::env::vars_os().collect();
563
564 assert!(
566 env.contains_key(&OsString::from("PATH")),
567 "PATH environment variable must be inherited for command resolution"
568 );
569 }
570}