{"repo":"NousResearch/hermes-agent","public_id":"34067","display_label":"config set key validation and path mapping fixes","summary":"These issues and PRs address 'hermes config set' accepting unknown or shortcut keys (like 'provider') that diverge from canonical dotted paths (like 'model.provider'), and debate how to handle unrecognized keys — via warnings, errors, or a --force flag.","subsystem":"hermes_cli","counts":{"members":9,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67988,"merged_at":"2026-07-20T12:39:00+00:00"},"resolution":{"state":"partially_resolved","by":"pr-67988"},"freshness":{"state":"verified","last_verified_at":"2026-07-20T12:39:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444446},"fix_edges":[{"issue_number":34067,"pr_number":67924,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-20T09:25:34+00:00"},{"issue_number":34067,"pr_number":67988,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T12:39:00+00:00"}],"members":[{"kind":"issue","number":34067,"title":"`hermes config set` silently accepts unknown keys, writing them where the runtime never reads","url":null,"state":"closed"},{"kind":"issue","number":41943,"title":"config: \"hermes config set provider\" writes to dead key, diverges from model.provider","url":null,"state":"open"},{"kind":"pr","number":34187,"title":"fix(cli): validate keys in hermes config set and add --force flag (#34067)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34067","number":34067},{"kind":"pr","value":"#67988","number":67988},{"kind":"pr","value":"#34250","number":34250}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":34250,"title":"fix(config): validate config-key schema, refuse unknown keys (#34067)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67988","number":67988},{"kind":"pr","value":"#67924","number":67924}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41956,"title":"fix(cli): resolve config set provider/base_url shortcuts to canonical dotted paths","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41970,"title":"fix(config): map `provider` shortcut to `model.provider` for hermes config set","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41956","number":41956}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42062,"title":"fix(cli): redirect 'hermes config set provider' to model.provider (#41943)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":67924,"title":"fix(config): remove unknown-top-level-key warning — top-level keys bridge to env","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_config_validation.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -5699,28 +5699,21 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List[\"\n             \"    base_url: https://...\",\n         ))\n \n-    # ── Unknown / misplaced root-level keys ──────────────────────────────\n-    # Typos like skillz:/secrity: were previously silent (only provider-like\n-    # fields were flagged). Warn on any unknown top-level key so config\n-    # hygiene surfaces without breaking startup.\n+    # ── Root-level keys that look misplaced ──────────────────────────────\n+    # Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary\n+    # unknown top-level keys are deliberately NOT warned about: top-level\n+    # scalars are bridged into os.environ (gateway/run.py, hermes send) so\n+    # users can feed skills and external apps env-style keys from config.yaml\n+    # — a closed-world allowlist can never enumerate those.\n     for key in config:\n         if key.startswith(\"_\"):\n             continue\n-        if key in _KNOWN_ROOT_KEYS:\n-            continue\n-        if key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n+        if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n             issues.append(ConfigIssue(\n                 \"warning\",\n                 f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n                 f\"Move '{key}' under the appropriate section\",\n             ))\n-        else:\n-            issues.append(ConfigIssue(\n-                \"warning\",\n-                f\"Unknown top-level config key '{key}' — it will be ignored\",\n-                \"Check for typos, or remove the key if it is not a supported config root. \"\n-                \"Run 'hermes doctor' for more detail.\",\n-            ))\n \n     return issues\n "]},{"file":"tests/hermes_cli/test_config_validation.py","hunks":["@@ -214,38 +214,27 @@ def test_equality(self):\n \n \n class TestUnknownTopLevelKeys:\n-    \"\"\"Unknown top-level keys should warn (not error); known roots stay silent.\"\"\"\n+    \"\"\"Arbitrary top-level keys must NOT warn — they are bridged to os.environ.\n \n-    def test_typo_top_level_key_warns_with_key_name(self):\n-        \"\"\"A typo like skillz: must surface as a warning naming the key.\"\"\"\n+    Top-level scalars in config.yaml are forwarded into the environment\n+    (gateway/run.py, hermes send) so users can feed skills and external apps\n+    env-style keys like DISCORD_HOME_CHANNEL or MY_APP_TOKEN. A closed-world\n+    allowlist can never enumerate those, so no \"Unknown top-level config key\"\n+    warning may exist.\n+    \"\"\"\n+\n+    def test_arbitrary_top_level_keys_stay_silent(self):\n+        \"\"\"Env-style and custom keys must produce no unknown-key warnings.\"\"\"\n         issues = validate_config_structure({\n             \"model\": {\"provider\": \"openrouter\"},\n+            \"DISCORD_HOME_CHANNEL\": \"12345\",\n+            \"TELEGRAM_HOME_CHANNEL\": \"-100987\",\n+            \"DISCORD_ALLOW_ALL_USERS\": True,\n+            \"MY_CUSTOM_SKILL_VAR\": \"hello\",\n             \"skillz\": {\"enabled\": True},\n-            \"secrity\": {\"redact\": True},\n         })\n-        warnings = [i for i in issues if i.severity == \"warning\"]\n-        unknown = [i for i in warnings if \"Unknown top-level config key\" in i.message]\n-        messages = \" \".join(i.message for i in unknown)\n-        assert \"skillz\" in messages\n-        assert \"secrity\" in messages\n-        assert all(i.severity == \"warning\" for i in unknown)\n-        assert not any(i.severity == \"error\" for i in unknown)\n-\n-    def test_all_default_config_roots_accepted_without_unknown_warning(self):\n-        \"\"\"Every DEFAULT_CONFIG root (and legacy extras) must not warn as unknown.\"\"\"\n-        config = {key: {} if isinstance(DEFAULT_CONFIG.get(key), dict) else DEFAULT_CONFIG.get(key)\n-                  for key in DEFAULT_CONFIG}\n-        for key in _EXTRA_KNOWN_ROOT_KEYS:\n-            if key == \"custom_providers\":\n-                config[key] = [{\"name\": \"x\", \"base_url\": \"https://example.com\"}]\n-                config.setdefault(\"model\", {\"provider\": \"custom\", \"default\": \"m\"})\n-            elif key == \"fallback_model\":\n-                config[key] = {\"provider\": \"openrouter\", \"model\": \"test\"}\n-            else:\n-                config[key] = {}\n-        issues = validate_config_structure(config)\n-        unknown = [i for i in issues if \"Unknown top-level config key\" in i.message]\n-        assert unknown == [], f\"Unexpected unknown-key warnings: {[i.message for i in unknown]}\"\n+        assert not any(\"Unknown top-level config key\" in i.message for i in issues)\n+        assert issues == []\n \n     def test_known_root_keys_derived_from_default_config(self):\n         \"\"\"_KNOWN_ROOT_KEYS must be DEFAULT_CONFIG.keys() plus extras — single source of truth.\"\"\"","@@ -253,30 +242,8 @@ def test_known_root_keys_derived_from_default_config(self):\n         assert _EXTRA_KNOWN_ROOT_KEYS.issubset(_KNOWN_ROOT_KEYS)\n         assert _KNOWN_ROOT_KEYS == frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS\n \n-    def test_hermes_written_roots_not_flagged_as_unknown(self):\n-        \"\"\"Roots Hermes itself writes/reads must not warn as unknown (#67397).\"\"\"\n-        issues = validate_config_structure({\n-            \"model\": {\"provider\": \"openrouter\"},\n-            \"known_plugin_toolsets\": {\"cli\": [\"spotify\"]},\n-            \"group_sessions_per_user\": True,\n-            \"thread_sessions_per_user\": False,\n-            \"stt_echo_transcripts\": True,\n-            \"reset_triggers\": [\"/new\"],\n-            \"always_log_local\": True,\n-            \"filter_silence_narration\": True,\n-        })\n-        unknown = [i for i in issues if \"Unknown top-level config key\" in i.message]\n-        messages = \" \".join(i.message for i in unknown)\n-        assert \"known_plugin_toolsets\" not in messages\n-        assert \"group_sessions_per_user\" not in messages\n-        assert \"thread_sessions_per_user\" not in messages\n-        assert \"stt_echo_transcripts\" not in messages\n-        assert \"reset_triggers\" not in messages\n-        assert \"always_log_local\" not in messages\n-        assert \"filter_silence_narration\" not in messages\n-\n     def test_provider_like_unknown_root_keeps_misplaced_message(self):\n-        \"\"\"Preserve existing base_url/api_key root-level guidance (not generic unknown).\"\"\"\n+        \"\"\"Preserve existing base_url/api_key root-level guidance.\"\"\"\n         issues = validate_config_structure({\n             \"base_url\": \"https://example.com/v1\",\n             \"api_key\": \"[redacted:secret-assignment]\",","@@ -285,19 +252,13 @@ def test_provider_like_unknown_root_keeps_misplaced_message(self):\n             i for i in issues\n             if i.severity == \"warning\" and \"looks misplaced\" in i.message\n         ]\n-        generic_unknown = [\n-            i for i in issues\n-            if \"Unknown top-level config key\" in i.message\n-        ]\n         assert any(\"base_url\" in i.message for i in misplaced)\n         assert any(\"api_key\" in i.message for i in misplaced)\n-        assert generic_unknown == []\n \n     def test_private_underscore_keys_not_flagged(self):\n-        \"\"\"Internal keys starting with _ remain ignored (except known defaults).\"\"\"\n+        \"\"\"Internal keys starting with _ remain ignored.\"\"\"\n         issues = validate_config_structure({\n             \"_internal_scratch\": True,\n             \"model\": {\"provider\": \"openrouter\"},\n         })\n-        assert not any(\"Unknown top-level\" in i.message for i in issues)\n-        assert not any(\"_internal_scratch\" in i.message for i in issues)\n+        assert issues == []\n"]}]},{"kind":"pr","number":67988,"title":"fix(config): warn on unrecognized keys in 'hermes config set' — write + notice, never block (#34067)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/subcommands/config.py","tests/hermes_cli/test_console_engine.py","tests/hermes_cli/test_placeholder_usage.py","tests/hermes_cli/test_set_config_value.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -8534,8 +8534,190 @@ def _default_value_for_key(dotted_key: str):\n     return node if not isinstance(node, dict) else None\n \n \n-def set_config_value(key: str, value: str):\n-    \"\"\"Set a configuration value.\"\"\"\n+# Known top-level config keys that intentionally accept arbitrary user-supplied\n+# child keys (\"dictionary-shaped\" config: the schema declares the dict but the\n+# user populates its keys). Schema validation accepts ANY path below these\n+# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``\n+# or ``providers.openrouter.api_key`` without us needing to know server names.\n+_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({\n+    \"providers\",\n+    \"credential_pool_strategies\",\n+    \"mcp_servers\",\n+    \"hooks\",\n+    \"quick_commands\",\n+    \"personalities\",\n+    \"command_allowlist\",\n+    \"model_catalog\",\n+    \"channel_prompts\",\n+    \"server_actions\",\n+    \"secrets\",\n+    \"goals\",\n+})\n+\n+# Top-level keys whose sub-keys are partially schema-defined (e.g. on a\n+# PlatformConfig dataclass) but where users may legitimately add fields\n+# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,\n+# etc.). For these we validate the FIRST segment but accept anything below.\n+_SCHEMA_DEFINED_DICT_KEYS = frozenset({\n+    # Platform configs — PlatformConfig dataclass + dynamic extras\n+    \"discord\", \"telegram\", \"slack\", \"whatsapp\", \"signal\", \"mattermost\",\n+    \"matrix\", \"feishu\", \"wecom\", \"weixin\", \"bluebubbles\", \"qqbot\", \"yuanbao\",\n+    \"email\", \"sms\", \"dingtalk\",\n+    # MCP server template / dynamic auth dicts\n+    \"sessions\", \"checkpoints\",\n+})\n+\n+# Top-level keys that can be ANY user-supplied name (platform/provider dict\n+# shapes where the outer key IS user-defined).\n+_DYNAMIC_TOP_LEVEL_KEYS = frozenset({\n+    \"custom_providers\",  # list-shaped, but indexed by position\n+})\n+\n+# Container keys whose immediate child IS a user-supplied platform name\n+# (``platforms.<name>.<field>``).  These appear both at the top level and\n+# nested under ``gateway`` — current docs configure platforms under\n+# ``gateway.platforms.<name>`` (website/docs/developer-guide/\n+# adding-platform-adapters.md) and ``gateway/config.py`` also resolves a\n+# top-level ``platforms`` map.  Anything below the platform-name segment is\n+# accepted because ``PlatformConfig`` carries an open ``extra`` mapping.\n+_PLATFORM_CONTAINER_KEYS = frozenset({\"platforms\"})\n+\n+\n+def _known_top_level_keys() -> set[str]:\n+    \"\"\"Return the union of known top-level config keys for validation.\n+\n+    Combines :data:`DEFAULT_CONFIG` with the dynamic categories that\n+    accept user-supplied child keys.  Used by :func:`_validate_config_key`\n+    to decide whether a ``hermes config set`` invocation is targeting a\n+    known shape.\n+    \"\"\"\n+    keys = set(DEFAULT_CONFIG.keys())\n+    keys.update(_OPEN_DICT_TOP_LEVEL_KEYS)\n+    keys.update(_DYNAMIC_TOP_LEVEL_KEYS)\n+    keys.update(_SCHEMA_DEFINED_DICT_KEYS)\n+    return keys\n+\n+\n+def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:\n+    \"\"\"Return the closest valid key name from ``candidates`` if any are\n+    similar enough to ``key``, else None.  Used by ``hermes config set``\n+    to point users at the right path when they've typo'd a top-level key.\n+\n+    Uses :func:`difflib.get_close_matches` with a conservative cutoff so\n+    we only suggest when there's a strong match — we'd rather say nothing\n+    than mislead a user toward a wrong-but-similar key.\n+    \"\"\"\n+    import difflib\n+    matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)\n+    return matches[0] if matches else None\n+\n+\n+def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:\n+    \"\"\"Validate a dotted config-key path against the known schema.\n+\n+    Returns ``(is_known, suggested_alternative_or_None)``.  Known keys\n+    return ``(True, None)``.  Unknown keys return ``(False, <suggestion>)``\n+    where ``<suggestion>`` may be ``None`` if no close match was found.\n+\n+    Validates as deep as DEFAULT_CONFIG can be safely walked, then stops\n+    at any segment that hits an open-dict container (mcp_servers,\n+    providers, hooks, etc.) where users define the inner keys themselves.\n+\n+    Headline case from #34067: ``gateway.discord.gateway_restart_notification``\n+    was silently written, even though ``gateway`` only has 4 known sub-keys\n+    (``strict``, ``media_delivery_allow_dirs``, ``trust_recent_files``,\n+    ``trust_recent_files_seconds``). The correct path is\n+    ``discord.gateway_restart_notification`` (platform configs live at the\n+    top level, not under a ``platforms`` namespace).\n+    \"\"\"\n+    if not key:\n+        return False, None\n+\n+    segments = key.split(\".\")\n+    top = segments[0]\n+\n+    # ── Underscore-prefixed keys are internal/test markers ───────────\n+    # A leading underscore on the top-level segment (e.g. ``_test.shim_marker``)\n+    # signals an intentionally non-schema, internal key. Test harnesses and\n+    # tooling use these to write a deterministic marker into config.yaml\n+    # without polluting the user-facing schema (see the Docker privilege-drop\n+    # shim test, which writes ``_test.shim_marker`` to probe file ownership).\n+    # Python's own convention treats a leading underscore as \"private\"; we\n+    # honour that here so schema validation never blocks deliberately-internal\n+    # keys. This is narrow: only the FIRST segment is checked, so a real typo\n+    # like ``agent._max_turns`` still gets caught at the sub-key level.\n+    if top.startswith(\"_\"):\n+        return True, None\n+\n+    known = _known_top_level_keys()\n+\n+    # ── First-segment validation ─────────────────────────────────────\n+    # Top-level ``platforms.<name>.<field>`` is a valid current shape:\n+    # ``gateway/config.py`` resolves a top-level ``platforms`` map in\n+    # addition to ``gateway.platforms``.  Accept anything below it.\n+    if top in _PLATFORM_CONTAINER_KEYS:\n+        return True, None\n+\n+    if top not in known:\n+        suggestion = _suggest_closest_key(top, known)\n+        if suggestion is not None:\n+            rest = \".\".join(segments[1:])\n+            suggested_full = f\"{suggestion}.{rest}\" if rest else suggestion\n+            return False, suggested_full\n+\n+        return False, None\n+\n+    # ── Deeper validation ────────────────────────────────────────────\n+    # Walk DEFAULT_CONFIG along the user's segments. Stop at:\n+    #   - An open-dict container (user-defined inner keys are OK below it)\n+    #   - A schema-defined-but-extensible dict (accept anything below)\n+    #   - A leaf scalar (the user's key is fully consumed and valid)\n+    #   - An unknown sub-key (return False with a same-level suggestion)\n+    if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:\n+        # Any path below these is accepted — the user defines the inner\n+        # shape themselves (mcp_servers.<name>.command, discord.<extras>,\n+        # providers.<name>.api_key, etc.).\n+        return True, None\n+\n+    node: Any = DEFAULT_CONFIG.get(top)\n+    consumed = [top]\n+    for seg in segments[1:]:\n+        # ``gateway.platforms.<name>.<field>`` (and any other nested\n+        # ``platforms`` container) — the segment after ``platforms`` is a\n+        # user-supplied platform name, so accept everything below it.\n+        if seg in _PLATFORM_CONTAINER_KEYS:\n+            return True, None\n+        if not isinstance(node, dict):\n+            # We hit a scalar leaf before consuming the user's full path —\n+            # they're trying to set ``foo.bar`` where ``foo`` is a string.\n+            # Accept it (set_config_value's coercion will replace the\n+            # leaf with a dict, matching pre-existing behavior).\n+            return True, None\n+        if seg not in node:\n+            # Suggest the closest sibling at this depth.\n+            sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))\n+            if sibling_suggestion is not None:\n+                fixed_path = \".\".join(consumed + [sibling_suggestion])\n+                return False, fixed_path\n+            return False, None\n+        consumed.append(seg)\n+        node = node[seg]\n+\n+    # Walked the entire user-supplied path without hitting an unknown\n+    # segment — it's known.\n+    return True, None\n+\n+\n+def set_config_value(key: str, value: str, force: bool = False):\n+    \"\"\"Set a configuration value.\n+\n+    Args:\n+        key: Dotted config path (e.g. ``terminal.backend``).\n+        value: String value (auto-coerced to bool/int/float when matching).\n+        force: When True, skip the unknown-key warning — useful for scripted\n+            writes of keys the running version doesn't recognize yet. The CLI\n+            exposes this via ``hermes config set --force``.\n+    \"\"\"\n     if is_managed():\n         managed_error(\"set configuration values\")\n         return","@@ -8564,7 +8746,16 @@ def set_config_value(key: str, value: str):\n         save_provider_env_credential(key.upper(), value)\n         print(f\"✓ Set {key} in {get_env_path()}\")\n         return\n-    \n+\n+    # Unknown-key notice (#34067): the key is still written (arbitrary keys\n+    # are supported — top-level scalars are bridged into os.environ for\n+    # skills and external apps), but a plausible-but-wrong dotted path like\n+    # ``gateway.discord.gateway_restart_notification`` previously reported\n+    # bare success and left the user debugging behavior that never changed.\n+    # Warn after the write so the user gets immediate feedback plus a\n+    # \"did you mean\" hint, without blocking legitimate unknown keys.\n+    is_known, suggestion = _validate_config_key(key)\n+\n     # Otherwise it goes to config.yaml\n     # Read the raw user config (not merged with defaults) to avoid\n     # dumping all default values back to the file","@@ -8631,6 +8822,23 @@ def set_config_value(key: str, value: str):\n         _display_value = value\n     print(f\"✓ Set {key} = {_display_value} in {config_path}\")\n \n+    # Post-write unknown-key notice (#34067): value IS saved, but tell the\n+    # user the runtime may never read it and suggest the likely-intended path.\n+    if not is_known and not force:\n+        print(color(\n+            f\"⚠ '{key}' is not a recognized config key — it was saved anyway, \"\n+            \"but Hermes may not read it.\",\n+            Colors.YELLOW,\n+        ))\n+        if suggestion:\n+            print(color(f\"  Did you mean: {suggestion}\", Colors.YELLOW))\n+        print(color(\n+            \"  (Custom top-level keys are supported and bridged to the \"\n+            \"environment for skills/external tools. Use --force to skip \"\n+            \"this notice.)\",\n+            Colors.DIM,\n+        ))\n+\n \n def get_config_value(key: str, *, as_json: bool = False):\n     \"\"\"Print a resolved configuration value.\"\"\"","@@ -8734,15 +8942,18 @@ def config_command(args):\n     elif subcmd == \"set\":\n         key = getattr(args, 'key', None)\n         value = getattr(args, 'value', None)\n+        force = bool(getattr(args, 'force', False))\n         if not key or value is None:\n-            print(\"Usage: hermes config set <key> <value>\")\n+            print(\"Usage: hermes config set [--force] <key> <value>\")\n             print()\n             print(\"Examples:\")\n             print(\"  hermes config set model anthropic/claude-sonnet-4\")\n             print(\"  hermes config set terminal.backend docker\")\n             print(\"  hermes config set OPENROUTER_API_KEY sk-or-...\")\n+            print()\n+            print(\"  --force: skip the unknown-key notice for unrecognized keys\")\n             sys.exit(1)\n-        set_config_value(key, value)\n+        set_config_value(key, value, force=force)\n \n     elif subcmd == \"unset\":\n         key = getattr(args, 'key', None)"]},{"file":"hermes_cli/subcommands/config.py","hunks":["@@ -40,6 +40,12 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:\n         \"key\", nargs=\"?\", help=\"Configuration key (e.g., model, terminal.backend)\"\n     )\n     config_set.add_argument(\"value\", nargs=\"?\", help=\"Value to set\")\n+    config_set.add_argument(\n+        \"--force\",\n+        action=\"store_true\",\n+        help=\"Skip the unknown-key notice printed after writing a key the \"\n+        \"running version doesn't recognize (the value is saved either way).\",\n+    )\n \n     # config unset\n     config_unset = config_subparsers.add_parser("]},{"file":"tests/hermes_cli/test_console_engine.py","hunks":["@@ -404,18 +404,21 @@ def test_help_lists_supported_commands_and_not_full_cli():\n def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):\n     engine = HermesConsoleEngine()\n \n-    pending = engine.execute(\"config set console.test true\")\n+    # Use a schema-known key path. Since #34067, `config set` refuses unknown\n+    # top-level keys, so this flow test must target a valid path (telegram is a\n+    # PlatformConfig-shaped dict that accepts arbitrary child keys).\n+    pending = engine.execute(\"config set telegram.test true\")\n     assert pending.status == \"confirm_required\"\n \n     from hermes_cli.config import read_raw_config\n \n     assert read_raw_config() == {}\n \n-    result = engine.execute(\"config set console.test true\", confirmed=True)\n+    result = engine.execute(\"config set telegram.test true\", confirmed=True)\n \n     assert result.status == \"ok\"\n-    assert \"console.test\" in result.output\n-    assert read_raw_config()[\"console\"][\"test\"] is True\n+    assert \"telegram.test\" in result.output\n+    assert read_raw_config()[\"telegram\"][\"test\"] is True\n \n \n def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):"]},{"file":"tests/hermes_cli/test_placeholder_usage.py","hunks":["@@ -18,7 +18,15 @@ def test_config_set_usage_marks_placeholders(capsys):\n \n     assert exc.value.code == 1\n     out = capsys.readouterr().out\n-    assert \"Usage: hermes config set <key> <value>\" in out\n+    # The usage line documents the optional --force flag added in #34067\n+    # (schema validation for unknown keys). Placeholder convention is preserved:\n+    # the literal ``<key>`` and ``<value>`` markers must still be present so\n+    # downstream tooling can detect placeholder syntax.\n+    assert \"Usage: hermes config set\" in out\n+    assert \"<key>\" in out\n+    assert \"<value>\" in out\n+    # --force escape hatch must be documented in the usage line.\n+    assert \"--force\" in out\n \n \n def test_config_unknown_command_help_marks_placeholders(capsys):"]},{"file":"tests/hermes_cli/test_set_config_value.py","hunks":["@@ -146,9 +146,11 @@ def test_empty_string_routes_to_config(self, _isolated_hermes_home):\n \n     def test_zero_routes_to_config(self, _isolated_hermes_home):\n         \"\"\"Setting a config key to '0' should write 0 to config.yaml.\"\"\"\n-        set_config_value(\"verbose\", \"0\")\n+        # Use a real DEFAULT_CONFIG sub-key so schema validation passes — the\n+        # original test used ``verbose`` which is not in the known schema.\n+        set_config_value(\"agent.gateway_timeout\", \"0\")\n         config = _read_config(_isolated_hermes_home)\n-        assert \"verbose: 0\" in config\n+        assert \"gateway_timeout: 0\" in config\n \n     def test_config_command_rejects_missing_value(self):\n         \"\"\"config set with no value arg (None) should still exit.\"\"\"","@@ -346,20 +348,23 @@ def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home):\n     def test_deeper_nesting_through_list(self, _isolated_hermes_home):\n         \"\"\"Navigation path mixing dict → list → dict → scalar.\"\"\"\n         self._write_config(_isolated_hermes_home, (\n-            \"platforms:\\n\"\n-            \"  telegram:\\n\"\n-            \"    allowlist:\\n\"\n+            \"telegram:\\n\"\n+            \"  allowlist:\\n\"\n             \"    - name: alice\\n\"\n             \"      role: admin\\n\"\n             \"    - name: bob\\n\"\n             \"      role: user\\n\"\n         ))\n \n-        set_config_value(\"platforms.telegram.allowlist.1.role\", \"admin\")\n+        # NOTE: original test path was ``platforms.telegram.allowlist.1.role``,\n+        # which #34067 schema validation correctly rejects (platform configs\n+        # live at the top level, not under a ``platforms`` namespace). Use\n+        # the canonical path.\n+        set_config_value(\"telegram.allowlist.1.role\", \"admin\")\n \n         import yaml\n         reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))\n-        allowlist = reloaded[\"platforms\"][\"telegram\"][\"allowlist\"]\n+        allowlist = reloaded[\"telegram\"][\"allowlist\"]\n         assert isinstance(allowlist, list)\n         assert allowlist[0] == {\"name\": \"alice\", \"role\": \"admin\"}\n         assert allowlist[1] == {\"name\": \"bob\", \"role\": \"admin\"}","@@ -398,7 +403,9 @@ def test_non_string_defaults_keep_existing_coercion(\n         assert type(node) is type(expected)\n \n     def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home):\n-        set_config_value(\"custom.enabled\", \"off\")\n+        # ``custom`` is not a known top-level key, so it now requires --force\n+        # (schema validation, #34067); coercion behavior is unchanged.\n+        set_config_value(\"custom.enabled\", \"off\", force=True)\n \n         import yaml\n         saved = yaml.safe_load(_read_config(_isolated_hermes_home))","@@ -457,3 +464,169 @@ def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys):\n \n         captured = capsys.readouterr()\n         assert \"Set model.reasoning_effort = high\" in captured.out\n+\n+# #34067: Schema validation for unknown keys\n+# ---------------------------------------------------------------------------\n+\n+class TestSchemaValidation:\n+    \"\"\"#34067: ``hermes config set`` must not report bare success for\n+    unrecognized keys. The key IS written (arbitrary keys are supported —\n+    top-level scalars bridge into os.environ for skills/external apps), but\n+    a post-write notice warns that Hermes may never read it and suggests the\n+    likely-intended path. Headline case: the plausible-but-wrong\n+    ``gateway.discord.gateway_restart_notification`` (correct path:\n+    ``discord.gateway_restart_notification``).\n+    \"\"\"\n+\n+    def test_unknown_top_level_key_written_with_notice(self, _isolated_hermes_home, capsys):\n+        \"\"\"An unknown top-level key is saved AND a notice is printed.\"\"\"\n+        set_config_value(\"totally_made_up_key\", \"value\")\n+        out = capsys.readouterr().out\n+        assert \"not a recognized config key\" in out\n+        assert \"totally_made_up_key\" in out\n+        assert \"saved anyway\" in out\n+        # The value WAS written.\n+        assert \"totally_made_up_key\" in _read_config(_isolated_hermes_home)\n+\n+    def test_unknown_subkey_written_with_notice(self, _isolated_hermes_home, capsys):\n+        \"\"\"The headline #34067 path: written, but warned about — no more\n+        bare success for gateway.discord.gateway_restart_notification.\"\"\"\n+        set_config_value(\"gateway.discord.gateway_restart_notification\", \"false\")\n+        out = capsys.readouterr().out\n+        assert \"✓ Set\" in out\n+        assert \"not a recognized config key\" in out\n+\n+    def test_platforms_container_is_accepted(self, _isolated_hermes_home, capsys):\n+        \"\"\"``platforms.<name>.<field>`` is a valid current shape: gateway/\n+        config.py resolves a top-level ``platforms`` map in addition to the\n+        top-level platform blocks, so it must NOT trigger the notice.\"\"\"\n+        set_config_value(\"platforms.discord.enabled\", \"true\")\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"enabled: true\" in content\n+        assert \"not a recognized config key\" not in capsys.readouterr().out\n+\n+    def test_gateway_platforms_nested_is_accepted(self, _isolated_hermes_home, capsys):\n+        \"\"\"Docs configure platforms under ``gateway.platforms.<name>`` — the\n+        canonical layout must validate as known (no notice).\"\"\"\n+        set_config_value(\"gateway.platforms.my_platform.extra.token\", \"abc\")\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"token: abc\" in content\n+        assert \"not a recognized config key\" not in capsys.readouterr().out\n+\n+    def test_unknown_approvals_subkey_warns_but_writes(self, _isolated_hermes_home, capsys):\n+        \"\"\"``approvals`` is a defined schema, so a typo'd sub-key gets the\n+        notice — but is still written.\"\"\"\n+        set_config_value(\"approvals.notarealkey\", \"true\")\n+        out = capsys.readouterr().out\n+        assert \"not a recognized config key\" in out\n+        assert \"notarealkey\" in _read_config(_isolated_hermes_home)\n+\n+    def test_known_approvals_subkey_is_accepted(self, _isolated_hermes_home, capsys):\n+        \"\"\"Real ``approvals.*`` keys still validate silently.\"\"\"\n+        set_config_value(\"approvals.mode\", \"off\")\n+        import yaml\n+        saved = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        assert saved[\"approvals\"][\"mode\"] == \"off\"\n+        assert \"not a recognized config key\" not in capsys.readouterr().out\n+\n+    def test_close_typo_suggests_correct_key(self, _isolated_hermes_home, capsys):\n+        \"\"\"Typo'd top-level keys should get a fuzzy-match suggestion.\"\"\"\n+        set_config_value(\"disco\", \"false\")\n+        out = capsys.readouterr().out\n+        assert \"Did you mean\" in out\n+        assert \"discord\" in out\n+\n+    def test_typoed_subkey_suggests_sibling(self, _isolated_hermes_home, capsys):\n+        \"\"\"``agent.max_turn`` should suggest ``agent.max_turns``.\"\"\"\n+        set_config_value(\"agent.max_turn\", \"100\")\n+        out = capsys.readouterr().out\n+        assert \"agent.max_turns\" in out\n+\n+    def test_force_suppresses_notice(self, _isolated_hermes_home, capsys):\n+        \"\"\"``--force`` writes unknown keys without the notice (scripted\n+        forward-compat writes).\"\"\"\n+        set_config_value(\"brand_new_future_key\", \"value\", force=True)\n+        out = capsys.readouterr().out\n+        assert \"not a recognized config key\" not in out\n+        # And the value WAS written.\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"brand_new_future_key\" in content\n+\n+    def test_known_top_level_key_accepted(self, _isolated_hermes_home):\n+        \"\"\"Sanity check: real config keys still work.\"\"\"\n+        set_config_value(\"terminal.backend\", \"docker\")\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"backend: docker\" in content\n+\n+    def test_known_platform_config_accepted(self, _isolated_hermes_home):\n+        \"\"\"Schema-defined-extensible top-level keys (platform configs) accept\n+        any sub-key path because PlatformConfig has dynamic ``extra`` fields.\"\"\"\n+        # discord is a platform config — sub-keys accept anything.\n+        set_config_value(\"discord.gateway_restart_notification\", \"false\")\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"gateway_restart_notification: false\" in content\n+\n+    def test_open_dict_mcp_servers_accepts_any_subkey(self, _isolated_hermes_home):\n+        \"\"\"``mcp_servers.<user-named-server>.<field>`` must work for any\n+        user-supplied server name.\"\"\"\n+        set_config_value(\"mcp_servers.my-server.command\", \"npx\")\n+        content = _read_config(_isolated_hermes_home)\n+        assert \"my-server\" in content\n+        assert \"command: npx\" in content\n+\n+\n+class TestValidateConfigKey:\n+    \"\"\"Unit tests for the validator itself.\"\"\"\n+\n+    @pytest.mark.parametrize(\"key\", [\n+        \"model\",\n+        \"terminal.backend\",\n+        \"agent.max_turns\",\n+        \"discord.gateway_restart_notification\",\n+        \"telegram.bot_token\",\n+        \"mcp_servers.foo.command\",\n+        \"providers.openrouter.api_key\",\n+        \"gateway.strict\",\n+        \"platforms.discord.enabled\",\n+        \"gateway.platforms.my_platform.extra.token\",\n+        \"approvals.mode\",\n+    ])\n+    def test_known_keys_pass(self, key):\n+        from hermes_cli.config import _validate_config_key\n+        is_known, _ = _validate_config_key(key)\n+        assert is_known, f\"Expected {key!r} to validate as known\"\n+\n+    @pytest.mark.parametrize(\"key,expected_in_suggestion\", [\n+        (\"gateway.discord.gateway_restart_notification\", None),  # no close suggestion\n+        (\"disco\", \"discord\"),\n+        (\"agent.max_turn\", \"agent.max_turns\"),\n+    ])\n+    def test_unknown_keys_with_suggestion(self, key, expected_in_suggestion):\n+        from hermes_cli.config import _validate_config_key\n+        is_known, suggestion = _validate_config_key(key)\n+        assert not is_known, f\"Expected {key!r} to validate as unknown\"\n+        if expected_in_suggestion is not None:\n+            assert suggestion is not None and expected_in_suggestion in suggestion, \\\n+                f\"Expected suggestion to contain {expected_in_suggestion!r}, got {suggestion!r}\"\n+\n+    @pytest.mark.parametrize(\"key\", [\n+        \"_test.shim_marker\",\n+        \"_internal\",\n+        \"_test.nested.deep.marker\",\n+        \"_x\",\n+    ])\n+    def test_underscore_prefixed_keys_are_accepted(self, key):\n+        \"\"\"Underscore-prefixed top-level keys are internal/test markers and\n+        bypass schema validation. The Docker privilege-drop shim test writes\n+        ``_test.shim_marker`` to probe config.yaml ownership; that must not\n+        be rejected. (Regression: #34250 schema validation broke this.)\"\"\"\n+        from hermes_cli.config import _validate_config_key\n+        is_known, _ = _validate_config_key(key)\n+        assert is_known, f\"Expected underscore-prefixed {key!r} to be accepted\"\n+\n+    def test_underscore_only_first_segment_escapes(self):\n+        \"\"\"The underscore escape only applies to the FIRST segment. A real\n+        typo in a sub-key (e.g. agent._max_turns) is still caught.\"\"\"\n+        from hermes_cli.config import _validate_config_key\n+        is_known, suggestion = _validate_config_key(\"agent._max_turns\")\n+        assert not is_known, \"Sub-key typo under a known top-level key must still be flagged\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}