# NousResearch/hermes-agent · config set key validation and path mapping fixes

## Open now

- 1 issue, 1 PR open on GitHub.
- #41943: config: "hermes config set provider" writes to dead key, diverges from model.provider
- PR #41956: fix(cli): resolve config set provider/base_url shortcuts to canonical dotted paths

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.

## TL;DR

- 9 member(s): 1 open issue(s), 1 closed issue(s), 1 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #67988. Verify it addresses your case.
- **Recommended:** read PR #67988 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-67988`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.20`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #41943: config: "hermes config set provider" writes to dead key, diverges from model.provider

## Closed / duplicate issues

- #34067: \`hermes config set\` silently accepts unknown keys, writing them where the runtime never reads

## Prior & competing PRs

- **#41970: fix(config): map \`provider\` shortcut to \`model.provider\` for hermes config set**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#41956`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#42062: fix(cli): redirect 'hermes config set provider' to model.provider (#41943)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#67924: fix(config): remove unknown-top-level-key warning — top-level keys bridge to env**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/config.py`, `tests/hermes_cli/test_config_validation.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -5699,28 +5699,21 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
                 "    base_url: https://...",
             ))
     
    -    # ── Unknown / misplaced root-level keys ──────────────────────────────
    -    # Typos like skillz:/secrity: were previously silent (only provider-like
    -    # fields were flagged). Warn on any unknown top-level key so config
    -    # hygiene surfaces without breaking startup.
    +    # ── Root-level keys that look misplaced ──────────────────────────────
    +    # Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary
    +    # unknown top-level keys are deliberately NOT warned about: top-level
    +    # scalars are bridged into os.environ (gateway/run.py, hermes send) so
    +    # users can feed skills and external apps env-style keys from config.yaml
    +    # — a closed-world allowlist can never enumerate those.
         for key in config:
             if key.startswith("_"):
                 continue
    -        if key in _KNOWN_ROOT_KEYS:
    -            continue
    -        if key in _CUSTOM_PROVIDER_LIKE_FIELDS:
    +        if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
                 issues.append(ConfigIssue(
                     "warning",
                     f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
                     f"Move '{key}' under the appropriate section",
                 ))
    -        else:
    -            issues.append(ConfigIssue(
    -                "warning",
    -                f"Unknown top-level config key '{key}' — it will be ignored",
    -                "Check for typos, or remove the key if it is not a supported config root. "
    -                "Run 'hermes doctor' for more detail.",
    -            ))
     
         return issues
     
    ```
  - `tests/hermes_cli/test_config_validation.py`

    ```diff
    @@ -214,38 +214,27 @@ def test_equality(self):
     
     
     class TestUnknownTopLevelKeys:
    -    """Unknown top-level keys should warn (not error); known roots stay silent."""
    +    """Arbitrary top-level keys must NOT warn — they are bridged to os.environ.
     
    -    def test_typo_top_level_key_warns_with_key_name(self):
    -        """A typo like skillz: must surface as a warning naming the key."""
    +    Top-level scalars in config.yaml are forwarded into the environment
    +    (gateway/run.py, hermes send) so users can feed skills and external apps
    +    env-style keys like DISCORD_HOME_CHANNEL or MY_APP_TOKEN. A closed-world
    +    allowlist can never enumerate those, so no "Unknown top-level config key"
    +    warning may exist.
    +    """
    +
    +    def test_arbitrary_top_level_keys_stay_silent(self):
    +        """Env-style and custom keys must produce no unknown-key warnings."""
             issues = validate_config_structure({
                 "model": {"provider": "openrouter"},
    +            "DISCORD_HOME_CHANNEL": "12345",
    +            "TELEGRAM_HOME_CHANNEL": "-100987",
    +            "DISCORD_ALLOW_ALL_USERS": True,
    +            "MY_CUSTOM_SKILL_VAR": "hello",
                 "skillz": {"enabled": True},
    -            "secrity": {"redact": True},
             })
    -        warnings = [i for i in issues if i.severity == "warning"]
    -        unknown = [i for i in warnings if "Unknown top-level config key" in i.message]
    -        messages = " ".join(i.message for i in unknown)
    -        assert "skillz" in messages
    -        assert "secrity" in messages
    -        assert all(i.severity == "warning" for i in unknown)
    -        assert not any(i.severity == "error" for i in unknown)
    -
    -    def test_all_default_config_roots_accepted_without_unknown_warning(self):
    -        """Every DEFAULT_CONFIG root (and legacy extras) must not warn as unknown."""
    -        config = {key: {} if isinstance(DEFAULT_CONFIG.get(key), dict) else DEFAULT_CONFIG.get(key)
    -                  for key in DEFAULT_CONFIG}
    -        for key in _EXTRA_KNOWN_ROOT_KEYS:
    -            if key == "custom_providers":
    -                config[key] = [{"name": "x", "base_url": "https://example.com"}]
    -                config.setdefault("model", {"provider": "custom", "default": "m"})
    -            elif key == "fallback_model":
    -                config[key] = {"provider": "openrouter", "model": "test"}
    -            else:
    -                config[key] = {}
    -        issues = validate_config_structure(config)
    -        unknown = [i for i in issues if "Unknown top-level config key" in i.message]
    -        assert unknown == [], f"Unexpected unknown-key warnings: {[i.message for i in unknown]}"
    +        assert not any("Unknown top-level config key" in i.message for i in issues)
    +        assert issues == []
     
         def test_known_root_keys_derived_from_default_config(self):
             """_KNOWN_ROOT_KEYS must be DEFAULT_CONFIG.keys() plus extras — single source of truth."""
    ```

    ```diff
    @@ -253,30 +242,8 @@ def test_known_root_keys_derived_from_default_config(self):
             assert _EXTRA_KNOWN_ROOT_KEYS.issubset(_KNOWN_ROOT_KEYS)
             assert _KNOWN_ROOT_KEYS == frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
     
    -    def test_hermes_written_roots_not_flagged_as_unknown(self):
    -        """Roots Hermes itself writes/reads must not warn as unknown (#67397)."""
    -        issues = validate_config_structure({
    -            "model": {"provider": "openrouter"},
    -            "known_plugin_toolsets": {"cli": ["spotify"]},
    -            "group_sessions_per_user": True,
    -            "thread_sessions_per_user": False,
    -            "stt_echo_transcripts": True,
    -            "reset_triggers": ["/new"],
    -            "always_log_local": True,
    -            "filter_silence_narration": True,
    -        })
    -        unknown = [i for i in issues if "Unknown top-level config key" in i.message]
    -        messages = " ".join(i.message for i in unknown)
    -        assert "known_plugin_toolsets" not in messages
    -        assert "group_sessions_per_user" not in messages
    -        assert "thread_sessions_per_user" not in messages
    -        assert "stt_echo_transcripts" not in messages
    -        assert "reset_triggers" not in messages
    -        assert "always_log_local" not in messages
    -        assert "filter_silence_narration" not in messages
    -
         def test_provider_like_unknown_root_keeps_misplaced_message(self):
    -        """Preserve existing base_url/api_key root-level guidance (not generic unknown)."""
    +        """Preserve existing base_url/api_key root-level guidance."""
             issues = validate_config_structure({
                 "base_url": "https://example.com/v1",
                 "api_key": "[redacted:secret-assignment]",
    ```

    ```diff
    @@ -285,19 +252,13 @@ def test_provider_like_unknown_root_keeps_misplaced_message(self):
                 i for i in issues
                 if i.severity == "warning" and "looks misplaced" in i.message
             ]
    -        generic_unknown = [
    -            i for i in issues
    -            if "Unknown top-level config key" in i.message
    -        ]
             assert any("base_url" in i.message for i in misplaced)
             assert any("api_key" in i.message for i in misplaced)
    -        assert generic_unknown == []
     
         def test_private_underscore_keys_not_flagged(self):
    -        """Internal keys starting with _ remain ignored (except known defaults)."""
    +        """Internal keys starting with _ remain ignored."""
             issues = validate_config_structure({
                 "_internal_scratch": True,
                 "model": {"provider": "openrouter"},
             })
    -        assert not any("Unknown top-level" in i.message for i in issues)
    -        assert not any("_internal_scratch" in i.message for i in issues)
    +        assert issues == []
    ```
- **#67988: fix(config): warn on unrecognized keys in 'hermes config set' — write + notice, never block (#34067)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `hermes_cli/config.py`

    ```diff
    @@ -8534,8 +8534,190 @@ def _default_value_for_key(dotted_key: str):
         return node if not isinstance(node, dict) else None
     
     
    -def set_config_value(key: str, value: str):
    -    """Set a configuration value."""
    +# Known top-level config keys that intentionally accept arbitrary user-supplied
    +# child keys ("dictionary-shaped" config: the schema declares the dict but the
    +# user populates its keys). Schema validation accepts ANY path below these
    +# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``
    +# or ``providers.openrouter.api_key`` without us needing to know server names.
    +_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
    +    "providers",
    +    "credential_pool_strategies",
    +    "mcp_servers",
    +    "hooks",
    +    "quick_commands",
    +    "personalities",
    +    "command_allowlist",
    +    "model_catalog",
    +    "channel_prompts",
    +    "server_actions",
    +    "secrets",
    +    "goals",
    +})
    +
    +# Top-level keys whose sub-keys are partially schema-defined (e.g. on a
    +# PlatformConfig dataclass) but where users may legitimately add fields
    +# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,
    +# etc.). For these we validate the FIRST segment but accept anything below.
    +_SCHEMA_DEFINED_DICT_KEYS = frozenset({
    +    # Platform configs — PlatformConfig dataclass + dynamic extras
    +    "discord", "telegram", "slack", "whatsapp", "signal", "mattermost",
    +    "matrix", "feishu", "wecom", "weixin", "bluebubbles", "qqbot", "yuanbao",
    +    "email", "sms", "dingtalk",
    +    # MCP server template / dynamic auth dicts
    +    "sessions", "checkpoints",
    +})
    +
    +# Top-level keys that can be ANY user-supplied name (platform/provider dict
    +# shapes where the outer key IS user-defined).
    +_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
    +    "custom_providers",  # list-shaped, but indexed by position
    +})
    +
    +# Container keys whose immediate child IS a user-supplied platform name
    +# (``platforms.<name>.<field>``).  These appear both at the top level and
    +# nested under ``gateway`` — current docs configure platforms under
    +# ``gateway.platforms.<name>`` (website/docs/developer-guide/
    +# adding-platform-adapters.md) and ``gateway/config.py`` also resolves a
    +# top-level ``platforms`` map.  Anything below the platform-name segment is
    +# accepted because ``PlatformConfig`` carries an open ``extra`` mapping.
    +_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
    +
    +
    +def _known_top_level_keys() -> set[str]:
    +    """Return the union of known top-level config keys for validation.
    +
    +    Combines :data:`DEFAULT_CONFIG` with the dynamic categories that
    +    accept user-supplied child keys.  Used by :func:`_validate_config_key`
    +    to decide whether a ``hermes config set`` invocation is targeting a
    +    known shape.
    +    """
    +    keys = set(DEFAULT_CONFIG.keys())
    +    keys.update(_OPEN_DICT_TOP_LEVEL_KEYS)
    +    keys.update(_DYNAMIC_TOP_LEVEL_KEYS)
    +    keys.update(_SCHEMA_DEFINED_DICT_KEYS)
    +    return keys
    +
    +
    +def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
    +    """Return the closest valid key name from ``candidates`` if any are
    +    similar enough to ``key``, else None.  Used by ``hermes config set``
    +    to point users at the right path when they've typo'd a top-level key.
    +
    +    Uses :func:`difflib.get_close_matches` with a conservative cutoff so
    +    we only suggest when there's a strong match — we'd rather say nothing
    +    than mislead a user toward a wrong-but-similar key.
    +    """
    +    import difflib
    +    matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)
    +    return matches[0] if matches else None
    +
    +
    +def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:
    +    """Validate a dotted config-key path against the known schema.
    +
    +    Returns ``(is_known, suggested_alternative_or_None)``.  Known keys
    +    return ``(True, None)``.  Unknown keys return ``(False, <suggestion>)``
    +    where ``<suggestion>`` may be ``None`` if no close match was found.
    +
    +    Validates as deep as DEFAULT_CONFIG can be safely walked, then stops
    +    at any segment that hits an open-dict container (mcp_servers,
    +    providers, hooks, etc.) where users define the inner keys themselves.
    +
    +    Headline case from #34067: ``gateway.discord.gateway_restart_notification``
    +    was silently written, even though ``gateway`` only has 4 known sub-keys
    +    (``strict``, ``media_delivery_allow_dirs``, ``trust_recent_files``,
    +    ``trust_recent_files_seconds``). The correct path is
    +    ``discord.gateway_restart_notification`` (platform configs live at the
    +    top level, not under a ``platforms`` namespace).
    +    """
    +    if not key:
    +        return False, None
    +
    +    segments = key.split(".")
    +    top = segments[0]
    +
    +    # ── Underscore-prefixed keys are internal/test markers ───────────
    +    # A leading underscore on the top-level segment (e.g. ``_test.shim_marker``)
    +    # signals an intentionally non-schema, internal key. Test harnesses and
    +    # tooling use these to write a deterministic marker into config.yaml
    +    # without polluting the user-facing schema (see the Docker privilege-drop
    +    # shim test, which writes ``_test.shim_marker`` to probe file ownership).
    +    # Python's own convention treats a leading underscore as "private"; we
    +    # honour that here so schema validation never blocks deliberately-internal
    +    # keys. This is narrow: only the FIRST segment is checked, so a real typo
    +    # like ``agent._max_turns`` still gets caught at the sub-key level.
    +    if top.startswith("_"):
    +        return True, None
    +
    +    known = _known_top_level_keys()
    +
    +    # ── First-segment validation ─────────────────────────────────────
    +    # Top-level ``platforms.<name>.<field>`` is a valid current shape:
    +    # ``gateway/config.py`` resolves a top-level ``platforms`` map in
    +    # addition to ``gateway.platforms``.  Accept anything below it.
    +    if top in _PLATFORM_CONTAINER_KEYS:
    +        return True, None
    +
    +    if top not in known:
    +        suggestion = _suggest_closest_key(top, known)
    +        if suggestion is not None:
    +            rest = ".".join(segments[1:])
    +            suggested_full = f"{suggestion}.{rest}" if rest else suggestion
    +            return False, suggested_full
    +
    +        return False, None
    +
    +    # ── Deeper validation ────────────────────────────────────────────
    +    # Walk DEFAULT_CONFIG along the user's segments. Stop at:
    +    #   - An open-dict container (user-defined inner keys are OK below it)
    +    #   - A schema-defined-but-extensible dict (accept anything below)
    +    #   - A leaf scalar (the user's key is fully consumed and valid)
    +    #   - An unknown sub-key (return False with a same-level suggestion)
    +    if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:
    +        # Any path below these is accepted — the user defines the inner
    +        # shape themselves (mcp_servers.<name>.command, discord.<extras>,
    +        # providers.<name>.api_key, etc.).
    +        return True, None
    +
    +    node: Any = DEFAULT_CONFIG.get(top)
    +    consumed = [top]
    +    for seg in segments[1:]:
    +        # ``gateway.platforms.<name>.<field>`` (and any other nested
    +        # ``platforms`` container) — the segment after ``platforms`` is a
    +        # user-supplied platform name, so accept everything below it.
    +        if seg in _PLATFORM_CONTAINER_KEYS:
    +            return True, None
    +        if not isinstance(node, dict):
    +            # We hit a scalar leaf before consuming the user's full path —
    +            # they're trying to set ``foo.bar`` where ``foo`` is a string.
    +            # Accept it (set_config_value's coercion will replace the
    +            # leaf with a dict, matching pre-existing behavior).
    +            return True, None
    +        if seg not in node:
    +            # Suggest the closest sibling at this depth.
    +            sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))
    +            if sibling_suggestion is not None:
    +                fixed_path = ".".join(consumed + [sibling_suggestion])
    +                return False, fixed_path
    +            return False, None
    +        consumed.append(seg)
    +        node = node[seg]
    +
    +    # Walked the entire user-supplied path without hitting an unknown
    +    # segment — it's known.
    +    return True, None
    +
    +
    +def set_config_value(key: str, value: str, force: bool = False):
    +    """Set a configuration value.
    +
    +    Args:
    +        key: Dotted config path (e.g. ``terminal.backend``).
    +        value: String value (auto-coerced to bool/int/float when matching).
    +        force: When True, skip the unknown-key warning — useful for scripted
    +            writes of keys the running version doesn't recognize yet. The CLI
    +            exposes this via ``hermes config set --force``.
    +    """
         if is_managed():
             managed_error("set configuration values")
             return
    ```

    ```diff
    @@ -8564,7 +8746,16 @@ def set_config_value(key: str, value: str):
             save_provider_env_credential(key.upper(), value)
             print(f"✓ Set {key} in {get_env_path()}")
             return
    -    
    +
    +    # Unknown-key notice (#34067): the key is still written (arbitrary keys
    +    # are supported — top-level scalars are bridged into os.environ for
    +    # skills and external apps), but a plausible-but-wrong dotted path like
    +    # ``gateway.discord.gateway_restart_notification`` previously reported
    +    # bare success and left the user debugging behavior that never changed.
    +    # Warn after the write so the user gets immediate feedback plus a
    +    # "did you mean" hint, without blocking legitimate unknown keys.
    +    is_known, suggestion = _validate_config_key(key)
    +
         # Otherwise it goes to config.yaml
         # Read the raw user config (not merged with defaults) to avoid
         # dumping all default values back to the file
    ```

    ```diff
    @@ -8631,6 +8822,23 @@ def set_config_value(key: str, value: str):
             _display_value = value
         print(f"✓ Set {key} = {_display_value} in {config_path}")
     
    +    # Post-write unknown-key notice (#34067): value IS saved, but tell the
    +    # user the runtime may never read it and suggest the likely-intended path.
    +    if not is_known and not force:
    +        print(color(
    +            f"⚠ '{key}' is not a recognized config key — it was saved anyway, "
    +            "but Hermes may not read it.",
    +            Colors.YELLOW,
    +        ))
    +        if suggestion:
    +            print(color(f"  Did you mean: {suggestion}", Colors.YELLOW))
    +        print(color(
    +            "  (Custom top-level keys are supported and bridged to the "
    +            "environment for skills/external tools. Use --force to skip "
    +            "this notice.)",
    +            Colors.DIM,
    +        ))
    +
     
     def get_config_value(key: str, *, as_json: bool = False):
         """Print a resolved configuration value."""
    ```

    ```diff
    @@ -8734,15 +8942,18 @@ def config_command(args):
         elif subcmd == "set":
             key = getattr(args, 'key', None)
             value = getattr(args, 'value', None)
    +        force = bool(getattr(args, 'force', False))
             if not key or value is None:
    -            print("Usage: hermes config set <key> <value>")
    +            print("Usage: hermes config set [--force] <key> <value>")
                 print()
                 print("Examples:")
                 print("  hermes config set model anthropic/claude-sonnet-4")
                 print("  hermes config set terminal.backend docker")
                 print("  hermes config set OPENROUTER_API_KEY sk-or-...")
    +            print()
    +            print("  --force: skip the unknown-key notice for unrecognized keys")
                 sys.exit(1)
    -        set_config_value(key, value)
    +        set_config_value(key, value, force=force)
     
         elif subcmd == "unset":
             key = getattr(args, 'key', None)
    ```
  - `hermes_cli/subcommands/config.py`

    ```diff
    @@ -40,6 +40,12 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
             "key", nargs="?", help="Configuration key (e.g., model, terminal.backend)"
         )
         config_set.add_argument("value", nargs="?", help="Value to set")
    +    config_set.add_argument(
    +        "--force",
    +        action="store_true",
    +        help="Skip the unknown-key notice printed after writing a key the "
    +        "running version doesn't recognize (the value is saved either way).",
    +    )
     
         # config unset
         config_unset = config_subparsers.add_parser(
    ```
  - `tests/hermes_cli/test_console_engine.py`

    ```diff
    @@ -404,18 +404,21 @@ def test_help_lists_supported_commands_and_not_full_cli():
     def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):
         engine = HermesConsoleEngine()
     
    -    pending = engine.execute("config set console.test true")
    +    # Use a schema-known key path. Since #34067, `config set` refuses unknown
    +    # top-level keys, so this flow test must target a valid path (telegram is a
    +    # PlatformConfig-shaped dict that accepts arbitrary child keys).
    +    pending = engine.execute("config set telegram.test true")
         assert pending.status == "confirm_required"
     
         from hermes_cli.config import read_raw_config
     
         assert read_raw_config() == {}
     
    -    result = engine.execute("config set console.test true", confirmed=True)
    +    result = engine.execute("config set telegram.test true", confirmed=True)
     
         assert result.status == "ok"
    -    assert "console.test" in result.output
    -    assert read_raw_config()["console"]["test"] is True
    +    assert "telegram.test" in result.output
    +    assert read_raw_config()["telegram"]["test"] is True
     
     
     def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):
    ```
  - `tests/hermes_cli/test_placeholder_usage.py`

    ```diff
    @@ -18,7 +18,15 @@ def test_config_set_usage_marks_placeholders(capsys):
     
         assert exc.value.code == 1
         out = capsys.readouterr().out
    -    assert "Usage: hermes config set <key> <value>" in out
    +    # The usage line documents the optional --force flag added in #34067
    +    # (schema validation for unknown keys). Placeholder convention is preserved:
    +    # the literal ``<key>`` and ``<value>`` markers must still be present so
    +    # downstream tooling can detect placeholder syntax.
    +    assert "Usage: hermes config set" in out
    +    assert "<key>" in out
    +    assert "<value>" in out
    +    # --force escape hatch must be documented in the usage line.
    +    assert "--force" in out
     
     
     def test_config_unknown_command_help_marks_placeholders(capsys):
    ```
  - `tests/hermes_cli/test_set_config_value.py`

    ```diff
    @@ -146,9 +146,11 @@ def test_empty_string_routes_to_config(self, _isolated_hermes_home):
     
         def test_zero_routes_to_config(self, _isolated_hermes_home):
             """Setting a config key to '0' should write 0 to config.yaml."""
    -        set_config_value("verbose", "0")
    +        # Use a real DEFAULT_CONFIG sub-key so schema validation passes — the
    +        # original test used ``verbose`` which is not in the known schema.
    +        set_config_value("agent.gateway_timeout", "0")
             config = _read_config(_isolated_hermes_home)
    -        assert "verbose: 0" in config
    +        assert "gateway_timeout: 0" in config
     
         def test_config_command_rejects_missing_value(self):
             """config set with no value arg (None) should still exit."""
    ```

    ```diff
    @@ -346,20 +348,23 @@ def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home):
         def test_deeper_nesting_through_list(self, _isolated_hermes_home):
             """Navigation path mixing dict → list → dict → scalar."""
             self._write_config(_isolated_hermes_home, (
    -            "platforms:\n"
    -            "  telegram:\n"
    -            "    allowlist:\n"
    +            "telegram:\n"
    +            "  allowlist:\n"
                 "    - name: alice\n"
                 "      role: admin\n"
                 "    - name: bob\n"
                 "      role: user\n"
             ))
     
    -        set_config_value("platforms.telegram.allowlist.1.role", "admin")
    +        # NOTE: original test path was ``platforms.telegram.allowlist.1.role``,
    +        # which #34067 schema validation correctly rejects (platform configs
    +        # live at the top level, not under a ``platforms`` namespace). Use
    +        # the canonical path.
    +        set_config_value("telegram.allowlist.1.role", "admin")
     
             import yaml
             reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
    -        allowlist = reloaded["platforms"]["telegram"]["allowlist"]
    +        allowlist = reloaded["telegram"]["allowlist"]
             assert isinstance(allowlist, list)
             assert allowlist[0] == {"name": "alice", "role": "admin"}
             assert allowlist[1] == {"name": "bob", "role": "admin"}
    ```

    ```diff
    @@ -398,7 +403,9 @@ def test_non_string_defaults_keep_existing_coercion(
             assert type(node) is type(expected)
     
         def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home):
    -        set_config_value("custom.enabled", "off")
    +        # ``custom`` is not a known top-level key, so it now requires --force
    +        # (schema validation, #34067); coercion behavior is unchanged.
    +        set_config_value("custom.enabled", "off", force=True)
     
             import yaml
             saved = yaml.safe_load(_read_config(_isolated_hermes_home))
    ```

    ```diff
    @@ -457,3 +464,169 @@ def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys):
     
             captured = capsys.readouterr()
             assert "Set model.reasoning_effort = high" in captured.out
    +
    +# #34067: Schema validation for unknown keys
    +# ---------------------------------------------------------------------------
    +
    +class TestSchemaValidation:
    +    """#34067: ``hermes config set`` must not report bare success for
    +    unrecognized keys. The key IS written (arbitrary keys are supported —
    +    top-level scalars bridge into os.environ for skills/external apps), but
    +    a post-write notice warns that Hermes may never read it and suggests the
    +    likely-intended path. Headline case: the plausible-but-wrong
    +    ``gateway.discord.gateway_restart_notification`` (correct path:
    +    ``discord.gateway_restart_notification``).
    +    """
    +
    +    def test_unknown_top_level_key_written_with_notice(self, _isolated_hermes_home, capsys):
    +        """An unknown top-level key is saved AND a notice is printed."""
    +        set_config_value("totally_made_up_key", "value")
    +        out = capsys.readouterr().out
    +        assert "not a recognized config key" in out
    +        assert "totally_made_up_key" in out
    +        assert "saved anyway" in out
    +        # The value WAS written.
    +        assert "totally_made_up_key" in _read_config(_isolated_hermes_home)
    +
    +    def test_unknown_subkey_written_with_notice(self, _isolated_hermes_home, capsys):
    +        """The headline #34067 path: written, but warned about — no more
    +        bare success for gateway.discord.gateway_restart_notification."""
    +        set_config_value("gateway.discord.gateway_restart_notification", "false")
    +        out = capsys.readouterr().out
    +        assert "✓ Set" in out
    +        assert "not a recognized config key" in out
    +
    +    def test_platforms_container_is_accepted(self, _isolated_hermes_home, capsys):
    +        """``platforms.<name>.<field>`` is a valid current shape: gateway/
    +        config.py resolves a top-level ``platforms`` map in addition to the
    +        top-level platform blocks, so it must NOT trigger the notice."""
    +        set_config_value("platforms.discord.enabled", "true")
    +        content = _read_config(_isolated_hermes_home)
    +        assert "enabled: true" in content
    +        assert "not a recognized config key" not in capsys.readouterr().out
    +
    +    def test_gateway_platforms_nested_is_accepted(self, _isolated_hermes_home, capsys):
    +        """Docs configure platforms under ``gateway.platforms.<name>`` — the
    +        canonical layout must validate as known (no notice)."""
    +        set_config_value("gateway.platforms.my_platform.extra.token", "abc")
    +        content = _read_config(_isolated_hermes_home)
    +        assert "token: abc" in content
    +        assert "not a recognized config key" not in capsys.readouterr().out
    +
    +    def test_unknown_approvals_subkey_warns_but_writes(self, _isolated_hermes_home, capsys):
    +        """``approvals`` is a defined schema, so a typo'd sub-key gets the
    +        notice — but is still written."""
    +        set_config_value("approvals.notarealkey", "true")
    +        out = capsys.readouterr().out
    +        assert "not a recognized config key" in out
    +        assert "notarealkey" in _read_config(_isolated_hermes_home)
    +
    +    def test_known_approvals_subkey_is_accepted(self, _isolated_hermes_home, capsys):
    +        """Real ``approvals.*`` keys still validate silently."""
    +        set_config_value("approvals.mode", "off")
    +        import yaml
    +        saved = yaml.safe_load(_read_config(_isolated_hermes_home))
    +        assert saved["approvals"]["mode"] == "off"
    +        assert "not a recognized config key" not in capsys.readouterr().out
    +
    +    def test_close_typo_suggests_correct_key(self, _isolated_hermes_home, capsys):
    +        """Typo'd top-level keys should get a fuzzy-match suggestion."""
    +        set_config_value("disco", "false")
    +        out = capsys.readouterr().out
    +        assert "Did you mean" in out
    +        assert "discord" in out
    +
    +    def test_typoed_subkey_suggests_sibling(self, _isolated_hermes_home, capsys):
    +        """``agent.max_turn`` should suggest ``agent.max_turns``."""
    +        set_config_value("agent.max_turn", "100")
    +        out = capsys.readouterr().out
    +        assert "agent.max_turns" in out
    +
    +    def test_force_suppresses_notice(self, _isolated_hermes_home, capsys):
    +        """``--force`` writes unknown keys without the notice (scripted
    +        forward-compat writes)."""
    +        set_config_value("brand_new_future_key", "value", force=True)
    +        out = capsys.readouterr().out
    +        assert "not a recognized config key" not in out
    +        # And the value WAS written.
    +        content = _read_config(_isolated_hermes_home)
    +        assert "brand_new_future_key" in content
    +
    +    def test_known_top_level_key_accepted(self, _isolated_hermes_home):
    +        """Sanity check: real config keys still work."""
    +        set_config_value("terminal.backend", "docker")
    +        content = _read_config(_isolated_hermes_home)
    +        assert "backend: docker" in content
    +
    +    def test_known_platform_config_accepted(self, _isolated_hermes_home):
    +        """Schema-defined-extensible top-level keys (platform configs) accept
    +        any sub-key path because PlatformConfig has dynamic ``extra`` fields."""
    +        # discord is a platform config — sub-keys accept anything.
    +        set_config_value("discord.gateway_restart_notification", "false")
    +        content = _read_config(_isolated_hermes_home)
    +        assert "gateway_restart_notification: false" in content
    +
    +    def test_open_dict_mcp_servers_accepts_any_subkey(self, _isolated_hermes_home):
    +        """``mcp_servers.<user-named-server>.<field>`` must work for any
    +        user-supplied server name."""
    +        set_config_value("mcp_servers.my-server.command", "npx")
    +        content = _read_config(_isolated_hermes_home)
    +        assert "my-server" in content
    +        assert "command: npx" in content
    +
    +
    +class TestValidateConfigKey:
    +    """Unit tests for the validator itself."""
    +
    +    @pytest.mark.parametrize("key", [
    +        "model",
    +        "terminal.backend",
    +        "agent.max_turns",
    +        "discord.gateway_restart_notification",
    +        "telegram.bot_token",
    +        "mcp_servers.foo.command",
    +        "providers.openrouter.api_key",
    +        "gateway.strict",
    +        "platforms.discord.enabled",
    +        "gateway.platforms.my_platform.extra.token",
    +        "approvals.mode",
    +    ])
    +    def test_known_keys_pass(self, key):
    +        from hermes_cli.config import _validate_config_key
    +        is_known, _ = _validate_config_key(key)
    +        assert is_known, f"Expected {key!r} to validate as known"
    +
    +    @pytest.mark.parametrize("key,expected_in_suggestion", [
    +        ("gateway.discord.gateway_restart_notification", None),  # no close suggestion
    +        ("disco", "discord"),
    +        ("agent.max_turn", "agent.max_turns"),
    +    ])
    +    def test_unknown_keys_with_suggestion(self, key, expected_in_suggestion):
    +        from hermes_cli.config import _validate_config_key
    +        is_known, suggestion = _validate_config_key(key)
    +        assert not is_known, f"Expected {key!r} to validate as unknown"
    +        if expected_in_suggestion is not None:
    +            assert suggestion is not None and expected_in_suggestion in suggestion, \
    +                f"Expected suggestion to contain {expected_in_suggestion!r}, got {suggestion!r}"
    +
    +    @pytest.mark.parametrize("key", [
    +        "_test.shim_marker",
    +        "_internal",
    +        "_test.nested.deep.marker",
    +        "_x",
    +    ])
    +    def test_underscore_prefixed_keys_are_accepted(self, key):
    +        """Underscore-prefixed top-level keys are internal/test markers and
    +        bypass schema validation. The Docker privilege-drop shim test writes
    +        ``_test.shim_marker`` to probe config.yaml ownership; that must not
    +        be rejected. (Regression: #34250 schema validation broke this.)"""
    +        from hermes_cli.config import _validate_config_key
    +        is_known, _ = _validate_config_key(key)
    +        assert is_known, f"Expected underscore-prefixed {key!r} to be accepted"
    +
    +    def test_underscore_only_first_segment_escapes(self):
    +        """The underscore escape only applies to the FIRST segment. A real
    +        typo in a sub-key (e.g. agent._max_turns) is still caught."""
    +        from hermes_cli.config import _validate_config_key
    +        is_known, suggestion = _validate_config_key("agent._max_turns")
    +        assert not is_known, "Sub-key typo under a known top-level key must still be flagged"
    ```
- **#34187: fix(cli): validate keys in hermes config set and add --force flag (#34067)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#34067`, `#67988`, `#34250`
- **#34250: fix(config): validate config-key schema, refuse unknown keys (#34067)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#67988`, `#67924`
- **#41956: fix(cli): resolve config set provider/base_url shortcuts to canonical dotted paths**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/config.py`, `tests/cli/test_config_set_shortcut.py`

## Fix status

- Merged to main in PR #67988 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-20`.
- Subsystem: `hermes_cli`
