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::config::ExecConfig;
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    /// Execution configuration (allowlist, timeouts).
68    config: Arc<ExecConfig>,
69    /// Access manager for permission checks.
70    access: Arc<Mutex<AccessManager>>,
71    /// Agent name for access control and audit logging.
72    /// `None` = unrestricted (tests / development mode).
73    agent_name: Option<String>,
74}
75
76impl ExecTool {
77    /// Create a new `ExecTool` with the given config and access manager.
78    ///
79    /// No agent context is attached, so access control is not enforced.
80    /// Use [`ExecTool::for_agent`] for production.
81    pub fn new(config: Arc<ExecConfig>, access: Arc<Mutex<AccessManager>>) -> Self {
82        Self {
83            config,
84            access,
85            agent_name: None,
86        }
87    }
88
89    /// Create an `ExecTool` from a [`KernelHandle`].
90    ///
91    /// Extracts `ExecConfig` and `AccessManager` from the kernel's exec facade
92    /// and binds the tool to the default agent name `"oxios-agent"`.
93    pub fn from_kernel(kernel: &crate::kernel_handle::KernelHandle) -> Self {
94        Self::for_agent(
95            Arc::new(kernel.exec.config().clone()),
96            kernel.exec.access_manager().clone(),
97            "oxios-agent".to_string(),
98        )
99    }
100
101    /// Create a new `ExecTool` bound to a specific agent.
102    ///
103    /// All executions through this instance are attributed to `agent_name`
104    /// for access control and audit logging.
105    pub fn for_agent(
106        config: Arc<ExecConfig>,
107        access: Arc<Mutex<AccessManager>>,
108        agent_name: String,
109    ) -> Self {
110        Self {
111            config,
112            access,
113            agent_name: Some(agent_name),
114        }
115    }
116
117    /// Execute a raw command string via `bash -c <cmd>`.
118    ///
119    /// Primary shell execution path.
120    /// The entire command string is forwarded to `bash -c`, so pipelines,
121    /// redirects, and compound commands all work.
122    pub async fn shell_exec(&self, command: &str, timeout_ms: u64) -> Result<ExecResult, String> {
123        // Check if shell mode is allowed
124        if !self.config.allow_shell_mode {
125            return Err(
126                "shell_exec: shell mode is disabled by configuration (allow_shell_mode = false). \
127                 Use mode='structured' instead, or set allow_shell_mode=true in config.toml"
128                    .to_string(),
129            );
130        }
131
132        if command.trim().is_empty() {
133            return Err("shell_exec: command must not be empty".to_string());
134        }
135
136        // Audit + access check.
137        if let Some(ref name) = self.agent_name {
138            let mut access = self.access.lock();
139            if !access.can_use_tool(name, "bash") {
140                return Err(format!(
141                    "shell_exec: agent '{}' is not allowed to execute 'bash'",
142                    name
143                ));
144            }
145            tracing::info!(
146                agent = %name,
147                mode = "shell",
148                command = %command.chars().take(200).collect::<String>(),
149                "ExecTool: executing shell command (shell mode enabled)",
150            );
151        } else {
152            tracing::warn!(
153                mode = "shell",
154                command = %command.chars().take(200).collect::<String>(),
155                "ExecTool: shell mode executing without agent context",
156            );
157        }
158
159        let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
160
161        let start = std::time::Instant::now();
162
163        let result = tokio::time::timeout(
164            std::time::Duration::from_millis(effective_timeout),
165            tokio::process::Command::new("bash")
166                .arg("-c")
167                .arg(command)
168                .env_clear()
169                .env("HOME", std::env::var("HOME").unwrap_or_default())
170                .env("USER", std::env::var("USER").unwrap_or_default())
171                .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
172                .env("PATH", std::env::var("PATH").unwrap_or_default())
173                .env(
174                    "LANG",
175                    std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
176                )
177                .env("TERM", "dumb")
178                .output(),
179        )
180        .await;
181
182        let duration_ms = start.elapsed().as_millis() as u64;
183
184        match result {
185            Ok(Ok(output)) => Ok(ExecResult {
186                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
187                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
188                exit_code: output.status.code().unwrap_or(-1),
189                duration_ms,
190            }),
191            Ok(Err(e)) => Err(format!("shell execution error: {e}")),
192            Err(_) => Err(format!(
193                "shell command timed out after {effective_timeout}ms"
194            )),
195        }
196    }
197
198    /// Execute a binary with explicit args, enforcing allowlist + metachar blocking.
199    ///
200    /// Primary structured execution path.
201    /// Security checks:
202    /// 1. Binary must be a bare name (no `/` or `..`).
203    /// 2. Binary must be in the allowlist (or allowlist is empty = dev mode).
204    /// 3. Arguments must not contain shell metacharacters or path traversal.
205    pub async fn structured_exec(
206        &self,
207        binary: &str,
208        args: Vec<String>,
209        timeout_ms: u64,
210    ) -> Result<ExecResult, String> {
211        // --- Access control ---
212        if let Some(ref name) = self.agent_name {
213            let mut access = self.access.lock();
214            if !access.can_use_tool(name, binary) {
215                return Err(format!(
216                    "structured_exec: agent '{}' is not allowed to execute '{}'",
217                    name, binary
218                ));
219            }
220        }
221
222        // --- Binary validation ---
223
224        // Log execution for audit trail
225        tracing::debug!(mode = "structured", binary = %binary, args = ?args, "ExecTool executing");
226
227        if binary.contains("..") {
228            return Err("structured_exec: path traversal in binary name".to_string());
229        }
230        if binary.contains('/') {
231            return Err("structured_exec: binary must be a bare name, not a path".to_string());
232        }
233        if !self.config.is_binary_allowed(binary) {
234            return Err(format!(
235                "structured_exec: binary '{binary}' is not in the allowlist"
236            ));
237        }
238
239        // --- Argument validation ---
240
241        if has_metacharacters(&args) {
242            return Err(
243                "structured_exec: shell metacharacters or path traversal not allowed in arguments"
244                    .to_string(),
245            );
246        }
247
248        let effective_timeout = timeout_ms.clamp(1_000, self.config.max_timeout_secs * 1_000);
249
250        let start = std::time::Instant::now();
251
252        let result = tokio::time::timeout(
253            std::time::Duration::from_millis(effective_timeout),
254            tokio::process::Command::new(binary)
255                .args(&args)
256                .env_clear()
257                .env("HOME", std::env::var("HOME").unwrap_or_default())
258                .env("USER", std::env::var("USER").unwrap_or_default())
259                .env("LOGNAME", std::env::var("LOGNAME").unwrap_or_default())
260                .env("PATH", std::env::var("PATH").unwrap_or_default())
261                .env(
262                    "LANG",
263                    std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
264                )
265                .env("TERM", "dumb")
266                .output(),
267        )
268        .await;
269
270        let duration_ms = start.elapsed().as_millis() as u64;
271
272        match result {
273            Ok(Ok(output)) => Ok(ExecResult {
274                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
275                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
276                exit_code: output.status.code().unwrap_or(-1),
277                duration_ms,
278            }),
279            Ok(Err(e)) => Err(format!("structured execution error: {e}")),
280            Err(_) => Err(format!(
281                "structured command timed out after {effective_timeout}ms"
282            )),
283        }
284    }
285}
286
287// ─── Helpers ───────────────────────────────────────────────────────────────
288
289/// Check whether any argument contains shell metacharacters or `..`.
290fn has_metacharacters(args: &[String]) -> bool {
291    for arg in args {
292        if arg.contains("..") {
293            return true;
294        }
295        if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
296            return true;
297        }
298    }
299    false
300}
301
302/// Format an `ExecResult` into a human-readable output string (matching the
303/// format consistent with agent expectations).
304fn format_exec_output(result: &ExecResult) -> String {
305    let mut output = String::new();
306
307    if result.stdout.is_empty() && result.stderr.is_empty() {
308        output.push_str("(no output)");
309    } else {
310        if !result.stdout.is_empty() {
311            output.push_str(&result.stdout);
312        }
313        if !result.stderr.is_empty() && !result.stdout.is_empty() {
314            output.push('\n');
315        }
316        if !result.stderr.is_empty() {
317            output.push_str(&result.stderr);
318        }
319    }
320
321    if result.exit_code != 0 {
322        output.push_str(&format!(
323            "\n\nCommand exited with code {}",
324            result.exit_code
325        ));
326    }
327
328    let secs = result.duration_ms / 1000;
329    let millis = result.duration_ms % 1000;
330
331    if secs >= 60 {
332        let mins = secs / 60;
333        let remain_secs = secs % 60;
334        output.push_str(&format!(
335            "\n\nTook {}m {:.1}s",
336            mins,
337            remain_secs as f64 + millis as f64 / 1000.0
338        ));
339    } else {
340        output.push_str(&format!(
341            "\n\nTook {:.1}s",
342            secs as f64 + millis as f64 / 1000.0
343        ));
344    }
345
346    output
347}
348
349// ─── Debug ─────────────────────────────────────────────────────────────────
350
351impl std::fmt::Debug for ExecTool {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        f.debug_struct("ExecTool").finish()
354    }
355}
356
357// ─── AgentTool implementation ──────────────────────────────────────────────
358
359#[async_trait]
360impl AgentTool for ExecTool {
361    fn name(&self) -> &str {
362        "exec"
363    }
364
365    fn label(&self) -> &str {
366        "Exec"
367    }
368
369    fn description(&self) -> &'static str {
370        "Execute a command. Use mode='shell' for raw shell strings (pipelines, redirects) or mode='structured' for a specific binary+args with allowlist security."
371    }
372
373    fn parameters_schema(&self) -> Value {
374        json!({
375            "type": "object",
376            "properties": {
377                "mode": {
378                    "type": "string",
379                    "enum": ["shell", "structured"],
380                    "description": "Execution mode: 'shell' for bash -c <command>, 'structured' for binary+args with allowlist enforcement"
381                },
382                "command": {
383                    "type": "string",
384                    "description": "Shell command string (mode='shell' only)"
385                },
386                "binary": {
387                    "type": "string",
388                    "description": "Binary name (mode='structured' only, must be in allowlist)"
389                },
390                "args": {
391                    "type": "array",
392                    "items": { "type": "string" },
393                    "description": "Binary arguments (mode='structured' only)"
394                },
395                "timeout": {
396                    "type": "integer",
397                    "description": "Timeout in seconds",
398                    "default": 120
399                }
400            },
401            "required": ["mode"]
402        })
403    }
404
405    async fn execute(
406        &self,
407        _tool_call_id: &str,
408        params: Value,
409        _signal: Option<oneshot::Receiver<()>>,
410        _ctx: &ToolContext,
411    ) -> Result<AgentToolResult, String> {
412        let mode = params.get("mode").and_then(|v| v.as_str()).ok_or_else(|| {
413            "Missing required parameter: mode (expected 'shell' or 'structured')".to_string()
414        })?;
415
416        let timeout_secs = params
417            .get("timeout")
418            .and_then(|v| v.as_u64())
419            .unwrap_or(self.config.default_timeout_secs);
420        let timeout_ms = (timeout_secs * 1000).min(self.config.max_timeout_secs * 1000);
421
422        match mode {
423            "shell" => {
424                let command = match params.get("command").and_then(|v| v.as_str()) {
425                    Some(c) => c,
426                    None => {
427                        return Ok(AgentToolResult::error(
428                            "shell mode requires 'command' parameter",
429                        ))
430                    }
431                };
432
433                match self.shell_exec(command, timeout_ms).await {
434                    Ok(result) => {
435                        let output = format_exec_output(&result);
436                        if result.exit_code == 0 {
437                            Ok(AgentToolResult::success(output))
438                        } else {
439                            Ok(AgentToolResult::error(output))
440                        }
441                    }
442                    Err(e) => Ok(AgentToolResult::error(format!("exec (shell): {e}"))),
443                }
444            }
445
446            "structured" => {
447                let binary = match params.get("binary").and_then(|v| v.as_str()) {
448                    Some(b) => b,
449                    None => {
450                        return Ok(AgentToolResult::error(
451                            "structured mode requires 'binary' parameter",
452                        ))
453                    }
454                };
455
456                let args: Vec<String> = params
457                    .get("args")
458                    .and_then(|v| v.as_array())
459                    .map(|arr| {
460                        arr.iter()
461                            .filter_map(|v| v.as_str().map(String::from))
462                            .collect()
463                    })
464                    .unwrap_or_default();
465
466                match self.structured_exec(binary, args, timeout_ms).await {
467                    Ok(result) => {
468                        let output = format_exec_output(&result);
469                        if result.exit_code == 0 {
470                            Ok(AgentToolResult::success(output))
471                        } else {
472                            Ok(AgentToolResult::error(output))
473                        }
474                    }
475                    Err(e) => Ok(AgentToolResult::error(format!("exec (structured): {e}"))),
476                }
477            }
478
479            other => Err(format!(
480                "Invalid mode '{other}': expected 'shell' or 'structured'"
481            )),
482        }
483    }
484}
485
486// ─── Tests ─────────────────────────────────────────────────────────────────
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    /// Helper: build an `ExecTool` with default config and empty access manager.
493    /// Shell mode is enabled for testing purposes.
494    fn make_tool(allowed_commands: Vec<&str>) -> ExecTool {
495        let mut config = ExecConfig::default();
496        config.allowed_commands = allowed_commands.into_iter().map(String::from).collect();
497        config.allow_shell_mode = true; // Enable for tests
498        ExecTool::new(Arc::new(config), Arc::new(Mutex::new(AccessManager::new())))
499    }
500
501    // ─── shell_exec ──────────────────────────────────────────────────
502
503    #[tokio::test]
504    async fn test_shell_exec_echo() {
505        let tool = make_tool(vec![]);
506        let result = tool.shell_exec("echo hello", 5_000).await;
507        assert!(result.is_ok());
508        let r = result.unwrap();
509        assert_eq!(r.exit_code, 0);
510        assert!(r.stdout.contains("hello"));
511        assert!(r.duration_ms < 5_000);
512    }
513
514    #[tokio::test]
515    async fn test_shell_exec_pipeline() {
516        let tool = make_tool(vec![]);
517        let result = tool.shell_exec("echo foo | tr f b", 5_000).await;
518        assert!(result.is_ok());
519        let r = result.unwrap();
520        assert_eq!(r.exit_code, 0);
521        assert!(r.stdout.contains("boo"));
522    }
523
524    #[tokio::test]
525    async fn test_shell_exec_nonzero_exit() {
526        let tool = make_tool(vec![]);
527        let result = tool.shell_exec("exit 42", 5_000).await;
528        assert!(result.is_ok());
529        assert_eq!(result.unwrap().exit_code, 42);
530    }
531
532    #[tokio::test]
533    async fn test_shell_exec_empty_command() {
534        let tool = make_tool(vec![]);
535        let result = tool.shell_exec("   ", 5_000).await;
536        assert!(result.is_err());
537        assert!(result.unwrap_err().contains("must not be empty"));
538    }
539
540    #[tokio::test]
541    async fn test_shell_exec_timeout() {
542        let tool = make_tool(vec![]);
543        let result = tool.shell_exec("sleep 300", 1_000).await;
544        assert!(result.is_err());
545        assert!(result.unwrap_err().contains("timed out"));
546    }
547
548    // ─── structured_exec ─────────────────────────────────────────────
549
550    #[tokio::test]
551    async fn test_structured_exec_echo() {
552        let tool = make_tool(vec!["echo"]);
553        let result = tool
554            .structured_exec("echo", vec!["hello".into()], 5_000)
555            .await;
556        assert!(result.is_ok());
557        let r = result.unwrap();
558        assert_eq!(r.exit_code, 0);
559        assert!(r.stdout.contains("hello"));
560    }
561
562    #[tokio::test]
563    async fn test_structured_exec_blocked_binary() {
564        let tool = make_tool(vec!["echo"]);
565        let result = tool
566            .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000)
567            .await;
568        assert!(result.is_err());
569        assert!(result.unwrap_err().contains("not in the allowlist"));
570    }
571
572    #[tokio::test]
573    async fn test_structured_exec_path_binary() {
574        let tool = make_tool(vec![]);
575        let result = tool.structured_exec("/usr/bin/echo", vec![], 5_000).await;
576        assert!(result.is_err());
577        assert!(result.unwrap_err().contains("bare name"));
578    }
579
580    #[tokio::test]
581    async fn test_structured_exec_traversal_binary() {
582        let tool = make_tool(vec![]);
583        let result = tool.structured_exec("../bin/evil", vec![], 5_000).await;
584        assert!(result.is_err());
585        assert!(result.unwrap_err().contains("path traversal"));
586    }
587
588    #[tokio::test]
589    async fn test_structured_exec_metachar_args() {
590        let tool = make_tool(vec!["echo"]);
591        let result = tool
592            .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000)
593            .await;
594        assert!(result.is_err());
595        assert!(result.unwrap_err().contains("metacharacters"));
596    }
597
598    #[tokio::test]
599    async fn test_structured_exec_path_traversal_args() {
600        let tool = make_tool(vec!["cat"]);
601        let result = tool
602            .structured_exec("cat", vec!["../etc/passwd".into()], 5_000)
603            .await;
604        assert!(result.is_err());
605        assert!(result.unwrap_err().contains("metacharacters"));
606    }
607
608    #[tokio::test]
609    async fn test_structured_exec_clean_args() {
610        let tool = make_tool(vec!["echo"]);
611        let result = tool
612            .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000)
613            .await;
614        assert!(result.is_ok());
615        let r = result.unwrap();
616        assert_eq!(r.exit_code, 0);
617        assert!(r.stdout.contains("hello world"));
618    }
619
620    // ─── AgentTool interface ─────────────────────────────────────────
621
622    #[test]
623    fn test_name_and_label() {
624        let tool = make_tool(vec![]);
625        assert_eq!(tool.name(), "exec");
626        assert_eq!(tool.label(), "Exec");
627    }
628
629    #[test]
630    fn test_parameters_schema() {
631        let tool = make_tool(vec![]);
632        let schema = tool.parameters_schema();
633
634        let props = schema["properties"].as_object().unwrap();
635        assert!(props.contains_key("mode"));
636        assert!(props.contains_key("command"));
637        assert!(props.contains_key("binary"));
638        assert!(props.contains_key("args"));
639        assert!(props.contains_key("timeout"));
640
641        let required = schema["required"].as_array().unwrap();
642        assert!(required.iter().any(|r| r.as_str() == Some("mode")));
643    }
644
645    #[tokio::test]
646    async fn test_agent_tool_shell_mode() {
647        let tool = make_tool(vec![]);
648
649        let result = tool
650            .execute(
651                "test-1",
652                json!({ "mode": "shell", "command": "echo hello" }),
653                None,
654                &ToolContext::default(),
655            )
656            .await;
657
658        assert!(result.is_ok());
659        let r = result.unwrap();
660        assert!(r.success, "Expected success, got: {}", r.output);
661        assert!(r.output.contains("hello"));
662    }
663
664    #[tokio::test]
665    async fn test_agent_tool_structured_mode() {
666        let tool = make_tool(vec!["echo"]);
667
668        let result = tool
669            .execute(
670                "test-2",
671                json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
672                None,
673                &ToolContext::default(),
674            )
675            .await;
676
677        assert!(result.is_ok());
678        let r = result.unwrap();
679        assert!(r.success, "Expected success, got: {}", r.output);
680        assert!(r.output.contains("hi"));
681    }
682
683    #[tokio::test]
684    async fn test_agent_tool_missing_mode() {
685        let tool = make_tool(vec![]);
686        let result = tool
687            .execute(
688                "test-3",
689                json!({ "command": "echo hi" }),
690                None,
691                &ToolContext::default(),
692            )
693            .await;
694        assert!(result.is_err());
695        assert!(result
696            .unwrap_err()
697            .contains("Missing required parameter: mode"));
698    }
699
700    #[tokio::test]
701    async fn test_agent_tool_invalid_mode() {
702        let tool = make_tool(vec![]);
703        let result = tool
704            .execute(
705                "test-4",
706                json!({ "mode": "docker" }),
707                None,
708                &ToolContext::default(),
709            )
710            .await;
711        assert!(result.is_err());
712        assert!(result.unwrap_err().contains("Invalid mode"));
713    }
714
715    #[tokio::test]
716    async fn test_agent_tool_shell_missing_command() {
717        let tool = make_tool(vec![]);
718        let result = tool
719            .execute(
720                "test-5",
721                json!({ "mode": "shell" }),
722                None,
723                &ToolContext::default(),
724            )
725            .await;
726        assert!(result.is_ok());
727        let r = result.unwrap();
728        assert!(!r.success);
729        assert!(r.output.contains("shell mode requires 'command' parameter"));
730    }
731
732    #[tokio::test]
733    async fn test_agent_tool_structured_missing_binary() {
734        let tool = make_tool(vec![]);
735        let result = tool
736            .execute(
737                "test-6",
738                json!({ "mode": "structured" }),
739                None,
740                &ToolContext::default(),
741            )
742            .await;
743        assert!(result.is_ok());
744        let r = result.unwrap();
745        assert!(!r.success);
746        assert!(r
747            .output
748            .contains("structured mode requires 'binary' parameter"));
749    }
750
751    #[tokio::test]
752    async fn test_agent_tool_nonzero_exit() {
753        let tool = make_tool(vec![]);
754
755        let result = tool
756            .execute(
757                "test-7",
758                json!({ "mode": "shell", "command": "exit 7" }),
759                None,
760                &ToolContext::default(),
761            )
762            .await;
763
764        assert!(result.is_ok());
765        let r = result.unwrap();
766        assert!(!r.success);
767        assert!(r.output.contains("exited with code 7"));
768    }
769
770    // ─── format_exec_output ──────────────────────────────────────────
771
772    #[test]
773    fn test_format_exec_output_success() {
774        let result = ExecResult {
775            stdout: "hello".to_string(),
776            stderr: String::new(),
777            exit_code: 0,
778            duration_ms: 1_500,
779        };
780        let output = format_exec_output(&result);
781        assert!(output.contains("hello"));
782        assert!(output.contains("Took 1.5s"));
783        assert!(!output.contains("exited with code"));
784    }
785
786    #[test]
787    fn test_format_exec_output_failure() {
788        let result = ExecResult {
789            stdout: String::new(),
790            stderr: "error!".to_string(),
791            exit_code: 1,
792            duration_ms: 500,
793        };
794        let output = format_exec_output(&result);
795        assert!(output.contains("error!"));
796        assert!(output.contains("exited with code 1"));
797    }
798
799    #[test]
800    fn test_format_exec_output_no_output() {
801        let result = ExecResult {
802            stdout: String::new(),
803            stderr: String::new(),
804            exit_code: 0,
805            duration_ms: 100,
806        };
807        let output = format_exec_output(&result);
808        assert!(output.contains("(no output)"));
809    }
810
811    #[test]
812    fn test_format_exec_output_minutes() {
813        let result = ExecResult {
814            stdout: "done".to_string(),
815            stderr: String::new(),
816            exit_code: 0,
817            duration_ms: 125_000, // 2m 5s
818        };
819        let output = format_exec_output(&result);
820        assert!(output.contains("Took 2m 5.0s"));
821    }
822
823    // ─── has_metacharacters ──────────────────────────────────────────
824
825    #[test]
826    fn test_has_metacharacters_clean() {
827        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
828    }
829
830    #[test]
831    fn test_has_metacharacters_semicolon() {
832        assert!(has_metacharacters(&["foo;bar".into()]));
833    }
834
835    #[test]
836    fn test_has_metacharacters_pipe() {
837        assert!(has_metacharacters(&["a | b".into()]));
838    }
839
840    #[test]
841    fn test_has_metacharacters_dollar() {
842        assert!(has_metacharacters(&["$(whoami)".into()]));
843    }
844
845    #[test]
846    fn test_has_metacharacters_backtick() {
847        assert!(has_metacharacters(&["`id`".into()]));
848    }
849
850    #[test]
851    fn test_has_metacharacters_traversal() {
852        assert!(has_metacharacters(&["../etc/passwd".into()]));
853    }
854
855    // ── Access control tests ────────────────────────────────────
856
857    /// Helper: build ExecTool bound to a named agent with specific permissions.
858    fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
859        let mut config = ExecConfig::default();
860        config.allow_shell_mode = true; // Enable for tests
861        let mut access = AccessManager::new();
862        // Create default permissions, then set specific allowed tools.
863        {
864            let perms = access.get_or_create_permissions(agent_name);
865            // Clear defaults first, then add only requested tools.
866            perms.allowed_tools.clear();
867            for tool in allowed_tools {
868                perms.allow_tool(tool);
869            }
870        }
871        ExecTool::for_agent(
872            Arc::new(config),
873            Arc::new(Mutex::new(access)),
874            agent_name.to_string(),
875        )
876    }
877
878    #[tokio::test]
879    async fn test_for_agent_structured_exec_allowed() {
880        let tool = make_agent_tool("test-agent", &["echo", "ls"]);
881        let result = tool
882            .structured_exec("echo", vec!["hello".into()], 5_000)
883            .await;
884        assert!(result.is_ok(), "Allowed binary should succeed");
885        let r = result.unwrap();
886        assert_eq!(r.exit_code, 0);
887        assert!(r.stdout.contains("hello"));
888    }
889
890    #[tokio::test]
891    async fn test_for_agent_structured_exec_denied() {
892        let tool = make_agent_tool("test-agent", &["ls"]); // no "echo"
893        let result = tool
894            .structured_exec("echo", vec!["hello".into()], 5_000)
895            .await;
896        assert!(result.is_err());
897        let err = result.unwrap_err();
898        assert!(
899            err.contains("not allowed to execute"),
900            "Error should mention denial: {err}"
901        );
902        assert!(
903            err.contains("echo"),
904            "Error should name the denied binary: {err}"
905        );
906    }
907
908    #[tokio::test]
909    async fn test_for_agent_shell_exec_allowed() {
910        let tool = make_agent_tool("test-agent", &["bash"]);
911        let result = tool.shell_exec("echo hello", 5_000).await;
912        assert!(
913            result.is_ok(),
914            "Agent with 'bash' permission should succeed"
915        );
916        assert!(result.unwrap().stdout.contains("hello"));
917    }
918
919    #[tokio::test]
920    async fn test_for_agent_shell_exec_denied() {
921        let tool = make_agent_tool("test-agent", &["ls"]); // no "bash"
922        let result = tool.shell_exec("echo hello", 5_000).await;
923        assert!(result.is_err());
924        let err = result.unwrap_err();
925        assert!(
926            err.contains("not allowed to execute"),
927            "Error should mention denial: {err}"
928        );
929        assert!(err.contains("bash"), "Error should name 'bash': {err}");
930    }
931
932    #[tokio::test]
933    async fn test_no_agent_name_bypasses_access_control() {
934        // ExecTool::new() (agent_name=None) should NOT check permissions,
935        // but shell mode must still be enabled in config.
936        let mut config = ExecConfig::default();
937        config.allow_shell_mode = true; // Enable shell mode for this test
938        let access = AccessManager::new(); // empty — no permissions for anyone
939        let tool = ExecTool::new(Arc::new(config), Arc::new(Mutex::new(access)));
940        let result = tool.shell_exec("echo unrestricted", 5_000).await;
941        assert!(
942            result.is_ok(),
943            "Shell mode enabled + no agent_name = unrestricted execution"
944        );
945    }
946
947    #[test]
948    fn test_agent_name_set_correctly() {
949        let tool = make_agent_tool("my-agent", &[]);
950        assert_eq!(tool.agent_name.as_deref(), Some("my-agent"));
951    }
952
953    #[test]
954    fn test_new_has_no_agent_name() {
955        let tool = make_tool(vec![]);
956        assert!(tool.agent_name.is_none());
957    }
958}