diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7678287a0e..6bd65653fd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -923,6 +923,10 @@ DEFAULT_CONFIG = { "domains": [], "shared_files": [], }, + "tool_governance": { + "skill_allowed_tools": False, + "channel_tool_review": False, + }, }, "cron": { @@ -3763,6 +3767,8 @@ def show_config(): print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}") print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}") + + _print_tool_governance_section(config) # Skill config try: @@ -3821,6 +3827,78 @@ def edit_config(): subprocess.run([editor, str(config_path)]) +def _print_tool_governance_section(config: dict, *, show_commands: bool = False) -> None: + print() + print(color("◆ Tool Governance", Colors.CYAN, Colors.BOLD)) + tool_governance = config.get('security', {}).get('tool_governance', {}) + skill_allowed_tools = tool_governance.get('skill_allowed_tools', False) + channel_tool_review = tool_governance.get('channel_tool_review', False) + + print(f" skill_allowed_tools: {'on' if skill_allowed_tools else color('off', Colors.DIM)}") + print(f" channel_tool_review: {'on' if channel_tool_review else color('off', Colors.DIM)}") + print( + " security.tool_governance.skill_allowed_tools" + f" {'on' if skill_allowed_tools else color('off', Colors.DIM)}" + ) + print( + " security.tool_governance.channel_tool_review" + f" {'on' if channel_tool_review else color('off', Colors.DIM)}" + ) + + if show_commands: + print() + print(" Presets:") + print(" hermes config governance --preset messaging-safe") + print(" hermes config governance --preset skill-safe") + print(" hermes config governance --preset balanced") + print() + print(" Enable with:") + print(" hermes config set security.tool_governance.skill_allowed_tools true") + print(" hermes config set security.tool_governance.channel_tool_review true") + print() + print(" Disable with:") + print(" hermes config set security.tool_governance.skill_allowed_tools false") + print(" hermes config set security.tool_governance.channel_tool_review false") + + + +def _apply_tool_governance_toggles(args) -> None: + enable_all = bool(getattr(args, 'enable_all', False)) + disable_all = bool(getattr(args, 'disable_all', False)) + enable_skill_allowed_tools = bool(getattr(args, 'enable_skill_allowed_tools', False)) + disable_skill_allowed_tools = bool(getattr(args, 'disable_skill_allowed_tools', False)) + enable_channel_review = bool(getattr(args, 'enable_channel_review', False)) + disable_channel_review = bool(getattr(args, 'disable_channel_review', False)) + preset = getattr(args, 'preset', None) + + if preset == 'messaging-safe': + enable_channel_review = True + disable_skill_allowed_tools = True + elif preset == 'skill-safe': + enable_skill_allowed_tools = True + disable_channel_review = True + elif preset == 'balanced': + enable_skill_allowed_tools = True + enable_channel_review = True + + if enable_all: + enable_skill_allowed_tools = True + enable_channel_review = True + if disable_all: + disable_skill_allowed_tools = True + disable_channel_review = True + + if enable_skill_allowed_tools: + set_config_value("security.tool_governance.skill_allowed_tools", "true") + elif disable_skill_allowed_tools: + set_config_value("security.tool_governance.skill_allowed_tools", "false") + + if enable_channel_review: + set_config_value("security.tool_governance.channel_tool_review", "true") + elif disable_channel_review: + set_config_value("security.tool_governance.channel_tool_review", "false") + + def set_config_value(key: str, value: str): """Set a configuration value.""" if is_managed(): @@ -3931,6 +4009,8 @@ def config_command(args): print("Examples:") print(" hermes config set model anthropic/claude-sonnet-4") print(" hermes config set terminal.backend docker") + print(" hermes config set security.tool_governance.skill_allowed_tools true") + print(" hermes config set security.tool_governance.channel_tool_review true") print(" hermes config set OPENROUTER_API_KEY sk-or-...") sys.exit(1) set_config_value(key, value) @@ -4032,8 +4112,17 @@ def config_command(args): print() print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW)) print(" Run 'hermes config migrate' to add them") + + _print_tool_governance_section(load_config()) print() + + elif subcmd == "governance": + print() + print(color("🛡 Tool Governance", Colors.CYAN, Colors.BOLD)) + _apply_tool_governance_toggles(args) + _print_tool_governance_section(load_config(), show_commands=True) + print() else: print(f"Unknown config command: {subcmd}") @@ -4043,6 +4132,7 @@ def config_command(args): print(" hermes config edit Open config in editor") print(" hermes config set Set a config value") print(" hermes config check Check for missing/outdated config") + print(" hermes config governance Show tool governance settings") print(" hermes config migrate Update config with new options") print(" hermes config path Show config file path") print(" hermes config env-path Show .env file path") diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index cba4ebcdd3..a366abd5fa 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -130,6 +130,27 @@ def check_info(text: str): print(f" {color('→', Colors.CYAN)} {text}") +def _report_tool_governance(config: dict) -> None: + tool_governance = (config.get("security") or {}).get("tool_governance") or {} + + print() + print(color("◆ Tool Governance", Colors.CYAN, Colors.BOLD)) + + skill_allowed_tools = tool_governance.get("skill_allowed_tools", False) + channel_tool_review = tool_governance.get("channel_tool_review", False) + check_ok( + f"Skill allowed-tools enforcement: {'enabled' if skill_allowed_tools else 'disabled'}", + "(config.yaml)", + ) + check_ok( + f"Channel tool review: {'enabled' if channel_tool_review else 'disabled'}", + "(config.yaml)", + ) + + if not skill_allowed_tools and not channel_tool_review: + check_info("Both governance policies are advisory/off by default until explicitly enabled") + + def _check_gateway_service_linger(issues: list[str]) -> None: """Warn when a systemd user gateway service will stop after logout.""" try: @@ -465,6 +486,14 @@ def run_doctor(args): except Exception: pass + try: + import yaml + with open(config_path, encoding="utf-8") as f: + raw_config = yaml.safe_load(f) or {} + _report_tool_governance(raw_config) + except Exception: + pass + # ========================================================================= # Check: Auth providers # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7de68d2cb4..41bfe40273 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7256,12 +7256,12 @@ For more help on a command: "setup", help="Interactive setup wizard", description="Configure Hermes Agent with an interactive wizard. " - "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent", + "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent|security", ) setup_parser.add_argument( "section", nargs="?", - choices=["model", "tts", "terminal", "gateway", "tools", "agent"], + choices=["model", "tts", "terminal", "gateway", "tools", "agent", "security"], default=None, help="Run a specific setup section instead of the full wizard", ) @@ -7823,6 +7823,36 @@ Examples: # config check config_subparsers.add_parser("check", help="Check for missing/outdated config") + # config governance + config_governance = config_subparsers.add_parser("governance", help="Show tool governance settings") + config_governance.add_argument("--enable-all", action="store_true", help="Enable all tool governance policies") + config_governance.add_argument("--disable-all", action="store_true", help="Disable all tool governance policies") + config_governance.add_argument( + "--preset", + choices=["messaging-safe", "skill-safe", "balanced"], + help="Apply a governance preset", + ) + config_governance.add_argument( + "--enable-skill-allowed-tools", + action="store_true", + help="Enable skill allowed-tools enforcement", + ) + config_governance.add_argument( + "--disable-skill-allowed-tools", + action="store_true", + help="Disable skill allowed-tools enforcement", + ) + config_governance.add_argument( + "--enable-channel-review", + action="store_true", + help="Enable channel tool review", + ) + config_governance.add_argument( + "--disable-channel-review", + action="store_true", + help="Disable channel tool review", + ) + # config migrate config_subparsers.add_parser("migrate", help="Update config with new options") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e28acd41b8..0654f9f060 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -596,6 +596,8 @@ def _print_setup_summary(config: dict, hermes_home): ) print(f" {color('hermes config set ', Colors.GREEN)}") print(" Set a specific value") + print(" e.g. security.tool_governance.skill_allowed_tools true") + print(" security.tool_governance.channel_tool_review true") print() print(" Or edit the files directly:") print(f" {color(f'nano {get_config_path()}', Colors.DIM)}") @@ -612,6 +614,49 @@ def _print_setup_summary(config: dict, hermes_home): print() +def _prompt_tool_governance_defaults(config: dict) -> None: + """Offer optional low-risk governance defaults during setup.""" + print() + print_header("Tool Governance (optional)") + print_info("Optional low-risk guardrails for messaging and skill-driven sessions.") + print_info("Defaults stay off unless you enable them here.") + print() + + if not prompt_yes_no("Configure tool governance defaults now?", False): + return + + choice = prompt_choice( + "Which governance preset would you like?", + [ + "Messaging-safe — enable channel tool review", + "Skill-safe — enforce skill allowed-tools", + "Enable both low-risk policies", + ], + 0, + ) + + tool_governance = config.setdefault("security", {}).setdefault("tool_governance", {}) + tool_governance.setdefault("skill_allowed_tools", False) + tool_governance.setdefault("channel_tool_review", False) + if choice == 0: + tool_governance["channel_tool_review"] = True + tool_governance["skill_allowed_tools"] = False + elif choice == 1: + tool_governance["skill_allowed_tools"] = True + tool_governance["channel_tool_review"] = False + elif choice == 2: + tool_governance["channel_tool_review"] = True + tool_governance["skill_allowed_tools"] = True + + print_success("Tool governance defaults updated") + print_info("You can change these later with 'hermes config governance'.") + + +def setup_security(config: dict): + """Configure security and governance defaults.""" + _prompt_tool_governance_defaults(config) + + def _prompt_container_resources(config: dict): """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" terminal = config.setdefault("terminal", {}) @@ -2861,6 +2906,7 @@ SETUP_SECTIONS = [ ("gateway", "Messaging Platforms (Gateway)", setup_gateway), ("tools", "Tools", setup_tools), ("agent", "Agent Settings", setup_agent_settings), + ("security", "Security & Governance", setup_security), ] # The returning-user menu intentionally omits standalone TTS because model setup @@ -2872,6 +2918,7 @@ RETURNING_USER_MENU_SECTION_KEYS = [ "gateway", "tools", "agent", + "security", ] @@ -2886,6 +2933,7 @@ def run_setup_wizard(args): hermes setup gateway — just messaging platforms hermes setup tools — just tool configuration hermes setup agent — just agent settings + hermes setup security — security/governance defaults """ from hermes_cli.config import is_managed, managed_error if is_managed(): @@ -3003,6 +3051,7 @@ def run_setup_wizard(args): "Messaging Platforms (Gateway)", "Tools", "Agent Settings", + "Security & Governance", "Exit", ] choice = prompt_choice("What would you like to do?", menu_choices, 0) @@ -3014,10 +3063,10 @@ def run_setup_wizard(args): elif choice == 1: # Full setup — fall through to run all sections pass - elif choice == 7: + elif choice == 8: print_info("Exiting. Run 'hermes setup' again when ready.") return - elif 2 <= choice <= 6: + elif 2 <= choice <= 7: # Individual section — map by key, not by position. # SETUP_SECTIONS includes TTS but the returning-user menu skips it, # so positional indexing (choice - 2) would dispatch the wrong section. @@ -3082,6 +3131,8 @@ def run_setup_wizard(args): if not (migration_ran and _skip_configured_section(config, "tools", "Tools")): setup_tools(config, first_install=not is_existing) + _prompt_tool_governance_defaults(config) + # Save and show summary save_config(config) _print_setup_summary(config, hermes_home) @@ -3149,6 +3200,8 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): setup_gateway(config) save_config(config) + _prompt_tool_governance_defaults(config) + print() print_success("Setup complete! You're ready to go.") print() diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index db66e1db1b..04bddc8b6e 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -82,6 +82,8 @@ TIPS = [ "hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.", "hermes config edit opens config.yaml in your default editor.", "hermes config check scans for missing or stale configuration options.", + "hermes config governance shows tool-governance status and supports quick presets like --preset messaging-safe.", + "hermes setup security opens the dedicated setup section for tool-governance defaults.", "hermes sessions browse opens an interactive session picker with search.", "hermes sessions stats shows session counts by platform and database size.", "hermes sessions prune --older-than 30 cleans up old sessions.", diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 37cad85164..de0b398f71 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -450,3 +450,48 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path ) assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls) assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls) + + +def test_run_doctor_reports_tool_governance_settings(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + """ +memory: {} +security: + tool_governance: + skill_allowed_tools: true + channel_tool_review: false +""".strip() + + "\n", + encoding="utf-8", + ) + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "Tool Governance" in out + assert "Skill allowed-tools enforcement: enabled" in out + assert "Channel tool review: disabled" in out diff --git a/tests/hermes_cli/test_placeholder_usage.py b/tests/hermes_cli/test_placeholder_usage.py index 3479d8f570..c44a897e9e 100644 --- a/tests/hermes_cli/test_placeholder_usage.py +++ b/tests/hermes_cli/test_placeholder_usage.py @@ -2,6 +2,7 @@ import os from argparse import Namespace +from pathlib import Path from unittest.mock import patch import pytest @@ -40,9 +41,154 @@ def test_show_config_marks_placeholders(tmp_path, capsys): assert "hermes config set " in out +def test_show_config_surfaces_tool_governance_settings(tmp_path, capsys): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + show_config() + + out = capsys.readouterr().out + assert "Tool Governance" in out + assert "security.tool_governance.skill_allowed_tools" in out + assert "security.tool_governance.channel_tool_review" in out + + def test_setup_summary_marks_placeholders(tmp_path, capsys): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): _print_setup_summary({"tts": {"provider": "edge"}}, tmp_path) out = capsys.readouterr().out assert "hermes config set " in out + + +def test_setup_summary_mentions_tool_governance_examples(tmp_path, capsys): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + _print_setup_summary({"tts": {"provider": "edge"}}, tmp_path) + + out = capsys.readouterr().out + assert "security.tool_governance.skill_allowed_tools" in out + assert "security.tool_governance.channel_tool_review" in out + + +def test_config_check_surfaces_tool_governance_settings(tmp_path, capsys): + config_path = Path(tmp_path) / "config.yaml" + config_path.write_text( + """ +security: + tool_governance: + skill_allowed_tools: true + channel_tool_review: false +""".strip() + + "\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_command(Namespace(config_command="check")) + + out = capsys.readouterr().out + assert "Tool Governance" in out + assert "skill_allowed_tools: on" in out + assert "channel_tool_review: off" in out + + +def test_config_governance_command_shows_focus_view(tmp_path, capsys): + config_path = Path(tmp_path) / "config.yaml" + config_path.write_text( + """ +security: + tool_governance: + skill_allowed_tools: false + channel_tool_review: true +""".strip() + + "\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_command(Namespace(config_command="governance")) + + out = capsys.readouterr().out + assert "Tool Governance" in out + assert "skill_allowed_tools: off" in out + assert "channel_tool_review: on" in out + assert "hermes config set security.tool_governance.skill_allowed_tools true" in out + assert "hermes config set security.tool_governance.channel_tool_review true" in out + + +def test_config_governance_enable_all_updates_config(tmp_path, capsys): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_command( + Namespace( + config_command="governance", + enable_all=True, + disable_all=False, + enable_skill_allowed_tools=False, + disable_skill_allowed_tools=False, + enable_channel_review=False, + disable_channel_review=False, + ) + ) + + out = capsys.readouterr().out + saved = (Path(tmp_path) / "config.yaml").read_text(encoding="utf-8") + assert "skill_allowed_tools: true" in saved + assert "channel_tool_review: true" in saved + assert "skill_allowed_tools: on" in out + assert "channel_tool_review: on" in out + + +def test_config_governance_can_toggle_individual_policy(tmp_path, capsys): + config_path = Path(tmp_path) / "config.yaml" + config_path.write_text( + """ +security: + tool_governance: + skill_allowed_tools: true + channel_tool_review: true +""".strip() + + "\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_command( + Namespace( + config_command="governance", + enable_all=False, + disable_all=False, + enable_skill_allowed_tools=False, + disable_skill_allowed_tools=True, + enable_channel_review=False, + disable_channel_review=False, + preset=None, + ) + ) + + out = capsys.readouterr().out + saved = config_path.read_text(encoding="utf-8") + assert "skill_allowed_tools: false" in saved + assert "channel_tool_review: true" in saved + assert "skill_allowed_tools: off" in out + assert "channel_tool_review: on" in out + + +def test_config_governance_preset_messaging_safe_updates_config(tmp_path, capsys): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_command( + Namespace( + config_command="governance", + enable_all=False, + disable_all=False, + enable_skill_allowed_tools=False, + disable_skill_allowed_tools=False, + enable_channel_review=False, + disable_channel_review=False, + preset="messaging-safe", + ) + ) + + out = capsys.readouterr().out + saved = (Path(tmp_path) / "config.yaml").read_text(encoding="utf-8") + assert "skill_allowed_tools: false" in saved + assert "channel_tool_review: true" in saved + assert "skill_allowed_tools: off" in out + assert "channel_tool_review: on" in out diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 03b4068755..feb7b679ab 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -2,6 +2,7 @@ import json import sys import types +from argparse import Namespace import pytest @@ -223,6 +224,86 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): assert "restart" in out.lower() +def test_prompt_tool_governance_defaults_can_enable_both(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = load_config() + + yes_no_answers = iter([True]) + choice_answers = iter([2]) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: next(yes_no_answers)) + monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: next(choice_answers)) + + setup_mod._prompt_tool_governance_defaults(config) + + assert config["security"]["tool_governance"]["skill_allowed_tools"] is True + assert config["security"]["tool_governance"]["channel_tool_review"] is True + + +def test_run_first_time_quick_setup_offers_tool_governance(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = load_config() + calls = [] + + monkeypatch.setattr(setup_mod, "setup_model_provider", lambda cfg, quick=True: calls.append(("model", quick))) + monkeypatch.setattr(setup_mod, "setup_gateway", lambda cfg: calls.append(("gateway", None))) + monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append(("governance", None))) + monkeypatch.setattr(setup_mod, "_print_setup_summary", lambda cfg, home: calls.append(("summary", None))) + monkeypatch.setattr(setup_mod, "_offer_launch_chat", lambda: calls.append(("chat", None))) + monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: 1) + + setup_mod._run_first_time_quick_setup(config, tmp_path, is_existing=False) + + assert ("governance", None) in calls + + +def test_run_setup_wizard_full_setup_offers_tool_governance(tmp_path, monkeypatch): + args = Namespace(non_interactive=False, section=None, reset=False) + config = load_config() + calls = [] + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(setup_mod, "ensure_hermes_home", lambda: None) + monkeypatch.setattr(setup_mod, "load_config", lambda: config) + monkeypatch.setattr(setup_mod, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr(setup_mod, "is_interactive_stdin", lambda: True) + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr(setup_mod, "_offer_openclaw_migration", lambda home: False) + choices = iter([1]) + monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr(setup_mod, "setup_model_provider", lambda cfg: calls.append("model")) + monkeypatch.setattr(setup_mod, "setup_terminal_backend", lambda cfg: calls.append("terminal")) + monkeypatch.setattr(setup_mod, "setup_agent_settings", lambda cfg: calls.append("agent")) + monkeypatch.setattr(setup_mod, "setup_gateway", lambda cfg: calls.append("gateway")) + monkeypatch.setattr(setup_mod, "setup_tools", lambda cfg, first_install=True: calls.append("tools")) + monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append("governance")) + monkeypatch.setattr(setup_mod, "_print_setup_summary", lambda cfg, home: calls.append("summary")) + monkeypatch.setattr(setup_mod, "_offer_launch_chat", lambda: calls.append("chat")) + monkeypatch.setattr(setup_mod, "save_config", lambda cfg: calls.append("save")) + + setup_mod.run_setup_wizard(args) + + assert "governance" in calls + + +def test_run_setup_wizard_security_section_dispatches_governance(tmp_path, monkeypatch): + args = Namespace(non_interactive=False, section="security", reset=False) + config = load_config() + calls = [] + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(setup_mod, "ensure_hermes_home", lambda: None) + monkeypatch.setattr(setup_mod, "load_config", lambda: config) + monkeypatch.setattr(setup_mod, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr(setup_mod, "is_interactive_stdin", lambda: True) + monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append("governance")) + monkeypatch.setattr(setup_mod, "save_config", lambda cfg: calls.append("save")) + + setup_mod.run_setup_wizard(args) + + assert calls == ["governance", "save"] + + def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch): """Removing the last custom provider in model setup should persist.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index e3e243b4cc..d45ac1c15d 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -226,6 +226,7 @@ class TestNonInteractiveSetup: "Messaging Platforms (Gateway)", "Tools", "Agent Settings", + "Security & Governance", "Exit", ] @@ -244,3 +245,19 @@ class TestNonInteractiveSetup: main_mod.main() assert received["section"] == "tts" + + def test_main_accepts_security_setup_section(self, monkeypatch): + """`hermes setup security` should parse and dispatch like other setup sections.""" + from hermes_cli import main as main_mod + + received = {} + + def fake_cmd_setup(args): + received["section"] = args.section + + monkeypatch.setattr(main_mod, "cmd_setup", fake_cmd_setup) + monkeypatch.setattr("sys.argv", ["hermes", "setup", "security"]) + + main_mod.main() + + assert received["section"] == "security" diff --git a/tests/hermes_cli/test_tips.py b/tests/hermes_cli/test_tips.py index b0287df964..79d3456836 100644 --- a/tests/hermes_cli/test_tips.py +++ b/tests/hermes_cli/test_tips.py @@ -70,3 +70,11 @@ class TestTipIntegrationInCLI: # Should not contain nested/broken Rich tags assert markup.count("[/]") == 1 assert "[dim #B8860B]" in markup + + +class TestToolGovernanceTips: + def test_has_config_governance_tip(self): + assert any("hermes config governance" in tip for tip in TIPS) + + def test_has_setup_security_tip(self): + assert any("hermes setup security" in tip for tip in TIPS)