Files
nanobot/nanobot/agent/tools/shell.py
T

226 lines
8.1 KiB
Python
Raw Normal View History

2026-02-01 07:36:42 +00:00
"""Shell execution tool."""
import asyncio
import os
2026-02-02 21:14:29 +03:00
import re
import shutil
import sys
2026-02-02 21:14:29 +03:00
from pathlib import Path
2026-02-01 07:36:42 +00:00
from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.config.paths import get_media_dir
2026-02-01 07:36:42 +00:00
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
working_dir=StringSchema("Optional working directory for the command"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
minimum=1,
maximum=600,
),
required=["command"],
)
)
2026-02-01 07:36:42 +00:00
class ExecTool(Tool):
"""Tool to execute shell commands."""
2026-02-02 21:14:29 +03:00
def __init__(
self,
timeout: int = 60,
working_dir: str | None = None,
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
sandbox: str = "",
path_append: str = "",
2026-02-02 21:14:29 +03:00
):
2026-02-01 07:36:42 +00:00
self.timeout = timeout
self.working_dir = working_dir
self.sandbox = sandbox
2026-02-02 21:14:29 +03:00
self.deny_patterns = deny_patterns or [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb
2026-02-02 21:14:29 +03:00
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
2026-02-01 07:36:42 +00:00
@property
def name(self) -> str:
return "exec"
_MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000
2026-02-01 07:36:42 +00:00
@property
def description(self) -> str:
return "Execute a shell command and return its output. Use with caution."
2026-02-01 07:36:42 +00:00
@property
def exclusive(self) -> bool:
return True
async def execute(
self, command: str, working_dir: str | None = None,
timeout: int | None = None, **kwargs: Any,
) -> str:
2026-02-01 07:36:42 +00:00
cwd = working_dir or self.working_dir or os.getcwd()
2026-02-02 21:14:29 +03:00
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
if self.sandbox:
workspace = self.working_dir or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
env = self._build_env()
bash = shutil.which("bash") or "/bin/bash"
2026-02-01 07:36:42 +00:00
try:
process = await asyncio.create_subprocess_exec(
bash, "-l", "-c", command,
2026-02-01 07:36:42 +00:00
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
2026-02-01 07:36:42 +00:00
)
2026-02-01 07:36:42 +00:00
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=effective_timeout,
2026-02-01 07:36:42 +00:00
)
except asyncio.TimeoutError:
process.kill()
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
finally:
if sys.platform != "win32":
try:
os.waitpid(process.pid, os.WNOHANG)
except (ProcessLookupError, ChildProcessError) as e:
logger.debug("Process already reaped or not found: {}", e)
return f"Error: Command timed out after {effective_timeout} seconds"
2026-02-01 07:36:42 +00:00
output_parts = []
2026-02-01 07:36:42 +00:00
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
2026-02-01 07:36:42 +00:00
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
2026-02-01 07:36:42 +00:00
result = "\n".join(output_parts) if output_parts else "(no output)"
# Head + tail truncation to preserve both start and end of output
max_len = self._MAX_OUTPUT
2026-02-01 07:36:42 +00:00
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
2026-02-01 07:36:42 +00:00
return result
2026-02-01 07:36:42 +00:00
except Exception as e:
return f"Error executing command: {str(e)}"
2026-02-02 21:14:29 +03:00
def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution.
Uses HOME so that ``bash -l`` sources the user's profile (which sets
PATH and other essentials). Only PATH is extended with *path_append*;
the parent process's environment is **not** inherited, preventing
secrets in env vars from leaking to LLM-generated commands.
"""
home = os.environ.get("HOME", "/tmp")
env: dict[str, str] = {
"HOME": home,
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"),
}
if self.path_append:
# Seed PATH so the login shell can append to it.
env["PATH"] = self.path_append
return env
2026-02-02 21:14:29 +03:00
def _guard_command(self, command: str, cwd: str) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return "Error: Command blocked by safety guard (dangerous pattern detected)"
if self.allow_patterns:
if not any(re.search(p, lower) for p in self.allow_patterns):
return "Error: Command blocked by safety guard (not in allowlist)"
from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd):
return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace:
2026-02-02 21:14:29 +03:00
if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)"
cwd_path = Path(cwd).resolve()
for raw in self._extract_absolute_paths(cmd):
2026-02-02 21:14:29 +03:00
try:
expanded = os.path.expandvars(raw.strip())
p = Path(expanded).expanduser().resolve()
2026-02-02 21:14:29 +03:00
except Exception:
continue
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
):
2026-02-02 21:14:29 +03:00
return "Error: Command blocked by safety guard (path outside working dir)"
return None
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
2026-03-11 15:43:04 +00:00
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths