Refactor terminal tool command approval process and enhance CLI feedback

- Updated the terminal tool's command approval flow to improve user interaction when executing potentially dangerous commands, replacing the previous confirmation method with a clear explanation and instructions for adding commands to the allowlist.
- Removed the internal `force` parameter from the model API, ensuring that dangerous command approvals are handled solely through user prompts.
- Enhanced the CLI to provide better feedback regarding tool availability, including improved messaging for enabled and disabled toolsets.
- Updated AGENTS.md to reflect changes in the command approval process and configuration instructions.
This commit is contained in:
teknium1
2026-02-02 23:46:41 -08:00
parent 76d929e177
commit 5d3398aa8a
4 changed files with 101 additions and 33 deletions

View File

@@ -281,8 +281,8 @@ The terminal tool includes safety checks for potentially destructive commands (e
**Approval Flow (Messaging):**
- Command is blocked with explanation
- Agent explains and asks user to confirm
- If user says "yes/approve/do it", agent retries with `force=True`
- Agent explains the command was blocked for safety
- User must add the pattern to their allowlist via `hermes config edit` or run the command directly on their machine
**Configuration:**
- `command_allowlist` in `~/.hermes/config.yaml` stores permanently allowed patterns

89
cli.py
View File

@@ -126,8 +126,20 @@ def load_cli_config() -> Dict[str, Any]:
try:
with open(config_path, "r") as f:
file_config = yaml.safe_load(f) or {}
# Deep merge with defaults
# Handle model config - can be string (new format) or dict (old format)
if "model" in file_config:
if isinstance(file_config["model"], str):
# New format: model is just a string, convert to dict structure
defaults["model"]["default"] = file_config["model"]
elif isinstance(file_config["model"], dict):
# Old format: model is a dict with default/base_url
defaults["model"].update(file_config["model"])
# Deep merge other keys with defaults
for key in defaults:
if key == "model":
continue # Already handled above
if key in file_config:
if isinstance(defaults[key], dict) and isinstance(file_config[key], dict):
defaults[key].update(file_config[key])
@@ -306,9 +318,17 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
enabled_toolsets: List of enabled toolset names
session_id: Unique session identifier for logging
"""
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
tools = tools or []
enabled_toolsets = enabled_toolsets or []
# Get unavailable tools info for coloring
_, unavailable_toolsets = check_tool_availability(quiet=True)
disabled_tools = set()
for item in unavailable_toolsets:
disabled_tools.update(item.get("tools", []))
# Build the side-by-side content using a table for precise control
layout_table = Table.grid(padding=(0, 2))
layout_table.add_column("left", justify="center")
@@ -334,8 +354,10 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
right_lines = []
right_lines.append("[bold #FFBF00]Available Tools[/]")
# Group tools by toolset
# Group tools by toolset (include all possible tools, both enabled and disabled)
toolsets_dict = {}
# First, add all enabled tools
for tool in tools:
tool_name = tool["function"]["name"]
toolset = get_toolset_for_tool(tool_name) or "other"
@@ -343,6 +365,17 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
toolsets_dict[toolset] = []
toolsets_dict[toolset].append(tool_name)
# Also add disabled toolsets so they show in the banner
for item in unavailable_toolsets:
# Map the internal toolset ID to display name
toolset_id = item["id"]
display_name = f"{toolset_id}_tools" if not toolset_id.endswith("_tools") else toolset_id
if display_name not in toolsets_dict:
toolsets_dict[display_name] = []
for tool_name in item.get("tools", []):
if tool_name not in toolsets_dict[display_name]:
toolsets_dict[display_name].append(tool_name)
# Display tools grouped by toolset (compact format, max 8 groups)
sorted_toolsets = sorted(toolsets_dict.keys())
display_toolsets = sorted_toolsets[:8]
@@ -350,11 +383,38 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
for toolset in display_toolsets:
tool_names = toolsets_dict[toolset]
# Join tool names with commas, wrap if too long
tools_str = ", ".join(sorted(tool_names))
if len(tools_str) > 45:
tools_str = tools_str[:42] + "..."
right_lines.append(f"[dim #B8860B]{toolset}:[/] [#FFF8DC]{tools_str}[/]")
# Color each tool name - red if disabled, normal if enabled
colored_names = []
for name in sorted(tool_names):
if name in disabled_tools:
colored_names.append(f"[red]{name}[/]")
else:
colored_names.append(f"[#FFF8DC]{name}[/]")
tools_str = ", ".join(colored_names)
# Truncate if too long (accounting for markup)
if len(", ".join(sorted(tool_names))) > 45:
# Rebuild with truncation
short_names = []
length = 0
for name in sorted(tool_names):
if length + len(name) + 2 > 42:
short_names.append("...")
break
short_names.append(name)
length += len(name) + 2
# Re-color the truncated list
colored_names = []
for name in short_names:
if name == "...":
colored_names.append("[dim]...[/]")
elif name in disabled_tools:
colored_names.append(f"[red]{name}[/]")
else:
colored_names.append(f"[#FFF8DC]{name}[/]")
tools_str = ", ".join(colored_names)
right_lines.append(f"[dim #B8860B]{toolset}:[/] {tools_str}")
if remaining_toolsets > 0:
right_lines.append(f"[dim #B8860B](and {remaining_toolsets} more toolsets...)[/]")
@@ -509,9 +569,14 @@ class HermesCLI:
self.verbose = verbose if verbose is not None else CLI_CONFIG["agent"].get("verbose", False)
# Configuration - priority: CLI args > env vars > config file
self.model = model or os.getenv("LLM_MODEL", CLI_CONFIG["model"]["default"])
self.base_url = base_url or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
# Model can come from: CLI arg, LLM_MODEL env, OPENAI_MODEL env (custom endpoint), or config
self.model = model or os.getenv("LLM_MODEL") or os.getenv("OPENAI_MODEL") or CLI_CONFIG["model"]["default"]
# Base URL: custom endpoint (OPENAI_BASE_URL) takes precedence over OpenRouter
self.base_url = base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
# API key: custom endpoint (OPENAI_API_KEY) takes precedence over OpenRouter
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
self.max_turns = max_turns if max_turns != 20 else CLI_CONFIG["agent"].get("max_turns", 20)
# Parse and validate toolsets
@@ -641,7 +706,7 @@ class HermesCLI:
def _show_status(self):
"""Show current status bar."""
# Get tool count
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets)
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
tool_count = len(tools) if tools else 0
# Format model name (shorten if needed)
@@ -684,7 +749,7 @@ class HermesCLI:
def show_tools(self):
"""Display available tools with kawaii ASCII art."""
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets)
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
if not tools:
print("(;_;) No tools available")

View File

@@ -274,11 +274,6 @@ def get_terminal_tool_definitions() -> List[Dict[str, Any]]:
"type": "integer",
"description": "Command timeout in seconds (optional)",
"minimum": 1
},
"force": {
"type": "boolean",
"description": "Skip dangerous command safety check. Only use after user explicitly confirms they want to run a blocked command.",
"default": False
}
},
"required": ["command"]
@@ -644,7 +639,8 @@ def get_tool_definitions(
if validate_toolset(toolset_name):
resolved_tools = resolve_toolset(toolset_name)
tools_to_include.update(resolved_tools)
print(f"✅ Enabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}")
if not quiet_mode:
print(f"✅ Enabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}")
else:
# Try legacy compatibility
if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools", "cronjob_tools"]:
@@ -666,9 +662,11 @@ def get_tool_definitions(
}
legacy_tools = legacy_map.get(toolset_name, [])
tools_to_include.update(legacy_tools)
print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
if not quiet_mode:
print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
else:
print(f"⚠️ Unknown toolset: {toolset_name}")
if not quiet_mode:
print(f"⚠️ Unknown toolset: {toolset_name}")
elif disabled_toolsets:
# Start with all tools from all toolsets, then remove disabled ones
# Note: Only tools that are part of toolsets are accessible
@@ -687,7 +685,8 @@ def get_tool_definitions(
if validate_toolset(toolset_name):
resolved_tools = resolve_toolset(toolset_name)
tools_to_include.difference_update(resolved_tools)
print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}")
if not quiet_mode:
print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}")
else:
# Try legacy compatibility
if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools", "cronjob_tools"]:
@@ -708,9 +707,11 @@ def get_tool_definitions(
}
legacy_tools = legacy_map.get(toolset_name, [])
tools_to_include.difference_update(legacy_tools)
print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
if not quiet_mode:
print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
else:
print(f"⚠️ Unknown toolset: {toolset_name}")
if not quiet_mode:
print(f"⚠️ Unknown toolset: {toolset_name}")
else:
# No filtering - include all tools from all defined toolsets
from toolsets import get_all_toolsets
@@ -781,9 +782,10 @@ def handle_terminal_function_call(function_name: str, function_args: Dict[str, A
command = function_args.get("command")
background = function_args.get("background", False)
timeout = function_args.get("timeout")
force = function_args.get("force", False) # Skip dangerous command check if user confirmed
# Note: force parameter exists internally but is NOT exposed to the model
# Dangerous command approval is handled via user prompts only
return terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id, force=force)
return terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id)
else:
return json.dumps({"error": f"Unknown terminal function: {function_name}"}, ensure_ascii=False)

View File

@@ -300,11 +300,12 @@ def _prompt_dangerous_approval(command: str, description: str, timeout_seconds:
os.environ["HERMES_SPINNER_PAUSE"] = "1"
try:
# Use simple ASCII art for compatibility (no ANSI color codes)
print()
print(f" ⚠️ \033[33mPotentially dangerous command detected:\033[0m {description}")
print(f" \033[2m{command[:100]}{'...' if len(command) > 100 else ''}\033[0m")
print(f" ⚠️ DANGEROUS COMMAND: {description}")
print(f" {command[:80]}{'...' if len(command) > 80 else ''}")
print()
print(f" [\033[32mo\033[0m]nce | [\033[33ms\033[0m]ession | [\033[36ma\033[0m]lways | [\033[31md\033[0m]eny")
print(f" [o]nce | [s]ession | [a]lways | [d]eny")
print()
sys.stdout.flush()
@@ -389,14 +390,14 @@ def _check_dangerous_command(command: str, env_type: str) -> dict:
return {
"approved": False,
"pattern_key": pattern_key,
"message": f"⚠️ This command was blocked because it's potentially dangerous ({description}). If you want me to run it anyway, please confirm by saying 'yes, run it' or 'approve'."
"message": f"BLOCKED: This command is potentially dangerous ({description}). Tell the user and ask if they want to add this command pattern to their allowlist. They can do this via 'hermes config edit' or by running the command directly on their machine."
}
# CLI context - prompt user
choice = _prompt_dangerous_approval(command, description)
if choice == "deny":
return {"approved": False, "message": "Command denied by user"}
return {"approved": False, "message": "BLOCKED: User denied this potentially dangerous command. Do NOT retry this command - the user has explicitly rejected it."}
# Handle approval
if choice == "session":
@@ -1304,7 +1305,7 @@ def terminal_tool(
>>> result = terminal_tool(command="long_task.sh", timeout=300)
# Force run after user confirmation
>>> result = terminal_tool(command="rm -rf /tmp/old", force=True)
# Note: force parameter is internal only, not exposed to model API
"""
global _active_environments, _last_activity