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(_) => {
281 let _ = child.kill().await;
283 let _ = child.wait().await; Err(format!(
285 "shell command timed out after {effective_timeout}ms"
286 ))
287 }
288 }
289 }
290 _ = shutdown_fut => {
291 let _ = child.kill().await;
293 let _ = child.wait().await; Err("Execution cancelled by shutdown signal".to_string())
295 }
296 };
297
298 result
299 }
300
301 pub async fn structured_exec(
312 &self,
313 binary: &str,
314 args: Vec<String>,
315 timeout_ms: u64,
316 shutdown: Option<oneshot::Receiver<()>>,
317 ) -> Result<ExecResult, String> {
318 if let Some(name) = self.agent_name() {
320 let mut access = self.access.lock();
321 if !access.can_use_tool(name, binary) {
322 return Err(format!(
323 "structured_exec: agent '{name}' is not allowed to execute '{binary}'"
324 ));
325 }
326 }
327
328 tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
332
333 if binary.contains("..") {
334 return Err("structured_exec: path traversal in binary name".to_string());
335 }
336 if binary.contains('/') {
337 return Err("structured_exec: binary must be a bare name, not a path".to_string());
338 }
339 if !self.config.read().is_binary_allowed(binary) {
340 return Err(format!(
341 "structured_exec: binary '{binary}' is not in the allowlist"
342 ));
343 }
344
345 if has_metacharacters(&args) {
348 return Err(
349 "structured_exec: shell metacharacters or path traversal not allowed in arguments"
350 .to_string(),
351 );
352 }
353
354 let effective_timeout =
355 timeout_ms.clamp(1_000, self.config.read().max_timeout_secs * 1_000);
356
357 let start = std::time::Instant::now();
358
359 let mut child = tokio::process::Command::new(binary)
361 .args(&args)
362 .env_clear()
363 .env("HOME", std::env::var("HOME").unwrap_or_default())
364 .env("USER", std::env::var("USER").unwrap_or_default())
365 .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
366 .env("PATH", std::env::var("PATH").unwrap_or_default())
367 .env(
368 "LANG",
369 std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
370 )
371 .env("TERM", "dumb")
372 .stdout(std::process::Stdio::piped())
373 .stderr(std::process::Stdio::piped())
374 .spawn()
375 .map_err(|e| format!("structured spawn error: {e}"))?;
376
377 let stdout_handle = child.stdout.take();
380 let stderr_handle = child.stderr.take();
381
382 let shutdown_fut = async {
385 if let Some(rx) = shutdown {
386 let _ = rx.await;
387 } else {
388 std::future::pending::<()>().await;
389 }
390 };
391
392 let result = tokio::select! {
393 status = tokio::time::timeout(
394 std::time::Duration::from_millis(effective_timeout),
395 child.wait(),
396 ) => {
397 match status {
398 Ok(Ok(status)) => {
399 let stdout = read_handle(stdout_handle).await;
400 let stderr = read_stderr_handle(stderr_handle).await;
401 Ok(ExecResult {
402 stdout,
403 stderr,
404 exit_code: status.code().unwrap_or(-1),
405 duration_ms: start.elapsed().as_millis() as u64,
406 })
407 }
408 Ok(Err(e)) => Err(format!("structured execution error: {e}")),
409 Err(_) => {
410 let _ = child.kill().await;
412 let _ = child.wait().await; Err(format!(
414 "structured command timed out after {effective_timeout}ms"
415 ))
416 }
417 }
418 }
419 _ = shutdown_fut => {
420 let _ = child.kill().await;
422 let _ = child.wait().await; Err("Execution cancelled by shutdown signal".to_string())
424 }
425 };
426
427 result
428 }
429}
430
431async fn read_handle(handle: Option<tokio::process::ChildStdout>) -> String {
435 match handle {
436 Some(mut h) => {
437 let mut buf = Vec::new();
438 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
453async fn read_stderr_handle(handle: Option<tokio::process::ChildStderr>) -> String {
455 match handle {
456 Some(mut h) => {
457 let mut buf = Vec::new();
458 match tokio::time::timeout(
459 std::time::Duration::from_secs(10),
460 tokio::io::AsyncReadExt::read_to_end(&mut h, &mut buf),
461 )
462 .await
463 {
464 Ok(Ok(_)) => String::from_utf8_lossy(&buf).to_string(),
465 _ => String::new(),
466 }
467 }
468 None => String::new(),
469 }
470}
471
472fn has_metacharacters(args: &[String]) -> bool {
474 for arg in args {
475 if arg.contains("..") {
476 return true;
477 }
478 if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
479 return true;
480 }
481 }
482 false
483}
484
485fn format_exec_output(result: &ExecResult) -> String {
488 let mut output = String::new();
489
490 if result.stdout.is_empty() && result.stderr.is_empty() {
491 output.push_str("(no output)");
492 } else {
493 if !result.stdout.is_empty() {
494 output.push_str(&result.stdout);
495 }
496 if !result.stderr.is_empty() && !result.stdout.is_empty() {
497 output.push('\n');
498 }
499 if !result.stderr.is_empty() {
500 output.push_str(&result.stderr);
501 }
502 }
503
504 if result.exit_code != 0 {
505 output.push_str(&format!(
506 "\n\nCommand exited with code {}",
507 result.exit_code
508 ));
509 }
510
511 let secs = result.duration_ms / 1000;
512 let millis = result.duration_ms % 1000;
513
514 if secs >= 60 {
515 let mins = secs / 60;
516 let remain_secs = secs % 60;
517 output.push_str(&format!(
518 "\n\nTook {}m {:.1}s",
519 mins,
520 remain_secs as f64 + millis as f64 / 1000.0
521 ));
522 } else {
523 output.push_str(&format!(
524 "\n\nTook {:.1}s",
525 secs as f64 + millis as f64 / 1000.0
526 ));
527 }
528
529 output
530}
531
532impl std::fmt::Debug for ExecTool {
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 f.debug_struct("ExecTool").finish()
537 }
538}
539
540#[async_trait]
543
544impl AgentTool for ExecTool {
545 fn name(&self) -> &str {
546 "exec"
547 }
548
549 fn label(&self) -> &str {
550 "Exec"
551 }
552
553 fn description(&self) -> &'static str {
554 "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
555 }
556
557 fn parameters_schema(&self) -> Value {
558 json!({
559 "type": "object",
560 "properties": {
561 "mode": {
562 "type": "string",
563 "enum": ["shell", "structured"],
564 "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
565 },
566 "command": {
567 "type": "string",
568 "description": "Shell command string (mode='shell' only)"
569 },
570 "binary": {
571 "type": "string",
572 "description": "Binary name (mode='structured' only, must be in allowlist)"
573 },
574 "args": {
575 "type": "array",
576 "items": { "type": "string" },
577 "description": "Binary arguments (mode='structured' only)"
578 },
579 "timeout": {
580 "type": "integer",
581 "description": "Timeout in seconds",
582 "default": 120
583 }
584 },
585 "required": ["mode"]
586 })
587 }
588
589 async fn execute(
590 &self,
591 _tool_call_id: &str,
592 params: Value,
593 shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
594 _ctx: &ToolContext,
595 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
596 let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
597 "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
598 })?;
599
600 let timeout_secs = params
601 .get("timeout")
602 .and_then(|v| v.as_u64())
603 .unwrap_or(self.config.read().default_timeout_secs);
604 let timeout_ms = (timeout_secs * 1000).min(self.config.read().max_timeout_secs * 1000);
605
606 match mode {
607 "shell" => {
608 let command = match params.get("command").and_then(|v| v.as_str()) {
609 Some(c) => c,
610 None => {
611 return Ok(AgentToolResult::error(
612 "shell mode requires 'command' parameter",
613 ));
614 }
615 };
616
617 match self.shell_exec(command, timeout_ms, shutdown).await {
618 Ok(result) => {
619 let output = format_exec_output(&result);
620 if result.exit_code == 0 {
621 Ok(AgentToolResult::success(output))
622 } else {
623 Ok(AgentToolResult::error(output))
624 }
625 }
626 Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
627 }
628 }
629
630 "structured" => {
631 let binary = match params.get("binary").and_then(|v| v.as_str()) {
632 Some(b) => b,
633 None => {
634 return Ok(AgentToolResult::error(
635 "structured mode requires 'binary' parameter",
636 ));
637 }
638 };
639
640 let args: Vec<String> = params
641 .get("args")
642 .and_then(|v| v.as_array())
643 .map(|arr| {
644 arr.iter()
645 .filter_map(|v| v.as_str().map(String::from))
646 .collect()
647 })
648 .unwrap_or_default();
649
650 match self
651 .structured_exec(binary, args, timeout_ms, shutdown)
652 .await
653 {
654 Ok(result) => {
655 let output = format_exec_output(&result);
656 if result.exit_code == 0 {
657 Ok(AgentToolResult::success(output))
658 } else {
659 Ok(AgentToolResult::error(output))
660 }
661 }
662 Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
663 }
664 }
665
666 other => Err(format!(
667 "Invalid mode '{other}': expected 'shell' or 'structured'"
668 )),
669 }
670 }
671}
672
673#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::config::ExecConfig;
679
680 fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
683 let mut config = ExecConfig {
684 allowlist_mode: crate::config::AllowlistMode::Permissive,
685 allow_shell_mode: true,
686 ..Default::default()
687 };
688 config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
689 ExecTool::new_unrestricted(
690 Arc::new(parking_lot::RwLock::new(config)),
691 Arc::new(Mutex::new(AccessManager::new())),
692 )
693 }
694
695 #[tokio::test]
698 async fn test_shell_exec_echo() {
699 let tool = make_tool(vec![]);
700 let result = tool.shell_exec("echo hello", 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("hello"));
705 assert!(r.duration_ms < 5_000);
706 }
707
708 #[tokio::test]
709 async fn test_shell_exec_pipeline() {
710 let tool = make_tool(vec![]);
711 let result = tool.shell_exec("echo foo | tr f b", 5_000, None).await;
712 assert!(result.is_ok());
713 let r = result.unwrap();
714 assert_eq!(r.exit_code, 0);
715 assert!(r.stdout.contains("boo"));
716 }
717
718 #[tokio::test]
719 async fn test_shell_exec_nonzero_exit() {
720 let tool = make_tool(vec![]);
721 let result = tool.shell_exec("exit 42", 5_000, None).await;
722 assert!(result.is_ok());
723 assert_eq!(result.unwrap().exit_code, 42);
724 }
725
726 #[tokio::test]
727 async fn test_shell_exec_empty_command() {
728 let tool = make_tool(vec![]);
729 let result = tool.shell_exec(" ", 5_000, None).await;
730 assert!(result.is_err());
731 assert!(result.unwrap_err().contains("must not be empty"));
732 }
733
734 #[tokio::test]
735 async fn test_shell_exec_timeout() {
736 let tool = make_tool(vec![]);
737 let result = tool.shell_exec("sleep 300", 200, None).await;
738 assert!(result.is_err());
739 assert!(result.unwrap_err().contains("timed out"));
740 }
741
742 #[tokio::test]
745 async fn test_structured_exec_echo() {
746 let tool = make_tool(vec!["echo"]);
747 let result = tool
748 .structured_exec("echo", vec!["hello".into()], 5_000, None)
749 .await;
750 assert!(result.is_ok());
751 let r = result.unwrap();
752 assert_eq!(r.exit_code, 0);
753 assert!(r.stdout.contains("hello"));
754 }
755
756 #[tokio::test]
757 async fn test_structured_exec_blocked_binary() {
758 let tool = make_tool(vec!["echo"]);
759 let result = tool
760 .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000, None)
761 .await;
762 assert!(result.is_err());
763 assert!(result.unwrap_err().contains("not in the allowlist"));
764 }
765
766 #[tokio::test]
767 async fn test_structured_exec_path_binary() {
768 let tool = make_tool(vec![]);
769 let result = tool
770 .structured_exec("/usr/bin/echo", vec![], 5_000, None)
771 .await;
772 assert!(result.is_err());
773 assert!(result.unwrap_err().contains("bare name"));
774 }
775
776 #[tokio::test]
777 async fn test_structured_exec_traversal_binary() {
778 let tool = make_tool(vec![]);
779 let result = tool
780 .structured_exec("../bin/evil", vec![], 5_000, None)
781 .await;
782 assert!(result.is_err());
783 assert!(result.unwrap_err().contains("path traversal"));
784 }
785
786 #[tokio::test]
787 async fn test_structured_exec_metachar_args() {
788 let tool = make_tool(vec!["echo"]);
789 let result = tool
790 .structured_exec("echo", vec!["foo; rm -rf /".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_path_traversal_args() {
798 let tool = make_tool(vec!["cat"]);
799 let result = tool
800 .structured_exec("cat", vec!["../etc/passwd".into()], 5_000, None)
801 .await;
802 assert!(result.is_err());
803 assert!(result.unwrap_err().contains("metacharacters"));
804 }
805
806 #[tokio::test]
807 async fn test_structured_exec_clean_args() {
808 let tool = make_tool(vec!["echo"]);
809 let result = tool
810 .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000, None)
811 .await;
812 assert!(result.is_ok());
813 let r = result.unwrap();
814 assert_eq!(r.exit_code, 0);
815 assert!(r.stdout.contains("hello world"));
816 }
817
818 #[test]
821 fn test_name_and_label() {
822 let tool = make_tool(vec![]);
823 assert_eq!(tool.name(), "exec");
824 assert_eq!(tool.label(), "Exec");
825 }
826
827 #[test]
828 fn test_parameters_schema() {
829 let tool = make_tool(vec![]);
830 let schema = tool.parameters_schema();
831
832 let props = schema["properties"].as_object().unwrap();
833 assert!(props.contains_key("mode"));
834 assert!(props.contains_key("command"));
835 assert!(props.contains_key("binary"));
836 assert!(props.contains_key("args"));
837 assert!(props.contains_key("timeout"));
838
839 let required = schema["required"].as_array().unwrap();
840 assert!(required.iter().any(|r| r.as_str() == Some("mode")));
841 }
842
843 #[tokio::test]
844 async fn test_agent_tool_shell_mode() {
845 let tool = make_tool(vec![]);
846
847 let result = tool
848 .execute(
849 "test-1",
850 json!({ "mode": "shell", "command": "echo hello" }),
851 None,
852 &ToolContext::default(),
853 )
854 .await;
855
856 assert!(result.is_ok());
857 let r = result.unwrap();
858 assert!(r.success, "Expected success, got: {}", r.output);
859 assert!(r.output.contains("hello"));
860 }
861
862 #[tokio::test]
863 async fn test_agent_tool_structured_mode() {
864 let tool = make_tool(vec!["echo"]);
865
866 let result = tool
867 .execute(
868 "test-2",
869 json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
870 None,
871 &ToolContext::default(),
872 )
873 .await;
874
875 assert!(result.is_ok());
876 let r = result.unwrap();
877 assert!(r.success, "Expected success, got: {}", r.output);
878 assert!(r.output.contains("hi"));
879 }
880
881 #[tokio::test]
882 async fn test_agent_tool_missing_mode() {
883 let tool = make_tool(vec![]);
884 let result = tool
885 .execute(
886 "test-3",
887 json!({ "command": "echo hi" }),
888 None,
889 &ToolContext::default(),
890 )
891 .await;
892 assert!(result.is_err());
893 assert!(
894 result
895 .unwrap_err()
896 .contains("Missing required parameter: mode")
897 );
898 }
899
900 #[tokio::test]
901 async fn test_agent_tool_invalid_mode() {
902 let tool = make_tool(vec![]);
903 let result = tool
904 .execute(
905 "test-4",
906 json!({ "mode": "docker" }),
907 None,
908 &ToolContext::default(),
909 )
910 .await;
911 assert!(result.is_err());
912 assert!(result.unwrap_err().contains("Invalid mode"));
913 }
914
915 #[tokio::test]
916 async fn test_agent_tool_shell_missing_command() {
917 let tool = make_tool(vec![]);
918 let result = tool
919 .execute(
920 "test-5",
921 json!({ "mode": "shell" }),
922 None,
923 &ToolContext::default(),
924 )
925 .await;
926 assert!(result.is_ok());
927 let r = result.unwrap();
928 assert!(!r.success);
929 assert!(r.output.contains("shell mode requires 'command' parameter"));
930 }
931
932 #[tokio::test]
933 async fn test_agent_tool_structured_missing_binary() {
934 let tool = make_tool(vec![]);
935 let result = tool
936 .execute(
937 "test-6",
938 json!({ "mode": "structured" }),
939 None,
940 &ToolContext::default(),
941 )
942 .await;
943 assert!(result.is_ok());
944 let r = result.unwrap();
945 assert!(!r.success);
946 assert!(
947 r.output
948 .contains("structured mode requires 'binary' parameter")
949 );
950 }
951
952 #[tokio::test]
953 async fn test_agent_tool_nonzero_exit() {
954 let tool = make_tool(vec![]);
955
956 let result = tool
957 .execute(
958 "test-7",
959 json!({ "mode": "shell", "command": "exit 7" }),
960 None,
961 &ToolContext::default(),
962 )
963 .await;
964
965 assert!(result.is_ok());
966 let r = result.unwrap();
967 assert!(!r.success);
968 assert!(r.output.contains("exited with code 7"));
969 }
970
971 #[test]
974 fn test_format_exec_output_success() {
975 let result = ExecResult {
976 stdout: "hello".to_string(),
977 stderr: String::new(),
978 exit_code: 0,
979 duration_ms: 1_500,
980 };
981 let output = format_exec_output(&result);
982 assert!(output.contains("hello"));
983 assert!(output.contains("Took 1.5s"));
984 assert!(!output.contains("exited with code"));
985 }
986
987 #[test]
988 fn test_format_exec_output_failure() {
989 let result = ExecResult {
990 stdout: String::new(),
991 stderr: "error!".to_string(),
992 exit_code: 1,
993 duration_ms: 500,
994 };
995 let output = format_exec_output(&result);
996 assert!(output.contains("error!"));
997 assert!(output.contains("exited with code 1"));
998 }
999
1000 #[test]
1001 fn test_format_exec_output_no_output() {
1002 let result = ExecResult {
1003 stdout: String::new(),
1004 stderr: String::new(),
1005 exit_code: 0,
1006 duration_ms: 100,
1007 };
1008 let output = format_exec_output(&result);
1009 assert!(output.contains("(no output)"));
1010 }
1011
1012 #[test]
1013 fn test_format_exec_output_minutes() {
1014 let result = ExecResult {
1015 stdout: "done".to_string(),
1016 stderr: String::new(),
1017 exit_code: 0,
1018 duration_ms: 125_000, };
1020 let output = format_exec_output(&result);
1021 assert!(output.contains("Took 2m 5.0s"));
1022 }
1023
1024 #[test]
1027 fn test_has_metacharacters_clean() {
1028 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
1029 }
1030
1031 #[test]
1032 fn test_has_metacharacters_semicolon() {
1033 assert!(has_metacharacters(&["foo;bar".into()]));
1034 }
1035
1036 #[test]
1037 fn test_has_metacharacters_pipe() {
1038 assert!(has_metacharacters(&["a | b".into()]));
1039 }
1040
1041 #[test]
1042 fn test_has_metacharacters_dollar() {
1043 assert!(has_metacharacters(&["$(whoami)".into()]));
1044 }
1045
1046 #[test]
1047 fn test_has_metacharacters_backtick() {
1048 assert!(has_metacharacters(&["`id`".into()]));
1049 }
1050
1051 #[test]
1052 fn test_has_metacharacters_traversal() {
1053 assert!(has_metacharacters(&["../etc/passwd".into()]));
1054 }
1055
1056 fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
1060 let config = ExecConfig {
1061 allowlist_mode: crate::config::AllowlistMode::Permissive,
1062 allow_shell_mode: true,
1063 ..Default::default()
1064 };
1065 let mut access = AccessManager::new();
1066 {
1068 let perms = access.get_or_create_permissions(agent_name);
1069 perms.allowed_tools.clear();
1071 for tool in allowed_tools {
1072 perms.allow_tool(tool);
1073 }
1074 }
1075 let ctx = crate::access_manager::AgentContext::test_fixture(agent_name);
1076 ExecTool::new(
1077 Arc::new(parking_lot::RwLock::new(config)),
1078 Arc::new(Mutex::new(access)),
1079 ctx,
1080 )
1081 }
1082
1083 #[tokio::test]
1084 async fn test_for_agent_structured_exec_allowed() {
1085 let tool = make_agent_tool("test-agent", &["echo", "ls"]);
1086 let result = tool
1087 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1088 .await;
1089 assert!(result.is_ok(), "Allowed binary should succeed");
1090 let r = result.unwrap();
1091 assert_eq!(r.exit_code, 0);
1092 assert!(r.stdout.contains("hello"));
1093 }
1094
1095 #[tokio::test]
1096 async fn test_for_agent_structured_exec_denied() {
1097 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
1099 .structured_exec("echo", vec!["hello".into()], 5_000, None)
1100 .await;
1101 assert!(result.is_err());
1102 let err = result.unwrap_err();
1103 assert!(
1104 err.contains("not allowed to execute"),
1105 "Error should mention denial: {err}"
1106 );
1107 assert!(
1108 err.contains("echo"),
1109 "Error should name the denied binary: {err}"
1110 );
1111 }
1112
1113 #[tokio::test]
1114 async fn test_for_agent_shell_exec_allowed() {
1115 let tool = make_agent_tool("test-agent", &["bash"]);
1116 let result = tool.shell_exec("echo hello", 5_000, None).await;
1117 assert!(
1118 result.is_ok(),
1119 "Agent with 'bash' permission should succeed"
1120 );
1121 assert!(result.unwrap().stdout.contains("hello"));
1122 }
1123
1124 #[tokio::test]
1125 async fn test_for_agent_shell_exec_denied() {
1126 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000, None).await;
1128 assert!(result.is_err());
1129 let err = result.unwrap_err();
1130 assert!(
1131 err.contains("not allowed to execute"),
1132 "Error should mention denial: {err}"
1133 );
1134 assert!(err.contains("bash"), "Error should name 'bash': {err}");
1135 }
1136
1137 #[tokio::test]
1138 async fn test_no_agent_name_bypasses_access_control() {
1139 let config = ExecConfig {
1142 allow_shell_mode: true,
1143 ..Default::default()
1144 };
1145 let access = AccessManager::new(); let tool = ExecTool::new_unrestricted(
1147 Arc::new(parking_lot::RwLock::new(config)),
1148 Arc::new(Mutex::new(access)),
1149 );
1150 let result = tool.shell_exec("echo unrestricted", 5_000, None).await;
1151 assert!(
1152 result.is_ok(),
1153 "Shell mode enabled + no agent_name = unrestricted execution"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_agent_name_set_correctly() {
1159 let tool = make_agent_tool("my-agent", &[]);
1160 assert_eq!(tool.agent_name(), Some("my-agent"));
1161 }
1162
1163 #[test]
1164 fn test_new_has_no_agent_name() {
1165 let tool = make_tool(vec![]);
1166 assert!(tool.agent_name().is_none());
1167 }
1168}