diff --git a/AGENTS.md b/AGENTS.md index 0e3eab528..c658ae6e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/cli.py b/cli.py index 301c45be7..15d1f8ed6 100755 --- a/cli.py +++ b/cli.py @@ -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") diff --git a/model_tools.py b/model_tools.py index 3bdcbf4f0..0b4885572 100644 --- a/model_tools.py +++ b/model_tools.py @@ -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) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 81578e7ca..72301ed19 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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