Newer
Older
navi-1 / navi / tools / terminal.py
"""Terminal tool — run shell commands locally.

If TERMINAL_ALLOWED_COMMANDS=* (default), any command is allowed and the shell
is invoked directly (supports pipes, redirects, subshells, etc.).

Set TERMINAL_ALLOWED_COMMANDS to a comma-separated list to restrict to specific
executables only (e.g. "ls,cat,git").
"""

import asyncio
import shlex

from navi.config import settings

from .base import Tool, ToolResult

_TIMEOUT = 180


class TerminalTool(Tool):
    name = "terminal"
    description = (
        "Run a shell command on the local machine. Full bash shell — "
        "pipes, redirects, env vars, multi-command chains all work. "
        "Use for system operations, package management, running processes, "
        "or any task more natural in shell than Python."
    )
    parameters = {
        "type": "object",
        "properties": {
            "command": {
                "type": "string",
                "description": "Shell command to execute",
            },
            "working_dir": {
                "type": "string",
                "description": "Working directory (optional, defaults to home directory)",
            },
            "timeout": {
                "type": "integer",
                "description": f"Timeout in seconds (default {_TIMEOUT})",
            },
        },
        "required": ["command"],
    }

    async def execute(self, params: dict) -> ToolResult:
        command = params["command"].strip()
        working_dir = params.get("working_dir") or None
        timeout = int(params.get("timeout") or _TIMEOUT)

        if not command:
            return ToolResult(success=False, output="Empty command.", error="empty_command")

        unrestricted = settings.terminal_allowed_commands.strip() == "*"

        if unrestricted:
            return await self._run_shell(command, working_dir, timeout)
        else:
            return await self._run_restricted(command, working_dir, timeout)

    async def _run_shell(self, command: str, cwd: str | None, timeout: int) -> ToolResult:
        """Run via shell — supports pipes, redirects, etc."""
        try:
            proc = await asyncio.create_subprocess_shell(
                command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=cwd,
            )
            try:
                stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
            except asyncio.TimeoutError:
                proc.kill()
                return ToolResult(success=False, output=f"Timed out after {timeout}s", error="timeout")

            output_parts = []
            if stdout:
                output_parts.append(stdout.decode(errors="replace"))
            if stderr:
                output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}")

            success = proc.returncode == 0
            return ToolResult(
                success=success,
                output="\n".join(output_parts) or "(no output)",
                metadata={"returncode": proc.returncode},
                error=None if success else f"Exit code {proc.returncode}",
            )
        except Exception as e:
            return ToolResult(success=False, output=f"Execution error: {e}", error=str(e))

    async def _run_restricted(self, command: str, cwd: str | None, timeout: int) -> ToolResult:
        """Run with allowlist check — exec directly, no shell features."""
        try:
            tokens = shlex.split(command)
        except ValueError as e:
            return ToolResult(success=False, output=f"Invalid command syntax: {e}", error=str(e))

        if not tokens:
            return ToolResult(success=False, output="Empty command.", error="empty_command")

        allowed = settings.terminal_allowed_commands_list
        if tokens[0] not in allowed:
            return ToolResult(
                success=False,
                output=f"Command '{tokens[0]}' is not in the allowed list. "
                       f"Allowed: {allowed}. Set TERMINAL_ALLOWED_COMMANDS=* to allow all.",
                error="not_allowed",
            )

        try:
            proc = await asyncio.create_subprocess_exec(
                *tokens,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=cwd,
            )
            try:
                stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
            except asyncio.TimeoutError:
                proc.kill()
                return ToolResult(success=False, output=f"Timed out after {timeout}s", error="timeout")

            output_parts = []
            if stdout:
                output_parts.append(stdout.decode(errors="replace"))
            if stderr:
                output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}")

            success = proc.returncode == 0
            return ToolResult(
                success=success,
                output="\n".join(output_parts) or "(no output)",
                metadata={"returncode": proc.returncode},
                error=None if success else f"Exit code {proc.returncode}",
            )
        except FileNotFoundError:
            return ToolResult(success=False, output=f"Command not found: {tokens[0]}", error="not_found")
        except Exception as e:
            return ToolResult(success=False, output=f"Execution error: {e}", error=str(e))