{"repo":"NousResearch/hermes-agent","public_id":"8035","display_label":"Hermes file and execution security boundary issues","summary":"These items concern security gaps where file read/write tools and code execution can access or modify Hermes credential stores, control-plane configs, and safety boundaries, along with proposed fixes involving path blocking, approval gating, and write-scope enforcement.","subsystem":"agent","counts":{"members":23,"open_issues":4,"open_issues_distinct":4,"open_prs":5,"closed_issues":6,"merged_prs":2,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30397},"resolution":{"state":"partially_resolved","by":"pr-30397,pr-30721"},"freshness":{"state":"verified","last_verified_at":"2026-05-23T03:15:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.06956521739130432},"fix_edges":[{"issue_number":8035,"pr_number":30721,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14072,"pr_number":30397,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17656,"pr_number":30721,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8035,"title":"Security: file_tools read path does not block ~/.hermes/auth.json and mcp-tokens/","url":null,"state":"closed"},{"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":"issue","number":17656,"title":"Security: read_file can exfiltrate credentials from auth.json and .anthropic_oauth.json","url":null,"state":"closed"},{"kind":"issue","number":36645,"title":"【Bug】terminal/execute_code bypasses HERMES_WRITE_SAFE_ROOT file safety","url":null,"state":"open"},{"kind":"issue","number":42701,"title":"fix(approval): smart mode blind to config mutations that break gateway connectivity","url":null,"state":"closed"},{"kind":"issue","number":42702,"title":"[Bug]: smart approval mode silently allows config mutations that break gateway connectivity","url":null,"state":"closed"},{"kind":"issue","number":45563,"title":"patch/write_file tool silently refuses Hermes config edit — should route through approvals system instead of hard-blocking","url":null,"state":"open"},{"kind":"issue","number":49576,"title":"Security: execute_code (Python) bypasses agent file edit restrictions","url":null,"state":"closed"},{"kind":"issue","number":49578,"title":"[Bug]: execute_code (Python) bypasses agent file edit restrictions","url":null,"state":"open"},{"kind":"issue","number":59293,"title":"hermes config set bypasses the system-config write protection — the CLI can disable the approval layer un-gated","url":null,"state":"open"},{"kind":"pr","number":8055,"title":"fix(security): block file_tools from reading auth.json, mcp-tokens, .env","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17659","number":17659},{"kind":"pr","value":"#30721","number":30721}],"category":"merged_via","implication":"superseded"},"merged":false},{"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":17659,"title":"fix(file-safety): block read_file on HERMES_HOME credential stores (#17656)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30721","number":30721},{"kind":"pr","value":"#8055","number":8055},{"kind":"pr","value":"#17656","number":17656}],"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","implication":"superseded"},"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"]}]},{"kind":"pr","number":30721,"title":"fix(file-safety): defense-in-depth read-deny on Hermes credential stores (#17659)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/file_safety.py","infographic/pr-17659-read-deny-credentials/infographic.png","tests/agent/test_file_safety_credentials.py","tools/file_tools.py"],"fix_hunks":[{"file":"agent/file_safety.py","hunks":["@@ -136,21 +136,121 @@ def is_write_denied(path: str) -> bool:\n \n \n def get_read_block_error(path: str) -> Optional[str]:\n-    \"\"\"Return an error message when a read targets internal Hermes cache files.\"\"\"\n+    \"\"\"Return an error message when a read targets a denied Hermes path.\n+\n+    Two categories are blocked:\n+\n+      * Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` —\n+        readable metadata that an attacker could use as a prompt-injection\n+        carrier.\n+      * Credential / secret stores under HERMES_HOME and the global Hermes\n+        root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,\n+        ``.env``, ``webhook_subscriptions.json``, and anything under\n+        ``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens,\n+        and HMAC secrets that the agent never needs to read directly —\n+        provider tools / gateway adapters consume them through internal\n+        channels.\n+\n+    **This is NOT a security boundary.** The terminal tool runs as the\n+    same OS user with shell access; the agent can still ``cat auth.json``\n+    or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists\n+    as defense-in-depth that:\n+\n+      * Returns a clear error to models that respect tool denials, which\n+        empirically prompts most modern models to stop rather than reach\n+        for the shell.\n+      * Surfaces a visible audit trail when something tries to read\n+        credentials — easier to spot in logs than a generic ``cat``.\n+\n+    Treat any user-visible framing around this as \"may help\" rather than\n+    \"stops attackers.\" A determined model or malicious instruction can\n+    always shell out.\n+\n+    Callers that resolve relative paths against a non-process cwd\n+    (e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve\n+    and pass the absolute path string.  This function's own ``resolve()``\n+    is anchored at the Python process cwd, so a relative input like\n+    ``\"auth.json\"`` would otherwise miss the denylist when the task's\n+    terminal cwd differs from the process cwd.\n+    \"\"\"\n     resolved = Path(path).expanduser().resolve()\n-    hermes_home = _hermes_home_path().resolve()\n-    blocked_dirs = [\n-        hermes_home / \"skills\" / \".hub\" / \"index-cache\",\n-        hermes_home / \"skills\" / \".hub\",\n-    ]\n-    for blocked in blocked_dirs:\n+\n+    # Resolve BOTH the active HERMES_HOME (profile-aware) AND the global\n+    # Hermes root so credential stores at <root>/auth.json etc. are also\n+    # blocked when running under a profile (HERMES_HOME points at\n+    # <root>/profiles/<name> in profile mode). Same shape as the write\n+    # deny widening (#15981, #14157).\n+    hermes_dirs: list[Path] = []\n+    for base in (_hermes_home_path(), _hermes_root_path()):\n         try:\n-            resolved.relative_to(blocked)\n+            real = base.resolve()\n+            if real not in hermes_dirs:\n+                hermes_dirs.append(real)\n+        except Exception:\n+            continue\n+\n+    # Skills .hub: prompt-injection carriers.\n+    for hd in hermes_dirs:\n+        blocked_dirs = [\n+            hd / \"skills\" / \".hub\" / \"index-cache\",\n+            hd / \"skills\" / \".hub\",\n+        ]\n+        for blocked in blocked_dirs:\n+            try:\n+                resolved.relative_to(blocked)\n+            except ValueError:\n+                continue\n+            return (\n+                f\"Access denied: {path} is an internal Hermes cache file \"\n+                \"and cannot be read directly to prevent prompt injection. \"\n+                \"Use the skills_list or skill_view tools instead.\"\n+            )\n+\n+    # Credential / secret stores. Exact-file matches under either\n+    # HERMES_HOME or <root>.\n+    credential_file_names = (\n+        \"auth.json\",\n+        \"auth.lock\",\n+        \".anthropic_oauth.json\",\n+        \".env\",\n+        \"webhook_subscriptions.json\",\n+    )\n+    for hd in hermes_dirs:\n+        for name in credential_file_names:\n+            try:\n+                blocked = (hd / name).resolve()\n+            except Exception:\n+                continue\n+            if resolved == blocked:\n+                return (\n+                    f\"Access denied: {path} is a Hermes credential store \"\n+                    \"and cannot be read directly. Provider tools consume \"\n+                    \"these credentials through internal channels. \"\n+                    \"(Defense-in-depth — not a security boundary; the \"\n+                    \"terminal tool can still bypass.)\"\n+                )\n+\n+    # mcp-tokens/: directory prefix match — anything inside is OAuth\n+    # token material.\n+    for hd in hermes_dirs:\n+        try:\n+            mcp_tokens = (hd / \"mcp-tokens\").resolve()\n+        except Exception:\n+            continue\n+        if resolved == mcp_tokens:\n+            return (\n+                f\"Access denied: {path} is the Hermes MCP token directory \"\n+                \"and cannot be read directly. (Defense-in-depth — not a \"\n+                \"security boundary; the terminal tool can still bypass.)\"\n+            )\n+        try:\n+            resolved.relative_to(mcp_tokens)\n         except ValueError:\n             continue\n         return (\n-            f\"Access denied: {path} is an internal Hermes cache file \"\n-            \"and cannot be read directly to prevent prompt injection. \"\n-            \"Use the skills_list or skill_view tools instead.\"\n+            f\"Access denied: {path} is a Hermes MCP token file \"\n+            \"and cannot be read directly. (Defense-in-depth — not a \"\n+            \"security boundary; the terminal tool can still bypass.)\"\n         )\n+\n     return None"]},{"file":"tests/agent/test_file_safety_credentials.py","hunks":["@@ -0,0 +1,275 @@\n+\"\"\"Tests for HERMES_HOME credential-file read blocking in file_safety.\n+\n+Regression for https://github.com/NousResearch/hermes-agent/issues/17656 —\n+``read_file`` was previously only sandboxed against ``HERMES_HOME`` itself,\n+which left ``auth.json`` and ``.anthropic_oauth.json`` (plaintext provider\n+keys + OAuth tokens) readable by the agent. A prompt-injection reaching\n+``read_file`` could exfiltrate active credentials.\n+\n+These tests verify that ``get_read_block_error`` returns a denial message\n+for the credential stores while leaving arbitrary ``HERMES_HOME`` files\n+readable, and that the existing ``skills/.hub`` deny still applies.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+from pathlib import Path\n+\n+import pytest\n+\n+\n[redacted:email]()\n+def fake_home(tmp_path, monkeypatch):\n+    \"\"\"Point ``_hermes_home_path()`` at a tmp dir for isolated checks.\"\"\"\n+    import agent.file_safety as fs\n+\n+    home = tmp_path / \"hermes_home\"\n+    home.mkdir()\n+    monkeypatch.setattr(fs, \"_hermes_home_path\", lambda: home)\n+    return home\n+\n+\n+def _create(home: Path, rel: str | Path) -> Path:\n+    \"\"\"Create the file (with parents) so realpath() resolves it.\"\"\"\n+    p = home / rel\n+    p.parent.mkdir(parents=True, exist_ok=True)\n+    p.write_text(\"dummy\", encoding=\"utf-8\")\n+    return p\n+\n+\n+def test_auth_json_blocked(fake_home):\n+    from agent.file_safety import get_read_block_error\n+\n+    auth = _create(fake_home, \"auth.json\")\n+    err = get_read_block_error(str(auth))\n+    assert err is not None\n+    assert \"credential store\" in err\n+    assert \"auth.json\" in err\n+\n+\n+def test_auth_lock_blocked(fake_home):\n+    from agent.file_safety import get_read_block_error\n+\n+    lock = _create(fake_home, \"auth.lock\")\n+    err = get_read_block_error(str(lock))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_anthropic_oauth_json_blocked(fake_home):\n+    from agent.file_safety import get_read_block_error\n+\n+    oauth = _create(fake_home, \".anthropic_oauth.json\")\n+    err = get_read_block_error(str(oauth))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_arbitrary_hermes_home_file_not_blocked(fake_home):\n+    \"\"\"Non-credential files inside HERMES_HOME stay readable.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    safe = _create(fake_home, \"session_log.txt\")\n+    assert get_read_block_error(str(safe)) is None\n+\n+\n+def test_subdirectory_named_auth_json_not_blocked(fake_home):\n+    \"\"\"Only the top-level auth.json is the credential store; a file with the\n+    same name in a subdirectory (e.g., a skill mock) must remain readable.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    nested = _create(fake_home, Path(\"skills\") / \"my-skill\" / \"auth.json\")\n+    assert get_read_block_error(str(nested)) is None\n+\n+\n+def test_skills_hub_block_still_applies(fake_home):\n+    \"\"\"Regression guard: the original skills/.hub deny must keep working.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    hub_file = _create(fake_home, \"skills/.hub/manifest.json\")\n+    err = get_read_block_error(str(hub_file))\n+    assert err is not None\n+    assert \"internal Hermes cache file\" in err\n+\n+\n+def test_path_traversal_resolves_to_blocked(fake_home, tmp_path):\n+    \"\"\"A path that traverses through a sibling dir back into HERMES_HOME's\n+    auth.json must still be caught — the check resolves through realpath.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    _create(fake_home, \"auth.json\")\n+    sibling = tmp_path / \"elsewhere\"\n+    sibling.mkdir()\n+    traversal = sibling / \"..\" / \"hermes_home\" / \"auth.json\"\n+    err = get_read_block_error(str(traversal))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_symlink_to_auth_json_blocked(fake_home, tmp_path):\n+    \"\"\"A symlink pointing at HERMES_HOME/auth.json from outside the home\n+    must be blocked — readlink-resolution catches the indirection.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    target = _create(fake_home, \"auth.json\")\n+    link = tmp_path / \"shim.json\"\n+    try:\n+        os.symlink(target, link)\n+    except (OSError, NotImplementedError):\n+        pytest.skip(\"symlinks not supported on this platform/filesystem\")\n+    err = get_read_block_error(str(link))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_read_file_tool_blocks_relative_path_under_terminal_cwd(\n+    fake_home, tmp_path, monkeypatch\n+):\n+    \"\"\"Bypass guard: a relative path like ``\"auth.json\"`` resolved by\n+    ``read_file_tool`` against ``TERMINAL_CWD == HERMES_HOME`` must still\n+    be blocked, even though ``get_read_block_error``'s own ``resolve()``\n+    is anchored at the (different) Python process cwd.\n+    \"\"\"\n+    import json\n+\n+    import tools.file_tools as ft\n+\n+    _create(fake_home, \"auth.json\")\n+    # Force the file_tools resolver to anchor relative paths at HERMES_HOME\n+    # while the Python process cwd remains tmp_path (a different directory).\n+    monkeypatch.setenv(\"TERMINAL_CWD\", str(fake_home))\n+    monkeypatch.chdir(tmp_path)\n+    monkeypatch.setattr(\n+        ft, \"_get_live_tracking_cwd\", lambda task_id=\"default\": None\n+    )\n+\n+    out = json.loads(ft.read_file_tool(\"auth.json\"))\n+    assert \"error\" in out\n+    assert \"credential store\" in out[\"error\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Widening: .env, webhook_subscriptions.json, mcp-tokens/\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_dotenv_blocked(fake_home):\n+    \"\"\".env in HERMES_HOME holds API keys — blocked.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    env = _create(fake_home, \".env\")\n+    err = get_read_block_error(str(env))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_webhook_subscriptions_blocked(fake_home):\n+    \"\"\"webhook_subscriptions.json holds per-route HMAC secrets — blocked.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    subs = _create(fake_home, \"webhook_subscriptions.json\")\n+    err = get_read_block_error(str(subs))\n+    assert err is not None\n+    assert \"credential store\" in err\n+\n+\n+def test_mcp_tokens_file_blocked(fake_home):\n+    \"\"\"Files under mcp-tokens/ hold OAuth tokens — blocked.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    tok = _create(fake_home, Path(\"mcp-tokens\") / \"github.json\")\n+    err = get_read_block_error(str(tok))\n+    assert err is not None\n+    assert \"MCP token\" in err\n+\n+\n+def test_mcp_tokens_nested_blocked(fake_home):\n+    \"\"\"Nested files inside mcp-tokens/ are also blocked.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    tok = _create(fake_home, Path(\"mcp-tokens\") / \"providers\" / \"azure.json\")\n+    err = get_read_block_error(str(tok))\n+    assert err is not None\n+    assert \"MCP token\" in err\n+\n+\n+def test_mcp_tokens_dir_itself_blocked(fake_home):\n+    \"\"\"The mcp-tokens directory itself is blocked (listing is exfiltrating).\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    tokens_dir = [redacted:secret-assignment] / \"mcp-tokens\"\n+    tokens_dir.mkdir(parents=True, exist_ok=True)\n+    err = get_read_block_error(str(tokens_dir))\n+    assert err is not None\n+    assert \"MCP token\" in err\n+\n+\n+def test_identically_named_files_outside_hermes_home_not_blocked(\n+    fake_home, tmp_path\n+):\n+    \"\"\"A project's ``.env``, ``auth.json``, or ``mcp-tokens/`` outside\n+    HERMES_HOME must remain readable — the gate is per-location, not\n+    per-filename.\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    project = tmp_path / \"myproject\"\n+    project.mkdir()\n+    for rel in (\".env\", \"auth.json\"):\n+        p = project / rel\n+        p.write_text(\"not secret here\", encoding=\"utf-8\")\n+        assert get_read_block_error(str(p)) is None, (\n+            f\"{rel} outside HERMES_HOME should NOT be blocked\"\n+        )\n+\n+    tokens = project / \"mcp-tokens\"\n+    tokens.mkdir()\n+    tok_file = tokens / \"token.json\"\n+    tok_file.write_text(\"not really a token\", encoding=\"utf-8\")\n+    assert get_read_block_error(str(tok_file)) is None\n+\n+\n+def test_config_yaml_not_blocked(fake_home):\n+    \"\"\"config.yaml is NOT a credential file — agent should still be\n+    able to read it for debugging.  (Writes are denied separately by\n+    is_write_denied; reads stay allowed.)\"\"\"\n+    from agent.file_safety import get_read_block_error\n+\n+    cfg = _create(fake_home, \"config.yaml\")\n+    assert get_read_block_error(str(cfg)) is None\n+\n+\n+def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch):\n+    \"\"\"Under a profile, HERMES_HOME = <root>/profiles/<name>, but\n+    <root>/auth.json must ALSO be blocked — credentials at root are\n+    inherited by every profile.\"\"\"\n+    import agent.file_safety as fs\n+\n+    root = tmp_path / \"hermes\"\n+    profile = root / \"profiles\" / \"coder\"\n+    profile.mkdir(parents=True)\n+    monkeypatch.setattr(fs, \"_hermes_home_path\", lambda: profile)\n+    monkeypatch.setattr(fs, \"_hermes_root_path\", lambda: root)\n+\n+    from agent.file_safety import get_read_block_error\n+\n+    # Profile-local credential store: blocked\n+    profile_auth = profile / \"auth.json\"\n+    profile_auth.write_text(\"x\")\n+    assert \"credential store\" in (get_read_block_error(str(profile_auth)) or \"\")\n+\n+    # Root-level credential store: ALSO blocked (this is the widening)\n+    root_auth = root / \"auth.json\"\n+    root_auth.write_text(\"x\")\n+    assert \"credential store\" in (get_read_block_error(str(root_auth)) or \"\")\n+\n+    # Root-level .env: blocked too\n+    root_env = root / \".env\"\n+    root_env.write_text(\"x\")\n+    assert \"credential store\" in (get_read_block_error(str(root_env)) or \"\")\n+\n+    # Root-level mcp-tokens: blocked\n+    root_tok = root / \"mcp-tokens\" / \"gh.json\"\n+    root_tok.parent.mkdir(parents=True, exist_ok=True)\n+    root_tok.write_text(\"x\")\n+    assert \"MCP token\" in (get_read_block_error(str(root_tok)) or \"\")"]},{"file":"tools/file_tools.py","hunks":["@@ -474,8 +474,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =\n             })\n \n         # ── Hermes internal path guard ────────────────────────────────\n-        # Prevent prompt injection via catalog or hub metadata files.\n-        block_error = get_read_block_error(path)\n+        # Prevent prompt injection via catalog or hub metadata files,\n+        # and block credential stores under HERMES_HOME.  Pass the\n+        # already-resolved path so a relative-path read against\n+        # TERMINAL_CWD == HERMES_HOME (e.g. \"auth.json\") still hits the\n+        # denylist — get_read_block_error's own resolve() runs against\n+        # the Python process cwd, which can differ.\n+        block_error = get_read_block_error(str(_resolved))\n         if block_error:\n             return json.dumps({\"error\": block_error})\n \n"]}]},{"kind":"pr","number":36826,"title":"fix(security): enforce HERMES_WRITE_SAFE_ROOT in terminal/execute_code (#36645)","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":39004,"title":"feat(security): add opt-in execution write scope (#36645)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"e078c8c6e","number":0},{"kind":"commit","value":"e078c8c6e","number":0},{"kind":"pr","value":"#70688","number":70688}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42708,"title":"fix(approval): flag model config mutations in DANGEROUS_PATTERNS","url":null,"state":"closed","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45692,"title":"fix(file): route Hermes config writes through approvals","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48552","number":48552}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59337,"title":"fix(approval): gate security config CLI mutations","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"914d10be3355e65fc1a4dbe56a839eb0f8302149","number":0},{"kind":"commit","value":"130b8f2c5dbca93a81aa396dd2ba44420d78f6f0","number":0},{"kind":"pr","value":"#59293","number":59293},{"kind":"pr","value":"#104059","number":104059},{"kind":"pr","value":"#81108","number":81108},{"kind":"pr","value":"#121758","number":121758},{"kind":"pr","value":"#81108","number":81108},{"kind":"pr","value":"#121758","number":121758}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":65592,"title":"fix(security): prevent approval-dialog bypass via execute_code and tool retry","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"fdfe26304a","number":0},{"kind":"commit","value":"69d126be9f","number":0},{"kind":"pr","value":"#113450","number":113450},{"kind":"pr","value":"#113458","number":113458},{"kind":"pr","value":"#113450","number":113450},{"kind":"pr","value":"#113458","number":113458},{"kind":"pr","value":"#113459","number":113459},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#113450","number":113450},{"kind":"pr","value":"#113458","number":113458},{"kind":"pr","value":"#113459","number":113459},{"kind":"pr","value":"#31683","number":31683},{"kind":"pr","value":"#46900","number":46900},{"kind":"pr","value":"#97657","number":97657},{"kind":"pr","value":"#94647","number":94647},{"kind":"pr","value":"#65592","number":65592},{"kind":"pr","value":"#49578","number":49578},{"kind":"pr","value":"#113450","number":113450},{"kind":"pr","value":"#113458","number":113458},{"kind":"pr","value":"#113459","number":113459}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":104110,"title":"docs(security): approvals.deny recipe for hermes config set/unset","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5ba559c9e4b1c8397df7788e319ab634144ca728","number":0},{"kind":"commit","value":"21b1a043d86742076834ea8de54b4f323eece36d","number":0},{"kind":"pr","value":"#59293","number":59293}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":121758,"title":"feat(terminal): add opt-in local execution broker","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}