diff --git a/.env.example b/.env.example index 29a20a6..3e76aef 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,17 @@ OLLAMA_HOST=http://localhost:11434 -OLLAMA_DEFAULT_MODEL=llama3.2 +OLLAMA_DEFAULT_MODEL=gemma4:e2b-it-q4_K_M OPENAI_API_KEY= ANTHROPIC_API_KEY= # Filesystem tool: comma-separated allowed root paths -FS_ALLOWED_PATHS=/tmp,/home +FS_ALLOWED_PATHS=/tmp,/home,/etc,/var,/opt -# Terminal tool: comma-separated allowed commands -TERMINAL_ALLOWED_COMMANDS=ls,cat,echo,pwd,git,python3,pip +# Terminal tool: "*" = allow all commands (default, suitable for local use) +# Restrict with a comma-separated list, e.g.: ls,cat,git,systemctl +TERMINAL_ALLOWED_COMMANDS=* + +# SSH tool: path to JSON file with named connections (see ssh_hosts.json.example) +SSH_HOSTS_FILE=ssh_hosts.json LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 8b22c1b..46fb772 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +ssh_hosts.json .venv/ __pycache__/ *.pyc diff --git a/navi/config.py b/navi/config.py index 406d73d..fedd6cc 100644 --- a/navi/config.py +++ b/navi/config.py @@ -10,8 +10,15 @@ openai_api_key: str = "" anthropic_api_key: str = "" - fs_allowed_paths: str = "/tmp" - terminal_allowed_commands: str = "ls,cat,echo,pwd,git,python3" + # Filesystem tool: comma-separated allowed root paths + fs_allowed_paths: str = "/tmp,/home,/etc,/var,/opt" + + # Terminal tool: "*" = allow all commands (recommended for local use) + # or comma-separated list of allowed executables, e.g. "ls,cat,git" + terminal_allowed_commands: str = "*" + + # SSH tool: path to JSON file with named connections + ssh_hosts_file: str = "ssh_hosts.json" log_level: str = "INFO" diff --git a/navi/core/registry.py b/navi/core/registry.py index 7f4c75c..0765a98 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -10,6 +10,7 @@ CodeExecTool, FilesystemTool, HttpRequestTool, + SshExecTool, TerminalTool, Tool, WebSearchTool, @@ -77,6 +78,7 @@ tools.register(HttpRequestTool()) tools.register(CodeExecTool()) tools.register(TerminalTool()) + tools.register(SshExecTool()) profiles = ProfileRegistry() for p in ALL_PROFILES: diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index bc0b1b2..db7d88f 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -19,7 +19,7 @@ - When troubleshooting, gather information first (logs, status) before making changes - Document any changes you make """, - enabled_tools=["terminal", "filesystem", "http_request", "web_search"], + enabled_tools=["terminal", "filesystem", "http_request", "web_search", "ssh_exec"], model="gemma4:e2b-it-q4_K_M", temperature=0.2, ) diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index bd47c0b..4c5ddb5 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -16,7 +16,7 @@ Always confirm before making irreversible changes to device state or automation configuration. When writing automations, prefer clear, well-commented YAML. """, - enabled_tools=["http_request", "filesystem", "code_exec", "terminal"], + enabled_tools=["http_request", "filesystem", "code_exec", "terminal", "ssh_exec"], model="gemma4:e2b-it-q4_K_M", temperature=0.3, ) diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 6aa059e..5270483 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -2,6 +2,7 @@ from .code_exec import CodeExecTool from .filesystem import FilesystemTool from .http_request import HttpRequestTool +from .ssh_exec import SshExecTool from .terminal import TerminalTool from .web_search import WebSearchTool @@ -13,4 +14,5 @@ "HttpRequestTool", "CodeExecTool", "TerminalTool", + "SshExecTool", ] diff --git a/navi/tools/ssh_exec.py b/navi/tools/ssh_exec.py new file mode 100644 index 0000000..d27f0c2 --- /dev/null +++ b/navi/tools/ssh_exec.py @@ -0,0 +1,162 @@ +"""SSH tool — execute commands on remote hosts via SSH. + +Connections are defined in ssh_hosts.json (see .env.example for path config). +The tool also accepts inline host/user/key parameters for one-off connections. + +ssh_hosts.json example: +{ + "prod": { + "host": "1.2.3.4", + "port": 22, + "username": "root", + "client_keys": ["~/.ssh/id_rsa"], + "known_hosts": null + }, + "staging": { + "host": "staging.example.com", + "username": "ubuntu" + } +} + +known_hosts: + null — use system ~/.ssh/known_hosts + "none" — skip host key verification (useful for fresh VPS, not recommended for prod) +""" + +import asyncio +import json +import os +from pathlib import Path + +import asyncssh + +from navi.config import settings + +from .base import Tool, ToolResult + +_TIMEOUT = 60 + + +def _load_hosts() -> dict: + path = Path(settings.ssh_hosts_file).expanduser() + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +class SshExecTool(Tool): + name = "ssh_exec" + description = ( + "Execute a command on a remote server via SSH. " + "Use a named connection from ssh_hosts.json or specify host/username directly." + ) + parameters = { + "type": "object", + "properties": { + "connection": { + "type": "string", + "description": ( + "Named connection from ssh_hosts.json (e.g. 'prod'), " + "or 'user@host' for a direct connection using default SSH keys." + ), + }, + "command": { + "type": "string", + "description": "Shell command to run on the remote host", + }, + "timeout": { + "type": "integer", + "description": f"Timeout in seconds (default {_TIMEOUT})", + }, + }, + "required": ["connection", "command"], + } + + async def execute(self, params: dict) -> ToolResult: + connection = params["connection"].strip() + command = params["command"].strip() + timeout = int(params.get("timeout") or _TIMEOUT) + + connect_kwargs = self._resolve_connection(connection) + if connect_kwargs is None: + hosts = list(_load_hosts().keys()) + hint = f"Available named connections: {hosts}" if hosts else "No ssh_hosts.json found." + return ToolResult( + success=False, + output=f"Unknown connection '{connection}'. {hint}", + error="unknown_connection", + ) + + try: + async with asyncssh.connect(**connect_kwargs) as conn: + result = await asyncio.wait_for( + conn.run(command, check=False), + timeout=timeout, + ) + + output_parts = [] + if result.stdout: + output_parts.append(result.stdout) + if result.stderr: + output_parts.append(f"[stderr]\n{result.stderr}") + + success = result.exit_status == 0 + return ToolResult( + success=success, + output="\n".join(output_parts) or "(no output)", + metadata={"exit_status": result.exit_status, "host": connect_kwargs.get("host")}, + error=None if success else f"Exit status {result.exit_status}", + ) + except asyncssh.DisconnectError as e: + return ToolResult(success=False, output=f"SSH disconnected: {e}", error=str(e)) + except asyncssh.PermissionDenied: + return ToolResult(success=False, output="SSH permission denied. Check credentials.", error="permission_denied") + except (TimeoutError, asyncio.TimeoutError): + return ToolResult(success=False, output=f"SSH command timed out after {timeout}s", error="timeout") + except Exception as e: + return ToolResult(success=False, output=f"SSH error: {e}", error=str(e)) + + def _resolve_connection(self, connection: str) -> dict | None: + hosts = _load_hosts() + + # Named connection + if connection in hosts: + cfg = hosts[connection] + return self._build_kwargs(cfg) + + # Inline user@host + if "@" in connection: + parts = connection.split("@", 1) + username, host = parts[0], parts[1] + return self._build_kwargs({"host": host, "username": username}) + + return None + + def _build_kwargs(self, cfg: dict) -> dict: + kwargs: dict = { + "host": cfg["host"], + "port": int(cfg.get("port", 22)), + "username": cfg.get("username", os.environ.get("USER", "root")), + } + + client_keys = cfg.get("client_keys") + if client_keys: + kwargs["client_keys"] = [str(Path(k).expanduser()) for k in client_keys] + + password = cfg.get("password") + if password: + kwargs["password"] = password + + known_hosts = cfg.get("known_hosts", None) + if known_hosts == "none": + kwargs["known_hosts"] = None + elif known_hosts is not None: + kwargs["known_hosts"] = str(Path(known_hosts).expanduser()) + # else: omit → asyncssh uses system known_hosts + + return kwargs + + diff --git a/navi/tools/terminal.py b/navi/tools/terminal.py index 2711aa4..4b852c5 100644 --- a/navi/tools/terminal.py +++ b/navi/tools/terminal.py @@ -1,4 +1,11 @@ -"""Terminal tool — run shell commands from an allowlist.""" +"""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 @@ -7,14 +14,15 @@ from .base import Tool, ToolResult -_TIMEOUT = 30 +_TIMEOUT = 60 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." + "Execute a shell command on the local machine. " + "Supports pipes, redirects, and multi-command chains. " + "Returns stdout and stderr." ) parameters = { "type": "object", @@ -25,7 +33,11 @@ }, "working_dir": { "type": "string", - "description": "Working directory (optional, defaults to /tmp)", + "description": "Working directory (optional, defaults to home directory)", + }, + "timeout": { + "type": "integer", + "description": f"Timeout in seconds (default {_TIMEOUT})", }, }, "required": ["command"], @@ -33,9 +45,52 @@ async def execute(self, params: dict) -> ToolResult: command = params["command"].strip() - working_dir = params.get("working_dir", "/tmp") + working_dir = params.get("working_dir") or None + timeout = int(params.get("timeout") or _TIMEOUT) - # Safety: check first token against allowlist + 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: @@ -48,7 +103,8 @@ if tokens[0] not in allowed: return ToolResult( success=False, - output=f"Command '{tokens[0]}' is not in the allowed list: {allowed}", + output=f"Command '{tokens[0]}' is not in the allowed list. " + f"Allowed: {allowed}. Set TERMINAL_ALLOWED_COMMANDS=* to allow all.", error="not_allowed", ) @@ -57,17 +113,13 @@ *tokens, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=working_dir, + cwd=cwd, ) try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) + 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", - ) + return ToolResult(success=False, output=f"Timed out after {timeout}s", error="timeout") output_parts = [] if stdout: @@ -76,18 +128,13 @@ 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, + 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", - ) + 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)) diff --git a/pyproject.toml b/pyproject.toml index 451bf59..530263f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ # HTTP / tools "httpx>=0.27", + "asyncssh>=2.14", "ddgs>=1.0", # Config diff --git a/ssh_hosts.json.example b/ssh_hosts.json.example new file mode 100644 index 0000000..a902255 --- /dev/null +++ b/ssh_hosts.json.example @@ -0,0 +1,14 @@ +{ + "myvps": { + "host": "1.2.3.4", + "port": 22, + "username": "root", + "client_keys": ["~/.ssh/id_rsa"], + "known_hosts": null + }, + "staging": { + "host": "staging.example.com", + "username": "ubuntu", + "known_hosts": "none" + } +}