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