"""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))