feat(approvals): add profile-local per-tool policies

This commit is contained in:
Teknium 2026-07-12 20:50:20 -07:00
parent e589b739ca
commit 5bff2c28ee
No known key found for this signature in database
13 changed files with 1307 additions and 39 deletions

View file

@ -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}

View file

@ -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)

View file

@ -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