Skip to main content

oxios_kernel/tools/
exec_tool.rs

1//! Unified execution tool for Oxios agents.
2//!
3//! Provides two execution modes:
4//! - **shell** — Execute a raw command string via `bash -c <cmd>`.
5//!   Intended for general-purpose workspace commands (compilation, tests, etc.).
6//!
7//! - **structured** — Execute a binary with explicit args, subject to allowlist
8//!   enforcement and shell-metacharacter blocking.
9//!   Intended for host-sensitive operations (git, gh, osascript, open) that
10//!   need stricter control.
11//!
12//! ## Security model
13//!
14//! `shell` mode: runs through `bash -c` — the command string is passed as-is.
15//! Access control is enforced upstream by `AccessManager` (RBAC, path sandboxing).
16//!
17//! `structured` mode: binary must be in the allowlist (from `ExecConfig`),
18//! and all arguments are validated against shell metacharacters (`;`, `|`, `$`,
19//! backtick, `<`, `>`, etc.) and path traversal (`..`).
20
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
25use parking_lot::Mutex;
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Value};
28use tokio::sync::oneshot;
29
30use crate::access_manager::AccessManager;
31use crate::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]
533impl AgentTool for ExecTool {
534    fn name(&self) -> &str {
535        "exec"
536    }
537
538    fn label(&self) -> &str {
539        "Exec"
540    }
541
542    fn description(&self) -> &'static str {
543        "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
544    }
545
546    fn parameters_schema(&self) -> Value {
547        json!({
548            "type": "object",
549            "properties": {
550                "mode": {
551                    "type": "string",
552                    "enum": ["shell", "structured"],
553                    "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
554                },
555                "command": {
556                    "type": "string",
557                    "description": "Shell command string (mode='shell' only)"
558                },
559                "binary": {
560                    "type": "string",
561                    "description": "Binary name (mode='structured' only, must be in allowlist)"
562                },
563                "args": {
564                    "type": "array",
565                    "items": { "type": "string" },
566                    "description": "Binary arguments (mode='structured' only)"
567                },
568                "timeout": {
569                    "type": "integer",
570                    "description": "Timeout in seconds",
571                    "default": 120
572                }
573            },
574            "required": ["mode"]
575        })
576    }
577
578    async fn execute(
579        &self,
580        _tool_call_id: &str,
581        params: Value,
582        shutdown: Option<oneshot::Receiver<()>>,
583        _ctx: &ToolContext,
584    ) -> Result<AgentToolResult, String> {
585        let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
586            "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
587        })?;
588
589        let timeout_secs = params
590            .get("timeout")
591            .and_then(|v| v.as_u64())
592            .unwrap_or(self.config.read().default_timeout_secs);
593        let timeout_ms = (timeout_secs * 1000).min(self.config.read().max_timeout_secs * 1000);
594
595        match mode {
596            "shell" => {
597                let command = match params.get("command").and_then(|v| v.as_str()) {
598                    Some(c) => c,
599                    None => {
600                        return Ok(AgentToolResult::error(
601                            "shell mode requires 'command' parameter",
602                        ))
603                    }
604                };
605
606                match self.shell_exec(command, timeout_ms, shutdown).await {
607                    Ok(result) => {
608                        let output = format_exec_output(&result);
609                        if result.exit_code == 0 {
610                            Ok(AgentToolResult::success(output))
611                        } else {
612                            Ok(AgentToolResult::error(output))
613                        }
614                    }
615                    Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
616                }
617            }
618
619            "structured" => {
620                let binary = match params.get("binary").and_then(|v| v.as_str()) {
621                    Some(b) => b,
622                    None => {
623                        return Ok(AgentToolResult::error(
624                            "structured mode requires 'binary' parameter",
625                        ))
626                    }
627                };
628
629                let args: Vec<String> = params
630                    .get("args")
631                    .and_then(|v| v.as_array())
632                    .map(|arr| {
633                        arr.iter()
634                            .filter_map(|v| v.as_str().map(String::from))
635                            .collect()
636                    })
637                    .unwrap_or_default();
638
639                match self
640                    .structured_exec(binary, args, timeout_ms, shutdown)
641                    .await
642                {
643                    Ok(result) => {
644                        let output = format_exec_output(&result);
645                        if result.exit_code == 0 {
646                            Ok(AgentToolResult::success(output))
647                        } else {
648                            Ok(AgentToolResult::error(output))
649                        }
650                    }
651                    Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
652                }
653            }
654
655            other => Err(format!(
656                "Invalid mode '{other}': expected 'shell' or 'structured'"
657            )),
658        }
659    }
660}
661
662// ─── Tests ─────────────────────────────────────────────────────────────────
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::config::ExecConfig;
668
669    /// Helper: build an `ExecTool` with default config and empty access manager.
670    /// Shell mode is enabled for testing purposes. No agent context.
671    fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
672        let mut config = ExecConfig {
673            allowlist_mode: crate::config::AllowlistMode::Permissive,
674            allow_shell_mode: true,
675            ..Default::default()
676        };
677        config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
678        ExecTool::new_unrestricted(
679            Arc::new(parking_lot::RwLock::new(config)),
680            Arc::new(Mutex::new(AccessManager::new())),
681        )
682    }
683
684    // ─── shell_exec ──────────────────────────────────────────────────
685
686    #[tokio::test]
687    async fn test_shell_exec_echo() {
688        let tool = make_tool(vec![]);
689        let result = tool.shell_exec("echo hello", 5_000, None).await;
690        assert!(result.is_ok());
691        let r = result.unwrap();
692        assert_eq!(r.exit_code, 0);
693        assert!(r.stdout.contains("hello"));
694        assert!(r.duration_ms < 5_000);
695    }
696
697    #[tokio::test]
698    async fn test_shell_exec_pipeline() {
699        let tool = make_tool(vec![]);
700        let result = tool.shell_exec("echo foo | tr f b", 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("boo"));
705    }
706
707    #[tokio::test]
708    async fn test_shell_exec_nonzero_exit() {
709        let tool = make_tool(vec![]);
710        let result = tool.shell_exec("exit 42", 5_000, None).await;
711        assert!(result.is_ok());
712        assert_eq!(result.unwrap().exit_code, 42);
713    }
714
715    #[tokio::test]
716    async fn test_shell_exec_empty_command() {
717        let tool = make_tool(vec![]);
718        let result = tool.shell_exec("   ", 5_000, None).await;
719        assert!(result.is_err());
720        assert!(result.unwrap_err().contains("must not be empty"));
721    }
722
723    #[tokio::test]
724    async fn test_shell_exec_timeout() {
725        let tool = make_tool(vec![]);
726        let result = tool.shell_exec("sleep 300", 200, None).await;
727        assert!(result.is_err());
728        assert!(result.unwrap_err().contains("timed out"));
729    }
730
731    // ─── structured_exec ─────────────────────────────────────────────
732
733    #[tokio::test]
734    async fn test_structured_exec_echo() {
735        let tool = make_tool(vec!["echo"]);
736        let result = tool
737            .structured_exec("echo", vec!["hello".into()], 5_000, None)
738            .await;
739        assert!(result.is_ok());
740        let r = result.unwrap();
741        assert_eq!(r.exit_code, 0);
742        assert!(r.stdout.contains("hello"));
743    }
744
745    #[tokio::test]
746    async fn test_structured_exec_blocked_binary() {
747        let tool = make_tool(vec!["echo"]);
748        let result = tool
749            .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000, None)
750            .await;
751        assert!(result.is_err());
752        assert!(result.unwrap_err().contains("not in the allowlist"));
753    }
754
755    #[tokio::test]
756    async fn test_structured_exec_path_binary() {
757        let tool = make_tool(vec![]);
758        let result = tool
759            .structured_exec("/usr/bin/echo", vec![], 5_000, None)
760            .await;
761        assert!(result.is_err());
762        assert!(result.unwrap_err().contains("bare name"));
763    }
764
765    #[tokio::test]
766    async fn test_structured_exec_traversal_binary() {
767        let tool = make_tool(vec![]);
768        let result = tool
769            .structured_exec("../bin/evil", vec![], 5_000, None)
770            .await;
771        assert!(result.is_err());
772        assert!(result.unwrap_err().contains("path traversal"));
773    }
774
775    #[tokio::test]
776    async fn test_structured_exec_metachar_args() {
777        let tool = make_tool(vec!["echo"]);
778        let result = tool
779            .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000, None)
780            .await;
781        assert!(result.is_err());
782        assert!(result.unwrap_err().contains("metacharacters"));
783    }
784
785    #[tokio::test]
786    async fn test_structured_exec_path_traversal_args() {
787        let tool = make_tool(vec!["cat"]);
788        let result = tool
789            .structured_exec("cat", vec!["../etc/passwd".into()], 5_000, None)
790            .await;
791        assert!(result.is_err());
792        assert!(result.unwrap_err().contains("metacharacters"));
793    }
794
795    #[tokio::test]
796    async fn test_structured_exec_clean_args() {
797        let tool = make_tool(vec!["echo"]);
798        let result = tool
799            .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000, None)
800            .await;
801        assert!(result.is_ok());
802        let r = result.unwrap();
803        assert_eq!(r.exit_code, 0);
804        assert!(r.stdout.contains("hello world"));
805    }
806
807    // ─── AgentTool interface ─────────────────────────────────────────
808
809    #[test]
810    fn test_name_and_label() {
811        let tool = make_tool(vec![]);
812        assert_eq!(tool.name(), "exec");
813        assert_eq!(tool.label(), "Exec");
814    }
815
816    #[test]
817    fn test_parameters_schema() {
818        let tool = make_tool(vec![]);
819        let schema = tool.parameters_schema();
820
821        let props = schema["properties"].as_object().unwrap();
822        assert!(props.contains_key("mode"));
823        assert!(props.contains_key("command"));
824        assert!(props.contains_key("binary"));
825        assert!(props.contains_key("args"));
826        assert!(props.contains_key("timeout"));
827
828        let required = schema["required"].as_array().unwrap();
829        assert!(required.iter().any(|r| r.as_str() == Some("mode")));
830    }
831
832    #[tokio::test]
833    async fn test_agent_tool_shell_mode() {
834        let tool = make_tool(vec![]);
835
836        let result = tool
837            .execute(
838                "test-1",
839                json!({ "mode": "shell", "command": "echo hello" }),
840                None,
841                &ToolContext::default(),
842            )
843            .await;
844
845        assert!(result.is_ok());
846        let r = result.unwrap();
847        assert!(r.success, "Expected success, got: {}", r.output);
848        assert!(r.output.contains("hello"));
849    }
850
851    #[tokio::test]
852    async fn test_agent_tool_structured_mode() {
853        let tool = make_tool(vec!["echo"]);
854
855        let result = tool
856            .execute(
857                "test-2",
858                json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
859                None,
860                &ToolContext::default(),
861            )
862            .await;
863
864        assert!(result.is_ok());
865        let r = result.unwrap();
866        assert!(r.success, "Expected success, got: {}", r.output);
867        assert!(r.output.contains("hi"));
868    }
869
870    #[tokio::test]
871    async fn test_agent_tool_missing_mode() {
872        let tool = make_tool(vec![]);
873        let result = tool
874            .execute(
875                "test-3",
876                json!({ "command": "echo hi" }),
877                None,
878                &ToolContext::default(),
879            )
880            .await;
881        assert!(result.is_err());
882        assert!(result
883            .unwrap_err()
884            .contains("Missing required parameter: mode"));
885    }
886
887    #[tokio::test]
888    async fn test_agent_tool_invalid_mode() {
889        let tool = make_tool(vec![]);
890        let result = tool
891            .execute(
892                "test-4",
893                json!({ "mode": "docker" }),
894                None,
895                &ToolContext::default(),
896            )
897            .await;
898        assert!(result.is_err());
899        assert!(result.unwrap_err().contains("Invalid mode"));
900    }
901
902    #[tokio::test]
903    async fn test_agent_tool_shell_missing_command() {
904        let tool = make_tool(vec![]);
905        let result = tool
906            .execute(
907                "test-5",
908                json!({ "mode": "shell" }),
909                None,
910                &ToolContext::default(),
911            )
912            .await;
913        assert!(result.is_ok());
914        let r = result.unwrap();
915        assert!(!r.success);
916        assert!(r.output.contains("shell mode requires 'command' parameter"));
917    }
918
919    #[tokio::test]
920    async fn test_agent_tool_structured_missing_binary() {
921        let tool = make_tool(vec![]);
922        let result = tool
923            .execute(
924                "test-6",
925                json!({ "mode": "structured" }),
926                None,
927                &ToolContext::default(),
928            )
929            .await;
930        assert!(result.is_ok());
931        let r = result.unwrap();
932        assert!(!r.success);
933        assert!(r
934            .output
935            .contains("structured mode requires 'binary' parameter"));
936    }
937
938    #[tokio::test]
939    async fn test_agent_tool_nonzero_exit() {
940        let tool = make_tool(vec![]);
941
942        let result = tool
943            .execute(
944                "test-7",
945                json!({ "mode": "shell", "command": "exit 7" }),
946                None,
947                &ToolContext::default(),
948            )
949            .await;
950
951        assert!(result.is_ok());
952        let r = result.unwrap();
953        assert!(!r.success);
954        assert!(r.output.contains("exited with code 7"));
955    }
956
957    // ─── format_exec_output ──────────────────────────────────────────
958
959    #[test]
960    fn test_format_exec_output_success() {
961        let result = ExecResult {
962            stdout: "hello".to_string(),
963            stderr: String::new(),
964            exit_code: 0,
965            duration_ms: 1_500,
966        };
967        let output = format_exec_output(&result);
968        assert!(output.contains("hello"));
969        assert!(output.contains("Took 1.5s"));
970        assert!(!output.contains("exited with code"));
971    }
972
973    #[test]
974    fn test_format_exec_output_failure() {
975        let result = ExecResult {
976            stdout: String::new(),
977            stderr: "error!".to_string(),
978            exit_code: 1,
979            duration_ms: 500,
980        };
981        let output = format_exec_output(&result);
982        assert!(output.contains("error!"));
983        assert!(output.contains("exited with code 1"));
984    }
985
986    #[test]
987    fn test_format_exec_output_no_output() {
988        let result = ExecResult {
989            stdout: String::new(),
990            stderr: String::new(),
991            exit_code: 0,
992            duration_ms: 100,
993        };
994        let output = format_exec_output(&result);
995        assert!(output.contains("(no output)"));
996    }
997
998    #[test]
999    fn test_format_exec_output_minutes() {
1000        let result = ExecResult {
1001            stdout: "done".to_string(),
1002            stderr: String::new(),
1003            exit_code: 0,
1004            duration_ms: 125_000, // 2m 5s
1005        };
1006        let output = format_exec_output(&result);
1007        assert!(output.contains("Took 2m 5.0s"));
1008    }
1009
1010    // ─── has_metacharacters ──────────────────────────────────────────
1011
1012    #[test]
1013    fn test_has_metacharacters_clean() {
1014        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
1015    }
1016
1017    #[test]
1018    fn test_has_metacharacters_semicolon() {
1019        assert!(has_metacharacters(&["foo;bar".into()]));
1020    }
1021
1022    #[test]
1023    fn test_has_metacharacters_pipe() {
1024        assert!(has_metacharacters(&["a | b".into()]));
1025    }
1026
1027    #[test]
1028    fn test_has_metacharacters_dollar() {
1029        assert!(has_metacharacters(&["$(whoami)".into()]));
1030    }
1031
1032    #[test]
1033    fn test_has_metacharacters_backtick() {
1034        assert!(has_metacharacters(&["`id`".into()]));
1035    }
1036
1037    #[test]
1038    fn test_has_metacharacters_traversal() {
1039        assert!(has_metacharacters(&["../etc/passwd".into()]));
1040    }
1041
1042    // ── Access control tests ────────────────────────────────────
1043
1044    /// Helper: build ExecTool bound to a named agent with specific permissions.
1045    fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
1046        let config = ExecConfig {
1047            allowlist_mode: crate::config::AllowlistMode::Permissive,
1048            allow_shell_mode: true,
1049            ..Default::default()
1050        };
1051        let mut access = AccessManager::new();
1052        // Create default permissions, then set specific allowed tools.
1053        {
1054            let perms = access.get_or_create_permissions(agent_name);
1055            // Clear defaults first, then add only requested tools.
1056            perms.allowed_tools.clear();
1057            for tool in allowed_tools {
1058                perms.allow_tool(tool);
1059            }
1060        }
1061        let ctx = crate::access_manager::AgentContext::test_fixture(agent_name);
1062        ExecTool::new(
1063            Arc::new(parking_lot::RwLock::new(config)),
1064            Arc::new(Mutex::new(access)),
1065            ctx,
1066        )
1067    }
1068
1069    #[tokio::test]
1070    async fn test_for_agent_structured_exec_allowed() {
1071        let tool = make_agent_tool("test-agent", &["echo", "ls"]);
1072        let result = tool
1073            .structured_exec("echo", vec!["hello".into()], 5_000, None)
1074            .await;
1075        assert!(result.is_ok(), "Allowed binary should succeed");
1076        let r = result.unwrap();
1077        assert_eq!(r.exit_code, 0);
1078        assert!(r.stdout.contains("hello"));
1079    }
1080
1081    #[tokio::test]
1082    async fn test_for_agent_structured_exec_denied() {
1083        let tool = make_agent_tool("test-agent", &["ls"]); // no "echo"
1084        let result = tool
1085            .structured_exec("echo", vec!["hello".into()], 5_000, None)
1086            .await;
1087        assert!(result.is_err());
1088        let err = result.unwrap_err();
1089        assert!(
1090            err.contains("not allowed to execute"),
1091            "Error should mention denial: {err}"
1092        );
1093        assert!(
1094            err.contains("echo"),
1095            "Error should name the denied binary: {err}"
1096        );
1097    }
1098
1099    #[tokio::test]
1100    async fn test_for_agent_shell_exec_allowed() {
1101        let tool = make_agent_tool("test-agent", &["bash"]);
1102        let result = tool.shell_exec("echo hello", 5_000, None).await;
1103        assert!(
1104            result.is_ok(),
1105            "Agent with 'bash' permission should succeed"
1106        );
1107        assert!(result.unwrap().stdout.contains("hello"));
1108    }
1109
1110    #[tokio::test]
1111    async fn test_for_agent_shell_exec_denied() {
1112        let tool = make_agent_tool("test-agent", &["ls"]); // no "bash"
1113        let result = tool.shell_exec("echo hello", 5_000, None).await;
1114        assert!(result.is_err());
1115        let err = result.unwrap_err();
1116        assert!(
1117            err.contains("not allowed to execute"),
1118            "Error should mention denial: {err}"
1119        );
1120        assert!(err.contains("bash"), "Error should name 'bash': {err}");
1121    }
1122
1123    #[tokio::test]
1124    async fn test_no_agent_name_bypasses_access_control() {
1125        // ExecTool::new_unrestricted() (no context) should NOT check permissions,
1126        // but shell mode must still be enabled in config.
1127        let mut config = ExecConfig::default();
1128        config.allow_shell_mode = true; // Enable shell mode for this test
1129        let access = AccessManager::new(); // empty — no permissions for anyone
1130        let tool = ExecTool::new_unrestricted(
1131            Arc::new(parking_lot::RwLock::new(config)),
1132            Arc::new(Mutex::new(access)),
1133        );
1134        let result = tool.shell_exec("echo unrestricted", 5_000, None).await;
1135        assert!(
1136            result.is_ok(),
1137            "Shell mode enabled + no agent_name = unrestricted execution"
1138        );
1139    }
1140
1141    #[test]
1142    fn test_agent_name_set_correctly() {
1143        let tool = make_agent_tool("my-agent", &[]);
1144        assert_eq!(tool.agent_name(), Some("my-agent"));
1145    }
1146
1147    #[test]
1148    fn test_new_has_no_agent_name() {
1149        let tool = make_tool(vec![]);
1150        assert!(tool.agent_name().is_none());
1151    }
1152}