{"repo":"NousResearch/hermes-agent","public_id":"14072","display_label":"Protecting Hermes control-plane files from write_file/patch modification","summary":"One issue reports that write_file/patch can modify ~/.hermes control-plane files such as auth.json, config.yaml, and webhook subscriptions. Two pull requests with near-identical titles propose protecting those files from prompt injection; the titles alone do not show their status or implementation details.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30397,"merged_at":"2026-05-22T11:32:14+00:00"},"resolution":{"state":"resolved","by":"pr-30397"},"freshness":{"state":"verified","last_verified_at":"2026-05-22T11:32:14+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14072,"pr_number":30397,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-22T11:32:14+00:00"}],"members":[{"kind":"issue","number":14072,"title":"Security: write_file/patch can modify ~/.hermes control-plane files (auth.json, config.yaml, webhook subscriptions)","url":null,"state":"closed"},{"kind":"pr","number":14157,"title":"fix(security): protect Hermes control-plane files from prompt injection #14072","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1f5219fda","number":0},{"kind":"pr","value":"#30397","number":30397},{"kind":"pr","value":"#15981","number":15981},{"kind":"pr","value":"#14072","number":14072}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30397,"title":"fix(security): protect Hermes control-plane files from prompt injection (#14157)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/file_safety.py","infographic/pr-14157-control-plane-write-deny/infographic.png","tests/tools/test_file_operations.py"],"fix_hunks":[{"file":"agent/file_safety.py","hunks":["@@ -97,6 +97,37 @@ def is_write_denied(path: str) -> bool:\n         if resolved.startswith(prefix):\n             return True\n \n+    # Hermes control-plane files: block both the ACTIVE profile's view\n+    # (hermes_home) AND the global root view. Without the root pass, a\n+    # profile-mode session leaves <root>/auth.json + <root>/config.yaml\n+    # writable — letting a prompt-injected write_file overwrite the global\n+    # files that every profile inherits from (same shape as #15981).\n+    control_file_names = (\"auth.json\", \"config.yaml\", \"webhook_subscriptions.json\")\n+    mcp_tokens_dir_name = \"[redacted:secret-assignment]\"\n+\n+    hermes_dirs = []\n+    for base in (_hermes_home_path(), _hermes_root_path()):\n+        try:\n+            real = os.path.realpath(base)\n+            if real not in hermes_dirs:\n+                hermes_dirs.append(real)\n+        except Exception:\n+            continue\n+\n+    for base_real in hermes_dirs:\n+        for name in control_file_names:\n+            try:\n+                if resolved == os.path.realpath(os.path.join(base_real, name)):\n+                    return True\n+            except Exception:\n+                continue\n+        try:\n+            mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))\n+            if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):\n+                return True\n+        except Exception:\n+            pass\n+\n     safe_root = get_safe_write_root()\n     if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):\n         return True"]},{"file":"tests/tools/test_file_operations.py","hunks":["@@ -60,6 +60,86 @@ def test_project_file_allowed(self):\n     def test_tilde_expansion(self):\n         assert _is_write_denied(\"~/.ssh/authorized_keys\") is True\n \n+    @pytest.mark.parametrize(\n+        \"path\",\n+        [\n+            \"auth.json\",\n+            \"config.yaml\",\n+            \"webhook_subscriptions.json\",\n+            \"mcp-tokens/token1.json\",\n+            \"mcp-tokens/subdir/token2.json\",\n+        ],\n+    )\n+    def test_hermes_control_files_and_mcp_tokens_denied(self, path):\n+        \"\"\"Hermes control files and mcp-tokens entries must be write-denied.\"\"\"\n+        from hermes_constants import get_hermes_home\n+        hermes_home = get_hermes_home()\n+        full_path = str(hermes_home / path)\n+        assert _is_write_denied(full_path) is True\n+\n+    @pytest.mark.parametrize(\n+        \"path\",\n+        [\n+            \"dummy/../config.yaml\",\n+            \"./auth.json\",\n+            \"mcp-tokens/../config.yaml\",\n+        ],\n+    )\n+    def test_hermes_control_files_traversal_denied(self, path):\n+        \"\"\"Path traversal attempts to control files must be blocked by realpath.\"\"\"\n+        from hermes_constants import get_hermes_home\n+        hermes_home = get_hermes_home()\n+        full_path = str(hermes_home / path)\n+        assert _is_write_denied(full_path) is True\n+\n+    @pytest.mark.parametrize(\n+        \"path\",\n+        [\n+            \"/tmp/standard_file.txt\",\n+            \"~/projects/myapp/main.py\",\n+            \"/var/log/app.log\",\n+        ],\n+    )\n+    def test_standard_paths_allowed(self, path):\n+        \"\"\"Unrelated paths must still be allowed.\"\"\"\n+        assert _is_write_denied(path) is False\n+\n+    @pytest.mark.parametrize(\n+        \"name\",\n+        [\"auth.json\", \"config.yaml\", \"webhook_subscriptions.json\"],\n+    )\n+    def test_control_files_protected_in_profile_mode(self, tmp_path, monkeypatch, name):\n+        \"\"\"Under a profile, BOTH <profile>/X and <root>/X must be denied (#15981 shape).\n+\n+        Without the root-level pass, a profile-mode session leaves the\n+        global ~/.hermes/{auth.json,config.yaml,webhook_subscriptions.json}\n+        writable — the same gap PR #15981 fixed for .env.\n+        \"\"\"\n+        # Simulate a profile-mode HERMES_HOME layout:\n+        #   <root>/profiles/coder/{auth.json,config.yaml,...}\n+        #   <root>/{auth.json,config.yaml,...}        ← must also be denied\n+        root = tmp_path / \"hermes\"\n+        profile = root / \"profiles\" / \"coder\"\n+        profile.mkdir(parents=True)\n+        monkeypatch.setenv(\"HERMES_HOME\", str(profile))\n+\n+        # Profile copy\n+        assert _is_write_denied(str(profile / name)) is True\n+        # Root copy — the gap this widening closes\n+        assert _is_write_denied(str(root / name)) is True\n+\n+    def test_mcp_tokens_dir_protected_in_profile_mode(self, tmp_path, monkeypatch):\n+        \"\"\"mcp-tokens/ under profile AND under root must both be denied.\"\"\"\n+        root = tmp_path / \"hermes\"\n+        profile = root / \"profiles\" / \"coder\"\n+        profile.mkdir(parents=True)\n+        monkeypatch.setenv(\"HERMES_HOME\", str(profile))\n+\n+        assert _is_write_denied(str(profile / \"mcp-tokens\" / \"tok.json\")) is True\n+        assert _is_write_denied(str(root / \"mcp-tokens\" / \"tok.json\")) is True\n+        # The directory itself must also be denied (not just files inside)\n+        assert _is_write_denied(str(root / \"mcp-tokens\")) is True\n+\n \n \n # =========================================================================\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}