1use async_trait::async_trait;
22use std::sync::Arc;
23
24use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
25use parking_lot::Mutex;
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28use tokio::sync::oneshot;
29
30use crate::access_manager::AccessManager;
31use crate::access_manager::{AccessGate, AgentContext};
32
33const SHELL_METACHARS: &[char] = &[
37 '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
38];
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ExecResult {
45 pub stdout: String,
47 pub stderr: String,
49 pub exit_code: i32,
51 pub duration_ms: u64,
53}
54
55pub struct ExecTool {
67 config: crate::kernel_handle::SharedExecConfig,
70 access: Arc<Mutex<AccessManager>>,
72 context: Option<AgentContext>,
74 #[allow(dead_code)] gate: Option<Arc<AccessGate>>,
77}
78
79impl ExecTool {
80 pub fn new(
84 config: crate::kernel_handle::SharedExecConfig,
85 access: Arc<Mutex<AccessManager>>,
86 context: AgentContext,
87 ) -> Self {
88 Self {
89 config,
90 access,
91 context: Some(context),
92 gate: None,
93 }
94 }
95
96 pub fn new_gated(
98 config: crate::kernel_handle::SharedExecConfig,
99 context: AgentContext,
100 gate: Arc<AccessGate>,
101 ) -> Self {
102 Self {
104 config,
105 access: gate.access_clone(),
106 context: Some(context),
107 gate: Some(gate),
108 }
109 }
110
111 pub fn from_kernel_with_context(
115 kernel: &crate::kernel_handle::KernelHandle,
116 context: AgentContext,
117 ) -> Self {
118 Self::new(
119 Arc::new(parking_lot::RwLock::new(kernel.exec.config_snapshot())),
120 kernel.exec.access_manager().clone(),
121 context,
122 )
123 }
124
125 pub fn from_kernel(kernel: &crate::kernel_handle::KernelHandle) -> Self {
130 Self {
131 config: Arc::new(parking_lot::RwLock::new(kernel.exec.config_snapshot())),
132 access: kernel.exec.access_manager().clone(),
133 context: None,
134 gate: None,
135 }
136 }
137
138 pub fn for_agent(
142 config: crate::kernel_handle::SharedExecConfig,
143 access: Arc<Mutex<AccessManager>>,
144 _agent_name: String,
145 ) -> Self {
146 Self {
147 config,
148 access,
149 context: None,
150 gate: None,
151 }
152 }
153
154 pub fn new_unrestricted(
159 config: crate::kernel_handle::SharedExecConfig,
160 access: Arc<Mutex<AccessManager>>,
161 ) -> Self {
162 Self {
163 config,
164 access,
165 context: None,
166 gate: None,
167 }
168 }
169
170 fn agent_name(&self) -> Option<&str> {
172 self.context.as_ref().map(|c| c.agent_name.as_str())
173 }
174
175 pub async fn shell_exec(
184 &self,
185 command: &str,
186 timeout_ms: u64,
187 shutdown: Option<oneshot::Receiver<()>>,
188 ) -> Result<ExecResult, String> {
189 let cfg = self.config.read().clone();
191 if !cfg.allow_shell_mode {
192 return Err(
193 "shell_exec: shell mode is disabled by configuration (allow_shell_mode = false). \
194 Use mode='structured' instead, or set allow_shell_mode=true in config.toml"
195 .to_string(),
196 );
197 }
198
199 if command.trim().is_empty() {
200 return Err("shell_exec: command must not be empty".to_string());
201 }
202
203 if let Some(name) = self.agent_name() {
205 let mut access = self.access.lock();
206 if !access.can_use_tool(name, "bash") {
207 return Err(format!(
208 "shell_exec: agent '{name}' is not allowed to execute 'bash'"
209 ));
210 }
211 tracing::info!(
212 agent = %name,
213 mode = "shell",
214 command = %command.chars().take(200).collect::<String>(),
215 "ExecTool: executing shell command (shell mode enabled)",
216 );
217 } else {
218 tracing::warn!(
219 mode = "shell",
220 command = %command.chars().take(200).collect::<String>(),
221 "ExecTool: shell mode executing without agent context",
222 );
223 }
224
225 let effective_timeout = timeout_ms.clamp(1_000, cfg.max_timeout_secs * 1_000);
226
227 let start = std::time::Instant::now();
228
229 let mut child = tokio::process::Command::new("bash")
231 .arg("-c")
232 .arg(command)
233 .env_clear()
234 .env("HOME", std::env::var("HOME").unwrap_or_default())
235 .env("USER", std::env::var("USER").unwrap_or_default())
236 .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
237 .env("PATH", std::env::var("PATH").unwrap_or_default())
238 .env(
239 "LANG",
240 std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
241 )
242 .env("TERM", "dumb")
243 .stdout(std::process::Stdio::piped())
244 .stderr(std::process::Stdio::piped())
245 .spawn()
246 .map_err(|e| format!("shell spawn error: {e}"))?;
247
248 let stdout_handle = child.stdout.take();
251 let stderr_handle = child.stderr.take();
252
253 let shutdown_fut = async {
256 if let Some(rx) = shutdown {
257 let _ = rx.await;
258 } else {
259 std::future::pending::<()>().await;
260 }
261 };
262
263 let result = tokio::select! {
264 status = tokio::time::timeout(
265 std::time::Duration::from_millis(effective_timeout),
266 child.wait(),
267 ) => {
268 match status {
269 Ok(Ok(status)) => {
270 let stdout = read_handle(stdout_handle).await;
271 let stderr = read_stderr_handle(stderr_handle).await;
272 Ok(ExecResult {
273 stdout,
274 stderr,
275 exit_code: status.code().unwrap_or(-1),
276 duration_ms: start.elapsed().as_millis() as u64,
277 })
278 }
279 Ok(Err(e)) => Err(format!("shell execution error: {e}")),
280 Err(_) => Err(format!(
281 "shell command timed out after {effective_timeout}ms"
282 )),
283 }
284 }
285 _ = shutdown_fut => {
286 let _ = child.kill().await;
288 let _ = child.wait().await; Err("Execution cancelled by shutdown signal".to_string())
290 }
291 };
292
293 result
294 }
295
296 pub async fn structured_exec(
307 &self,
308 binary: &str,
309 args: Vec<String>,
310 timeout_ms: u64,
311 shutdown: Option<oneshot::Receiver<()>>,
312 ) -> Result<ExecResult, String> {
313 if let Some(name) = self.agent_name() {
315 let mut access = self.access.lock();
316 if !access.can_use_tool(name, binary) {
317 return Err(format!(
318 "structured_exec: agent '{name}' is not allowed to execute '{binary}'"
319 ));
320 }
321 }
322
323 tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
327
328 if binary.contains("..") {
329 return Err("structured_exec: path traversal in binary name".to_string());
330 }
331 if binary.contains('/') {
332 return Err("structured_exec: binary must be a bare name, not a path".to_string());
333 }
334 if !self.config.read().is_binary_allowed(binary) {
335 return Err(format!(
336 "structured_exec: binary '{binary}' is not in the allowlist"
337 ));
338 }
339
340 if has_metacharacters(&args) {
343 return Err(
344 "structured_exec: shell metacharacters or path traversal not allowed in arguments"
345 .to_string(),
346 );
347 }
348
349 let effective_timeout =
350 timeout_ms.clamp(1_000, self.config.read().max_timeout_secs * 1_000);
351
352 let start = std::time::Instant::now();
353
354 let mut child = tokio::process::Command::new(binary)
356 .args(&args)
357 .env_clear()
358 .env("HOME", std::env::var("HOME").unwrap_or_default())
359 .env("USER", std::env::var("USER").unwrap_or_default())
360 .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
361 .env("PATH", std::env::var("PATH").unwrap_or_default())
362 .env(
363 "LANG",
364 std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
365 )
366 .env("TERM", "dumb")
367 .stdout(std::process::Stdio::piped())
368 .stderr(std::process::Stdio::piped())
369 .spawn()
370 .map_err(|e| format!("structured spawn error: {e}"))?;
371
372 let stdout_handle = child.stdout.take();
375 let stderr_handle = child.stderr.take();
376
377 let shutdown_fut = async {
380 if let Some(rx) = shutdown {
381 let _ = rx.await;
382 } else {
383 std::future::pending::<()>().await;
384 }
385 };
386
387 let result = tokio::select! {
388 status = tokio::time::timeout(
389 std::time::Duration::from_millis(effective_timeout),
390 child.wait(),
391 ) => {
392 match status {
393 Ok(Ok(status)) => {
394 let stdout = read_handle(stdout_handle).await;
395 let stderr = read_stderr_handle(stderr_handle).await;
396 Ok(ExecResult {
397 stdout,
398 stderr,
399 exit_code: status.code().unwrap_or(-1),
400 duration_ms: start.elapsed().as_millis() as u64,
401 })
402 }
403 Ok(Err(e)) => Err(format!("structured execution error: {e}")),
404 Err(_) => Err(format!(
405 "structured command timed out after {effective_timeout}ms"
406 )),
407 }
408 }
409 _ = shutdown_fut => {
410 let _ = child.kill().await;
412 let _ = child.wait().await; Err("Execution cancelled by shutdown signal".to_string())
414 }
415 };
416
417 result
418 }
419}
420
421async fn read_handle(handle: Option<tokio::process::ChildStdout>) -> String {
425 match handle {
426 Some(mut h) => {
427 let mut buf = Vec::new();
428 match tokio::time::timeout(
430 std::time::Duration::from_secs(10),
431 tokio::io::AsyncReadExt::read_to_end(&mut h, &mut buf),
432 )
433 .await
434 {
435 Ok(Ok(_)) => String::from_utf8_lossy(&buf).to_string(),
436 _ => String::new(),
437 }
438 }
439 None => String::new(),
440 }
441}
442
443async fn read_stderr_handle(handle: Option<tokio::process::ChildStderr>) -> String {
445 match handle {
446 Some(mut h) => {
447 let mut buf = Vec::new();
448 match tokio::time::timeout(
449 std::time::Duration::from_secs(10),
450 tokio::io::AsyncReadExt::read_to_end(&mut h, &mut buf),
451 )
452 .await
453 {
454 Ok(Ok(_)) => String::from_utf8_lossy(&buf).to_string(),
455 _ => String::new(),
456 }
457 }
458 None => String::new(),
459 }
460}
461
462fn has_metacharacters(args: &[String]) -> bool {
464 for arg in args {
465 if arg.contains("..") {
466 return true;
467 }
468 if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
469 return true;
470 }
471 }
472 false
473}
474
475fn format_exec_output(result: &ExecResult) -> String {
478 let mut output = String::new();
479
480 if result.stdout.is_empty() && result.stderr.is_empty() {
481 output.push_str("(no output)");
482 } else {
483 if !result.stdout.is_empty() {
484 output.push_str(&result.stdout);
485 }
486 if !result.stderr.is_empty() && !result.stdout.is_empty() {
487 output.push('\n');
488 }
489 if !result.stderr.is_empty() {
490 output.push_str(&result.stderr);
491 }
492 }
493
494 if result.exit_code != 0 {
495 output.push_str(&format!(
496 "\n\nCommand exited with code {}",
497 result.exit_code
498 ));
499 }
500
501 let secs = result.duration_ms / 1000;
502 let millis = result.duration_ms % 1000;
503
504 if secs >= 60 {
505 let mins = secs / 60;
506 let remain_secs = secs % 60;
507 output.push_str(&format!(
508 "\n\nTook {}m {:.1}s",
509 mins,
510 remain_secs as f64 + millis as f64 / 1000.0
511 ));
512 } else {
513 output.push_str(&format!(
514 "\n\nTook {:.1}s",
515 secs as f64 + millis as f64 / 1000.0
516 ));
517 }
518
519 output
520}
521
522impl std::fmt::Debug for ExecTool {
525 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526 f.debug_struct("ExecTool").finish()
527 }
528}
529
530#[async_trait]
533
534impl AgentTool for ExecTool {
535 fn name(&self) -> &str {
536 "exec"
537 }
538
539 fn label(&self) -> &str {
540 "Exec"
541 }
542
543 fn description(&self) -> &'static str {
544 "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
545 }
546
547 fn parameters_schema(&self) -> Value {
548 json!({
549 "type": "object",
550 "properties": {
551 "mode": {
552 "type": "string",
553 "enum": ["shell", "structured"],
554 "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
555 },
556 "command": {
557 "type": "string",
558 "description": "Shell command string (mode='shell' only)"
559 },
560 "binary": {
561 "type": "string",
562 "description": "Binary name (mode='structured' only, must be in allowlist)"
563 },
564 "args": {
565 "type": "array",
566 "items": { "type": "string" },
567 "description": "Binary arguments (mode='structured' only)"
568 },
569 "timeout": {
570 "type": "integer",
571 "description": "Timeout in seconds",
572 "default": 120
573 }
574 },
575 "required": ["mode"]
576 })
577 }
578
579 async fn execute(
580 &self,
581 _tool_call_id: &str,
582 params: Value,
583 shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
584 _ctx: &ToolContext,
585 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
586 let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
587 "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
588 })?;
589
590 let timeout_secs = params
591 .get("timeout")
592 .and_then(|v| v.as_u64())
593 .unwrap_or(self.config.read().default_timeout_secs);
594 let timeout_ms = (timeout_secs * 1000).min(self.config.read().max_timeout_secs * 1000);
595
596 match mode {
597 "shell" => {
598 let command = match params.get("command").and_then(|v| v.as_str()) {
599 Some(c) => c,
600 None => {
601 return Ok(AgentToolResult::error(
602 "shell mode requires 'command' parameter",
603 ));
604 }
605 };
606
607 match self.shell_exec(command, timeout_ms, shutdown).await {
608 Ok(result) => {
609 let output = format_exec_output(&result);
610 if result.exit_code == 0 {
611 Ok(AgentToolResult::success(output))
612 } else {
613 Ok(AgentToolResult::error(output))
614 }
615 }
616 Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
617 }
618 }
619
620 "structured" => {
621 let binary = match params.get("binary").and_then(|v| v.as_str()) {
622 Some(b) => b,
623 None => {
624 return Ok(AgentToolResult::error(
625 "structured mode requires 'binary' parameter",
626 ));
627 }
628 };
629
630 let args: Vec<String> = params
631 .get("args")
632 .and_then(|v| v.as_array())
633 .map(|arr| {
634 arr.iter()
635 .filter_map(|v| v.as_str().map(String::from))
636 .collect()
637 })
638 .unwrap_or_default();
639
640 match self
641 .structured_exec(binary, args, timeout_ms, shutdown)
642 .await
643 {
644 Ok(result) => {
645 let output = format_exec_output(&result);
646 if result.exit_code == 0 {
647 Ok(AgentToolResult::success(output))
648 } else {
649 Ok(AgentToolResult::error(output))
650 }
651 }
652 Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
653 }
654 }
655
656 other => Err(format!(
657 "Invalid mode '{other}': expected 'shell' or 'structured'"
658 )),
659 }
660 }
661}
662
663#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::config::ExecConfig;
669
670 fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
673 let mut config = ExecConfig {
674 allowlist_mode: crate::config::AllowlistMode::Permissive,
675 allow_shell_mode: true,
676 ..Default::default()
677 };
678 config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
679 ExecTool::new_unrestricted(
680 Arc::new(parking_lot::RwLock::new(config)),
681 Arc::new(Mutex::new(AccessManager::new())),
682 )
683 }
684
685 #[tokio::test]
688 async fn test_shell_exec_echo() {
689 let tool = make_tool(vec![]);
690 let result = tool.shell_exec("echo hello", 5_000, None).await;
691 assert!(result.is_ok());
692 let r = result.unwrap();
693 assert_eq!(r.exit_code, 0);
694 assert!(r.stdout.contains("hello"));
695 assert!(r.duration_ms < 5_000);
696 }
697
698 #[tokio::test]
699 async fn test_shell_exec_pipeline() {
700 let tool = make_tool(vec![]);
701 let result = tool.shell_exec("echo foo | tr f b", 5_000, None).await;
702 assert!(result.is_ok());
703 let r = result.unwrap();
704 assert_eq!(r.exit_code, 0);
705 assert!(r.stdout.contains("boo"));
706 }
707
708 #[tokio::test]
709 async fn test_shell_exec_nonzero_exit() {
710 let tool = make_tool(vec![]);
711 let result = tool.shell_exec("exit 42", 5_000, None).await;
712 assert!(result.is_ok());
713 assert_eq!(result.unwrap().exit_code, 42);
714 }
715
716 #[tokio::test]
717 async fn test_shell_exec_empty_command() {
718 let tool = make_tool(vec![]);
719 let result = tool.shell_exec(" ", 5_000, None).await;
720 assert!(result.is_err());
721 assert!(result.unwrap_err().contains("must not be empty"));
722 }
723
724 #[tokio::test]
725 async fn test_shell_exec_timeout() {
726 let tool = make_tool(vec![]);
727 let result = tool.shell_exec("sleep 300", 200, None).await;
728 assert!(result.is_err());
729 assert!(result.unwrap_err().contains("timed out"));
730 }
731
732 #[tokio::test]
735 async fn test_structured_exec_echo() {
736 let tool = make_tool(vec!["echo"]);
737 let result = tool
738 .structured_exec("echo", vec!["hello".into()], 5_000, None)
739 .await;
740 assert!(result.is_ok());
741 let r = result.unwrap();
742 assert_eq!(r.exit_code, 0);
743 assert!(r.stdout.contains("hello"));
744 }
745
746 #[tokio::test]
747 async fn test_structured_exec_blocked_binary() {
748 let tool = make_tool(vec!["echo"]);
749 let result = tool
750 .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000, None)
751 .await;
752 assert!(result.is_err());
753 assert!(result.unwrap_err().contains("not in the allowlist"));
754 }
755
756 #[tokio::test]
757 async fn test_structured_exec_path_binary() {
758 let tool = make_tool(vec![]);
759 let result = tool
760 .structured_exec("/usr/bin/echo", vec![], 5_000, None)
761 .await;
762 assert!(result.is_err());
763 assert!(result.unwrap_err().contains("bare name"));
764 }
765
766 #[tokio::test]
767 async fn test_structured_exec_traversal_binary() {
768 let tool = make_tool(vec![]);
769 let result = tool
770 .structured_exec("../bin/evil", vec![], 5_000, None)
771 .await;
772 assert!(result.is_err());
773 assert!(result.unwrap_err().contains("path traversal"));
774 }
775
776 #[tokio::test]
777 async fn test_structured_exec_metachar_args() {
778 let tool = make_tool(vec!["echo"]);
779 let result = tool
780 .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000, None)
781 .await;
782 assert!(result.is_err());
783 assert!(result.unwrap_err().contains("metacharacters"));
784 }
785
786 #[tokio::test]
787 async fn test_structured_exec_path_traversal_args() {
788 let tool = make_tool(vec!["cat"]);
789 let result = tool
790 .structured_exec("cat", vec!["../etc/passwd".into()], 5_000, None)
791 .await;
792 assert!(result.is_err());
793 assert!(result.unwrap_err().contains("metacharacters"));
794 }
795
796 #[tokio::test]
797 async fn test_structured_exec_clean_args() {
798 let tool = make_tool(vec!["echo"]);
799 let result = tool
800 .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000, None)
801 .await;
802 assert!(result.is_ok());
803 let r = result.unwrap();
804 assert_eq!(r.exit_code, 0);
805 assert!(r.stdout.contains("hello world"));
806 }
807
808 #[test]
811 fn test_name_and_label() {
812 let tool = make_tool(vec![]);
813 assert_eq!(tool.name(), "exec");
814 assert_eq!(tool.label(), "Exec");
815 }
816
817 #[test]
818 fn test_parameters_schema() {
819 let tool = make_tool(vec![]);
820 let schema = tool.parameters_schema();
821
822 let props = schema["properties"].as_object().unwrap();
823 assert!(props.contains_key("mode"));
824 assert!(props.contains_key("command"));
825 assert!(props.contains_key("binary"));
826 assert!(props.contains_key("args"));
827 assert!(props.contains_key("timeout"));
828
829 let required = schema["required"].as_array().unwrap();
830 assert!(required.iter().any(|r| r.as_str() == Some("mode")));
831 }
832
833 #[tokio::test]
834 async fn test_agent_tool_shell_mode() {
835 let tool = make_tool(vec![]);
836
837 let result = tool
838 .execute(
839 "test-1",
840 json!({ "mode": "shell", "command": "echo hello" }),
841 None,
842 &ToolContext::default(),
843 )
844 .await;
845
846 assert!(result.is_ok());
847 let r = result.unwrap();
848 assert!(r.success, "Expected success, got: {}", r.output);
849 assert!(r.output.contains("hello"));
850 }
851
852 #[tokio::test]
853 async fn test_agent_tool_structured_mode() {
854 let tool = make_tool(vec!["echo"]);
855
856 let result = tool
857 .execute(
858 "test-2",
859 json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
860 None,
861 &ToolContext::default(),
862 )
863 .await;
864
865 assert!(result.is_ok());
866 let r = result.unwrap();
867 assert!(r.success, "Expected success, got: {}", r.output);
868 assert!(r.output.contains("hi"));
869 }
870
871 #[tokio::test]
872 async fn test_agent_tool_missing_mode() {
873 let tool = make_tool(vec![]);
874 let result = tool
875 .execute(
876 "test-3",
877 json!({ "command": "echo hi" }),
878 None,
879 &ToolContext::default(),
880 )
881 .await;
882 assert!(result.is_err());
883 assert!(
884 result
885 .unwrap_err()
886 .contains("Missing required parameter: mode")
887 );
888 }
889
890 #[tokio::test]
891 async fn test_agent_tool_invalid_mode() {
892 let tool = make_tool(vec![]);
893 let result = tool
894 .execute(
895 "test-4",
896 json!({ "mode": "docker" }),
897 None,
898 &ToolContext::default(),
899 )
900 .await;
901 assert!(result.is_err());
902 assert!(result.unwrap_err().contains("Invalid mode"));
903 }
904
905 #[tokio::test]
906 async fn test_agent_tool_shell_missing_command() {
907 let tool = make_tool(vec![]);
908 let result = tool
909 .execute(
910 "test-5",
911 json!({ "mode": "shell" }),
912 None,
913 &ToolContext::default(),
914 )
915 .await;
916 assert!(result.is_ok());
917 let r = result.unwrap();
918 assert!(!r.success);
919 assert!(r.output.contains("shell mode requires 'command' parameter"));
920 }
921
922 #[tokio::test]
923 async fn test_agent_tool_structured_missing_binary() {
924 let tool = make_tool(vec![]);
925 let result = tool
926 .execute(
927 "test-6",
928 json!({ "mode": "structured" }),
929 None,
930 &ToolContext::default(),
931 )
932 .await;
933 assert!(result.is_ok());
934 let r = result.unwrap();
935 assert!(!r.success);
936 assert!(
937 r.output
938 .contains("structured mode requires 'binary' parameter")
939 );
940 }
941
942 #[tokio::test]
943 async fn test_agent_tool_nonzero_exit() {
944 let tool = make_tool(vec![]);
945
946 let result = tool
947 .execute(
948 "test-7",
949 json!({ "mode": "shell", "command": "exit 7" }),
950 None,
951 &ToolContext::default(),
952 )
953 .await;
954
955 assert!(result.is_ok());
956 let r = result.unwrap();
957 assert!(!r.success);
958 assert!(r.output.contains("exited with code 7"));
959 }
960
961 #[test]
964 fn test_format_exec_output_success() {
965 let result = ExecResult {
966 stdout: "hello".to_string(),
967 stderr: String::new(),
968 exit_code: 0,
969 duration_ms: 1_500,
970 };
971 let output = format_exec_output(&result);
972 assert!(output.contains("hello"));
973 assert!(output.contains("Took 1.5s"));
974 assert!(!output.contains("exited with code"));
975 }
976
977 #[test]
978 fn test_format_exec_output_failure() {
979 let result = ExecResult {
980 stdout: String::new(),
981 stderr: "error!".to_string(),
982 exit_code: 1,
983 duration_ms: 500,
984 };
985 let output = format_exec_output(&result);
986 assert!(output.contains("error!"));
987 assert!(output.contains("exited with code 1"));
988 }
989
990 #[test]
991 fn test_format_exec_output_no_output() {
992 let result = ExecResult {
993 stdout: String::new(),
994 stderr: String::new(),
995 exit_code: 0,
996 duration_ms: 100,
997 };
998 let output = format_exec_output(&result);
999 assert!(output.contains("(no output)"));
1000 }
1001
1002 #[test]
1003 fn test_format_exec_output_minutes() {
1004 let result = ExecResult {
1005 stdout: "done".to_string(),
1006 stderr: String::new(),
1007 exit_code: 0,
1008 duration_ms: 125_000, };
1010 let output = format_exec_output(&result);
1011 assert!(output.contains("Took 2m 5.0s"));
1012 }
1013
1014 #[test]
1017 fn test_has_metacharacters_clean() {
1018 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
1019 }
1020
1021 #[test]
1022 fn test_has_metacharacters_semicolon() {
1023 assert!(has_metacharacters(&["foo;bar".into()]));
1024 }
1025
1026 #[test]
1027 fn test_has_metacharacters_pipe() {
1028 assert!(has_metacharacters(&["a | b".into()]));
1029 }
1030
1031 #[test]
1032 fn test_has_metacharacters_dollar() {
1033 assert!(has_metacharacters(&["$(whoami)".into()]));
1034 }
1035
1036 #[test]
1037 fn test_has_metacharacters_backtick() {
1038 assert!(has_metacharacters(&["`id`".into()]));
1039 }
1040
1041 #[test]
1042 fn test_has_metacharacters_traversal() {
1043 assert!(has_metacharacters(&["../etc/passwd".into()]));
1044 }
1045
1046 fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
1050 let config = ExecConfig {
1051 allowlist_mode: crate::config::AllowlistMode::Permissive,
1052 allow_shell_mode: true,
1053 ..Default::default()
1054 };
1055 let mut access = AccessManager::new();
1056 {
1058 let perms = access.get_or_create_permissions(agent_name);
1059 perms.allowed_tools.clear();
1061 for tool in allowed_tools {
1062 perms.allow_tool(tool);
1063 }
1064 }
1065 let ctx = crate::access_manager::AgentContext::test_fixture(agent_name);
1066 ExecTool::new(
1067 Arc::new(parking_lot::RwLock::new(config)),
1068 Arc::new(Mutex::new(access)),
1069 ctx,
1070 )
1071 }
1072
1073 #[tokio::test]
1074 async fn test_for_agent_structured_exec_allowed() {
1075 let tool = make_agent_tool("test-agent", &["echo", "ls"]);
1076 let result = tool
1077 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1078 .await;
1079 assert!(result.is_ok(), "Allowed binary should succeed");
1080 let r = result.unwrap();
1081 assert_eq!(r.exit_code, 0);
1082 assert!(r.stdout.contains("hello"));
1083 }
1084
1085 #[tokio::test]
1086 async fn test_for_agent_structured_exec_denied() {
1087 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
1089 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1090 .await;
1091 assert!(result.is_err());
1092 let err = result.unwrap_err();
1093 assert!(
1094 err.contains("not allowed to execute"),
1095 "Error should mention denial: {err}"
1096 );
1097 assert!(
1098 err.contains("echo"),
1099 "Error should name the denied binary: {err}"
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn test_for_agent_shell_exec_allowed() {
1105 let tool = make_agent_tool("test-agent", &["bash"]);
1106 let result = tool.shell_exec("echo hello", 5_000, None).await;
1107 assert!(
1108 result.is_ok(),
1109 "Agent with 'bash' permission should succeed"
1110 );
1111 assert!(result.unwrap().stdout.contains("hello"));
1112 }
1113
1114 #[tokio::test]
1115 async fn test_for_agent_shell_exec_denied() {
1116 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000, None).await;
1118 assert!(result.is_err());
1119 let err = result.unwrap_err();
1120 assert!(
1121 err.contains("not allowed to execute"),
1122 "Error should mention denial: {err}"
1123 );
1124 assert!(err.contains("bash"), "Error should name 'bash': {err}");
1125 }
1126
1127 #[tokio::test]
1128 async fn test_no_agent_name_bypasses_access_control() {
1129 let mut config = ExecConfig::default();
1132 config.allow_shell_mode = true; let access = AccessManager::new(); let tool = ExecTool::new_unrestricted(
1135 Arc::new(parking_lot::RwLock::new(config)),
1136 Arc::new(Mutex::new(access)),
1137 );
1138 let result = tool.shell_exec("echo unrestricted", 5_000, None).await;
1139 assert!(
1140 result.is_ok(),
1141 "Shell mode enabled + no agent_name = unrestricted execution"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_agent_name_set_correctly() {
1147 let tool = make_agent_tool("my-agent", &[]);
1148 assert_eq!(tool.agent_name(), Some("my-agent"));
1149 }
1150
1151 #[test]
1152 fn test_new_has_no_agent_name() {
1153 let tool = make_tool(vec![]);
1154 assert!(tool.agent_name().is_none());
1155 }
1156}