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