{"repo":"NousResearch/hermes-agent","public_id":"47515","display_label":"Bool coercion corrupting string enum config values","summary":"A bug where CLI config commands silently convert string values like 'off'/'on' to Python booleans, corrupting schema-validated enum settings. Multiple fix attempts address skipping bool coercion for string-typed or string-enum config keys.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63499},"resolution":{"state":"resolved","by":"pr-63499"},"freshness":{"state":"verified","last_verified_at":"2026-07-13T06:44:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":47515,"pr_number":63499,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":47515,"title":"[Bug]: `hermes config set` silently coerces enum string values \"off\"/\"on\" to Python booleans, corrupting schema-validated enum settings","url":null,"state":"closed"},{"kind":"pr","number":47519,"title":"fix(cli): skip bool coercion for string-typed config keys","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63499","number":63499}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":47607,"title":"fix(config): skip bool coercion for string-enum config keys (#47515)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63499","number":63499}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47618,"title":"fix(cli): skip bool coercion for string-typed config keys","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63499","number":63499}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":63499,"title":"fix(config): preserve string-typed config values","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_set_config_value.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -8097,6 +8097,20 @@ def edit_config():\n     subprocess.run([editor, str(config_path)])\n \n \n+def _default_value_for_key(dotted_key: str):\n+    \"\"\"Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``.\n+\n+    Unknown keys and non-leaf paths return ``None`` so they retain the legacy\n+    best-effort coercion used by ``config set``.\n+    \"\"\"\n+    node = DEFAULT_CONFIG\n+    for part in dotted_key.split(\".\"):\n+        if not isinstance(node, dict) or part not in node:\n+            return None\n+        node = node[part]\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     if is_managed():","@@ -8154,16 +8168,21 @@ def set_config_value(key: str, value: str):\n     # _set_nested which preserves list-typed nodes; before #17876 the\n     # inline navigation here silently overwrote lists with dicts.\n \n-    # Convert value to appropriate type\n-    if value.lower() in {'true', 'yes', 'on'}:\n-        value = True\n-    elif value.lower() in {'false', 'no', 'off'}:\n-        value = False\n-    elif value.isdigit():\n-        value = int(value)\n-    elif value.replace('.', '', 1).isdigit():\n-        value = float(value)\n-\n+    # Preserve values for string-typed settings.  In particular, enum members\n+    # such as approvals.mode=\"off\" must not become YAML booleans.  Unknown keys\n+    # retain the historical best-effort coercion behavior.\n+    coerced_value: Any = value\n+    if not isinstance(_default_value_for_key(key), str):\n+        if value.lower() in {'true', 'yes', 'on'}:\n+            coerced_value = True\n+        elif value.lower() in {'false', 'no', 'off'}:\n+            coerced_value = False\n+        elif value.isdigit():\n+            coerced_value = int(value)\n+        elif value.replace('.', '', 1).isdigit():\n+            coerced_value = float(value)\n+\n+    value = coerced_value\n     _set_nested(user_config, key, value)\n     # Normalize the api_base → base_url alias at set-time too (issue #8919),\n     # so a fresh `hermes config set model.api_base ...` lands on the canonical"]},{"file":"tests/hermes_cli/test_set_config_value.py","hunks":["@@ -249,6 +249,46 @@ def test_deeper_nesting_through_list(self, _isolated_hermes_home):\n         assert allowlist[1] == {\"name\": \"bob\", \"role\": \"admin\"}\n \n \n+# ---------------------------------------------------------------------------\n+# String-typed config values — regression tests for #47515\n+# ---------------------------------------------------------------------------\n+\n+class TestStringTypedConfigValues:\n+    @pytest.mark.parametrize(\"value\", [\"off\", \"on\", \"yes\", \"no\", \"true\", \"false\", \"01\"])\n+    def test_string_typed_values_are_not_coerced(self, _isolated_hermes_home, value):\n+        \"\"\"Values stay strings when DEFAULT_CONFIG declares the leaf as a string.\"\"\"\n+        set_config_value(\"approvals.mode\", value)\n+\n+        import yaml\n+        saved = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        assert saved[\"approvals\"][\"mode\"] == value\n+        assert isinstance(saved[\"approvals\"][\"mode\"], str)\n+\n+    @pytest.mark.parametrize(\"key, value, expected\", [\n+        (\"terminal.persistent_shell\", \"off\", False),\n+        (\"approvals.timeout\", \"30\", 30),\n+    ])\n+    def test_non_string_defaults_keep_existing_coercion(\n+        self, _isolated_hermes_home, key, value, expected\n+    ):\n+        set_config_value(key, value)\n+\n+        import yaml\n+        saved = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        node = saved\n+        for part in key.split(\".\"):\n+            node = node[part]\n+        assert node == expected\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+\n+        import yaml\n+        saved = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        assert saved[\"custom\"][\"enabled\"] is False\n+\n+\n # ---------------------------------------------------------------------------\n # Secret redaction in display output (issue #50245)\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}