1use std::sync::Arc;
22
23use async_trait::async_trait;
24use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
25use parking_lot::Mutex;
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Value};
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]
533impl AgentTool for ExecTool {
534 fn name(&self) -> &str {
535 "exec"
536 }
537
538 fn label(&self) -> &str {
539 "Exec"
540 }
541
542 fn description(&self) -> &'static str {
543 "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
544 }
545
546 fn parameters_schema(&self) -> Value {
547 json!({
548 "type": "object",
549 "properties": {
550 "mode": {
551 "type": "string",
552 "enum": ["shell", "structured"],
553 "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
554 },
555 "command": {
556 "type": "string",
557 "description": "Shell command string (mode='shell' only)"
558 },
559 "binary": {
560 "type": "string",
561 "description": "Binary name (mode='structured' only, must be in allowlist)"
562 },
563 "args": {
564 "type": "array",
565 "items": { "type": "string" },
566 "description": "Binary arguments (mode='structured' only)"
567 },
568 "timeout": {
569 "type": "integer",
570 "description": "Timeout in seconds",
571 "default": 120
572 }
573 },
574 "required": ["mode"]
575 })
576 }
577
578 async fn execute(
579 &self,
580 _tool_call_id: &str,
581 params: Value,
582 shutdown: Option<oneshot::Receiver<()>>,
583 _ctx: &ToolContext,
584 ) -> Result<AgentToolResult, String> {
585 let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
586 "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
587 })?;
588
589 let timeout_secs = params
590 .get("timeout")
591 .and_then(|v| v.as_u64())
592 .unwrap_or(self.config.read().default_timeout_secs);
593 let timeout_ms = (timeout_secs * 1000).min(self.config.read().max_timeout_secs * 1000);
594
595 match mode {
596 "shell" => {
597 let command = match params.get("command").and_then(|v| v.as_str()) {
598 Some(c) => c,
599 None => {
600 return Ok(AgentToolResult::error(
601 "shell mode requires 'command' parameter",
602 ))
603 }
604 };
605
606 match self.shell_exec(command, timeout_ms, shutdown).await {
607 Ok(result) => {
608 let output = format_exec_output(&result);
609 if result.exit_code == 0 {
610 Ok(AgentToolResult::success(output))
611 } else {
612 Ok(AgentToolResult::error(output))
613 }
614 }
615 Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
616 }
617 }
618
619 "structured" => {
620 let binary = match params.get("binary").and_then(|v| v.as_str()) {
621 Some(b) => b,
622 None => {
623 return Ok(AgentToolResult::error(
624 "structured mode requires 'binary' parameter",
625 ))
626 }
627 };
628
629 let args: Vec<String> = params
630 .get("args")
631 .and_then(|v| v.as_array())
632 .map(|arr| {
633 arr.iter()
634 .filter_map(|v| v.as_str().map(String::from))
635 .collect()
636 })
637 .unwrap_or_default();
638
639 match self
640 .structured_exec(binary, args, timeout_ms, shutdown)
641 .await
642 {
643 Ok(result) => {
644 let output = format_exec_output(&result);
645 if result.exit_code == 0 {
646 Ok(AgentToolResult::success(output))
647 } else {
648 Ok(AgentToolResult::error(output))
649 }
650 }
651 Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
652 }
653 }
654
655 other => Err(format!(
656 "Invalid mode '{other}': expected 'shell' or 'structured'"
657 )),
658 }
659 }
660}
661
662#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::config::ExecConfig;
668
669 fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
672 let mut config = ExecConfig {
673 allowlist_mode: crate::config::AllowlistMode::Permissive,
674 allow_shell_mode: true,
675 ..Default::default()
676 };
677 config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
678 ExecTool::new_unrestricted(
679 Arc::new(parking_lot::RwLock::new(config)),
680 Arc::new(Mutex::new(AccessManager::new())),
681 )
682 }
683
684 #[tokio::test]
687 async fn test_shell_exec_echo() {
688 let tool = make_tool(vec![]);
689 let result = tool.shell_exec("echo hello", 5_000, None).await;
690 assert!(result.is_ok());
691 let r = result.unwrap();
692 assert_eq!(r.exit_code, 0);
693 assert!(r.stdout.contains("hello"));
694 assert!(r.duration_ms < 5_000);
695 }
696
697 #[tokio::test]
698 async fn test_shell_exec_pipeline() {
699 let tool = make_tool(vec![]);
700 let result = tool.shell_exec("echo foo | tr f b", 5_000, None).await;
701 assert!(result.is_ok());
702 let r = result.unwrap();
703 assert_eq!(r.exit_code, 0);
704 assert!(r.stdout.contains("boo"));
705 }
706
707 #[tokio::test]
708 async fn test_shell_exec_nonzero_exit() {
709 let tool = make_tool(vec![]);
710 let result = tool.shell_exec("exit 42", 5_000, None).await;
711 assert!(result.is_ok());
712 assert_eq!(result.unwrap().exit_code, 42);
713 }
714
715 #[tokio::test]
716 async fn test_shell_exec_empty_command() {
717 let tool = make_tool(vec![]);
718 let result = tool.shell_exec(" ", 5_000, None).await;
719 assert!(result.is_err());
720 assert!(result.unwrap_err().contains("must not be empty"));
721 }
722
723 #[tokio::test]
724 async fn test_shell_exec_timeout() {
725 let tool = make_tool(vec![]);
726 let result = tool.shell_exec("sleep 300", 200, None).await;
727 assert!(result.is_err());
728 assert!(result.unwrap_err().contains("timed out"));
729 }
730
731 #[tokio::test]
734 async fn test_structured_exec_echo() {
735 let tool = make_tool(vec!["echo"]);
736 let result = tool
737 .structured_exec("echo", vec!["hello".into()], 5_000, None)
738 .await;
739 assert!(result.is_ok());
740 let r = result.unwrap();
741 assert_eq!(r.exit_code, 0);
742 assert!(r.stdout.contains("hello"));
743 }
744
745 #[tokio::test]
746 async fn test_structured_exec_blocked_binary() {
747 let tool = make_tool(vec!["echo"]);
748 let result = tool
749 .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000, None)
750 .await;
751 assert!(result.is_err());
752 assert!(result.unwrap_err().contains("not in the allowlist"));
753 }
754
755 #[tokio::test]
756 async fn test_structured_exec_path_binary() {
757 let tool = make_tool(vec![]);
758 let result = tool
759 .structured_exec("/usr/bin/echo", vec![], 5_000, None)
760 .await;
761 assert!(result.is_err());
762 assert!(result.unwrap_err().contains("bare name"));
763 }
764
765 #[tokio::test]
766 async fn test_structured_exec_traversal_binary() {
767 let tool = make_tool(vec![]);
768 let result = tool
769 .structured_exec("../bin/evil", vec![], 5_000, None)
770 .await;
771 assert!(result.is_err());
772 assert!(result.unwrap_err().contains("path traversal"));
773 }
774
775 #[tokio::test]
776 async fn test_structured_exec_metachar_args() {
777 let tool = make_tool(vec!["echo"]);
778 let result = tool
779 .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000, None)
780 .await;
781 assert!(result.is_err());
782 assert!(result.unwrap_err().contains("metacharacters"));
783 }
784
785 #[tokio::test]
786 async fn test_structured_exec_path_traversal_args() {
787 let tool = make_tool(vec!["cat"]);
788 let result = tool
789 .structured_exec("cat", vec!["../etc/passwd".into()], 5_000, None)
790 .await;
791 assert!(result.is_err());
792 assert!(result.unwrap_err().contains("metacharacters"));
793 }
794
795 #[tokio::test]
796 async fn test_structured_exec_clean_args() {
797 let tool = make_tool(vec!["echo"]);
798 let result = tool
799 .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000, None)
800 .await;
801 assert!(result.is_ok());
802 let r = result.unwrap();
803 assert_eq!(r.exit_code, 0);
804 assert!(r.stdout.contains("hello world"));
805 }
806
807 #[test]
810 fn test_name_and_label() {
811 let tool = make_tool(vec![]);
812 assert_eq!(tool.name(), "exec");
813 assert_eq!(tool.label(), "Exec");
814 }
815
816 #[test]
817 fn test_parameters_schema() {
818 let tool = make_tool(vec![]);
819 let schema = tool.parameters_schema();
820
821 let props = schema["properties"].as_object().unwrap();
822 assert!(props.contains_key("mode"));
823 assert!(props.contains_key("command"));
824 assert!(props.contains_key("binary"));
825 assert!(props.contains_key("args"));
826 assert!(props.contains_key("timeout"));
827
828 let required = schema["required"].as_array().unwrap();
829 assert!(required.iter().any(|r| r.as_str() == Some("mode")));
830 }
831
832 #[tokio::test]
833 async fn test_agent_tool_shell_mode() {
834 let tool = make_tool(vec![]);
835
836 let result = tool
837 .execute(
838 "test-1",
839 json!({ "mode": "shell", "command": "echo hello" }),
840 None,
841 &ToolContext::default(),
842 )
843 .await;
844
845 assert!(result.is_ok());
846 let r = result.unwrap();
847 assert!(r.success, "Expected success, got: {}", r.output);
848 assert!(r.output.contains("hello"));
849 }
850
851 #[tokio::test]
852 async fn test_agent_tool_structured_mode() {
853 let tool = make_tool(vec!["echo"]);
854
855 let result = tool
856 .execute(
857 "test-2",
858 json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
859 None,
860 &ToolContext::default(),
861 )
862 .await;
863
864 assert!(result.is_ok());
865 let r = result.unwrap();
866 assert!(r.success, "Expected success, got: {}", r.output);
867 assert!(r.output.contains("hi"));
868 }
869
870 #[tokio::test]
871 async fn test_agent_tool_missing_mode() {
872 let tool = make_tool(vec![]);
873 let result = tool
874 .execute(
875 "test-3",
876 json!({ "command": "echo hi" }),
877 None,
878 &ToolContext::default(),
879 )
880 .await;
881 assert!(result.is_err());
882 assert!(result
883 .unwrap_err()
884 .contains("Missing required parameter: mode"));
885 }
886
887 #[tokio::test]
888 async fn test_agent_tool_invalid_mode() {
889 let tool = make_tool(vec![]);
890 let result = tool
891 .execute(
892 "test-4",
893 json!({ "mode": "docker" }),
894 None,
895 &ToolContext::default(),
896 )
897 .await;
898 assert!(result.is_err());
899 assert!(result.unwrap_err().contains("Invalid mode"));
900 }
901
902 #[tokio::test]
903 async fn test_agent_tool_shell_missing_command() {
904 let tool = make_tool(vec![]);
905 let result = tool
906 .execute(
907 "test-5",
908 json!({ "mode": "shell" }),
909 None,
910 &ToolContext::default(),
911 )
912 .await;
913 assert!(result.is_ok());
914 let r = result.unwrap();
915 assert!(!r.success);
916 assert!(r.output.contains("shell mode requires 'command' parameter"));
917 }
918
919 #[tokio::test]
920 async fn test_agent_tool_structured_missing_binary() {
921 let tool = make_tool(vec![]);
922 let result = tool
923 .execute(
924 "test-6",
925 json!({ "mode": "structured" }),
926 None,
927 &ToolContext::default(),
928 )
929 .await;
930 assert!(result.is_ok());
931 let r = result.unwrap();
932 assert!(!r.success);
933 assert!(r
934 .output
935 .contains("structured mode requires 'binary' parameter"));
936 }
937
938 #[tokio::test]
939 async fn test_agent_tool_nonzero_exit() {
940 let tool = make_tool(vec![]);
941
942 let result = tool
943 .execute(
944 "test-7",
945 json!({ "mode": "shell", "command": "exit 7" }),
946 None,
947 &ToolContext::default(),
948 )
949 .await;
950
951 assert!(result.is_ok());
952 let r = result.unwrap();
953 assert!(!r.success);
954 assert!(r.output.contains("exited with code 7"));
955 }
956
957 #[test]
960 fn test_format_exec_output_success() {
961 let result = ExecResult {
962 stdout: "hello".to_string(),
963 stderr: String::new(),
964 exit_code: 0,
965 duration_ms: 1_500,
966 };
967 let output = format_exec_output(&result);
968 assert!(output.contains("hello"));
969 assert!(output.contains("Took 1.5s"));
970 assert!(!output.contains("exited with code"));
971 }
972
973 #[test]
974 fn test_format_exec_output_failure() {
975 let result = ExecResult {
976 stdout: String::new(),
977 stderr: "error!".to_string(),
978 exit_code: 1,
979 duration_ms: 500,
980 };
981 let output = format_exec_output(&result);
982 assert!(output.contains("error!"));
983 assert!(output.contains("exited with code 1"));
984 }
985
986 #[test]
987 fn test_format_exec_output_no_output() {
988 let result = ExecResult {
989 stdout: String::new(),
990 stderr: String::new(),
991 exit_code: 0,
992 duration_ms: 100,
993 };
994 let output = format_exec_output(&result);
995 assert!(output.contains("(no output)"));
996 }
997
998 #[test]
999 fn test_format_exec_output_minutes() {
1000 let result = ExecResult {
1001 stdout: "done".to_string(),
1002 stderr: String::new(),
1003 exit_code: 0,
1004 duration_ms: 125_000, };
1006 let output = format_exec_output(&result);
1007 assert!(output.contains("Took 2m 5.0s"));
1008 }
1009
1010 #[test]
1013 fn test_has_metacharacters_clean() {
1014 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
1015 }
1016
1017 #[test]
1018 fn test_has_metacharacters_semicolon() {
1019 assert!(has_metacharacters(&["foo;bar".into()]));
1020 }
1021
1022 #[test]
1023 fn test_has_metacharacters_pipe() {
1024 assert!(has_metacharacters(&["a | b".into()]));
1025 }
1026
1027 #[test]
1028 fn test_has_metacharacters_dollar() {
1029 assert!(has_metacharacters(&["$(whoami)".into()]));
1030 }
1031
1032 #[test]
1033 fn test_has_metacharacters_backtick() {
1034 assert!(has_metacharacters(&["`id`".into()]));
1035 }
1036
1037 #[test]
1038 fn test_has_metacharacters_traversal() {
1039 assert!(has_metacharacters(&["../etc/passwd".into()]));
1040 }
1041
1042 fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
1046 let config = ExecConfig {
1047 allowlist_mode: crate::config::AllowlistMode::Permissive,
1048 allow_shell_mode: true,
1049 ..Default::default()
1050 };
1051 let mut access = AccessManager::new();
1052 {
1054 let perms = access.get_or_create_permissions(agent_name);
1055 perms.allowed_tools.clear();
1057 for tool in allowed_tools {
1058 perms.allow_tool(tool);
1059 }
1060 }
1061 let ctx = crate::access_manager::AgentContext::test_fixture(agent_name);
1062 ExecTool::new(
1063 Arc::new(parking_lot::RwLock::new(config)),
1064 Arc::new(Mutex::new(access)),
1065 ctx,
1066 )
1067 }
1068
1069 #[tokio::test]
1070 async fn test_for_agent_structured_exec_allowed() {
1071 let tool = make_agent_tool("test-agent", &["echo", "ls"]);
1072 let result = tool
1073 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1074 .await;
1075 assert!(result.is_ok(), "Allowed binary should succeed");
1076 let r = result.unwrap();
1077 assert_eq!(r.exit_code, 0);
1078 assert!(r.stdout.contains("hello"));
1079 }
1080
1081 #[tokio::test]
1082 async fn test_for_agent_structured_exec_denied() {
1083 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
1085 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1086 .await;
1087 assert!(result.is_err());
1088 let err = result.unwrap_err();
1089 assert!(
1090 err.contains("not allowed to execute"),
1091 "Error should mention denial: {err}"
1092 );
1093 assert!(
1094 err.contains("echo"),
1095 "Error should name the denied binary: {err}"
1096 );
1097 }
1098
1099 #[tokio::test]
1100 async fn test_for_agent_shell_exec_allowed() {
1101 let tool = make_agent_tool("test-agent", &["bash"]);
1102 let result = tool.shell_exec("echo hello", 5_000, None).await;
1103 assert!(
1104 result.is_ok(),
1105 "Agent with 'bash' permission should succeed"
1106 );
1107 assert!(result.unwrap().stdout.contains("hello"));
1108 }
1109
1110 #[tokio::test]
1111 async fn test_for_agent_shell_exec_denied() {
1112 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000, None).await;
1114 assert!(result.is_err());
1115 let err = result.unwrap_err();
1116 assert!(
1117 err.contains("not allowed to execute"),
1118 "Error should mention denial: {err}"
1119 );
1120 assert!(err.contains("bash"), "Error should name 'bash': {err}");
1121 }
1122
1123 #[tokio::test]
1124 async fn test_no_agent_name_bypasses_access_control() {
1125 let mut config = ExecConfig::default();
1128 config.allow_shell_mode = true; let access = AccessManager::new(); let tool = ExecTool::new_unrestricted(
1131 Arc::new(parking_lot::RwLock::new(config)),
1132 Arc::new(Mutex::new(access)),
1133 );
1134 let result = tool.shell_exec("echo unrestricted", 5_000, None).await;
1135 assert!(
1136 result.is_ok(),
1137 "Shell mode enabled + no agent_name = unrestricted execution"
1138 );
1139 }
1140
1141 #[test]
1142 fn test_agent_name_set_correctly() {
1143 let tool = make_agent_tool("my-agent", &[]);
1144 assert_eq!(tool.agent_name(), Some("my-agent"));
1145 }
1146
1147 #[test]
1148 fn test_new_has_no_agent_name() {
1149 let tool = make_tool(vec![]);
1150 assert!(tool.agent_name().is_none());
1151 }
1152}