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