mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(approvals): add profile-local per-tool policies
This commit is contained in:
parent
e589b739ca
commit
5bff2c28ee
13 changed files with 1307 additions and 39 deletions
|
|
@ -50,6 +50,19 @@ _approval_tool_call_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|||
"approval_tool_call_id",
|
||||
default="",
|
||||
)
|
||||
_tool_policy_terminal_allow: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"tool_policy_terminal_allow",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
def set_tool_policy_terminal_allow() -> contextvars.Token[bool]:
|
||||
"""Allow ordinary terminal prompts for the current tool execution only."""
|
||||
return _tool_policy_terminal_allow.set(True)
|
||||
|
||||
|
||||
def reset_tool_policy_terminal_allow(token: contextvars.Token[bool]) -> None:
|
||||
_tool_policy_terminal_allow.reset(token)
|
||||
|
||||
# Interactive-CLI flag. Concurrent ACP sessions run on a shared
|
||||
# ThreadPoolExecutor (acp_adapter/server.py), so mutating the process-global
|
||||
|
|
@ -1605,8 +1618,9 @@ def approve_permanent(pattern_key: str):
|
|||
|
||||
|
||||
def load_permanent(patterns: set):
|
||||
"""Bulk-load permanent allowlist entries from config."""
|
||||
"""Replace permanent entries with the active profile's allowlist."""
|
||||
with _lock:
|
||||
_permanent_approved.clear()
|
||||
_permanent_approved.update(patterns)
|
||||
|
||||
|
||||
|
|
@ -1662,10 +1676,14 @@ def load_permanent_allowlist() -> set:
|
|||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
patterns = set(config.get("command_allowlist", []) or [])
|
||||
if patterns:
|
||||
load_permanent(patterns)
|
||||
# Replace rather than merge: long-lived processes can reload under a
|
||||
# different profile, whose permanent approvals must remain isolated.
|
||||
load_permanent(patterns)
|
||||
return patterns
|
||||
except Exception as e:
|
||||
# Fail closed across malformed profile transitions: stale grants from
|
||||
# the previously active profile must not remain live in this process.
|
||||
load_permanent(set())
|
||||
logger.warning("Failed to load permanent allowlist: %s", e)
|
||||
return set()
|
||||
|
||||
|
|
@ -2031,6 +2049,7 @@ def _run_approval_gate(
|
|||
autoapprove_log_prefix: str,
|
||||
fail_closed_when_no_human: bool = False,
|
||||
no_human_block_message: str = "",
|
||||
enforce_under_yolo: bool = False,
|
||||
) -> dict:
|
||||
"""Shared human-approval gate for a flagged action (command or tool).
|
||||
|
||||
|
|
@ -2075,7 +2094,9 @@ def _run_approval_gate(
|
|||
# --yolo bypasses all approval prompts (session- or process-scoped).
|
||||
# Hardline blocks are handled by the caller BEFORE this gate, so yolo
|
||||
# here only skips the recoverable approval layer.
|
||||
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled():
|
||||
if not enforce_under_yolo and (
|
||||
_YOLO_MODE_FROZEN or is_current_session_yolo_enabled()
|
||||
):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
session_key = get_current_session_key()
|
||||
|
|
@ -2298,6 +2319,12 @@ def check_dangerous_command(command: str, env_type: str,
|
|||
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled():
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# A declarative terminal allow skips only the ordinary dangerous-command
|
||||
# approval layer. Hardline and user-deny checks above still win, and cron
|
||||
# never honors this shortcut because its unattended policy outranks allow.
|
||||
if _tool_policy_terminal_allow.get() and not env_var_enabled("HERMES_CRON_SESSION"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
if _command_matches_permanent_allowlist(command):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
|
|
@ -2329,6 +2356,7 @@ def request_tool_approval(
|
|||
*,
|
||||
rule_key: str = "",
|
||||
approval_callback=None,
|
||||
enforce_under_yolo: bool = False,
|
||||
) -> dict:
|
||||
"""Escalate an arbitrary tool call to the human-approval gate.
|
||||
|
||||
|
|
@ -2407,6 +2435,7 @@ def request_tool_approval(
|
|||
"but no interactive user or gateway is present to approve it. "
|
||||
"A plugin flagged this action for human confirmation."
|
||||
),
|
||||
enforce_under_yolo=enforce_under_yolo,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2613,6 +2642,12 @@ def check_all_command_guards(command: str, env_type: str,
|
|||
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off":
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Per-tool allow is intentionally narrower than yolo/mode=off: it skips
|
||||
# routine terminal prompts only after hardline/user-deny checks, and never
|
||||
# overrides cron's unattended policy.
|
||||
if _tool_policy_terminal_allow.get() and not env_var_enabled("HERMES_CRON_SESSION"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
if _command_matches_permanent_allowlist(command):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
|
|
|
|||
|
|
@ -5315,7 +5315,10 @@ def refresh_agent_mcp_tools(
|
|||
# (``build_api_kwargs``) can't see a partial rebuild or a cross-attribute
|
||||
# half-swap. ``staged_engine_names`` are the context-engine routing names
|
||||
# this rebuild actually appended (matching agent_init's dedup-aware add).
|
||||
staged_engine_names = _reinject_post_build_tools(agent, new_defs, new_names)
|
||||
staged_dynamic_entries: dict = {}
|
||||
staged_engine_names = _reinject_post_build_tools(
|
||||
agent, new_defs, new_names, dynamic_entries=staged_dynamic_entries
|
||||
)
|
||||
|
||||
# Single atomic read-diff-publish so the returned ``added`` is consistent
|
||||
# with what was actually published, even under concurrent callers, and a
|
||||
|
|
@ -5335,12 +5338,14 @@ def refresh_agent_mcp_tools(
|
|||
for t in (getattr(agent, "tools", None) or [])
|
||||
}
|
||||
if new_names == current:
|
||||
# No change → leave the live snapshot untouched (no churn), but
|
||||
# record the generation so an in-flight older caller can't clobber.
|
||||
# No schema change, but refresh dynamic policy identities in case
|
||||
# this agent predates metadata propagation.
|
||||
agent._dynamic_tool_entries = staged_dynamic_entries
|
||||
agent._tool_snapshot_generation = max(published_gen, snapshot_generation)
|
||||
return set()
|
||||
agent.tools = new_defs
|
||||
agent.valid_tool_names = new_names
|
||||
agent._dynamic_tool_entries = staged_dynamic_entries
|
||||
# Publish context-engine routing names atomically with the snapshot.
|
||||
engine_names = getattr(agent, "_context_engine_tool_names", None)
|
||||
if isinstance(engine_names, set):
|
||||
|
|
@ -5350,7 +5355,9 @@ def refresh_agent_mcp_tools(
|
|||
return new_names - current
|
||||
|
||||
|
||||
def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:
|
||||
def _reinject_post_build_tools(
|
||||
agent, tools_list: list, name_set: set, *, dynamic_entries: Optional[dict] = None
|
||||
) -> set:
|
||||
"""Append memory-provider and context-engine tools onto staged locals.
|
||||
|
||||
Mirrors the post-``get_tool_definitions`` injection in ``agent_init`` so a
|
||||
|
|
@ -5365,12 +5372,16 @@ def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:
|
|||
caller publishes this into ``agent._context_engine_tool_names`` atomically
|
||||
with the snapshot.
|
||||
"""
|
||||
def _add(schema: dict) -> bool:
|
||||
def _add(schema: dict, toolset: str) -> bool:
|
||||
name = schema.get("name", "")
|
||||
if not name or name in name_set:
|
||||
return False
|
||||
tools_list.append({"type": "function", "function": schema})
|
||||
name_set.add(name)
|
||||
if dynamic_entries is not None:
|
||||
from tools.registry import DynamicToolEntry
|
||||
|
||||
dynamic_entries[name] = DynamicToolEntry(name, toolset)
|
||||
return True
|
||||
|
||||
# Memory-provider tools (mem0/honcho/byterover/supermemory/…).
|
||||
|
|
@ -5383,7 +5394,7 @@ def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:
|
|||
if "memory" in name_set or memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None)):
|
||||
for schema in get_mem_schemas():
|
||||
if isinstance(schema, dict):
|
||||
_add(schema)
|
||||
_add(schema, "memory")
|
||||
except Exception:
|
||||
logger.debug("Memory-provider tool re-injection skipped", exc_info=True)
|
||||
|
||||
|
|
@ -5407,7 +5418,7 @@ def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:
|
|||
# Only claim the routing name when WE appended the schema, so a
|
||||
# name already owned by a registry/plugin tool keeps its own
|
||||
# dispatch (matches agent_init.py's `continue`-before-claim).
|
||||
if _add(schema) and name:
|
||||
if _add(schema, "context_engine") and name:
|
||||
staged_engine_names.add(name)
|
||||
except Exception:
|
||||
logger.debug("Context-engine tool re-injection skipped", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import logging
|
|||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
|
||||
|
|
@ -107,6 +108,14 @@ class ToolEntry:
|
|||
self.dynamic_schema_overrides = dynamic_schema_overrides
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicToolEntry:
|
||||
"""Policy identity for a tool advertised outside the global registry."""
|
||||
|
||||
name: str
|
||||
toolset: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_fn TTL cache
|
||||
#
|
||||
|
|
@ -614,13 +623,31 @@ class ToolRegistry:
|
|||
entry = self.get_entry(name)
|
||||
if not entry:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"})
|
||||
try:
|
||||
|
||||
def execute(next_args: dict) -> str | dict:
|
||||
if entry.is_async:
|
||||
from model_tools import _run_async
|
||||
result = _run_async(entry.handler(args, **kwargs))
|
||||
result = _run_async(entry.handler(next_args, **kwargs))
|
||||
else:
|
||||
result = entry.handler(args, **kwargs)
|
||||
result = entry.handler(next_args, **kwargs)
|
||||
return self._normalize_handler_result(name, result)
|
||||
|
||||
try:
|
||||
# This is the narrowest shared execution boundary: model calls,
|
||||
# PluginContext, MCP, and direct framework callers converge here.
|
||||
# The actual entry is passed through so custom registries resolve
|
||||
# their own toolset. Only an explicitly marked, immediate wrapper
|
||||
# handoff is deduplicated; recursive dispatch remains a new call.
|
||||
from hermes_cli.middleware import run_tool_execution_middleware
|
||||
|
||||
return run_tool_execution_middleware(
|
||||
name,
|
||||
args,
|
||||
execute,
|
||||
registry_entry=entry,
|
||||
registry_dispatch=self,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Tool %s dispatch error: %s", name, e)
|
||||
# Route through the sanitizer so framing tokens / CDATA / fences
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue