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