mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
commit
bf54f1fb2f
83 changed files with 5435 additions and 470 deletions
|
|
@ -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')"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue