merge: resolve conflict with main (add mcp + homeassistant extras)

This commit is contained in:
0xbyt4
2026-03-03 14:52:22 +03:00
81 changed files with 8138 additions and 776 deletions

View File

@@ -60,7 +60,7 @@ def detect_dangerous_command(command: str) -> tuple:
"""
command_lower = command.lower()
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, command_lower, re.IGNORECASE):
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
pattern_key = pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20]
return (True, pattern_key, description)
return (False, None, None)

View File

@@ -20,6 +20,7 @@ Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows.
import json
import logging
import os
import platform
import signal
import socket
import subprocess
@@ -28,6 +29,8 @@ import tempfile
import threading
import time
import uuid
_IS_WINDOWS = platform.system() == "Windows"
from typing import Any, Dict, List, Optional
# Availability gate: UDS requires a POSIX OS
@@ -405,7 +408,7 @@ def execute_code(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
# --- Poll loop: watch for exit, timeout, and interrupt ---
@@ -514,7 +517,10 @@ def execute_code(
def _kill_process_group(proc, escalate: bool = False):
"""Kill the child and its entire process group."""
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
if _IS_WINDOWS:
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
try:
proc.kill()
@@ -527,7 +533,10 @@ def _kill_process_group(proc, escalate: bool = False):
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
if _IS_WINDOWS:
proc.kill()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
proc.kill()

View File

@@ -38,7 +38,7 @@ DELEGATE_BLOCKED_TOOLS = frozenset([
MAX_CONCURRENT_CHILDREN = 3
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
DEFAULT_MAX_ITERATIONS = 25
DEFAULT_MAX_ITERATIONS = 50
DEFAULT_TOOLSETS = ["terminal", "file", "web"]
@@ -531,8 +531,8 @@ DELEGATE_TASK_SCHEMA = {
"max_iterations": {
"type": "integer",
"description": (
"Max tool-calling turns per subagent (default: 25). "
"Lower for simple tasks, higher for complex ones."
"Max tool-calling turns per subagent (default: 50). "
"Only set lower for simple tasks."
),
},
},

View File

@@ -1,7 +1,8 @@
"""Docker execution environment wrapping mini-swe-agent's DockerEnvironment.
Adds security hardening, configurable resource limits (CPU, memory, disk),
and optional filesystem persistence via `docker commit`/`docker create --image`.
Adds security hardening (cap-drop ALL, no-new-privileges, PID limits),
configurable resource limits (CPU, memory, disk), and optional filesystem
persistence via bind mounts.
"""
import logging
@@ -19,13 +20,15 @@ logger = logging.getLogger(__name__)
# Security flags applied to every container
# Security flags applied to every container.
# The container itself is the security boundary (isolated from host).
# We drop all capabilities, block privilege escalation, and limit PIDs.
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
_SECURITY_ARGS = [
"--read-only",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--pids-limit", "256",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m",
"--tmpfs", "/tmp:rw,nosuid,size=512m",
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
"--tmpfs", "/run:rw,noexec,nosuid,size=64m",
]
@@ -37,12 +40,13 @@ _storage_opt_ok: Optional[bool] = None # cached result across instances
class DockerEnvironment(BaseEnvironment):
"""Hardened Docker container execution with resource limits and persistence.
Security: read-only root, all capabilities dropped, no privilege escalation,
PID limits, tmpfs for writable scratch. Writable overlay for /home and cwd
via tmpfs or bind mounts.
Security: all capabilities dropped, no privilege escalation, PID limits,
size-limited tmpfs for scratch dirs. The container itself is the security
boundary — the filesystem inside is writable so agents can install packages
(pip, npm, apt) as needed. Writable workspace via tmpfs or bind mounts.
Persistence: when enabled, `docker commit` saves the container state on
cleanup, and the next creation restores from that image.
Persistence: when enabled, bind mounts preserve /workspace and /root
across container restarts.
"""
def __init__(
@@ -114,9 +118,9 @@ class DockerEnvironment(BaseEnvironment):
"--tmpfs", "/root:rw,exec,size=1g",
]
# All containers get full security hardening (read-only root + writable
# mounts for the workspace). Persistence uses Docker volumes, not
# filesystem layer commits, so --read-only is always safe.
# All containers get security hardening (capabilities dropped, no privilege
# escalation, PID limits). The container filesystem is writable so agents
# can install packages as needed.
# User-configured volume mounts (from config.yaml docker_volumes)
volume_args = []
for vol in (volumes or []):

View File

@@ -1,14 +1,54 @@
"""Local execution environment with interrupt support and non-blocking I/O."""
import os
import platform
import shutil
import signal
import subprocess
import threading
import time
_IS_WINDOWS = platform.system() == "Windows"
from tools.environments.base import BaseEnvironment
def _find_shell() -> str:
"""Find the best shell for command execution.
On Unix: uses $SHELL, falls back to bash.
On Windows: uses Git Bash (bundled with Git for Windows).
Raises RuntimeError if no suitable shell is found on Windows.
"""
if not _IS_WINDOWS:
return os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
# Windows: look for Git Bash (installed with Git for Windows).
# Allow override via env var (same pattern as Claude Code).
custom = os.environ.get("HERMES_GIT_BASH_PATH")
if custom and os.path.isfile(custom):
return custom
# shutil.which finds bash.exe if Git\bin is on PATH
found = shutil.which("bash")
if found:
return found
# Check common Git for Windows install locations
for candidate in (
os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
):
if candidate and os.path.isfile(candidate):
return candidate
raise RuntimeError(
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
"Install it from: https://git-scm.com/download/win\n"
"Or set HERMES_GIT_BASH_PATH to your bash.exe location."
)
# Noise lines emitted by interactive shells when stdin is not a terminal.
# Filtered from output to keep tool results clean.
_SHELL_NOISE_SUBSTRINGS = (
@@ -63,7 +103,7 @@ class LocalEnvironment(BaseEnvironment):
# tools like nvm, pyenv, and cargo install their init scripts.
# -l alone isn't enough: .profile sources .bashrc, but the guard
# returns early because the shell isn't interactive.
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
user_shell = _find_shell()
proc = subprocess.Popen(
[user_shell, "-lic", exec_command],
text=True,
@@ -74,7 +114,7 @@ class LocalEnvironment(BaseEnvironment):
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
if stdin_data is not None:
@@ -107,12 +147,15 @@ class LocalEnvironment(BaseEnvironment):
while proc.poll() is None:
if _interrupt_event.is_set():
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
if _IS_WINDOWS:
proc.terminate()
else:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
proc.kill()
reader.join(timeout=2)
@@ -122,7 +165,10 @@ class LocalEnvironment(BaseEnvironment):
}
if time.monotonic() > deadline:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
if _IS_WINDOWS:
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
proc.kill()
reader.join(timeout=2)

View File

@@ -107,7 +107,7 @@ class ReadResult:
similar_files: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items() if v is not None and v != [] and v != ""}
return {k: v for k, v in self.__dict__.items() if v is not None and v != []}
@dataclass

1047
tools/mcp_tool.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ Usage:
import json
import logging
import os
import platform
import shlex
import shutil
import signal
@@ -39,6 +40,9 @@ import subprocess
import threading
import time
import uuid
_IS_WINDOWS = platform.system() == "Windows"
from tools.environments.local import _find_shell
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -145,11 +149,13 @@ class ProcessRegistry:
# Try PTY mode for interactive CLI tools
try:
import ptyprocess
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
user_shell = _find_shell()
pty_env = os.environ | (env_vars or {})
pty_env["PYTHONUNBUFFERED"] = "1"
pty_proc = ptyprocess.PtyProcess.spawn(
[user_shell, "-lic", command],
cwd=session.cwd,
env=os.environ | (env_vars or {}),
env=pty_env,
dimensions=(30, 120),
)
session.pid = pty_proc.pid
@@ -181,18 +187,23 @@ class ProcessRegistry:
# Standard Popen path (non-PTY or PTY fallback)
# Use the user's login shell for consistency with LocalEnvironment --
# ensures rc files are sourced and user tools are available.
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
user_shell = _find_shell()
# Force unbuffered output for Python scripts so progress is visible
# during background execution (libraries like tqdm/datasets buffer when
# stdout is a pipe, hiding output from process(action="poll")).
bg_env = os.environ | (env_vars or {})
bg_env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
[user_shell, "-lic", command],
text=True,
cwd=session.cwd,
env=os.environ | (env_vars or {}),
env=bg_env,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
preexec_fn=os.setsid,
preexec_fn=None if _IS_WINDOWS else os.setsid,
)
session.process = proc
@@ -544,7 +555,10 @@ class ProcessRegistry:
elif session.process:
# Local process -- kill the process group
try:
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
if _IS_WINDOWS:
session.process.terminate()
else:
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
session.process.kill()
elif session.env_ref and session.pid:

View File

@@ -520,8 +520,8 @@ class ClawHubSource(SkillSource):
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/search",
params={"q": query, "limit": limit},
f"{self.BASE_URL}/skills",
params={"search": query, "limit": limit},
timeout=15,
)
if resp.status_code != 200:
@@ -530,82 +530,154 @@ class ClawHubSource(SkillSource):
except (httpx.HTTPError, json.JSONDecodeError):
return []
skills_data = data.get("skills", data) if isinstance(data, dict) else data
skills_data = data.get("items", data) if isinstance(data, dict) else data
if not isinstance(skills_data, list):
return []
results = []
for item in skills_data[:limit]:
name = item.get("name", item.get("slug", ""))
if not name:
slug = item.get("slug")
if not slug:
continue
meta = SkillMeta(
name=name,
description=item.get("description", ""),
display_name = item.get("displayName") or item.get("name") or slug
summary = item.get("summary") or item.get("description") or ""
tags = item.get("tags", [])
if not isinstance(tags, list):
tags = []
results.append(SkillMeta(
name=display_name,
description=summary,
source="clawhub",
identifier=item.get("slug", name),
identifier=slug,
trust_level="community",
tags=item.get("tags", []),
)
results.append(meta)
tags=[str(t) for t in tags],
))
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
return results
def fetch(self, identifier: str) -> Optional[SkillBundle]:
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/{identifier}/versions/latest/files",
timeout=30,
)
if resp.status_code != 200:
return None
data = resp.json()
except (httpx.HTTPError, json.JSONDecodeError):
slug = identifier.split("/")[-1]
skill_data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
if not isinstance(skill_data, dict):
return None
files: Dict[str, str] = {}
file_list = data.get("files", data) if isinstance(data, dict) else data
if isinstance(file_list, list):
for f in file_list:
fname = f.get("name", f.get("path", ""))
content = f.get("content", "")
if fname and content:
files[fname] = content
elif isinstance(file_list, dict):
files = {k: v for k, v in file_list.items() if isinstance(v, str)}
latest_version = self._resolve_latest_version(slug, skill_data)
if not latest_version:
logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug)
return None
version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}")
if not isinstance(version_data, dict):
return None
files = self._extract_files(version_data)
if "SKILL.md" not in files:
logger.warning(
"ClawHub fetch for %s resolved version %s but no inline/raw file content was available",
slug,
latest_version,
)
return None
return SkillBundle(
name=identifier.split("/")[-1] if "/" in identifier else identifier,
name=slug,
files=files,
source="clawhub",
identifier=identifier,
identifier=slug,
trust_level="community",
)
def inspect(self, identifier: str) -> Optional[SkillMeta]:
slug = identifier.split("/")[-1]
data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
if not isinstance(data, dict):
return None
tags = data.get("tags", [])
if not isinstance(tags, list):
tags = []
return SkillMeta(
name=data.get("displayName") or data.get("name") or data.get("slug") or slug,
description=data.get("summary") or data.get("description") or "",
source="clawhub",
identifier=data.get("slug") or slug,
trust_level="community",
tags=[str(t) for t in tags],
)
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
try:
resp = httpx.get(
f"{self.BASE_URL}/skills/{identifier}",
timeout=15,
)
resp = httpx.get(url, timeout=timeout)
if resp.status_code != 200:
return None
data = resp.json()
return resp.json()
except (httpx.HTTPError, json.JSONDecodeError):
return None
return SkillMeta(
name=data.get("name", identifier),
description=data.get("description", ""),
source="clawhub",
identifier=identifier,
trust_level="community",
tags=data.get("tags", []),
)
def _resolve_latest_version(self, slug: str, skill_data: Dict[str, Any]) -> Optional[str]:
latest = skill_data.get("latestVersion")
if isinstance(latest, dict):
version = latest.get("version")
if isinstance(version, str) and version:
return version
tags = skill_data.get("tags")
if isinstance(tags, dict):
latest_tag = tags.get("latest")
if isinstance(latest_tag, str) and latest_tag:
return latest_tag
versions_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions")
if isinstance(versions_data, list) and versions_data:
first = versions_data[0]
if isinstance(first, dict):
version = first.get("version")
if isinstance(version, str) and version:
return version
return None
def _extract_files(self, version_data: Dict[str, Any]) -> Dict[str, str]:
files: Dict[str, str] = {}
file_list = version_data.get("files")
if isinstance(file_list, dict):
return {k: v for k, v in file_list.items() if isinstance(v, str)}
if not isinstance(file_list, list):
return files
for file_meta in file_list:
if not isinstance(file_meta, dict):
continue
fname = file_meta.get("path") or file_meta.get("name")
if not fname or not isinstance(fname, str):
continue
inline_content = file_meta.get("content")
if isinstance(inline_content, str):
files[fname] = inline_content
continue
raw_url = file_meta.get("rawUrl") or file_meta.get("downloadUrl") or file_meta.get("url")
if isinstance(raw_url, str) and raw_url.startswith("http"):
content = self._fetch_text(raw_url)
if content is not None:
files[fname] = content
return files
def _fetch_text(self, url: str) -> Optional[str]:
try:
resp = httpx.get(url, timeout=20)
if resp.status_code == 200:
return resp.text
except httpx.HTTPError:
return None
return None
# ---------------------------------------------------------------------------

View File

@@ -443,7 +443,33 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
# If a specific file path is requested, read that instead
if file_path and skill_dir:
# Security: Prevent path traversal attacks
normalized_path = Path(file_path)
if ".." in normalized_path.parts:
return json.dumps({
"success": False,
"error": "Path traversal ('..') is not allowed.",
"hint": "Use a relative path within the skill directory"
}, ensure_ascii=False)
target_file = skill_dir / file_path
# Security: Verify resolved path is still within skill directory
try:
resolved = target_file.resolve()
skill_dir_resolved = skill_dir.resolve()
if not str(resolved).startswith(str(skill_dir_resolved) + "/") and resolved != skill_dir_resolved:
return json.dumps({
"success": False,
"error": "Path escapes skill directory boundary.",
"hint": "Use a relative path within the skill directory"
}, ensure_ascii=False)
except (OSError, ValueError):
return json.dumps({
"success": False,
"error": f"Invalid file path: '{file_path}'",
"hint": "Use a valid relative path within the skill directory"
}, ensure_ascii=False)
if not target_file.exists():
# List available files in the skill directory, organized by type
available_files = {

View File

@@ -346,7 +346,9 @@ Do NOT use sed/awk to edit files — use patch instead.
Do NOT use echo/cat heredoc to create files — use write_file instead.
Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
Background processes: Set background=true to get a session_id, then use the 'process' tool to poll/wait/kill/write.
Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for everything that finishes.
Background: ONLY for long-running servers, watchers, or processes that never exit. Set background=true to get a session_id, then use process(action="wait") to block until done — it returns instantly on completion, same as foreground. Use process(action="poll") only when you need a progress check without blocking.
Do NOT use background for scripts, builds, or installs — foreground with a generous timeout is always better (fewer tool calls, instant results).
Working directory: Use 'workdir' for per-command cwd.
PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).
@@ -435,7 +437,7 @@ def _get_env_config() -> Dict[str, Any]:
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"cwd": cwd,
"timeout": int(os.getenv("TERMINAL_TIMEOUT", "60")),
"timeout": int(os.getenv("TERMINAL_TIMEOUT", "180")),
"lifetime_seconds": int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")),
# SSH-specific config
"ssh_host": os.getenv("TERMINAL_SSH_HOST", ""),
@@ -636,19 +638,18 @@ def get_active_environments_info() -> Dict[str, Any]:
"workdirs": {},
}
# Calculate total disk usage
# Calculate total disk usage (per-task to avoid double-counting)
total_size = 0
for task_id in _active_environments.keys():
# Check sandbox and workdir sizes
scratch_dir = _get_scratch_dir()
for pattern in [f"hermes-*{task_id[:8]}*"]:
import glob
for path in glob.glob(str(scratch_dir / "hermes-*")):
try:
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
total_size += size
except OSError:
pass
pattern = f"hermes-*{task_id[:8]}*"
import glob
for path in glob.glob(str(scratch_dir / pattern)):
try:
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
total_size += size
except OSError:
pass
info["total_disk_usage_mb"] = round(total_size / (1024 * 1024), 2)
return info
@@ -1154,12 +1155,12 @@ TERMINAL_SCHEMA = {
},
"background": {
"type": "boolean",
"description": "Whether to run the command in the background (default: false)",
"description": "ONLY for servers/watchers that never exit. For scripts, builds, installs — use foreground with timeout instead (it returns instantly when done).",
"default": False
},
"timeout": {
"type": "integer",
"description": "Command timeout in seconds (optional)",
"description": "Max seconds to wait (default: 180). Returns INSTANTLY when command finishes — set high for long tasks, you won't wait unnecessarily.",
"minimum": 1
},
"workdir": {