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