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 #[tokio::test]
504 async fn test_shell_exec_echo() {
505 let tool = make_tool(vec![]);
506 let result = tool.shell_exec("echo hello", 5_000).await;
507 assert!(result.is_ok());
508 let r = result.unwrap();
509 assert_eq!(r.exit_code, 0);
510 assert!(r.stdout.contains("hello"));
511 assert!(r.duration_ms < 5_000);
512 }
513
514 #[tokio::test]
515 async fn test_shell_exec_pipeline() {
516 let tool = make_tool(vec![]);
517 let result = tool.shell_exec("echo foo | tr f b", 5_000).await;
518 assert!(result.is_ok());
519 let r = result.unwrap();
520 assert_eq!(r.exit_code, 0);
521 assert!(r.stdout.contains("boo"));
522 }
523
524 #[tokio::test]
525 async fn test_shell_exec_nonzero_exit() {
526 let tool = make_tool(vec![]);
527 let result = tool.shell_exec("exit 42", 5_000).await;
528 assert!(result.is_ok());
529 assert_eq!(result.unwrap().exit_code, 42);
530 }
531
532 #[tokio::test]
533 async fn test_shell_exec_empty_command() {
534 let tool = make_tool(vec![]);
535 let result = tool.shell_exec(" ", 5_000).await;
536 assert!(result.is_err());
537 assert!(result.unwrap_err().contains("must not be empty"));
538 }
539
540 #[tokio::test]
541 async fn test_shell_exec_timeout() {
542 let tool = make_tool(vec![]);
543 let result = tool.shell_exec("sleep 300", 1_000).await;
544 assert!(result.is_err());
545 assert!(result.unwrap_err().contains("timed out"));
546 }
547
548 #[tokio::test]
551 async fn test_structured_exec_echo() {
552 let tool = make_tool(vec!["echo"]);
553 let result = tool
554 .structured_exec("echo", vec!["hello".into()], 5_000)
555 .await;
556 assert!(result.is_ok());
557 let r = result.unwrap();
558 assert_eq!(r.exit_code, 0);
559 assert!(r.stdout.contains("hello"));
560 }
561
562 #[tokio::test]
563 async fn test_structured_exec_blocked_binary() {
564 let tool = make_tool(vec!["echo"]);
565 let result = tool
566 .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000)
567 .await;
568 assert!(result.is_err());
569 assert!(result.unwrap_err().contains("not in the allowlist"));
570 }
571
572 #[tokio::test]
573 async fn test_structured_exec_path_binary() {
574 let tool = make_tool(vec![]);
575 let result = tool.structured_exec("/usr/bin/echo", vec![], 5_000).await;
576 assert!(result.is_err());
577 assert!(result.unwrap_err().contains("bare name"));
578 }
579
580 #[tokio::test]
581 async fn test_structured_exec_traversal_binary() {
582 let tool = make_tool(vec![]);
583 let result = tool.structured_exec("../bin/evil", vec![], 5_000).await;
584 assert!(result.is_err());
585 assert!(result.unwrap_err().contains("path traversal"));
586 }
587
588 #[tokio::test]
589 async fn test_structured_exec_metachar_args() {
590 let tool = make_tool(vec!["echo"]);
591 let result = tool
592 .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000)
593 .await;
594 assert!(result.is_err());
595 assert!(result.unwrap_err().contains("metacharacters"));
596 }
597
598 #[tokio::test]
599 async fn test_structured_exec_path_traversal_args() {
600 let tool = make_tool(vec!["cat"]);
601 let result = tool
602 .structured_exec("cat", vec!["../etc/passwd".into()], 5_000)
603 .await;
604 assert!(result.is_err());
605 assert!(result.unwrap_err().contains("metacharacters"));
606 }
607
608 #[tokio::test]
609 async fn test_structured_exec_clean_args() {
610 let tool = make_tool(vec!["echo"]);
611 let result = tool
612 .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000)
613 .await;
614 assert!(result.is_ok());
615 let r = result.unwrap();
616 assert_eq!(r.exit_code, 0);
617 assert!(r.stdout.contains("hello world"));
618 }
619
620 #[test]
623 fn test_name_and_label() {
624 let tool = make_tool(vec![]);
625 assert_eq!(tool.name(), "exec");
626 assert_eq!(tool.label(), "Exec");
627 }
628
629 #[test]
630 fn test_parameters_schema() {
631 let tool = make_tool(vec![]);
632 let schema = tool.parameters_schema();
633
634 let props = schema["properties"].as_object().unwrap();
635 assert!(props.contains_key("mode"));
636 assert!(props.contains_key("command"));
637 assert!(props.contains_key("binary"));
638 assert!(props.contains_key("args"));
639 assert!(props.contains_key("timeout"));
640
641 let required = schema["required"].as_array().unwrap();
642 assert!(required.iter().any(|r| r.as_str() == Some("mode")));
643 }
644
645 #[tokio::test]
646 async fn test_agent_tool_shell_mode() {
647 let tool = make_tool(vec![]);
648
649 let result = tool
650 .execute(
651 "test-1",
652 json!({ "mode": "shell", "command": "echo hello" }),
653 None,
654 &ToolContext::default(),
655 )
656 .await;
657
658 assert!(result.is_ok());
659 let r = result.unwrap();
660 assert!(r.success, "Expected success, got: {}", r.output);
661 assert!(r.output.contains("hello"));
662 }
663
664 #[tokio::test]
665 async fn test_agent_tool_structured_mode() {
666 let tool = make_tool(vec!["echo"]);
667
668 let result = tool
669 .execute(
670 "test-2",
671 json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
672 None,
673 &ToolContext::default(),
674 )
675 .await;
676
677 assert!(result.is_ok());
678 let r = result.unwrap();
679 assert!(r.success, "Expected success, got: {}", r.output);
680 assert!(r.output.contains("hi"));
681 }
682
683 #[tokio::test]
684 async fn test_agent_tool_missing_mode() {
685 let tool = make_tool(vec![]);
686 let result = tool
687 .execute(
688 "test-3",
689 json!({ "command": "echo hi" }),
690 None,
691 &ToolContext::default(),
692 )
693 .await;
694 assert!(result.is_err());
695 assert!(result
696 .unwrap_err()
697 .contains("Missing required parameter: mode"));
698 }
699
700 #[tokio::test]
701 async fn test_agent_tool_invalid_mode() {
702 let tool = make_tool(vec![]);
703 let result = tool
704 .execute(
705 "test-4",
706 json!({ "mode": "docker" }),
707 None,
708 &ToolContext::default(),
709 )
710 .await;
711 assert!(result.is_err());
712 assert!(result.unwrap_err().contains("Invalid mode"));
713 }
714
715 #[tokio::test]
716 async fn test_agent_tool_shell_missing_command() {
717 let tool = make_tool(vec![]);
718 let result = tool
719 .execute(
720 "test-5",
721 json!({ "mode": "shell" }),
722 None,
723 &ToolContext::default(),
724 )
725 .await;
726 assert!(result.is_ok());
727 let r = result.unwrap();
728 assert!(!r.success);
729 assert!(r.output.contains("shell mode requires 'command' parameter"));
730 }
731
732 #[tokio::test]
733 async fn test_agent_tool_structured_missing_binary() {
734 let tool = make_tool(vec![]);
735 let result = tool
736 .execute(
737 "test-6",
738 json!({ "mode": "structured" }),
739 None,
740 &ToolContext::default(),
741 )
742 .await;
743 assert!(result.is_ok());
744 let r = result.unwrap();
745 assert!(!r.success);
746 assert!(r
747 .output
748 .contains("structured mode requires 'binary' parameter"));
749 }
750
751 #[tokio::test]
752 async fn test_agent_tool_nonzero_exit() {
753 let tool = make_tool(vec![]);
754
755 let result = tool
756 .execute(
757 "test-7",
758 json!({ "mode": "shell", "command": "exit 7" }),
759 None,
760 &ToolContext::default(),
761 )
762 .await;
763
764 assert!(result.is_ok());
765 let r = result.unwrap();
766 assert!(!r.success);
767 assert!(r.output.contains("exited with code 7"));
768 }
769
770 #[test]
773 fn test_format_exec_output_success() {
774 let result = ExecResult {
775 stdout: "hello".to_string(),
776 stderr: String::new(),
777 exit_code: 0,
778 duration_ms: 1_500,
779 };
780 let output = format_exec_output(&result);
781 assert!(output.contains("hello"));
782 assert!(output.contains("Took 1.5s"));
783 assert!(!output.contains("exited with code"));
784 }
785
786 #[test]
787 fn test_format_exec_output_failure() {
788 let result = ExecResult {
789 stdout: String::new(),
790 stderr: "error!".to_string(),
791 exit_code: 1,
792 duration_ms: 500,
793 };
794 let output = format_exec_output(&result);
795 assert!(output.contains("error!"));
796 assert!(output.contains("exited with code 1"));
797 }
798
799 #[test]
800 fn test_format_exec_output_no_output() {
801 let result = ExecResult {
802 stdout: String::new(),
803 stderr: String::new(),
804 exit_code: 0,
805 duration_ms: 100,
806 };
807 let output = format_exec_output(&result);
808 assert!(output.contains("(no output)"));
809 }
810
811 #[test]
812 fn test_format_exec_output_minutes() {
813 let result = ExecResult {
814 stdout: "done".to_string(),
815 stderr: String::new(),
816 exit_code: 0,
817 duration_ms: 125_000, };
819 let output = format_exec_output(&result);
820 assert!(output.contains("Took 2m 5.0s"));
821 }
822
823 #[test]
826 fn test_has_metacharacters_clean() {
827 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
828 }
829
830 #[test]
831 fn test_has_metacharacters_semicolon() {
832 assert!(has_metacharacters(&["foo;bar".into()]));
833 }
834
835 #[test]
836 fn test_has_metacharacters_pipe() {
837 assert!(has_metacharacters(&["a | b".into()]));
838 }
839
840 #[test]
841 fn test_has_metacharacters_dollar() {
842 assert!(has_metacharacters(&["$(whoami)".into()]));
843 }
844
845 #[test]
846 fn test_has_metacharacters_backtick() {
847 assert!(has_metacharacters(&["`id`".into()]));
848 }
849
850 #[test]
851 fn test_has_metacharacters_traversal() {
852 assert!(has_metacharacters(&["../etc/passwd".into()]));
853 }
854
855 fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
859 let mut config = ExecConfig::default();
860 config.allow_shell_mode = true; let mut access = AccessManager::new();
862 {
864 let perms = access.get_or_create_permissions(agent_name);
865 perms.allowed_tools.clear();
867 for tool in allowed_tools {
868 perms.allow_tool(tool);
869 }
870 }
871 ExecTool::for_agent(
872 Arc::new(config),
873 Arc::new(Mutex::new(access)),
874 agent_name.to_string(),
875 )
876 }
877
878 #[tokio::test]
879 async fn test_for_agent_structured_exec_allowed() {
880 let tool = make_agent_tool("test-agent", &["echo", "ls"]);
881 let result = tool
882 .structured_exec("echo", vec!["hello".into()], 5_000)
883 .await;
884 assert!(result.is_ok(), "Allowed binary should succeed");
885 let r = result.unwrap();
886 assert_eq!(r.exit_code, 0);
887 assert!(r.stdout.contains("hello"));
888 }
889
890 #[tokio::test]
891 async fn test_for_agent_structured_exec_denied() {
892 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool
894 .structured_exec("echo", vec!["hello".into()], 5_000)
895 .await;
896 assert!(result.is_err());
897 let err = result.unwrap_err();
898 assert!(
899 err.contains("not allowed to execute"),
900 "Error should mention denial: {err}"
901 );
902 assert!(
903 err.contains("echo"),
904 "Error should name the denied binary: {err}"
905 );
906 }
907
908 #[tokio::test]
909 async fn test_for_agent_shell_exec_allowed() {
910 let tool = make_agent_tool("test-agent", &["bash"]);
911 let result = tool.shell_exec("echo hello", 5_000).await;
912 assert!(
913 result.is_ok(),
914 "Agent with 'bash' permission should succeed"
915 );
916 assert!(result.unwrap().stdout.contains("hello"));
917 }
918
919 #[tokio::test]
920 async fn test_for_agent_shell_exec_denied() {
921 let tool = make_agent_tool("test-agent", &["ls"]); let result = tool.shell_exec("echo hello", 5_000).await;
923 assert!(result.is_err());
924 let err = result.unwrap_err();
925 assert!(
926 err.contains("not allowed to execute"),
927 "Error should mention denial: {err}"
928 );
929 assert!(err.contains("bash"), "Error should name 'bash': {err}");
930 }
931
932 #[tokio::test]
933 async fn test_no_agent_name_bypasses_access_control() {
934 let mut config = ExecConfig::default();
937 config.allow_shell_mode = true; let access = AccessManager::new(); let tool = ExecTool::new(Arc::new(config), Arc::new(Mutex::new(access)));
940 let result = tool.shell_exec("echo unrestricted", 5_000).await;
941 assert!(
942 result.is_ok(),
943 "Shell mode enabled + no agent_name = unrestricted execution"
944 );
945 }
946
947 #[test]
948 fn test_agent_name_set_correctly() {
949 let tool = make_agent_tool("my-agent", &[]);
950 assert_eq!(tool.agent_name.as_deref(), Some("my-agent"));
951 }
952
953 #[test]
954 fn test_new_has_no_agent_name() {
955 let tool = make_tool(vec![]);
956 assert!(tool.agent_name.is_none());
957 }
958}