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