Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-14 18:26:05 -05:00
commit bf54f1fb2f
83 changed files with 5435 additions and 470 deletions

View file

@ -87,7 +87,7 @@ DANGEROUS_PATTERNS = [
(r'\bDELETE\s+FROM\b(?!.*\bWHERE\b)', "SQL DELETE without WHERE"),
(r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"),
(r'>\s*/etc/', "overwrite system config"),
(r'\bsystemctl\s+(stop|disable|mask)\b', "stop/disable system service"),
(r'\bsystemctl\s+(-[^\s]+\s+)*(stop|restart|disable|mask)\b', "stop/restart system service"),
(r'\bkill\s+-9\s+-1\b', "kill all processes"),
(r'\bpkill\s+-9\b', "force kill processes"),
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
@ -101,6 +101,11 @@ DANGEROUS_PATTERNS = [
(r'\bxargs\s+.*\brm\b', "xargs with rm"),
(r'\bfind\b.*-exec\s+(/\S*/)?rm\b', "find -exec rm"),
(r'\bfind\b.*-delete\b', "find -delete"),
# Gateway lifecycle protection: prevent the agent from killing its own
# gateway process. These commands trigger a gateway restart/stop that
# terminates all running agents mid-work.
(r'\bhermes\s+gateway\s+(stop|restart)\b', "stop/restart hermes gateway (kills running agents)"),
(r'\bhermes\s+update\b', "hermes update (restarts gateway, kills running agents)"),
# Gateway protection: never start gateway outside systemd management
(r'gateway\s+run\b.*(&\s*$|&\s*;|\bdisown\b|\bsetsid\b)', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
(r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),

View file

@ -1748,7 +1748,7 @@ def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str:
try:
tab_info = _ensure_tab(task_id or "default")
tab_id = tab_info.get("tab_id") or tab_info.get("id")
resp = _post(f"/tabs/{tab_id}/eval", body={"expression": expression})
resp = _post(f"/tabs/{tab_id}/evaluate", body={"expression": expression, "userId": tab_info["user_id"]})
# Camofox returns the result in a JSON envelope
raw_result = resp.get("result") if isinstance(resp, dict) else resp

View file

@ -219,6 +219,58 @@ def _sanitize_error(text: str) -> str:
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
# ---------------------------------------------------------------------------
# MCP tool description content scanning
# ---------------------------------------------------------------------------
# Patterns that indicate potential prompt injection in MCP tool descriptions.
# These are WARNING-level — we log but don't block, since false positives
# would break legitimate MCP servers.
_MCP_INJECTION_PATTERNS = [
(re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.I),
"prompt override attempt ('ignore previous instructions')"),
(re.compile(r"you\s+are\s+now\s+a", re.I),
"identity override attempt ('you are now a...')"),
(re.compile(r"your\s+new\s+(task|role|instructions?)\s+(is|are)", re.I),
"task override attempt"),
(re.compile(r"system\s*:\s*", re.I),
"system prompt injection attempt"),
(re.compile(r"<\s*(system|human|assistant)\s*>", re.I),
"role tag injection attempt"),
(re.compile(r"do\s+not\s+(tell|inform|mention|reveal)", re.I),
"concealment instruction"),
(re.compile(r"(curl|wget|fetch)\s+https?://", re.I),
"network command in description"),
(re.compile(r"base64\.(b64decode|decodebytes)", re.I),
"base64 decode reference"),
(re.compile(r"exec\s*\(|eval\s*\(", re.I),
"code execution reference"),
(re.compile(r"import\s+(subprocess|os|shutil|socket)", re.I),
"dangerous import reference"),
]
def _scan_mcp_description(server_name: str, tool_name: str, description: str) -> List[str]:
"""Scan an MCP tool description for prompt injection patterns.
Returns a list of finding strings (empty = clean).
"""
findings = []
if not description:
return findings
for pattern, reason in _MCP_INJECTION_PATTERNS:
if pattern.search(description):
findings.append(reason)
if findings:
logger.warning(
"MCP server '%s' tool '%s': suspicious description content — %s. "
"Description: %.200s",
server_name, tool_name, "; ".join(findings),
description,
)
return findings
def _prepend_path(env: dict, directory: str) -> dict:
"""Prepend *directory* to env PATH if it is not already present."""
updated = dict(env or {})
@ -798,6 +850,9 @@ class MCPServerTask:
from toolsets import TOOLSETS
async with self._refresh_lock:
# Capture old tool names for change diff
old_tool_names = set(self._registered_tool_names)
# 1. Fetch current tool list from server
tools_result = await self.session.list_tools()
new_mcp_tools = tools_result.tools if hasattr(tools_result, "tools") else []
@ -817,10 +872,26 @@ class MCPServerTask:
self.name, self, self._config
)
logger.info(
"MCP server '%s': dynamically refreshed %d tool(s)",
self.name, len(self._registered_tool_names),
)
# 5. Log what changed (user-visible notification)
new_tool_names = set(self._registered_tool_names)
added = new_tool_names - old_tool_names
removed = old_tool_names - new_tool_names
changes = []
if added:
changes.append(f"added: {', '.join(sorted(added))}")
if removed:
changes.append(f"removed: {', '.join(sorted(removed))}")
if changes:
logger.warning(
"MCP server '%s': tools changed dynamically — %s. "
"Verify these changes are expected.",
self.name, "; ".join(changes),
)
else:
logger.info(
"MCP server '%s': dynamically refreshed %d tool(s) (no changes)",
self.name, len(self._registered_tool_names),
)
async def _run_stdio(self, config: dict):
"""Run the server using stdio transport."""
@ -1838,6 +1909,10 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
if not _should_register(mcp_tool.name):
logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name)
continue
# Scan tool description for prompt injection patterns
_scan_mcp_description(name, mcp_tool.name, mcp_tool.description or "")
schema = _convert_mcp_schema(name, mcp_tool)
tool_name_prefixed = schema["name"]

View file

@ -23,7 +23,6 @@ Design:
- Frozen snapshot pattern: system prompt is stable, tool responses show live state
"""
import fcntl
import json
import logging
import os
@ -34,6 +33,17 @@ from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Dict, Any, List, Optional
# fcntl is Unix-only; on Windows use msvcrt for file locking
msvcrt = None
try:
import fcntl
except ImportError:
fcntl = None
try:
import msvcrt
except ImportError:
pass
logger = logging.getLogger(__name__)
# Where memory files live — resolved dynamically so profile overrides
@ -139,12 +149,31 @@ class MemoryStore:
"""
lock_path = path.with_suffix(path.suffix + ".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = open(lock_path, "w")
if fcntl is None and msvcrt is None:
yield
return
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
lock_path.write_text(" ", encoding="utf-8")
fd = open(lock_path, "r+" if msvcrt else "a+")
try:
fcntl.flock(fd, fcntl.LOCK_EX)
if fcntl:
fcntl.flock(fd, fcntl.LOCK_EX)
else:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
if fcntl:
fcntl.flock(fd, fcntl.LOCK_UN)
elif msvcrt:
try:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
except (OSError, IOError):
pass
fd.close()
@staticmethod

View file

@ -117,11 +117,27 @@ class ToolRegistry:
with self._lock:
existing = self._tools.get(name)
if existing and existing.toolset != toolset:
logger.warning(
"Tool name collision: '%s' (toolset '%s') is being "
"overwritten by toolset '%s'",
name, existing.toolset, toolset,
# Allow MCP-to-MCP overwrites (legitimate: server refresh,
# or two MCP servers with overlapping tool names).
both_mcp = (
existing.toolset.startswith("mcp-")
and toolset.startswith("mcp-")
)
if both_mcp:
logger.debug(
"Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'",
name, toolset, existing.toolset,
)
else:
# Reject shadowing — prevent plugins/MCP from overwriting
# built-in tools or vice versa.
logger.error(
"Tool registration REJECTED: '%s' (toolset '%s') would "
"shadow existing tool from toolset '%s'. Deregister the "
"existing tool first if this is intentional.",
name, toolset, existing.toolset,
)
return
self._tools[name] = ToolEntry(
name=name,
toolset=toolset,

View file

@ -64,11 +64,11 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:
report = format_scan_report(result)
return f"Security scan blocked this skill ({reason}):\n{report}"
if allowed is None:
# "ask" — allow but include the warning so the user sees the findings
# "ask" verdict — for agent-created skills this means dangerous
# findings were detected. Block the skill and include the report.
report = format_scan_report(result)
logger.warning("Agent-created skill has security findings: %s", reason)
# Don't block — return None to allow, but log the warning
return None
logger.warning("Agent-created skill blocked (dangerous findings): %s", reason)
return f"Security scan blocked this skill ({reason}):\n{report}"
except Exception as e:
logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True)
return None

View file

@ -126,6 +126,20 @@ class SkillReadinessStatus(str, Enum):
UNSUPPORTED = "unsupported"
# Prompt injection detection — shared by local-skill and plugin-skill paths.
_INJECTION_PATTERNS: list = [
"ignore previous instructions",
"ignore all previous",
"you are now",
"disregard your",
"forget your instructions",
"new instructions:",
"system prompt:",
"<system>",
"]]>",
]
def set_secret_capture_callback(callback) -> None:
global _secret_capture_callback
_secret_capture_callback = callback
@ -698,12 +712,102 @@ def skills_list(category: str = None, task_id: str = None) -> str:
return tool_error(str(e), success=False)
# ── Plugin skill serving ──────────────────────────────────────────────────
def _serve_plugin_skill(
skill_md: Path,
namespace: str,
bare: str,
) -> str:
"""Read a plugin-provided skill, apply guards, return JSON."""
from hermes_cli.plugins import _get_disabled_plugins, get_plugin_manager
if namespace in _get_disabled_plugins():
return json.dumps(
{
"success": False,
"error": (
f"Plugin '{namespace}' is disabled. "
f"Re-enable with: hermes plugins enable {namespace}"
),
},
ensure_ascii=False,
)
try:
content = skill_md.read_text(encoding="utf-8")
except Exception as e:
return json.dumps(
{"success": False, "error": f"Failed to read skill '{namespace}:{bare}': {e}"},
ensure_ascii=False,
)
parsed_frontmatter: Dict[str, Any] = {}
try:
parsed_frontmatter, _ = _parse_frontmatter(content)
except Exception:
pass
if not skill_matches_platform(parsed_frontmatter):
return json.dumps(
{
"success": False,
"error": f"Skill '{namespace}:{bare}' is not supported on this platform.",
"readiness_status": SkillReadinessStatus.UNSUPPORTED.value,
},
ensure_ascii=False,
)
# Injection scan — log but still serve (matches local-skill behaviour)
if any(p in content.lower() for p in _INJECTION_PATTERNS):
logger.warning(
"Plugin skill '%s:%s' contains patterns that may indicate prompt injection",
namespace, bare,
)
description = str(parsed_frontmatter.get("description", ""))
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
# Bundle context banner — tells the agent about sibling skills
try:
siblings = [
s for s in get_plugin_manager().list_plugin_skills(namespace)
if s != bare
]
if siblings:
sib_list = ", ".join(siblings)
banner = (
f"[Bundle context: This skill is part of the '{namespace}' plugin.\n"
f"Sibling skills: {sib_list}.\n"
f"Use qualified form to invoke siblings (e.g. {namespace}:{siblings[0]}).]\n\n"
)
else:
banner = f"[Bundle context: This skill is part of the '{namespace}' plugin.]\n\n"
except Exception:
banner = ""
return json.dumps(
{
"success": True,
"name": f"{namespace}:{bare}",
"content": f"{banner}{content}" if banner else content,
"description": description,
"linked_files": None,
"readiness_status": SkillReadinessStatus.AVAILABLE.value,
},
ensure_ascii=False,
)
def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
"""
View the content of a skill or a specific file within a skill directory.
Args:
name: Name or path of the skill (e.g., "axolotl" or "03-fine-tuning/axolotl")
name: Name or path of the skill (e.g., "axolotl" or "03-fine-tuning/axolotl").
Qualified names like "plugin:skill" resolve to plugin-provided skills.
file_path: Optional path to a specific file within the skill (e.g., "references/api.md")
task_id: Optional task identifier used to probe the active backend
@ -711,6 +815,63 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
JSON string with skill content or error message
"""
try:
# ── Qualified name dispatch (plugin skills) ──────────────────
# Names containing ':' are routed to the plugin skill registry.
# Bare names fall through to the existing flat-tree scan below.
if ":" in name:
from agent.skill_utils import is_valid_namespace, parse_qualified_name
from hermes_cli.plugins import discover_plugins, get_plugin_manager
namespace, bare = parse_qualified_name(name)
if not is_valid_namespace(namespace):
return json.dumps(
{
"success": False,
"error": (
f"Invalid namespace '{namespace}' in '{name}'. "
f"Namespaces must match [a-zA-Z0-9_-]+."
),
},
ensure_ascii=False,
)
discover_plugins() # idempotent
pm = get_plugin_manager()
plugin_skill_md = pm.find_plugin_skill(name)
if plugin_skill_md is not None:
if not plugin_skill_md.exists():
# Stale registry entry — file deleted out of band
pm.remove_plugin_skill(name)
return json.dumps(
{
"success": False,
"error": (
f"Skill '{name}' file no longer exists at "
f"{plugin_skill_md}. The registry entry has "
f"been cleaned up — try again after the "
f"plugin is reloaded."
),
},
ensure_ascii=False,
)
return _serve_plugin_skill(plugin_skill_md, namespace, bare)
# Plugin exists but this specific skill is missing?
available = pm.list_plugin_skills(namespace)
if available:
return json.dumps(
{
"success": False,
"error": f"Skill '{bare}' not found in plugin '{namespace}'.",
"available_skills": [f"{namespace}:{s}" for s in available],
"hint": f"The '{namespace}' plugin provides {len(available)} skill(s).",
},
ensure_ascii=False,
)
# Plugin itself not found — fall through to flat-tree scan
# which will return a normal "not found" with suggestions.
from agent.skill_utils import get_external_skills_dirs
# Build list of all skill directories to search
@ -805,17 +966,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
continue
# Security: detect common prompt injection patterns
_INJECTION_PATTERNS = [
"ignore previous instructions",
"ignore all previous",
"you are now",
"disregard your",
"forget your instructions",
"new instructions:",
"system prompt:",
"<system>",
"]]>",
]
# (pattern list at module level as _INJECTION_PATTERNS)
_content_lower = content.lower()
_injection_detected = any(p in _content_lower for p in _INJECTION_PATTERNS)
@ -1235,7 +1386,7 @@ SKILL_VIEW_SCHEMA = {
"properties": {
"name": {
"type": "string",
"description": "The skill name (use skills_list to see available skills)",
"description": "The skill name (use skills_list to see available skills). For plugin-provided skills, use the qualified form 'plugin:skill' (e.g. 'superpowers:writing-plans').",
},
"file_path": {
"type": "string",