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 async_trait::async_trait;
22use std::sync::Arc;
23
24use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
25use parking_lot::Mutex;
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28use tokio::sync::oneshot;
29
30use crate::access_manager::AccessManager;
31use crate::access_manager::AgentContext;
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    config: crate::kernel_handle::SharedExecConfig,
68    access: Arc<Mutex<AccessManager>>,
69    context: Option<AgentContext>,
70}
71
72impl ExecTool {
73    /// Create a new `ExecTool` with an `AgentContext` (production path).
74    pub fn new(
75        config: crate::kernel_handle::SharedExecConfig,
76        access: Arc<Mutex<AccessManager>>,
77        context: AgentContext,
78    ) -> Self {
79        Self {
80            config,
81            access,
82            context: Some(context),
83        }
84    }
85
86    /// Create an `ExecTool` from a [`KernelHandle`] with an agent context.
87    ///
88    /// Note (RFC-035): human-in-the-loop approval for shell exec now lives
89    /// in `GatedTool::execute` (Step 2.5) — the bespoke Phase D approval
90    /// fields previously set here have moved up.
91    pub fn from_kernel_with_context(
92        kernel: &crate::kernel_handle::KernelHandle,
93        context: AgentContext,
94    ) -> Self {
95        Self::new(
96            Arc::new(parking_lot::RwLock::new(kernel.exec.config_snapshot())),
97            kernel.exec.access_manager().clone(),
98            context,
99        )
100    }
101
102    /// Create an `ExecTool` from a [`KernelHandle`] (legacy, no context).
103    pub fn from_kernel(kernel: &crate::kernel_handle::KernelHandle) -> Self {
104        Self {
105            config: Arc::new(parking_lot::RwLock::new(kernel.exec.config_snapshot())),
106            access: kernel.exec.access_manager().clone(),
107            context: None,
108        }
109    }
110
111    /// Prefer `new()` with `AgentContext` for full security.
112    pub fn for_agent(
113        config: crate::kernel_handle::SharedExecConfig,
114        access: Arc<Mutex<AccessManager>>,
115        _agent_name: String,
116    ) -> Self {
117        Self {
118            config,
119            access,
120            context: None,
121        }
122    }
123
124    /// Legacy constructor without agent context (for backward compatibility).
125    ///
126    /// **Warning:** This bypasses the `AgentContext` path.
127    /// Use only for migration or testing.
128    pub fn new_unrestricted(
129        config: crate::kernel_handle::SharedExecConfig,
130        access: Arc<Mutex<AccessManager>>,
131    ) -> Self {
132        Self {
133            config,
134            access,
135            context: None,
136        }
137    }
138
139    /// Returns the agent name if a context is attached.
140    fn agent_name(&self) -> Option<&str> {
141        self.context.as_ref().map(|c| c.agent_name.as_str())
142    }
143
144    /// Execute a raw command string via `bash -c <cmd>`.
145    ///
146    /// Primary shell execution path.
147    /// The entire command string is forwarded to `bash -c`, so pipelines,
148    /// redirects, and compound commands all work.
149    ///
150    /// If a `shutdown` signal is provided and fires before the command
151    /// completes, the child process is killed and an error is returned.
152    pub async fn shell_exec(
153        &self,
154        command: &str,
155        timeout_ms: u64,
156        shutdown: Option<oneshot::Receiver<()>>,
157    ) -> Result<ExecResult, String> {
158        // Check if shell mode is allowed
159        let cfg = self.config.read().clone();
160        if !cfg.allow_shell_mode {
161            return Err(
162                "shell_exec: shell mode is disabled by configuration (allow_shell_mode = false). \
163                 Use mode='structured' instead, or set allow_shell_mode=true in config.toml"
164                    .to_string(),
165            );
166        }
167
168        if command.trim().is_empty() {
169            return Err("shell_exec: command must not be empty".to_string());
170        }
171
172        // Audit + access check.
173        if let Some(name) = self.agent_name() {
174            let mut access = self.access.lock();
175            if !access.can_use_tool(name, "bash") {
176                return Err(format!(
177                    "shell_exec: agent '{name}' is not allowed to execute 'bash'"
178                ));
179            }
180            tracing::info!(
181                agent = %name,
182                mode = "shell",
183                command = %command.chars().take(200).collect::<String>(),
184                "ExecTool: executing shell command (shell mode enabled)",
185            );
186        } else {
187            tracing::warn!(
188                mode = "shell",
189                command = %command.chars().take(200).collect::<String>(),
190                "ExecTool: shell mode executing without agent context",
191            );
192        }
193
194        let effective_timeout = timeout_ms.clamp(1_000, cfg.max_timeout_secs * 1_000);
195
196        let start = std::time::Instant::now();
197
198        // Spawn the child process so we can kill it on shutdown.
199        let mut child = tokio::process::Command::new("bash")
200            .arg("-c")
201            .arg(command)
202            .env_clear()
203            .env("HOME", std::env::var("HOME").unwrap_or_default())
204            .env("USER", std::env::var("USER").unwrap_or_default())
205            .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
206            .env("PATH", std::env::var("PATH").unwrap_or_default())
207            .env(
208                "LANG",
209                std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
210            )
211            .env("TERM", "dumb")
212            .stdout(std::process::Stdio::piped())
213            .stderr(std::process::Stdio::piped())
214            .spawn()
215            .map_err(|e| format!("shell spawn error: {e}"))?;
216
217        // Take stdout/stderr handles before the select! so we can read them
218        // after wait() completes (wait_with_output consumes the child).
219        let stdout_handle = child.stdout.take();
220        let stderr_handle = child.stderr.take();
221
222        // Build a shutdown-aware future that either waits for the signal or
223        // stays pending forever (so select! only fires when a signal exists).
224        let shutdown_fut = async {
225            if let Some(rx) = shutdown {
226                let _ = rx.await;
227            } else {
228                std::future::pending::<()>().await;
229            }
230        };
231
232        let result = tokio::select! {
233            status = tokio::time::timeout(
234                std::time::Duration::from_millis(effective_timeout),
235                child.wait(),
236            ) => {
237                match status {
238                    Ok(Ok(status)) => {
239                        let stdout = read_handle(stdout_handle).await;
240                        let stderr = read_stderr_handle(stderr_handle).await;
241                        Ok(ExecResult {
242                            stdout,
243                            stderr,
244                            exit_code: status.code().unwrap_or(-1),
245                            duration_ms: start.elapsed().as_millis() as u64,
246                        })
247                    }
248                    Ok(Err(e)) => Err(format!("shell execution error: {e}")),
249                    Err(_) => {
250                        // Timeout elapsed — kill the child to avoid orphans.
251                        let _ = child.kill().await;
252                        let _ = child.wait().await; // reap to avoid zombies
253                        Err(format!(
254                            "shell command timed out after {effective_timeout}ms"
255                        ))
256                    }
257                }
258            }
259            _ = shutdown_fut => {
260                // Shutdown signal received — kill the child process.
261                let _ = child.kill().await;
262                let _ = child.wait().await; // reap to avoid zombies
263                Err("Execution cancelled by shutdown signal".to_string())
264            }
265        };
266
267        result
268    }
269
270    /// Execute a binary with explicit args, enforcing allowlist + metachar blocking.
271    ///
272    /// Primary structured execution path.
273    /// Security checks:
274    /// 1. Binary must be a bare name (no `/` or `..`).
275    /// 2. Binary must be in the allowlist (or allowlist is empty = dev mode).
276    /// 3. Arguments must not contain shell metacharacters or path traversal.
277    ///
278    /// If a `shutdown` signal is provided and fires before the command
279    /// completes, the child process is killed and an error is returned.
280    pub async fn structured_exec(
281        &self,
282        binary: &str,
283        args: Vec<String>,
284        timeout_ms: u64,
285        shutdown: Option<oneshot::Receiver<()>>,
286    ) -> Result<ExecResult, String> {
287        // --- Access control ---
288        if let Some(name) = self.agent_name() {
289            let mut access = self.access.lock();
290            if !access.can_use_tool(name, binary) {
291                return Err(format!(
292                    "structured_exec: agent '{name}' is not allowed to execute '{binary}'"
293                ));
294            }
295        }
296
297        // --- Binary validation ---
298
299        // Log execution for audit trail
300        tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
301
302        if binary.contains("..") {
303            return Err("structured_exec: path traversal in binary name".to_string());
304        }
305        if binary.contains('/') {
306            return Err("structured_exec: binary must be a bare name, not a path".to_string());
307        }
308        if !self.config.read().is_binary_allowed(binary) {
309            return Err(format!(
310                "structured_exec: binary '{binary}' is not in the allowlist"
311            ));
312        }
313
314        // --- Argument validation ---
315
316        if has_metacharacters(&args) {
317            return Err(
318                "structured_exec: shell metacharacters or path traversal not allowed in arguments"
319                    .to_string(),
320            );
321        }
322
323        let effective_timeout =
324            timeout_ms.clamp(1_000, self.config.read().max_timeout_secs * 1_000);
325
326        let start = std::time::Instant::now();
327
328        // Spawn the child process so we can kill it on shutdown.
329        let mut child = tokio::process::Command::new(binary)
330            .args(&args)
331            .env_clear()
332            .env("HOME", std::env::var("HOME").unwrap_or_default())
333            .env("USER", std::env::var("USER").unwrap_or_default())
334            .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
335            .env("PATH", std::env::var("PATH").unwrap_or_default())
336            .env(
337                "LANG",
338                std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
339            )
340            .env("TERM", "dumb")
341            .stdout(std::process::Stdio::piped())
342            .stderr(std::process::Stdio::piped())
343            .spawn()
344            .map_err(|e| format!("structured spawn error: {e}"))?;
345
346        // Take stdout/stderr handles before the select! so we can read them
347        // after wait() completes (wait_with_output consumes the child).
348        let stdout_handle = child.stdout.take();
349        let stderr_handle = child.stderr.take();
350
351        // Build a shutdown-aware future that either waits for the signal or
352        // stays pending forever (so select! only fires when a signal exists).
353        let shutdown_fut = async {
354            if let Some(rx) = shutdown {
355                let _ = rx.await;
356            } else {
357                std::future::pending::<()>().await;
358            }
359        };
360
361        let result = tokio::select! {
362            status = tokio::time::timeout(
363                std::time::Duration::from_millis(effective_timeout),
364                child.wait(),
365            ) => {
366                match status {
367                    Ok(Ok(status)) => {
368                        let stdout = read_handle(stdout_handle).await;
369                        let stderr = read_stderr_handle(stderr_handle).await;
370                        Ok(ExecResult {
371                            stdout,
372                            stderr,
373                            exit_code: status.code().unwrap_or(-1),
374                            duration_ms: start.elapsed().as_millis() as u64,
375                        })
376                    }
377                    Ok(Err(e)) => Err(format!("structured execution error: {e}")),
378                    Err(_) => {
379                        // Timeout elapsed — kill the child to avoid orphans.
380                        let _ = child.kill().await;
381                        let _ = child.wait().await; // reap to avoid zombies
382                        Err(format!(
383                            "structured command timed out after {effective_timeout}ms"
384                        ))
385                    }
386                }
387            }
388            _ = shutdown_fut => {
389                // Shutdown signal received — kill the child process.
390                let _ = child.kill().await;
391                let _ = child.wait().await; // reap to avoid zombies
392                Err("Execution cancelled by shutdown signal".to_string())
393            }
394        };
395
396        result
397    }
398}
399
400// ─── Helpers ───────────────────────────────────────────────────────────────
401
402/// Read a piped stdout/stderr handle to a String, returning empty on failure.
403async fn read_handle(handle: Option<tokio::process::ChildStdout>) -> String {
404    match handle {
405        Some(mut h) => {
406            let mut buf = Vec::new();
407            // Use a timeout so we don't block forever on a stuck pipe.
408            match tokio::time::timeout(
409                std::time::Duration::from_secs(10),
410                tokio::io::AsyncReadExt::read_to_end(&mut h, &mut buf),
411            )
412            .await
413            {
414                Ok(Ok(_)) => String::from_utf8_lossy(&buf).to_string(),
415                _ => String::new(),
416            }
417        }
418        None => String::new(),
419    }
420}
421
422/// Read a piped stderr handle to a String, returning empty on failure.
423async fn read_stderr_handle(handle: Option<tokio::process::ChildStderr>) -> String {
424    match handle {
425        Some(mut h) => {
426            let mut buf = Vec::new();
427            match tokio::time::timeout(
428                std::time::Duration::from_secs(10),
429                tokio::io::AsyncReadExt::read_to_end(&mut h, &mut buf),
430            )
431            .await
432            {
433                Ok(Ok(_)) => String::from_utf8_lossy(&buf).to_string(),
434                _ => String::new(),
435            }
436        }
437        None => String::new(),
438    }
439}
440
441/// Check whether any argument contains shell metacharacters or `..`.
442fn has_metacharacters(args: &[String]) -> bool {
443    for arg in args {
444        if arg.contains("..") {
445            return true;
446        }
447        if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
448            return true;
449        }
450    }
451    false
452}
453
454/// Format an `ExecResult` into a human-readable output string (matching the
455/// format consistent with agent expectations).
456fn format_exec_output(result: &ExecResult) -> String {
457    let mut output = String::new();
458
459    if result.stdout.is_empty() && result.stderr.is_empty() {
460        output.push_str("(no output)");
461    } else {
462        if !result.stdout.is_empty() {
463            output.push_str(&result.stdout);
464        }
465        if !result.stderr.is_empty() && !result.stdout.is_empty() {
466            output.push('\n');
467        }
468        if !result.stderr.is_empty() {
469            output.push_str(&result.stderr);
470        }
471    }
472
473    if result.exit_code != 0 {
474        output.push_str(&format!(
475            "\n\nCommand exited with code {}",
476            result.exit_code
477        ));
478    }
479
480    let secs = result.duration_ms / 1000;
481    let millis = result.duration_ms % 1000;
482
483    if secs >= 60 {
484        let mins = secs / 60;
485        let remain_secs = secs % 60;
486        output.push_str(&format!(
487            "\n\nTook {}m {:.1}s",
488            mins,
489            remain_secs as f64 + millis as f64 / 1000.0
490        ));
491    } else {
492        output.push_str(&format!(
493            "\n\nTook {:.1}s",
494            secs as f64 + millis as f64 / 1000.0
495        ));
496    }
497
498    output
499}
500
501// ─── Debug ─────────────────────────────────────────────────────────────────
502
503impl std::fmt::Debug for ExecTool {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        f.debug_struct("ExecTool").finish()
506    }
507}
508
509// ─── AgentTool implementation ──────────────────────────────────────────────
510
511#[async_trait]
512
513impl AgentTool for ExecTool {
514    fn name(&self) -> &str {
515        "exec"
516    }
517
518    fn label(&self) -> &str {
519        "Exec"
520    }
521
522    fn description(&self) -> &'static str {
523        "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
524    }
525
526    fn parameters_schema(&self) -> Value {
527        json!({
528            "type": "object",
529            "properties": {
530                "mode": {
531                    "type": "string",
532                    "enum": ["shell", "structured"],
533                    "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
534                },
535                "command": {
536                    "type": "string",
537                    "description": "Shell command string (mode='shell' only)"
538                },
539                "binary": {
540                    "type": "string",
541                    "description": "Binary name (mode='structured' only, must be in allowlist)"
542                },
543                "args": {
544                    "type": "array",
545                    "items": { "type": "string" },
546                    "description": "Binary arguments (mode='structured' only)"
547                },
548                "timeout": {
549                    "type": "integer",
550                    "description": "Timeout in seconds",
551                    "default": 120
552                }
553            },
554            "required": ["mode"]
555        })
556    }
557
558    async fn execute(
559        &self,
560        _tool_call_id: &str,
561        params: Value,
562        shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
563        _ctx: &ToolContext,
564    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
565        let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
566            "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
567        })?;
568
569        let timeout_secs = params
570            .get("timeout")
571            .and_then(|v| v.as_u64())
572            .unwrap_or(self.config.read().default_timeout_secs);
573        let timeout_ms = (timeout_secs * 1000).min(self.config.read().max_timeout_secs * 1000);
574
575        match mode {
576            "shell" => {
577                let command = match params.get("command").and_then(|v| v.as_str()) {
578                    Some(c) => c,
579                    None => {
580                        return Ok(AgentToolResult::error(
581                            "shell mode requires 'command' parameter",
582                        ));
583                    }
584                };
585                // RFC-035 Step 2.5 in `GatedTool::execute` now handles exec
586                // approval uniformly with all other tools. The bespoke
587                // shell-only approval block that previously lived here has
588                // been removed; exec has `ToolPolicy::OnDemand` by default,
589                // so prompts/auto-runs are governed by the kernel's
590                // `[security.approval]` config via `ApprovalGate`.
591                match self.shell_exec(command, timeout_ms, shutdown).await {
592                    Ok(result) => {
593                        let output = format_exec_output(&result);
594                        if result.exit_code == 0 {
595                            Ok(AgentToolResult::success(output))
596                        } else {
597                            Ok(AgentToolResult::error(output))
598                        }
599                    }
600                    Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
601                }
602            }
603
604            "structured" => {
605                let binary = match params.get("binary").and_then(|v| v.as_str()) {
606                    Some(b) => b,
607                    None => {
608                        return Ok(AgentToolResult::error(
609                            "structured mode requires 'binary' parameter",
610                        ));
611                    }
612                };
613
614                let args: Vec<String> = params
615                    .get("args")
616                    .and_then(|v| v.as_array())
617                    .map(|arr| {
618                        arr.iter()
619                            .filter_map(|v| v.as_str().map(String::from))
620                            .collect()
621                    })
622                    .unwrap_or_default();
623
624                match self
625                    .structured_exec(binary, args, timeout_ms, shutdown)
626                    .await
627                {
628                    Ok(result) => {
629                        let output = format_exec_output(&result);
630                        if result.exit_code == 0 {
631                            Ok(AgentToolResult::success(output))
632                        } else {
633                            Ok(AgentToolResult::error(output))
634                        }
635                    }
636                    Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
637                }
638            }
639
640            other => Err(format!(
641                "Invalid mode '{other}': expected 'shell' or 'structured'"
642            )),
643        }
644    }
645}
646
647// ─── Tests ─────────────────────────────────────────────────────────────────
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::config::ExecConfig;
653
654    /// Helper: build an `ExecTool` with default config and empty access manager.
655    /// Shell mode is enabled for testing purposes. No agent context.
656    fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
657        let mut config = ExecConfig {
658            allowlist_mode: crate::config::AllowlistMode::Permissive,
659            allow_shell_mode: true,
660            ..Default::default()
661        };
662        config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
663        ExecTool::new_unrestricted(
664            Arc::new(parking_lot::RwLock::new(config)),
665            Arc::new(Mutex::new(AccessManager::new())),
666        )
667    }
668
669    // ─── shell_exec ──────────────────────────────────────────────────
670
671    #[tokio::test]
672    async fn test_shell_exec_echo() {
673        let tool = make_tool(vec![]);
674        let result = tool.shell_exec("echo hello", 5_000, None).await;
675        assert!(result.is_ok());
676        let r = result.unwrap();
677        assert_eq!(r.exit_code, 0);
678        assert!(r.stdout.contains("hello"));
679        assert!(r.duration_ms < 5_000);
680    }
681
682    #[tokio::test]
683    async fn test_shell_exec_pipeline() {
684        let tool = make_tool(vec![]);
685        let result = tool.shell_exec("echo foo | tr f b", 5_000, None).await;
686        assert!(result.is_ok());
687        let r = result.unwrap();
688        assert_eq!(r.exit_code, 0);
689        assert!(r.stdout.contains("boo"));
690    }
691
692    #[tokio::test]
693    async fn test_shell_exec_nonzero_exit() {
694        let tool = make_tool(vec![]);
695        let result = tool.shell_exec("exit 42", 5_000, None).await;
696        assert!(result.is_ok());
697        assert_eq!(result.unwrap().exit_code, 42);
698    }
699
700    #[tokio::test]
701    async fn test_shell_exec_empty_command() {
702        let tool = make_tool(vec![]);
703        let result = tool.shell_exec("   ", 5_000, None).await;
704        assert!(result.is_err());
705        assert!(result.unwrap_err().contains("must not be empty"));
706    }
707
708    #[tokio::test]
709    async fn test_shell_exec_timeout() {
710        let tool = make_tool(vec![]);
711        let result = tool.shell_exec("sleep 300", 200, None).await;
712        assert!(result.is_err());
713        assert!(result.unwrap_err().contains("timed out"));
714    }
715
716    // ─── structured_exec ─────────────────────────────────────────────
717
718    #[tokio::test]
719    async fn test_structured_exec_echo() {
720        let tool = make_tool(vec!["echo"]);
721        let result = tool
722            .structured_exec("echo", vec!["hello".into()], 5_000, None)
723            .await;
724        assert!(result.is_ok());
725        let r = result.unwrap();
726        assert_eq!(r.exit_code, 0);
727        assert!(r.stdout.contains("hello"));
728    }
729
730    #[tokio::test]
731    async fn test_structured_exec_blocked_binary() {
732        let tool = make_tool(vec!["echo"]);
733        let result = tool
734            .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000, None)
735            .await;
736        assert!(result.is_err());
737        assert!(result.unwrap_err().contains("not in the allowlist"));
738    }
739
740    #[tokio::test]
741    async fn test_structured_exec_path_binary() {
742        let tool = make_tool(vec![]);
743        let result = tool
744            .structured_exec("/usr/bin/echo", vec![], 5_000, None)
745            .await;
746        assert!(result.is_err());
747        assert!(result.unwrap_err().contains("bare name"));
748    }
749
750    #[tokio::test]
751    async fn test_structured_exec_traversal_binary() {
752        let tool = make_tool(vec![]);
753        let result = tool
754            .structured_exec("../bin/evil", vec![], 5_000, None)
755            .await;
756        assert!(result.is_err());
757        assert!(result.unwrap_err().contains("path traversal"));
758    }
759
760    #[tokio::test]
761    async fn test_structured_exec_metachar_args() {
762        let tool = make_tool(vec!["echo"]);
763        let result = tool
764            .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000, None)
765            .await;
766        assert!(result.is_err());
767        assert!(result.unwrap_err().contains("metacharacters"));
768    }
769
770    #[tokio::test]
771    async fn test_structured_exec_path_traversal_args() {
772        let tool = make_tool(vec!["cat"]);
773        let result = tool
774            .structured_exec("cat", vec!["../etc/passwd".into()], 5_000, None)
775            .await;
776        assert!(result.is_err());
777        assert!(result.unwrap_err().contains("metacharacters"));
778    }
779
780    #[tokio::test]
781    async fn test_structured_exec_clean_args() {
782        let tool = make_tool(vec!["echo"]);
783        let result = tool
784            .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000, None)
785            .await;
786        assert!(result.is_ok());
787        let r = result.unwrap();
788        assert_eq!(r.exit_code, 0);
789        assert!(r.stdout.contains("hello world"));
790    }
791
792    // ─── AgentTool interface ─────────────────────────────────────────
793
794    #[test]
795    fn test_name_and_label() {
796        let tool = make_tool(vec![]);
797        assert_eq!(tool.name(), "exec");
798        assert_eq!(tool.label(), "Exec");
799    }
800
801    #[test]
802    fn test_parameters_schema() {
803        let tool = make_tool(vec![]);
804        let schema = tool.parameters_schema();
805
806        let props = schema["properties"].as_object().unwrap();
807        assert!(props.contains_key("mode"));
808        assert!(props.contains_key("command"));
809        assert!(props.contains_key("binary"));
810        assert!(props.contains_key("args"));
811        assert!(props.contains_key("timeout"));
812
813        let required = schema["required"].as_array().unwrap();
814        assert!(required.iter().any(|r| r.as_str() == Some("mode")));
815    }
816
817    #[tokio::test]
818    async fn test_agent_tool_shell_mode() {
819        let tool = make_tool(vec![]);
820
821        let result = tool
822            .execute(
823                "test-1",
824                json!({ "mode": "shell", "command": "echo hello" }),
825                None,
826                &ToolContext::default(),
827            )
828            .await;
829
830        assert!(result.is_ok());
831        let r = result.unwrap();
832        assert!(r.success, "Expected success, got: {}", r.output);
833        assert!(r.output.contains("hello"));
834    }
835
836    #[tokio::test]
837    async fn test_agent_tool_structured_mode() {
838        let tool = make_tool(vec!["echo"]);
839
840        let result = tool
841            .execute(
842                "test-2",
843                json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
844                None,
845                &ToolContext::default(),
846            )
847            .await;
848
849        assert!(result.is_ok());
850        let r = result.unwrap();
851        assert!(r.success, "Expected success, got: {}", r.output);
852        assert!(r.output.contains("hi"));
853    }
854
855    #[tokio::test]
856    async fn test_agent_tool_missing_mode() {
857        let tool = make_tool(vec![]);
858        let result = tool
859            .execute(
860                "test-3",
861                json!({ "command": "echo hi" }),
862                None,
863                &ToolContext::default(),
864            )
865            .await;
866        assert!(result.is_err());
867        assert!(
868            result
869                .unwrap_err()
870                .contains("Missing required parameter: mode")
871        );
872    }
873
874    #[tokio::test]
875    async fn test_agent_tool_invalid_mode() {
876        let tool = make_tool(vec![]);
877        let result = tool
878            .execute(
879                "test-4",
880                json!({ "mode": "docker" }),
881                None,
882                &ToolContext::default(),
883            )
884            .await;
885        assert!(result.is_err());
886        assert!(result.unwrap_err().contains("Invalid mode"));
887    }
888
889    #[tokio::test]
890    async fn test_agent_tool_shell_missing_command() {
891        let tool = make_tool(vec![]);
892        let result = tool
893            .execute(
894                "test-5",
895                json!({ "mode": "shell" }),
896                None,
897                &ToolContext::default(),
898            )
899            .await;
900        assert!(result.is_ok());
901        let r = result.unwrap();
902        assert!(!r.success);
903        assert!(r.output.contains("shell mode requires 'command' parameter"));
904    }
905
906    #[tokio::test]
907    async fn test_agent_tool_structured_missing_binary() {
908        let tool = make_tool(vec![]);
909        let result = tool
910            .execute(
911                "test-6",
912                json!({ "mode": "structured" }),
913                None,
914                &ToolContext::default(),
915            )
916            .await;
917        assert!(result.is_ok());
918        let r = result.unwrap();
919        assert!(!r.success);
920        assert!(
921            r.output
922                .contains("structured mode requires 'binary' parameter")
923        );
924    }
925
926    #[tokio::test]
927    async fn test_agent_tool_nonzero_exit() {
928        let tool = make_tool(vec![]);
929
930        let result = tool
931            .execute(
932                "test-7",
933                json!({ "mode": "shell", "command": "exit 7" }),
934                None,
935                &ToolContext::default(),
936            )
937            .await;
938
939        assert!(result.is_ok());
940        let r = result.unwrap();
941        assert!(!r.success);
942        assert!(r.output.contains("exited with code 7"));
943    }
944
945    // ─── format_exec_output ──────────────────────────────────────────
946
947    #[test]
948    fn test_format_exec_output_success() {
949        let result = ExecResult {
950            stdout: "hello".to_string(),
951            stderr: String::new(),
952            exit_code: 0,
953            duration_ms: 1_500,
954        };
955        let output = format_exec_output(&result);
956        assert!(output.contains("hello"));
957        assert!(output.contains("Took 1.5s"));
958        assert!(!output.contains("exited with code"));
959    }
960
961    #[test]
962    fn test_format_exec_output_failure() {
963        let result = ExecResult {
964            stdout: String::new(),
965            stderr: "error!".to_string(),
966            exit_code: 1,
967            duration_ms: 500,
968        };
969        let output = format_exec_output(&result);
970        assert!(output.contains("error!"));
971        assert!(output.contains("exited with code 1"));
972    }
973
974    #[test]
975    fn test_format_exec_output_no_output() {
976        let result = ExecResult {
977            stdout: String::new(),
978            stderr: String::new(),
979            exit_code: 0,
980            duration_ms: 100,
981        };
982        let output = format_exec_output(&result);
983        assert!(output.contains("(no output)"));
984    }
985
986    #[test]
987    fn test_format_exec_output_minutes() {
988        let result = ExecResult {
989            stdout: "done".to_string(),
990            stderr: String::new(),
991            exit_code: 0,
992            duration_ms: 125_000, // 2m 5s
993        };
994        let output = format_exec_output(&result);
995        assert!(output.contains("Took 2m 5.0s"));
996    }
997
998    // ─── has_metacharacters ──────────────────────────────────────────
999
1000    #[test]
1001    fn test_has_metacharacters_clean() {
1002        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
1003    }
1004
1005    #[test]
1006    fn test_has_metacharacters_semicolon() {
1007        assert!(has_metacharacters(&["foo;bar".into()]));
1008    }
1009
1010    #[test]
1011    fn test_has_metacharacters_pipe() {
1012        assert!(has_metacharacters(&["a | b".into()]));
1013    }
1014
1015    #[test]
1016    fn test_has_metacharacters_dollar() {
1017        assert!(has_metacharacters(&["$(whoami)".into()]));
1018    }
1019
1020    #[test]
1021    fn test_has_metacharacters_backtick() {
1022        assert!(has_metacharacters(&["`id`".into()]));
1023    }
1024
1025    #[test]
1026    fn test_has_metacharacters_traversal() {
1027        assert!(has_metacharacters(&["../etc/passwd".into()]));
1028    }
1029
1030    // ── Access control tests ────────────────────────────────────
1031
1032    /// Helper: build ExecTool bound to a named agent with specific permissions.
1033    fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
1034        let config = ExecConfig {
1035            allowlist_mode: crate::config::AllowlistMode::Permissive,
1036            allow_shell_mode: true,
1037            ..Default::default()
1038        };
1039        let mut access = AccessManager::new();
1040        // Create default permissions, then set specific allowed tools.
1041        {
1042            let perms = access.get_or_create_permissions(agent_name);
1043            // Clear defaults first, then add only requested tools.
1044            perms.allowed_tools.clear();
1045            for tool in allowed_tools {
1046                perms.allow_tool(tool);
1047            }
1048        }
1049        let ctx = crate::access_manager::AgentContext::test_fixture(agent_name);
1050        ExecTool::new(
1051            Arc::new(parking_lot::RwLock::new(config)),
1052            Arc::new(Mutex::new(access)),
1053            ctx,
1054        )
1055    }
1056
1057    #[tokio::test]
1058    async fn test_for_agent_structured_exec_allowed() {
1059        let tool = make_agent_tool("test-agent", &["echo", "ls"]);
1060        let result = tool
1061            .structured_exec("echo", vec!["hello".into()], 5_000, None)
1062            .await;
1063        assert!(result.is_ok(), "Allowed binary should succeed");
1064        let r = result.unwrap();
1065        assert_eq!(r.exit_code, 0);
1066        assert!(r.stdout.contains("hello"));
1067    }
1068
1069    #[tokio::test]
1070    async fn test_for_agent_structured_exec_denied() {
1071        let tool = make_agent_tool("test-agent", &["ls"]); // no "echo"
1072        let result = tool
1073            .structured_exec("echo", vec!["hello".into()], 5_000, None)
1074            .await;
1075        assert!(result.is_err());
1076        let err = result.unwrap_err();
1077        assert!(
1078            err.contains("not allowed to execute"),
1079            "Error should mention denial: {err}"
1080        );
1081        assert!(
1082            err.contains("echo"),
1083            "Error should name the denied binary: {err}"
1084        );
1085    }
1086
1087    #[tokio::test]
1088    async fn test_for_agent_shell_exec_allowed() {
1089        let tool = make_agent_tool("test-agent", &["bash"]);
1090        let result = tool.shell_exec("echo hello", 5_000, None).await;
1091        assert!(
1092            result.is_ok(),
1093            "Agent with 'bash' permission should succeed"
1094        );
1095        assert!(result.unwrap().stdout.contains("hello"));
1096    }
1097
1098    #[tokio::test]
1099    async fn test_for_agent_shell_exec_denied() {
1100        let tool = make_agent_tool("test-agent", &["ls"]); // no "bash"
1101        let result = tool.shell_exec("echo hello", 5_000, None).await;
1102        assert!(result.is_err());
1103        let err = result.unwrap_err();
1104        assert!(
1105            err.contains("not allowed to execute"),
1106            "Error should mention denial: {err}"
1107        );
1108        assert!(err.contains("bash"), "Error should name 'bash': {err}");
1109    }
1110
1111    #[tokio::test]
1112    async fn test_no_agent_name_bypasses_access_control() {
1113        // ExecTool::new_unrestricted() (no context) should NOT check permissions,
1114        // but shell mode must still be enabled in config.
1115        let config = ExecConfig {
1116            allow_shell_mode: true,
1117            ..Default::default()
1118        };
1119        let access = AccessManager::new(); // empty — no permissions for anyone
1120        let tool = ExecTool::new_unrestricted(
1121            Arc::new(parking_lot::RwLock::new(config)),
1122            Arc::new(Mutex::new(access)),
1123        );
1124        let result = tool.shell_exec("echo unrestricted", 5_000, None).await;
1125        assert!(
1126            result.is_ok(),
1127            "Shell mode enabled + no agent_name = unrestricted execution"
1128        );
1129    }
1130
1131    #[test]
1132    fn test_agent_name_set_correctly() {
1133        let tool = make_agent_tool("my-agent", &[]);
1134        assert_eq!(tool.agent_name(), Some("my-agent"));
1135    }
1136
1137    #[test]
1138    fn test_new_has_no_agent_name() {
1139        let tool = make_tool(vec![]);
1140        assert!(tool.agent_name().is_none());
1141    }
1142}