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;
18
19#[derive(Clone)]
21pub struct CommandTool {
22 workspace_root: PathBuf,
23 policy: CommandPolicyEvaluator,
24 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 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 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 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 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 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 validate_command(command, &self.workspace_root, &working_dir, confirm_ok).await?;
121 }
122 }
123
124 if risky_command && confirm_ok {
125 log_audit_for_command(
127 &format_command(command),
128 "Confirmed destructive operation by agent",
129 );
130 }
131
132 let resolved_invocation =
136 if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') {
137 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 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 #[cfg(test)]
175 pub(crate) async fn validate_args(&self, input: &EnhancedTerminalInput) -> Result<()> {
176 self.prepare_invocation(input).await.map(|_| ())
177 }
178}
179
180#[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 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 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" }
228
229fn log_audit_for_command(_command: &str, _reason: &str) {
230 }
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 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 let env: HashMap<OsString, OsString> = std::env::vars_os().collect();
587
588 assert!(
590 env.contains_key(&OsString::from("PATH")),
591 "PATH environment variable must be inherited for command resolution"
592 );
593 }
594}