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

This commit is contained in:
0xbyt4 2026-03-03 14:52:22 +03:00
commit aefc330b8f
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": {