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

import asyncio
import shlex

from navi.config import settings

from .base import Tool, ToolResult

_TIMEOUT = 30


class TerminalTool(Tool):
    name = "terminal"
    description = (
        "Execute a shell command. Only commands whose first token is in the allowed list "
        "can be run. Returns stdout and stderr."
    )
    parameters = {
        "type": "object",
        "properties": {
            "command": {
                "type": "string",
                "description": "Shell command to execute",
            },
            "working_dir": {
                "type": "string",
                "description": "Working directory (optional, defaults to /tmp)",
            },
        },
        "required": ["command"],
    }

    async def execute(self, params: dict) -> ToolResult:
        command = params["command"].strip()
        working_dir = params.get("working_dir", "/tmp")

        # Safety: check first token against allowlist
        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: {allowed}",
                error="not_allowed",
            )

        try:
            proc = await asyncio.create_subprocess_exec(
                *tokens,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=working_dir,
            )
            try:
                stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT)
            except asyncio.TimeoutError:
                proc.kill()
                return ToolResult(
                    success=False,
                    output=f"Command 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
            output = "\n".join(output_parts) or "(no output)"
            return ToolResult(
                success=success,
                output=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))