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::config::ExecConfig;
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: Arc<ExecConfig>,
69 access: Arc<Mutex<AccessManager>>,
71 agent_name: Option<String>,
74}
75
76impl ExecTool {
77 pub fn new(config: Arc<ExecConfig>, access: Arc<Mutex<AccessManager>>) -> Self {
82 Self {
83 config,
84 access,
85 agent_name: None,
86 }
87 }
88
89 pub fn from_kernel(kernel: &crate::kernel_handle::KernelHandle) -> Self {
94 Self::for_agent(
95 Arc::new(kernel.exec.config().clone()),
96 kernel.exec.access_manager().clone(),
97 "oxios-agent".to_string(),
98 )
99 }
100
101 pub fn for_agent(
106 config: Arc<ExecConfig>,
107 access: Arc<Mutex<AccessManager>>,
108 agent_name: String,
109 ) -> Self {
110 Self {
111 config,
112 access,
113 agent_name: Some(agent_name),
114 }
115 }
116
117 pub async fn shell_exec(&self, command: &str, timeout_ms: u64) -> Result<ExecResult, String> {
123 if !self.config.allow_shell_mode {
125 return Err(
126 "shell_exec: shell mode is disabled by configuration (allow_shell_mode = false). \
127 Use mode='structured' instead, or set allow_shell_mode=true in config.toml"
128 .to_string(),
129 );
130 }
131
132 if command.trim().is_empty() {
133 return Err("shell_exec: command must not be empty".to_string());
134 }
135
136 if let Some(ref name) = self.agent_name {
138 let mut access = self.access.lock();
139 if !access.can_use_tool(name, "bash") {
140 return Err(format!(
141 "shell_exec: agent '{}' is not allowed to execute 'bash'",
142 name
143 ));
144 }
145 tracing::info!(
146 agent = %name,
147 mode = "shell",
148 command = %command.chars().take(200).collect::<String>(),
149 "ExecTool: executing shell command (shell mode enabled)",
150 );
151 } else {
152 tracing::warn!(
153 mode = "shell",
154 command = %command.chars().take(200).collect::<String>(),
155 "ExecTool: shell mode executing without agent context",
156 );
157 }
158
159 let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
160
161 let start = std::time::Instant::now();
162
163 let result = tokio::time::timeout(
164 std::time::Duration::from_millis(effective_timeout),
165 tokio::process::Command::new("bash")
166 .arg("-c")
167 .arg(command)
168 .env_clear()
169 .env("HOME", std::env::var("HOME").unwrap_or_default())
170 .env("USER", std::env::var("USER").unwrap_or_default())
171 .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
172 .env("PATH", std::env::var("PATH").unwrap_or_default())
173 .env(
174 "LANG",
175 std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
176 )
177 .env("TERM", "dumb")
178 .output(),
179 )
180 .await;
181
182 let duration_ms = start.elapsed().as_millis() as u64;
183
184 match result {
185 Ok(Ok(output)) => Ok(ExecResult {
186 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
187 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
188 exit_code: output.status.code().unwrap_or(-1),
189 duration_ms,
190 }),
191 Ok(Err(e)) => Err(format!("shell execution error: {e}")),
192 Err(_) => Err(format!(
193 "shell command timed out after {effective_timeout}ms"
194 )),
195 }
196 }
197
198 pub async fn structured_exec(
206 &self,
207 binary: &str,
208 args: Vec<String>,
209 timeout_ms: u64,
210 ) -> Result<ExecResult, String> {
211 if let Some(ref name) = self.agent_name {
213 let mut access = self.access.lock();
214 if !access.can_use_tool(name, binary) {
215 return Err(format!(
216 "structured_exec: agent '{}' is not allowed to execute '{}'",
217 name, binary
218 ));
219 }
220 }
221
222 tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
226
227 if binary.contains("..") {
228 return Err("structured_exec: path traversal in binary name".to_string());
229 }
230 if binary.contains('/') {
231 return Err("structured_exec: binary must be a bare name, not a path".to_string());
232 }
233 if !self.config.is_binary_allowed(binary) {
234 return Err(format!(
235 "structured_exec: binary '{binary}' is not in the allowlist"
236 ));
237 }
238
239 if has_metacharacters(&args) {
242 return Err(
243 "structured_exec: shell metacharacters or path traversal not allowed in arguments"
244 .to_string(),
245 );
246 }
247
248 let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
249
250 let start = std::time::Instant::now();
251
252 let result = tokio::time::timeout(
253 std::time::Duration::from_millis(effective_timeout),
254 tokio::process::Command::new(binary)
255 .args(&args)
256 .env_clear()
257 .env("HOME", std::env::var("HOME").unwrap_or_default())
258 .env("USER", std::env::var("USER").unwrap_or_default())
259 .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
260 .env("PATH", std::env::var("PATH").unwrap_or_default())
261 .env(
262 "LANG",
263 std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
264 )
265 .env("TERM", "dumb")
266 .output(),
267 )
268 .await;
269
270 let duration_ms = start.elapsed().as_millis() as u64;
271
272 match result {
273 Ok(Ok(output)) => Ok(ExecResult {
274 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
275 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
276 exit_code: output.status.code().unwrap_or(-1),
277 duration_ms,
278 }),
279 Ok(Err(e)) => Err(format!("structured execution error: {e}")),
280 Err(_) => Err(format!(
281 "structured command timed out after {effective_timeout}ms"
282 )),
283 }
284 }
285}
286
287fn has_metacharacters(args: &[String]) -> bool {
291 for arg in args {
292 if arg.contains("..") {
293 return true;
294 }
295 if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
296 return true;
297 }
298 }
299 false
300}
301
302fn format_exec_output(result: &ExecResult) -> String {
305 let mut output = String::new();
306
307 if result.stdout.is_empty() && result.stderr.is_empty() {
308 output.push_str("(no output)");
309 } else {
310 if !result.stdout.is_empty() {
311 output.push_str(&result.stdout);
312 }
313 if !result.stderr.is_empty() && !result.stdout.is_empty() {
314 output.push('\n');
315 }
316 if !result.stderr.is_empty() {
317 output.push_str(&result.stderr);
318 }
319 }
320
321 if result.exit_code != 0 {
322 output.push_str(&format!(
323 "\n\nCommand exited with code {}",
324 result.exit_code
325 ));
326 }
327
328 let secs = result.duration_ms / 1000;
329 let millis = result.duration_ms % 1000;
330
331 if secs >= 60 {
332 let mins = secs / 60;
333 let remain_secs = secs % 60;
334 output.push_str(&format!(
335 "\n\nTook {}m {:.1}s",
336 mins,
337 remain_secs as f64 + millis as f64 / 1000.0
338 ));
339 } else {
340 output.push_str(&format!(
341 "\n\nTook {:.1}s",
342 secs as f64 + millis as f64 / 1000.0
343 ));
344 }
345
346 output
347}
348
349impl std::fmt::Debug for ExecTool {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 f.debug_struct("ExecTool").finish()
354 }
355}
356
357#[async_trait]
360impl AgentTool for ExecTool {
361 fn name(&self) -> &str {
362 "exec"
363 }
364
365 fn label(&self) -> &str {
366 "Exec"
367 }
368
369 fn description(&self) -> &'static str {
370 "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
371 }
372
373 fn parameters_schema(&self) -> Value {
374 json!({
375 "type": "object",
376 "properties": {
377 "mode": {
378 "type": "string",
379 "enum": ["shell", "structured"],
380 "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
381 },
382 "command": {
383 "type": "string",
384 "description": "Shell command string (mode='shell' only)"
385 },
386 "binary": {
387 "type": "string",
388 "description": "Binary name (mode='structured' only, must be in allowlist)"
389 },
390 "args": {
391 "type": "array",
392 "items": { "type": "string" },
393 "description": "Binary arguments (mode='structured' only)"
394 },
395 "timeout": {
396 "type": "integer",
397 "description": "Timeout in seconds",
398 "default": 120
399 }
400 },
401 "required": ["mode"]
402 })
403 }
404
405 async fn execute(
406 &self,
407 _tool_call_id: &str,
408 params: Value,
409 _signal: Option<oneshot::Receiver<()>>,
410 _ctx: &ToolContext,
411 ) -> Result<AgentToolResult, String> {
412 let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
413 "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
414 })?;
415
416 let timeout_secs = params
417 .get("timeout")
418 .and_then(|v| v.as_u64())
419 .unwrap_or(self.config.default_timeout_secs);
420 let timeout_ms = (timeout_secs * 1000).min(self.config.max_timeout_secs * 1000);
421
422 match mode {
423 "shell" => {
424 let command = match params.get("command").and_then(|v| v.as_str()) {
425 Some(c) => c,
426 None => {
427 return Ok(AgentToolResult::error(
428 "shell mode requires 'command' parameter",
429 ))
430 }
431 };
432
433 match self.shell_exec(command, timeout_ms).await {
434 Ok(result) => {
435 let output = format_exec_output(&result);
436 if result.exit_code == 0 {
437 Ok(AgentToolResult::success(output))
438 } else {
439 Ok(AgentToolResult::error(output))
440 }
441 }
442 Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
443 }
444 }
445
446 "structured" => {
447 let binary = match params.get("binary").and_then(|v| v.as_str()) {
448 Some(b) => b,
449 None => {
450 return Ok(AgentToolResult::error(
451 "structured mode requires 'binary' parameter",
452 ))
453 }
454 };
455
456 let args: Vec<String> = params
457 .get("args")
458 .and_then(|v| v.as_array())
459 .map(|arr| {
460 arr.iter()
461 .filter_map(|v| v.as_str().map(String::from))
462 .collect()
463 })
464 .unwrap_or_default();
465
466 match self.structured_exec(binary, args, timeout_ms).await {
467 Ok(result) => {
468 let output = format_exec_output(&result);
469 if result.exit_code == 0 {
470 Ok(AgentToolResult::success(output))
471 } else {
472 Ok(AgentToolResult::error(output))
473 }
474 }
475 Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
476 }
477 }
478
479 other => Err(format!(
480 "Invalid mode '{other}': expected 'shell' or 'structured'"
481 )),
482 }
483 }
484}
485
486#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
495 let mut config = ExecConfig::default();
496 config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
497 config.allow_shell_mode = true; ExecTool::new(Arc::new(config), Arc::new(Mutex::new(AccessManager::new())))
499 }
500
501 fn make_tool_with_config(config: ExecConfig) -> ExecTool {
502 let mut cfg = config.clone();
503 cfg.allow_shell_mode = true; ExecTool::new(Arc::new(cfg), Arc::new(Mutex::new(AccessManager::new())))
505 }
506
507 #[tokio::test]
510 async fn test_shell_exec_echo() {
511 let tool = make_tool(vec![]);
512 let result = tool.shell_exec("echo hello", 5_000).await;
513 assert!(result.is_ok());
514 let r = result.unwrap();
515 assert_eq!(r.exit_code, 0);
516 assert!(r.stdout.contains("hello"));
517 assert!(r.duration_ms < 5_000);
518 }
519
520 #[tokio::test]
521 async fn test_shell_exec_pipeline() {
522 let tool = make_tool(vec![]);
523 let result = tool.shell_exec("echo foo | tr f b", 5_000).await;
524 assert!(result.is_ok());
525 let r = result.unwrap();
526 assert_eq!(r.exit_code, 0);
527 assert!(r.stdout.contains("boo"));
528 }
529
530 #[tokio::test]
531 async fn test_shell_exec_nonzero_exit() {
532 let tool = make_tool(vec![]);
533 let result = tool.shell_exec("exit 42", 5_000).await;
534 assert!(result.is_ok());
535 assert_eq!(result.unwrap().exit_code, 42);
536 }
537
538 #[tokio::test]
539 async fn test_shell_exec_empty_command() {
540 let tool = make_tool(vec![]);
541 let result = tool.shell_exec(" ", 5_000).await;
542 assert!(result.is_err());
543 assert!(result.unwrap_err().contains("must not be empty"));
544 }
545
546 #[tokio::test]
547 async fn test_shell_exec_timeout() {
548 let tool = make_tool(vec![]);
549 let result = tool.shell_exec("sleep 300", 1_000).await;
550 assert!(result.is_err());
551 assert!(result.unwrap_err().contains("timed out"));
552 }
553
554 #[tokio::test]
557 async fn test_structured_exec_echo() {
558 let tool = make_tool(vec!["echo"]);
559 let result = tool
560 .structured_exec("echo", vec!["hello".into()], 5_000)
561 .await;
562 assert!(result.is_ok());
563 let r = result.unwrap();
564 assert_eq!(r.exit_code, 0);
565 assert!(r.stdout.contains("hello"));
566 }
567
568 #[tokio::test]
569 async fn test_structured_exec_blocked_binary() {
570 let tool = make_tool(vec!["echo"]);
571 let result = tool
572 .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000)
573 .await;
574 assert!(result.is_err());
575 assert!(result.unwrap_err().contains("not in the allowlist"));
576 }
577
578 #[tokio::test]
579 async fn test_structured_exec_path_binary() {
580 let tool = make_tool(vec![]);
581 let result = tool.structured_exec("/usr/bin/echo", vec![], 5_000).await;
582 assert!(result.is_err());
583 assert!(result.unwrap_err().contains("bare name"));
584 }
585
586 #[tokio::test]
587 async fn test_structured_exec_traversal_binary() {
588 let tool = make_tool(vec![]);
589 let result = tool.structured_exec("../bin/evil", vec![], 5_000).await;
590 assert!(result.is_err());
591 assert!(result.unwrap_err().contains("path traversal"));
592 }
593
594 #[tokio::test]
595 async fn test_structured_exec_metachar_args() {
596 let tool = make_tool(vec!["echo"]);
597 let result = tool
598 .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000)
599 .await;
600 assert!(result.is_err());
601 assert!(result.unwrap_err().contains("metacharacters"));
602 }
603
604 #[tokio::test]
605 async fn test_structured_exec_path_traversal_args() {
606 let tool = make_tool(vec!["cat"]);
607 let result = tool
608 .structured_exec("cat", vec!["../etc/passwd".into()], 5_000)
609 .await;
610 assert!(result.is_err());
611 assert!(result.unwrap_err().contains("metacharacters"));
612 }
613
614 #[tokio::test]
615 async fn test_structured_exec_clean_args() {
616 let tool = make_tool(vec!["echo"]);
617 let result = tool
618 .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000)
619 .await;
620 assert!(result.is_ok());
621 let r = result.unwrap();
622 assert_eq!(r.exit_code, 0);
623 assert!(r.stdout.contains("hello world"));
624 }
625
626 #[test]
629 fn test_name_and_label() {
630 let tool = make_tool(vec![]);
631 assert_eq!(tool.name(), "exec");
632 assert_eq!(tool.label(), "Exec");
633 }
634
635 #[test]
636 fn test_parameters_schema() {
637 let tool = make_tool(vec![]);
638 let schema = tool.parameters_schema();
639
640 let props = schema["properties"].as_object().unwrap();
641 assert!(props.contains_key("mode"));
642 assert!(props.contains_key("command"));
643 assert!(props.contains_key("binary"));
644 assert!(props.contains_key("args"));
645 assert!(props.contains_key("timeout"));
646
647 let required = schema["required"].as_array().unwrap();
648 assert!(required.iter().any(|r| r.as_str() == Some("mode")));
649 }
650
651 #[tokio::test]
652 async fn test_agent_tool_shell_mode() {
653 let tool = make_tool(vec![]);
654
655 let result = tool
656 .execute(
657 "test-1",
658 json!({ "mode": "shell", "command": "echo hello" }),
659 None,
660 &ToolContext::default(),
661 )
662 .await;
663
664 assert!(result.is_ok());
665 let r = result.unwrap();
666 assert!(r.success, "Expected success, got: {}", r.output);
667 assert!(r.output.contains("hello"));
668 }
669
670 #[tokio::test]
671 async fn test_agent_tool_structured_mode() {
672 let tool = make_tool(vec!["echo"]);
673
674 let result = tool
675 .execute(
676 "test-2",
677 json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
678 None,
679 &ToolContext::default(),
680 )
681 .await;
682
683 assert!(result.is_ok());
684 let r = result.unwrap();
685 assert!(r.success, "Expected success, got: {}", r.output);
686 assert!(r.output.contains("hi"));
687 }
688
689 #[tokio::test]
690 async fn test_agent_tool_missing_mode() {
691 let tool = make_tool(vec![]);
692 let result = tool
693 .execute("test-3", json!({ "command": "echo hi" }), None, &ToolContext::default())
694 .await;
695 assert!(result.is_err());
696 assert!(result
697 .unwrap_err()
698 .contains("Missing required parameter: mode"));
699 }
700
701 #[tokio::test]
702 async fn test_agent_tool_invalid_mode() {
703 let tool = make_tool(vec![]);
704 let result = tool
705 .execute("test-4", json!({ "mode": "docker" }), None, &ToolContext::default())
706 .await;
707 assert!(result.is_err());
708 assert!(result.unwrap_err().contains("Invalid mode"));
709 }
710
711 #[tokio::test]
712 async fn test_agent_tool_shell_missing_command() {
713 let tool = make_tool(vec![]);
714 let result = tool
715 .execute("test-5", json!({ "mode": "shell" }), None, &ToolContext::default())
716 .await;
717 assert!(result.is_ok());
718 let r = result.unwrap();
719 assert!(!r.success);
720 assert!(r.output.contains("shell mode requires 'command' parameter"));
721 }
722
723 #[tokio::test]
724 async fn test_agent_tool_structured_missing_binary() {
725 let tool = make_tool(vec![]);
726 let result = tool
727 .execute("test-6", json!({ "mode": "structured" }), None, &ToolContext::default())
728 .await;
729 assert!(result.is_ok());
730 let r = result.unwrap();
731 assert!(!r.success);
732 assert!(r
733 .output
734 .contains("structured mode requires 'binary' parameter"));
735 }
736
737 #[tokio::test]
738 async fn test_agent_tool_nonzero_exit() {
739 let tool = make_tool(vec![]);
740
741 let result = tool
742 .execute(
743 "test-7",
744 json!({ "mode": "shell", "command": "exit 7" }),
745 None,
746 &ToolContext::default(),
747 )
748 .await;
749
750 assert!(result.is_ok());
751 let r = result.unwrap();
752 assert!(!r.success);
753 assert!(r.output.contains("exited with code 7"));
754 }
755
756 #[test]
759 fn test_format_exec_output_success() {
760 let result = ExecResult {
761 stdout: "hello".to_string(),
762 stderr: String::new(),
763 exit_code: 0,
764 duration_ms: 1_500,
765 };
766 let output = format_exec_output(&result);
767 assert!(output.contains("hello"));
768 assert!(output.contains("Took 1.5s"));
769 assert!(!output.contains("exited with code"));
770 }
771
772 #[test]
773 fn test_format_exec_output_failure() {
774 let result = ExecResult {
775 stdout: String::new(),
776 stderr: "error!".to_string(),
777 exit_code: 1,
778 duration_ms: 500,
779 };
780 let output = format_exec_output(&result);
781 assert!(output.contains("error!"));
782 assert!(output.contains("exited with code 1"));
783 }
784
785 #[test]
786 fn test_format_exec_output_no_output() {
787 let result = ExecResult {
788 stdout: String::new(),
789 stderr: String::new(),
790 exit_code: 0,
791 duration_ms: 100,
792 };
793 let output = format_exec_output(&result);
794 assert!(output.contains("(no output)"));
795 }
796
797 #[test]
798 fn test_format_exec_output_minutes() {
799 let result = ExecResult {
800 stdout: "done".to_string(),
801 stderr: String::new(),
802 exit_code: 0,
803 duration_ms: 125_000, };
805 let output = format_exec_output(&result);
806 assert!(output.contains("Took 2m 5.0s"));
807 }
808
809 #[test]
812 fn test_has_metacharacters_clean() {
813 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
814 }
815
816 #[test]
817 fn test_has_metacharacters_semicolon() {
818 assert!(has_metacharacters(&["foo;bar".into()]));
819 }
820
821 #[test]
822 fn test_has_metacharacters_pipe() {
823 assert!(has_metacharacters(&["a | b".into()]));
824 }
825
826 #[test]
827 fn test_has_metacharacters_dollar() {
828 assert!(has_metacharacters(&["$(whoami)".into()]));
829 }
830
831 #[test]
832 fn test_has_metacharacters_backtick() {
833 assert!(has_metacharacters(&["`id`".into()]));
834 }
835
836 #[test]
837 fn test_has_metacharacters_traversal() {
838 assert!(has_metacharacters(&["../etc/passwd".into()]));
839 }
840
841 fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
845 let mut config = ExecConfig::default();
846 config.allow_shell_mode = true; let mut access = AccessManager::new();
848 {
850 let perms = access.get_or_create_permissions(agent_name);
851 perms.allowed_tools.clear();
853 for tool in allowed_tools {
854 perms.allow_tool(tool);
855 }
856 }
857 ExecTool::for_agent(
858 Arc::new(config),
859 Arc::new(Mutex::new(access)),
860 agent_name.to_string(),
861 )
862 }
863
864 #[tokio::test]
865 async fn test_for_agent_structured_exec_allowed() {
866 let tool = make_agent_tool("test-agent", &["echo", "ls"]);
867 let result = tool
868 .structured_exec("echo", vec!["hello".into()], 5_000)
869 .await;
870 assert!(result.is_ok(), "Allowed binary should succeed");
871 let r = result.unwrap();
872 assert_eq!(r.exit_code, 0);
873 assert!(r.stdout.contains("hello"));
874 }
875
876 #[tokio::test]
877 async fn test_for_agent_structured_exec_denied() {
878 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
880 .structured_exec("echo", vec!["hello".into()], 5_000)
881 .await;
882 assert!(result.is_err());
883 let err = result.unwrap_err();
884 assert!(
885 err.contains("not allowed to execute"),
886 "Error should mention denial: {err}"
887 );
888 assert!(
889 err.contains("echo"),
890 "Error should name the denied binary: {err}"
891 );
892 }
893
894 #[tokio::test]
895 async fn test_for_agent_shell_exec_allowed() {
896 let tool = make_agent_tool("test-agent", &["bash"]);
897 let result = tool.shell_exec("echo hello", 5_000).await;
898 assert!(
899 result.is_ok(),
900 "Agent with 'bash' permission should succeed"
901 );
902 assert!(result.unwrap().stdout.contains("hello"));
903 }
904
905 #[tokio::test]
906 async fn test_for_agent_shell_exec_denied() {
907 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000).await;
909 assert!(result.is_err());
910 let err = result.unwrap_err();
911 assert!(
912 err.contains("not allowed to execute"),
913 "Error should mention denial: {err}"
914 );
915 assert!(err.contains("bash"), "Error should name 'bash': {err}");
916 }
917
918 #[tokio::test]
919 async fn test_no_agent_name_bypasses_access_control() {
920 let mut config = ExecConfig::default();
923 config.allow_shell_mode = true; let access = AccessManager::new(); let tool = ExecTool::new(Arc::new(config), Arc::new(Mutex::new(access)));
926 let result = tool.shell_exec("echo unrestricted", 5_000).await;
927 assert!(
928 result.is_ok(),
929 "Shell mode enabled + no agent_name = unrestricted execution"
930 );
931 }
932
933 #[test]
934 fn test_agent_name_set_correctly() {
935 let tool = make_agent_tool("my-agent", &[]);
936 assert_eq!(tool.agent_name.as_deref(), Some("my-agent"));
937 }
938
939 #[test]
940 fn test_new_has_no_agent_name() {
941 let tool = make_tool(vec![]);
942 assert!(tool.agent_name.is_none());
943 }
944}