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    fn make_tool_with_config(config: ExecConfig) -> ExecTool {
502        let mut cfg = config.clone();
503        cfg.allow_shell_mode = true;  // Enable for tests
504        ExecTool::new(Arc::new(cfg), Arc::new(Mutex::new(AccessManager::new())))
505    }
506
507    // ─── shell_exec ──────────────────────────────────────────────────
508
509    #[tokio::test]
510    async fn test_shell_exec_echo() {
511        let tool = make_tool(vec![]);
512        let result = tool.shell_exec("echo hello", 5_000).await;
513        assert!(result.is_ok());
514        let r = result.unwrap();
515        assert_eq!(r.exit_code, 0);
516        assert!(r.stdout.contains("hello"));
517        assert!(r.duration_ms < 5_000);
518    }
519
520    #[tokio::test]
521    async fn test_shell_exec_pipeline() {
522        let tool = make_tool(vec![]);
523        let result = tool.shell_exec("echo foo | tr f b", 5_000).await;
524        assert!(result.is_ok());
525        let r = result.unwrap();
526        assert_eq!(r.exit_code, 0);
527        assert!(r.stdout.contains("boo"));
528    }
529
530    #[tokio::test]
531    async fn test_shell_exec_nonzero_exit() {
532        let tool = make_tool(vec![]);
533        let result = tool.shell_exec("exit 42", 5_000).await;
534        assert!(result.is_ok());
535        assert_eq!(result.unwrap().exit_code, 42);
536    }
537
538    #[tokio::test]
539    async fn test_shell_exec_empty_command() {
540        let tool = make_tool(vec![]);
541        let result = tool.shell_exec("   ", 5_000).await;
542        assert!(result.is_err());
543        assert!(result.unwrap_err().contains("must not be empty"));
544    }
545
546    #[tokio::test]
547    async fn test_shell_exec_timeout() {
548        let tool = make_tool(vec![]);
549        let result = tool.shell_exec("sleep 300", 1_000).await;
550        assert!(result.is_err());
551        assert!(result.unwrap_err().contains("timed out"));
552    }
553
554    // ─── structured_exec ─────────────────────────────────────────────
555
556    #[tokio::test]
557    async fn test_structured_exec_echo() {
558        let tool = make_tool(vec!["echo"]);
559        let result = tool
560            .structured_exec("echo", vec!["hello".into()], 5_000)
561            .await;
562        assert!(result.is_ok());
563        let r = result.unwrap();
564        assert_eq!(r.exit_code, 0);
565        assert!(r.stdout.contains("hello"));
566    }
567
568    #[tokio::test]
569    async fn test_structured_exec_blocked_binary() {
570        let tool = make_tool(vec!["echo"]);
571        let result = tool
572            .structured_exec("rm", vec!["-rf".into(), "/".into()], 5_000)
573            .await;
574        assert!(result.is_err());
575        assert!(result.unwrap_err().contains("not in the allowlist"));
576    }
577
578    #[tokio::test]
579    async fn test_structured_exec_path_binary() {
580        let tool = make_tool(vec![]);
581        let result = tool.structured_exec("/usr/bin/echo", vec![], 5_000).await;
582        assert!(result.is_err());
583        assert!(result.unwrap_err().contains("bare name"));
584    }
585
586    #[tokio::test]
587    async fn test_structured_exec_traversal_binary() {
588        let tool = make_tool(vec![]);
589        let result = tool.structured_exec("../bin/evil", vec![], 5_000).await;
590        assert!(result.is_err());
591        assert!(result.unwrap_err().contains("path traversal"));
592    }
593
594    #[tokio::test]
595    async fn test_structured_exec_metachar_args() {
596        let tool = make_tool(vec!["echo"]);
597        let result = tool
598            .structured_exec("echo", vec!["foo; rm -rf /".into()], 5_000)
599            .await;
600        assert!(result.is_err());
601        assert!(result.unwrap_err().contains("metacharacters"));
602    }
603
604    #[tokio::test]
605    async fn test_structured_exec_path_traversal_args() {
606        let tool = make_tool(vec!["cat"]);
607        let result = tool
608            .structured_exec("cat", vec!["../etc/passwd".into()], 5_000)
609            .await;
610        assert!(result.is_err());
611        assert!(result.unwrap_err().contains("metacharacters"));
612    }
613
614    #[tokio::test]
615    async fn test_structured_exec_clean_args() {
616        let tool = make_tool(vec!["echo"]);
617        let result = tool
618            .structured_exec("echo", vec!["hello".into(), "world".into()], 5_000)
619            .await;
620        assert!(result.is_ok());
621        let r = result.unwrap();
622        assert_eq!(r.exit_code, 0);
623        assert!(r.stdout.contains("hello world"));
624    }
625
626    // ─── AgentTool interface ─────────────────────────────────────────
627
628    #[test]
629    fn test_name_and_label() {
630        let tool = make_tool(vec![]);
631        assert_eq!(tool.name(), "exec");
632        assert_eq!(tool.label(), "Exec");
633    }
634
635    #[test]
636    fn test_parameters_schema() {
637        let tool = make_tool(vec![]);
638        let schema = tool.parameters_schema();
639
640        let props = schema["properties"].as_object().unwrap();
641        assert!(props.contains_key("mode"));
642        assert!(props.contains_key("command"));
643        assert!(props.contains_key("binary"));
644        assert!(props.contains_key("args"));
645        assert!(props.contains_key("timeout"));
646
647        let required = schema["required"].as_array().unwrap();
648        assert!(required.iter().any(|r| r.as_str() == Some("mode")));
649    }
650
651    #[tokio::test]
652    async fn test_agent_tool_shell_mode() {
653        let tool = make_tool(vec![]);
654
655        let result = tool
656            .execute(
657                "test-1",
658                json!({ "mode": "shell", "command": "echo hello" }),
659                None,
660                &ToolContext::default(),
661            )
662            .await;
663
664        assert!(result.is_ok());
665        let r = result.unwrap();
666        assert!(r.success, "Expected success, got: {}", r.output);
667        assert!(r.output.contains("hello"));
668    }
669
670    #[tokio::test]
671    async fn test_agent_tool_structured_mode() {
672        let tool = make_tool(vec!["echo"]);
673
674        let result = tool
675            .execute(
676                "test-2",
677                json!({ "mode": "structured", "binary": "echo", "args": ["hi"] }),
678                None,
679                &ToolContext::default(),
680            )
681            .await;
682
683        assert!(result.is_ok());
684        let r = result.unwrap();
685        assert!(r.success, "Expected success, got: {}", r.output);
686        assert!(r.output.contains("hi"));
687    }
688
689    #[tokio::test]
690    async fn test_agent_tool_missing_mode() {
691        let tool = make_tool(vec![]);
692        let result = tool
693            .execute("test-3", json!({ "command": "echo hi" }), None, &ToolContext::default())
694            .await;
695        assert!(result.is_err());
696        assert!(result
697            .unwrap_err()
698            .contains("Missing required parameter: mode"));
699    }
700
701    #[tokio::test]
702    async fn test_agent_tool_invalid_mode() {
703        let tool = make_tool(vec![]);
704        let result = tool
705            .execute("test-4", json!({ "mode": "docker" }), None, &ToolContext::default())
706            .await;
707        assert!(result.is_err());
708        assert!(result.unwrap_err().contains("Invalid mode"));
709    }
710
711    #[tokio::test]
712    async fn test_agent_tool_shell_missing_command() {
713        let tool = make_tool(vec![]);
714        let result = tool
715            .execute("test-5", json!({ "mode": "shell" }), None, &ToolContext::default())
716            .await;
717        assert!(result.is_ok());
718        let r = result.unwrap();
719        assert!(!r.success);
720        assert!(r.output.contains("shell mode requires 'command' parameter"));
721    }
722
723    #[tokio::test]
724    async fn test_agent_tool_structured_missing_binary() {
725        let tool = make_tool(vec![]);
726        let result = tool
727            .execute("test-6", json!({ "mode": "structured" }), None, &ToolContext::default())
728            .await;
729        assert!(result.is_ok());
730        let r = result.unwrap();
731        assert!(!r.success);
732        assert!(r
733            .output
734            .contains("structured mode requires 'binary' parameter"));
735    }
736
737    #[tokio::test]
738    async fn test_agent_tool_nonzero_exit() {
739        let tool = make_tool(vec![]);
740
741        let result = tool
742            .execute(
743                "test-7",
744                json!({ "mode": "shell", "command": "exit 7" }),
745                None,
746                &ToolContext::default(),
747            )
748            .await;
749
750        assert!(result.is_ok());
751        let r = result.unwrap();
752        assert!(!r.success);
753        assert!(r.output.contains("exited with code 7"));
754    }
755
756    // ─── format_exec_output ──────────────────────────────────────────
757
758    #[test]
759    fn test_format_exec_output_success() {
760        let result = ExecResult {
761            stdout: "hello".to_string(),
762            stderr: String::new(),
763            exit_code: 0,
764            duration_ms: 1_500,
765        };
766        let output = format_exec_output(&result);
767        assert!(output.contains("hello"));
768        assert!(output.contains("Took 1.5s"));
769        assert!(!output.contains("exited with code"));
770    }
771
772    #[test]
773    fn test_format_exec_output_failure() {
774        let result = ExecResult {
775            stdout: String::new(),
776            stderr: "error!".to_string(),
777            exit_code: 1,
778            duration_ms: 500,
779        };
780        let output = format_exec_output(&result);
781        assert!(output.contains("error!"));
782        assert!(output.contains("exited with code 1"));
783    }
784
785    #[test]
786    fn test_format_exec_output_no_output() {
787        let result = ExecResult {
788            stdout: String::new(),
789            stderr: String::new(),
790            exit_code: 0,
791            duration_ms: 100,
792        };
793        let output = format_exec_output(&result);
794        assert!(output.contains("(no output)"));
795    }
796
797    #[test]
798    fn test_format_exec_output_minutes() {
799        let result = ExecResult {
800            stdout: "done".to_string(),
801            stderr: String::new(),
802            exit_code: 0,
803            duration_ms: 125_000, // 2m 5s
804        };
805        let output = format_exec_output(&result);
806        assert!(output.contains("Took 2m 5.0s"));
807    }
808
809    // ─── has_metacharacters ──────────────────────────────────────────
810
811    #[test]
812    fn test_has_metacharacters_clean() {
813        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
814    }
815
816    #[test]
817    fn test_has_metacharacters_semicolon() {
818        assert!(has_metacharacters(&["foo;bar".into()]));
819    }
820
821    #[test]
822    fn test_has_metacharacters_pipe() {
823        assert!(has_metacharacters(&["a | b".into()]));
824    }
825
826    #[test]
827    fn test_has_metacharacters_dollar() {
828        assert!(has_metacharacters(&["$(whoami)".into()]));
829    }
830
831    #[test]
832    fn test_has_metacharacters_backtick() {
833        assert!(has_metacharacters(&["`id`".into()]));
834    }
835
836    #[test]
837    fn test_has_metacharacters_traversal() {
838        assert!(has_metacharacters(&["../etc/passwd".into()]));
839    }
840
841    // ── Access control tests ────────────────────────────────────
842
843    /// Helper: build ExecTool bound to a named agent with specific permissions.
844    fn make_agent_tool(agent_name: &str, allowed_tools: &[&str]) -> ExecTool {
845        let mut config = ExecConfig::default();
846        config.allow_shell_mode = true;  // Enable for tests
847        let mut access = AccessManager::new();
848        // Create default permissions, then set specific allowed tools.
849        {
850            let perms = access.get_or_create_permissions(agent_name);
851            // Clear defaults first, then add only requested tools.
852            perms.allowed_tools.clear();
853            for tool in allowed_tools {
854                perms.allow_tool(tool);
855            }
856        }
857        ExecTool::for_agent(
858            Arc::new(config),
859            Arc::new(Mutex::new(access)),
860            agent_name.to_string(),
861        )
862    }
863
864    #[tokio::test]
865    async fn test_for_agent_structured_exec_allowed() {
866        let tool = make_agent_tool("test-agent", &["echo", "ls"]);
867        let result = tool
868            .structured_exec("echo", vec!["hello".into()], 5_000)
869            .await;
870        assert!(result.is_ok(), "Allowed binary should succeed");
871        let r = result.unwrap();
872        assert_eq!(r.exit_code, 0);
873        assert!(r.stdout.contains("hello"));
874    }
875
876    #[tokio::test]
877    async fn test_for_agent_structured_exec_denied() {
878        let tool = make_agent_tool("test-agent", &["ls"]); // no "echo"
879        let result = tool
880            .structured_exec("echo", vec!["hello".into()], 5_000)
881            .await;
882        assert!(result.is_err());
883        let err = result.unwrap_err();
884        assert!(
885            err.contains("not allowed to execute"),
886            "Error should mention denial: {err}"
887        );
888        assert!(
889            err.contains("echo"),
890            "Error should name the denied binary: {err}"
891        );
892    }
893
894    #[tokio::test]
895    async fn test_for_agent_shell_exec_allowed() {
896        let tool = make_agent_tool("test-agent", &["bash"]);
897        let result = tool.shell_exec("echo hello", 5_000).await;
898        assert!(
899            result.is_ok(),
900            "Agent with 'bash' permission should succeed"
901        );
902        assert!(result.unwrap().stdout.contains("hello"));
903    }
904
905    #[tokio::test]
906    async fn test_for_agent_shell_exec_denied() {
907        let tool = make_agent_tool("test-agent", &["ls"]); // no "bash"
908        let result = tool.shell_exec("echo hello", 5_000).await;
909        assert!(result.is_err());
910        let err = result.unwrap_err();
911        assert!(
912            err.contains("not allowed to execute"),
913            "Error should mention denial: {err}"
914        );
915        assert!(err.contains("bash"), "Error should name 'bash': {err}");
916    }
917
918    #[tokio::test]
919    async fn test_no_agent_name_bypasses_access_control() {
920        // ExecTool::new() (agent_name=None) should NOT check permissions,
921        // but shell mode must still be enabled in config.
922        let mut config = ExecConfig::default();
923        config.allow_shell_mode = true;  // Enable shell mode for this test
924        let access = AccessManager::new(); // empty — no permissions for anyone
925        let tool = ExecTool::new(Arc::new(config), Arc::new(Mutex::new(access)));
926        let result = tool.shell_exec("echo unrestricted", 5_000).await;
927        assert!(
928            result.is_ok(),
929            "Shell mode enabled + no agent_name = unrestricted execution"
930        );
931    }
932
933    #[test]
934    fn test_agent_name_set_correctly() {
935        let tool = make_agent_tool("my-agent", &[]);
936        assert_eq!(tool.agent_name.as_deref(), Some("my-agent"));
937    }
938
939    #[test]
940    fn test_new_has_no_agent_name() {
941        let tool = make_tool(vec![]);
942        assert!(tool.agent_name.is_none());
943    }
944}