Skip to main content

oxios_kernel/tools/
exec_tool.rs

1//! Unified execution tool for Oxios agents.
2//!
3//! Provides two execution modes:
4//! - **shell** — Execute a raw command string via `bash -c <cmd>`.
5//!   Intended for general-purpose workspace commands (compilation, tests, etc.).
6//!
7//! - **structured** — Execute a binary with explicit args, subject to allowlist
8//!   enforcement and shell-metacharacter blocking.
9//!   Intended for host-sensitive operations (git, gh, osascript, open) that
10//!   need stricter control.
11//!
12//! ## Security model
13//!
14//! `shell` mode: runs through `bash -c` — the command string is passed as-is.
15//! Access control is enforced upstream by `AccessManager` (RBAC, path sandboxing).
16//!
17//! `structured` mode: binary must be in the allowlist (from `ExecConfig`),
18//! and all arguments are validated against shell metacharacters (`;`, `|`, `$`,
19//! backtick, `<`, `>`, etc.) and path traversal (`..`).
20
21use 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
33// ─── Shell metacharacter blocklist ──────────
34
35/// Characters that are rejected in structured-mode arguments.
36const SHELL_METACHARS: &[char] = &[
37    '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
38];
39
40// ─── ExecResult ────────────────────────────────────────────────────────────
41
42/// Result of a command execution.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ExecResult {
45    /// Standard output captured from the process.
46    pub stdout: String,
47    /// Standard error captured from the process.
48    pub stderr: String,
49    /// Process exit code (0 = success, -1 = signal / timeout).
50    pub exit_code: i32,
51    /// Wall-clock execution duration in milliseconds.
52    pub duration_ms: u64,
53}
54
55// ─── ExecTool ──────────────────────────────────────────────────────────────
56
57/// Unified execution tool for agents.
58///
59/// Wraps both shell-string and structured binary+args execution behind a
60/// single `AgentTool` implementation that uses a `mode` parameter to
61/// dispatch to the appropriate method.
62///
63/// Access control is enforced based on `agent_name`:
64/// - **shell_exec**: audit logging (cannot sandbox arbitrary shell).
65/// - **structured_exec**: pre-flight permission check via `AccessManager`.
66pub struct ExecTool {
67    /// Execution configuration (allowlist, timeouts).
68    config: Arc<ExecConfig>,
69    /// Access manager for permission checks.
70    access: Arc<Mutex<AccessManager>>,
71    /// Agent name for access control and audit logging.
72    /// `None` = unrestricted (tests / development mode).
73    agent_name: Option<String>,
74}
75
76impl ExecTool {
77    /// Create a new `ExecTool` with the given config and access manager.
78    ///
79    /// No agent context is attached, so access control is not enforced.
80    /// Use [`ExecTool::for_agent`] for production.
81    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    /// Create an `ExecTool` from a [`KernelHandle`].
90    ///
91    /// Extracts `ExecConfig` and `AccessManager` from the kernel's exec facade
92    /// and binds the tool to the default agent name `"oxios-agent"`.
93    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    /// Create a new `ExecTool` bound to a specific agent.
102    ///
103    /// All executions through this instance are attributed to `agent_name`
104    /// for access control and audit logging.
105    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    /// Execute a raw command string via `bash -c <cmd>`.
118    ///
119    /// Primary shell execution path.
120    /// The entire command string is forwarded to `bash -c`, so pipelines,
121    /// redirects, and compound commands all work.
122    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        // Audit + access check.
128        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    /// Execute a binary with explicit args, enforcing allowlist + metachar blocking.
190    ///
191    /// Primary structured execution path.
192    /// Security checks:
193    /// 1. Binary must be a bare name (no `/` or `..`).
194    /// 2. Binary must be in the allowlist (or allowlist is empty = dev mode).
195    /// 3. Arguments must not contain shell metacharacters or path traversal.
196    pub async fn structured_exec(
197        &self,
198        binary: &str,
199        args: Vec<String>,
200        timeout_ms: u64,
201    ) -> Result<ExecResult, String> {
202        // --- Access control ---
203        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        // --- Binary validation ---
214
215        // Log execution for audit trail
216        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        // --- Argument validation ---
231
232        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
278// ─── Helpers ───────────────────────────────────────────────────────────────
279
280/// Check whether any argument contains shell metacharacters or `..`.
281fn 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
293/// Format an `ExecResult` into a human-readable output string (matching the
294/// format consistent with agent expectations).
295fn 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
340// ─── Debug ─────────────────────────────────────────────────────────────────
341
342impl 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// ─── AgentTool implementation ──────────────────────────────────────────────
349
350#[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// ─── Tests ─────────────────────────────────────────────────────────────────
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    /// Helper: build an `ExecTool` with default config and empty access manager.
484    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    // ─── shell_exec ──────────────────────────────────────────────────
495
496    #[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    // ─── structured_exec ─────────────────────────────────────────────
542
543    #[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    // ─── AgentTool interface ─────────────────────────────────────────
614
615    #[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    // ─── format_exec_output ──────────────────────────────────────────
741
742    #[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, // 2m 5s
788        };
789        let output = format_exec_output(&result);
790        assert!(output.contains("Took 2m 5.0s"));
791    }
792
793    // ─── has_metacharacters ──────────────────────────────────────────
794
795    #[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    // ── Access control tests ────────────────────────────────────
826
827    /// Helper: build ExecTool bound to a named agent with specific permissions.
828    fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
829        let config = ExecConfig::default();
830        let mut access = AccessManager::new();
831        // Create default permissions, then set specific allowed tools.
832        {
833            let perms = access.get_or_create_permissions(agent_name);
834            // Clear defaults first, then add only requested tools.
835            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"]); // no "echo"
862        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"]); // no "bash"
891        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        // ExecTool::new() (agent_name=None) should NOT check permissions.
904        // This is the test/dev mode path.
905        let config = ExecConfig::default();
906        let access = AccessManager::new(); // empty — no permissions for anyone
907        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}