# NousResearch/hermes-agent · Hermes file and execution security boundary issues

## Open now

- 4 issues, 5 PRs open on GitHub.
- #36645: 【Bug】terminal/execute_code bypasses HERMES_WRITE_SAFE_ROOT file safety
- #45563: patch/write_file tool silently refuses Hermes config edit — should route through approvals system instead of hard-blocking
- #49578: \[Bug\]: execute_code (Python) bypasses agent file edit restrictions
- #59293: hermes config set bypasses the system-config write protection — the CLI can disable the approval layer un-gated
- PR #39004: feat(security): add opt-in execution write scope (#36645)
- PR #59337: fix(approval): gate security config CLI mutations
- PR #65592: fix(security): prevent approval-dialog bypass via execute_code and tool retry
- PR #104110: docs(security): approvals.deny recipe for hermes config set/unset
- PR #121758: feat(terminal): add opt-in local execution broker

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.

## TL;DR

- 23 member(s): 4 open issue(s), 6 closed issue(s), 5 open PR(s), 2 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #30397. Verify it addresses your case.
- **Recommended:** read PR #30397 (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-30397,pr-30721`. 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.07`: 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

- #36645: 【Bug】terminal/execute_code bypasses HERMES_WRITE_SAFE_ROOT file safety
- #45563: patch/write_file tool silently refuses Hermes config edit — should route through approvals system instead of hard-blocking
- #49578: \[Bug\]: execute_code (Python) bypasses agent file edit restrictions
- #59293: hermes config set bypasses the system-config write protection — the CLI can disable the approval layer un-gated

## Closed / duplicate issues

- #8035: Security: file_tools read path does not block ~/.hermes/auth.json and mcp-tokens/
- #14072: Security: write_file/patch can modify ~/.hermes control-plane files (auth.json, config.yaml, webhook subscriptions)
- #17656: Security: read_file can exfiltrate credentials from auth.json and .anthropic_oauth.json
- #42701: fix(approval): smart mode blind to config mutations that break gateway connectivity
- #42702: \[Bug\]: smart approval mode silently allows config mutations that break gateway connectivity
- #49576: Security: execute_code (Python) bypasses agent file edit restrictions

## Prior & competing PRs

- **#30397: fix(security): protect Hermes control-plane files from prompt injection (#14157)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/file_safety.py`, `infographic/pr-14157-control-plane-write-deny/infographic.png`, `tests/tools/test_file_operations.py`
  - fix hunks:
  - `agent/file_safety.py`

    ```diff
    @@ -97,6 +97,37 @@ def is_write_denied(path: str) -> bool:
             if resolved.startswith(prefix):
                 return True
     
    +    # Hermes control-plane files: block both the ACTIVE profile's view
    +    # (hermes_home) AND the global root view. Without the root pass, a
    +    # profile-mode session leaves <root>/auth.json + <root>/config.yaml
    +    # writable — letting a prompt-injected write_file overwrite the global
    +    # files that every profile inherits from (same shape as #15981).
    +    control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
    +    mcp_tokens_dir_name = "[redacted:secret-assignment]"
    +
    +    hermes_dirs = []
    +    for base in (_hermes_home_path(), _hermes_root_path()):
    +        try:
    +            real = os.path.realpath(base)
    +            if real not in hermes_dirs:
    +                hermes_dirs.append(real)
    +        except Exception:
    +            continue
    +
    +    for base_real in hermes_dirs:
    +        for name in control_file_names:
    +            try:
    +                if resolved == os.path.realpath(os.path.join(base_real, name)):
    +                    return True
    +            except Exception:
    +                continue
    +        try:
    +            mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
    +            if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
    +                return True
    +        except Exception:
    +            pass
    +
         safe_root = get_safe_write_root()
         if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
             return True
    ```
  - `tests/tools/test_file_operations.py`

    ```diff
    @@ -60,6 +60,86 @@ def test_project_file_allowed(self):
         def test_tilde_expansion(self):
             assert _is_write_denied("~/.ssh/authorized_keys") is True
     
    +    @pytest.mark.parametrize(
    +        "path",
    +        [
    +            "auth.json",
    +            "config.yaml",
    +            "webhook_subscriptions.json",
    +            "mcp-tokens/token1.json",
    +            "mcp-tokens/subdir/token2.json",
    +        ],
    +    )
    +    def test_hermes_control_files_and_mcp_tokens_denied(self, path):
    +        """Hermes control files and mcp-tokens entries must be write-denied."""
    +        from hermes_constants import get_hermes_home
    +        hermes_home = get_hermes_home()
    +        full_path = str(hermes_home / path)
    +        assert _is_write_denied(full_path) is True
    +
    +    @pytest.mark.parametrize(
    +        "path",
    +        [
    +            "dummy/../config.yaml",
    +            "./auth.json",
    +            "mcp-tokens/../config.yaml",
    +        ],
    +    )
    +    def test_hermes_control_files_traversal_denied(self, path):
    +        """Path traversal attempts to control files must be blocked by realpath."""
    +        from hermes_constants import get_hermes_home
    +        hermes_home = get_hermes_home()
    +        full_path = str(hermes_home / path)
    +        assert _is_write_denied(full_path) is True
    +
    +    @pytest.mark.parametrize(
    +        "path",
    +        [
    +            "/tmp/standard_file.txt",
    +            "~/projects/myapp/main.py",
    +            "/var/log/app.log",
    +        ],
    +    )
    +    def test_standard_paths_allowed(self, path):
    +        """Unrelated paths must still be allowed."""
    +        assert _is_write_denied(path) is False
    +
    +    @pytest.mark.parametrize(
    +        "name",
    +        ["auth.json", "config.yaml", "webhook_subscriptions.json"],
    +    )
    +    def test_control_files_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
    +        """Under a profile, BOTH <profile>/X and <root>/X must be denied (#15981 shape).
    +
    +        Without the root-level pass, a profile-mode session leaves the
    +        global ~/.hermes/{auth.json,config.yaml,webhook_subscriptions.json}
    +        writable — the same gap PR #15981 fixed for .env.
    +        """
    +        # Simulate a profile-mode HERMES_HOME layout:
    +        #   <root>/profiles/coder/{auth.json,config.yaml,...}
    +        #   <root>/{auth.json,config.yaml,...}        ← must also be denied
    +        root = tmp_path / "hermes"
    +        profile = root / "profiles" / "coder"
    +        profile.mkdir(parents=True)
    +        monkeypatch.setenv("HERMES_HOME", str(profile))
    +
    +        # Profile copy
    +        assert _is_write_denied(str(profile / name)) is True
    +        # Root copy — the gap this widening closes
    +        assert _is_write_denied(str(root / name)) is True
    +
    +    def test_mcp_tokens_dir_protected_in_profile_mode(self, tmp_path, monkeypatch):
    +        """mcp-tokens/ under profile AND under root must both be denied."""
    +        root = tmp_path / "hermes"
    +        profile = root / "profiles" / "coder"
    +        profile.mkdir(parents=True)
    +        monkeypatch.setenv("HERMES_HOME", str(profile))
    +
    +        assert _is_write_denied(str(profile / "mcp-tokens" / "tok.json")) is True
    +        assert _is_write_denied(str(root / "mcp-tokens" / "tok.json")) is True
    +        # The directory itself must also be denied (not just files inside)
    +        assert _is_write_denied(str(root / "mcp-tokens")) is True
    +
     
     
     # =========================================================================
    ```
- **#30721: fix(file-safety): defense-in-depth read-deny on Hermes credential stores (#17659)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/file_safety.py`

    ```diff
    @@ -136,21 +136,121 @@ def is_write_denied(path: str) -> bool:
     
     
     def get_read_block_error(path: str) -> Optional[str]:
    -    """Return an error message when a read targets internal Hermes cache files."""
    +    """Return an error message when a read targets a denied Hermes path.
    +
    +    Two categories are blocked:
    +
    +      * Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` —
    +        readable metadata that an attacker could use as a prompt-injection
    +        carrier.
    +      * Credential / secret stores under HERMES_HOME and the global Hermes
    +        root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
    +        ``.env``, ``webhook_subscriptions.json``, and anything under
    +        ``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens,
    +        and HMAC secrets that the agent never needs to read directly —
    +        provider tools / gateway adapters consume them through internal
    +        channels.
    +
    +    **This is NOT a security boundary.** The terminal tool runs as the
    +    same OS user with shell access; the agent can still ``cat auth.json``
    +    or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists
    +    as defense-in-depth that:
    +
    +      * Returns a clear error to models that respect tool denials, which
    +        empirically prompts most modern models to stop rather than reach
    +        for the shell.
    +      * Surfaces a visible audit trail when something tries to read
    +        credentials — easier to spot in logs than a generic ``cat``.
    +
    +    Treat any user-visible framing around this as "may help" rather than
    +    "stops attackers." A determined model or malicious instruction can
    +    always shell out.
    +
    +    Callers that resolve relative paths against a non-process cwd
    +    (e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve
    +    and pass the absolute path string.  This function's own ``resolve()``
    +    is anchored at the Python process cwd, so a relative input like
    +    ``"auth.json"`` would otherwise miss the denylist when the task's
    +    terminal cwd differs from the process cwd.
    +    """
         resolved = Path(path).expanduser().resolve()
    -    hermes_home = _hermes_home_path().resolve()
    -    blocked_dirs = [
    -        hermes_home / "skills" / ".hub" / "index-cache",
    -        hermes_home / "skills" / ".hub",
    -    ]
    -    for blocked in blocked_dirs:
    +
    +    # Resolve BOTH the active HERMES_HOME (profile-aware) AND the global
    +    # Hermes root so credential stores at <root>/auth.json etc. are also
    +    # blocked when running under a profile (HERMES_HOME points at
    +    # <root>/profiles/<name> in profile mode). Same shape as the write
    +    # deny widening (#15981, #14157).
    +    hermes_dirs: list[Path] = []
    +    for base in (_hermes_home_path(), _hermes_root_path()):
             try:
    -            resolved.relative_to(blocked)
    +            real = base.resolve()
    +            if real not in hermes_dirs:
    +                hermes_dirs.append(real)
    +        except Exception:
    +            continue
    +
    +    # Skills .hub: prompt-injection carriers.
    +    for hd in hermes_dirs:
    +        blocked_dirs = [
    +            hd / "skills" / ".hub" / "index-cache",
    +            hd / "skills" / ".hub",
    +        ]
    +        for blocked in blocked_dirs:
    +            try:
    +                resolved.relative_to(blocked)
    +            except ValueError:
    +                continue
    +            return (
    +                f"Access denied: {path} is an internal Hermes cache file "
    +                "and cannot be read directly to prevent prompt injection. "
    +                "Use the skills_list or skill_view tools instead."
    +            )
    +
    +    # Credential / secret stores. Exact-file matches under either
    +    # HERMES_HOME or <root>.
    +    credential_file_names = (
    +        "auth.json",
    +        "auth.lock",
    +        ".anthropic_oauth.json",
    +        ".env",
    +        "webhook_subscriptions.json",
    +    )
    +    for hd in hermes_dirs:
    +        for name in credential_file_names:
    +            try:
    +                blocked = (hd / name).resolve()
    +            except Exception:
    +                continue
    +            if resolved == blocked:
    +                return (
    +                    f"Access denied: {path} is a Hermes credential store "
    +                    "and cannot be read directly. Provider tools consume "
    +                    "these credentials through internal channels. "
    +                    "(Defense-in-depth — not a security boundary; the "
    +                    "terminal tool can still bypass.)"
    +                )
    +
    +    # mcp-tokens/: directory prefix match — anything inside is OAuth
    +    # token material.
    +    for hd in hermes_dirs:
    +        try:
    +            mcp_tokens = (hd / "mcp-tokens").resolve()
    +        except Exception:
    +            continue
    +        if resolved == mcp_tokens:
    +            return (
    +                f"Access denied: {path} is the Hermes MCP token directory "
    +                "and cannot be read directly. (Defense-in-depth — not a "
    +                "security boundary; the terminal tool can still bypass.)"
    +            )
    +        try:
    +            resolved.relative_to(mcp_tokens)
             except ValueError:
                 continue
             return (
    -            f"Access denied: {path} is an internal Hermes cache file "
    -            "and cannot be read directly to prevent prompt injection. "
    -            "Use the skills_list or skill_view tools instead."
    +            f"Access denied: {path} is a Hermes MCP token file "
    +            "and cannot be read directly. (Defense-in-depth — not a "
    +            "security boundary; the terminal tool can still bypass.)"
             )
    +
         return None
    ```
  - `tests/agent/test_file_safety_credentials.py`

    ```diff
    @@ -0,0 +1,275 @@
    +"""Tests for HERMES_HOME credential-file read blocking in file_safety.
    +
    +Regression for https://github.com/NousResearch/hermes-agent/issues/17656 —
    +``read_file`` was previously only sandboxed against ``HERMES_HOME`` itself,
    +which left ``auth.json`` and ``.anthropic_oauth.json`` (plaintext provider
    +keys + OAuth tokens) readable by the agent. A prompt-injection reaching
    +``read_file`` could exfiltrate active credentials.
    +
    +These tests verify that ``get_read_block_error`` returns a denial message
    +for the credential stores while leaving arbitrary ``HERMES_HOME`` files
    +readable, and that the existing ``skills/.hub`` deny still applies.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +from pathlib import Path
    +
    +import pytest
    +
    +
    [redacted:email]()
    +def fake_home(tmp_path, monkeypatch):
    +    """Point ``_hermes_home_path()`` at a tmp dir for isolated checks."""
    +    import agent.file_safety as fs
    +
    +    home = tmp_path / "hermes_home"
    +    home.mkdir()
    +    monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
    +    return home
    +
    +
    +def _create(home: Path, rel: str | Path) -> Path:
    +    """Create the file (with parents) so realpath() resolves it."""
    +    p = home / rel
    +    p.parent.mkdir(parents=True, exist_ok=True)
    +    p.write_text("dummy", encoding="utf-8")
    +    return p
    +
    +
    +def test_auth_json_blocked(fake_home):
    +    from agent.file_safety import get_read_block_error
    +
    +    auth = _create(fake_home, "auth.json")
    +    err = get_read_block_error(str(auth))
    +    assert err is not None
    +    assert "credential store" in err
    +    assert "auth.json" in err
    +
    +
    +def test_auth_lock_blocked(fake_home):
    +    from agent.file_safety import get_read_block_error
    +
    +    lock = _create(fake_home, "auth.lock")
    +    err = get_read_block_error(str(lock))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_anthropic_oauth_json_blocked(fake_home):
    +    from agent.file_safety import get_read_block_error
    +
    +    oauth = _create(fake_home, ".anthropic_oauth.json")
    +    err = get_read_block_error(str(oauth))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_arbitrary_hermes_home_file_not_blocked(fake_home):
    +    """Non-credential files inside HERMES_HOME stay readable."""
    +    from agent.file_safety import get_read_block_error
    +
    +    safe = _create(fake_home, "session_log.txt")
    +    assert get_read_block_error(str(safe)) is None
    +
    +
    +def test_subdirectory_named_auth_json_not_blocked(fake_home):
    +    """Only the top-level auth.json is the credential store; a file with the
    +    same name in a subdirectory (e.g., a skill mock) must remain readable."""
    +    from agent.file_safety import get_read_block_error
    +
    +    nested = _create(fake_home, Path("skills") / "my-skill" / "auth.json")
    +    assert get_read_block_error(str(nested)) is None
    +
    +
    +def test_skills_hub_block_still_applies(fake_home):
    +    """Regression guard: the original skills/.hub deny must keep working."""
    +    from agent.file_safety import get_read_block_error
    +
    +    hub_file = _create(fake_home, "skills/.hub/manifest.json")
    +    err = get_read_block_error(str(hub_file))
    +    assert err is not None
    +    assert "internal Hermes cache file" in err
    +
    +
    +def test_path_traversal_resolves_to_blocked(fake_home, tmp_path):
    +    """A path that traverses through a sibling dir back into HERMES_HOME's
    +    auth.json must still be caught — the check resolves through realpath."""
    +    from agent.file_safety import get_read_block_error
    +
    +    _create(fake_home, "auth.json")
    +    sibling = tmp_path / "elsewhere"
    +    sibling.mkdir()
    +    traversal = sibling / ".." / "hermes_home" / "auth.json"
    +    err = get_read_block_error(str(traversal))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_symlink_to_auth_json_blocked(fake_home, tmp_path):
    +    """A symlink pointing at HERMES_HOME/auth.json from outside the home
    +    must be blocked — readlink-resolution catches the indirection."""
    +    from agent.file_safety import get_read_block_error
    +
    +    target = _create(fake_home, "auth.json")
    +    link = tmp_path / "shim.json"
    +    try:
    +        os.symlink(target, link)
    +    except (OSError, NotImplementedError):
    +        pytest.skip("symlinks not supported on this platform/filesystem")
    +    err = get_read_block_error(str(link))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_read_file_tool_blocks_relative_path_under_terminal_cwd(
    +    fake_home, tmp_path, monkeypatch
    +):
    +    """Bypass guard: a relative path like ``"auth.json"`` resolved by
    +    ``read_file_tool`` against ``TERMINAL_CWD == HERMES_HOME`` must still
    +    be blocked, even though ``get_read_block_error``'s own ``resolve()``
    +    is anchored at the (different) Python process cwd.
    +    """
    +    import json
    +
    +    import tools.file_tools as ft
    +
    +    _create(fake_home, "auth.json")
    +    # Force the file_tools resolver to anchor relative paths at HERMES_HOME
    +    # while the Python process cwd remains tmp_path (a different directory).
    +    monkeypatch.setenv("TERMINAL_CWD", str(fake_home))
    +    monkeypatch.chdir(tmp_path)
    +    monkeypatch.setattr(
    +        ft, "_get_live_tracking_cwd", lambda task_id="default": None
    +    )
    +
    +    out = json.loads(ft.read_file_tool("auth.json"))
    +    assert "error" in out
    +    assert "credential store" in out["error"]
    +
    +
    +# ---------------------------------------------------------------------------
    +# Widening: .env, webhook_subscriptions.json, mcp-tokens/
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_dotenv_blocked(fake_home):
    +    """.env in HERMES_HOME holds API keys — blocked."""
    +    from agent.file_safety import get_read_block_error
    +
    +    env = _create(fake_home, ".env")
    +    err = get_read_block_error(str(env))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_webhook_subscriptions_blocked(fake_home):
    +    """webhook_subscriptions.json holds per-route HMAC secrets — blocked."""
    +    from agent.file_safety import get_read_block_error
    +
    +    subs = _create(fake_home, "webhook_subscriptions.json")
    +    err = get_read_block_error(str(subs))
    +    assert err is not None
    +    assert "credential store" in err
    +
    +
    +def test_mcp_tokens_file_blocked(fake_home):
    +    """Files under mcp-tokens/ hold OAuth tokens — blocked."""
    +    from agent.file_safety import get_read_block_error
    +
    +    tok = _create(fake_home, Path("mcp-tokens") / "github.json")
    +    err = get_read_block_error(str(tok))
    +    assert err is not None
    +    assert "MCP token" in err
    +
    +
    +def test_mcp_tokens_nested_blocked(fake_home):
    +    """Nested files inside mcp-tokens/ are also blocked."""
    +    from agent.file_safety import get_read_block_error
    +
    +    tok = _create(fake_home, Path("mcp-tokens") / "providers" / "azure.json")
    +    err = get_read_block_error(str(tok))
    +    assert err is not None
    +    assert "MCP token" in err
    +
    +
    +def test_mcp_tokens_dir_itself_blocked(fake_home):
    +    """The mcp-tokens directory itself is blocked (listing is exfiltrating)."""
    +    from agent.file_safety import get_read_block_error
    +
    +    tokens_dir = [redacted:secret-assignment] / "mcp-tokens"
    +    tokens_dir.mkdir(parents=True, exist_ok=True)
    +    err = get_read_block_error(str(tokens_dir))
    +    assert err is not None
    +    assert "MCP token" in err
    +
    +
    +def test_identically_named_files_outside_hermes_home_not_blocked(
    +    fake_home, tmp_path
    +):
    +    """A project's ``.env``, ``auth.json``, or ``mcp-tokens/`` outside
    +    HERMES_HOME must remain readable — the gate is per-location, not
    +    per-filename."""
    +    from agent.file_safety import get_read_block_error
    +
    +    project = tmp_path / "myproject"
    +    project.mkdir()
    +    for rel in (".env", "auth.json"):
    +        p = project / rel
    +        p.write_text("not secret here", encoding="utf-8")
    +        assert get_read_block_error(str(p)) is None, (
    +            f"{rel} outside HERMES_HOME should NOT be blocked"
    +        )
    +
    +    tokens = project / "mcp-tokens"
    +    tokens.mkdir()
    +    tok_file = tokens / "token.json"
    +    tok_file.write_text("not really a token", encoding="utf-8")
    +    assert get_read_block_error(str(tok_file)) is None
    +
    +
    +def test_config_yaml_not_blocked(fake_home):
    +    """config.yaml is NOT a credential file — agent should still be
    +    able to read it for debugging.  (Writes are denied separately by
    +    is_write_denied; reads stay allowed.)"""
    +    from agent.file_safety import get_read_block_error
    +
    +    cfg = _create(fake_home, "config.yaml")
    +    assert get_read_block_error(str(cfg)) is None
    +
    +
    +def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch):
    +    """Under a profile, HERMES_HOME = <root>/profiles/<name>, but
    +    <root>/auth.json must ALSO be blocked — credentials at root are
    +    inherited by every profile."""
    +    import agent.file_safety as fs
    +
    +    root = tmp_path / "hermes"
    +    profile = root / "profiles" / "coder"
    +    profile.mkdir(parents=True)
    +    monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile)
    +    monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
    +
    +    from agent.file_safety import get_read_block_error
    +
    +    # Profile-local credential store: blocked
    +    profile_auth = profile / "auth.json"
    +    profile_auth.write_text("x")
    +    assert "credential store" in (get_read_block_error(str(profile_auth)) or "")
    +
    +    # Root-level credential store: ALSO blocked (this is the widening)
    +    root_auth = root / "auth.json"
    +    root_auth.write_text("x")
    +    assert "credential store" in (get_read_block_error(str(root_auth)) or "")
    +
    +    # Root-level .env: blocked too
    +    root_env = root / ".env"
    +    root_env.write_text("x")
    +    assert "credential store" in (get_read_block_error(str(root_env)) or "")
    +
    +    # Root-level mcp-tokens: blocked
    +    root_tok = root / "mcp-tokens" / "gh.json"
    +    root_tok.parent.mkdir(parents=True, exist_ok=True)
    +    root_tok.write_text("x")
    +    assert "MCP token" in (get_read_block_error(str(root_tok)) or "")
    ```
  - `tools/file_tools.py`

    ```diff
    @@ -474,8 +474,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
                 })
     
             # ── Hermes internal path guard ────────────────────────────────
    -        # Prevent prompt injection via catalog or hub metadata files.
    -        block_error = get_read_block_error(path)
    +        # Prevent prompt injection via catalog or hub metadata files,
    +        # and block credential stores under HERMES_HOME.  Pass the
    +        # already-resolved path so a relative-path read against
    +        # TERMINAL_CWD == HERMES_HOME (e.g. "auth.json") still hits the
    +        # denylist — get_read_block_error's own resolve() runs against
    +        # the Python process cwd, which can differ.
    +        block_error = get_read_block_error(str(_resolved))
             if block_error:
                 return json.dumps({"error": block_error})
     
    ```
- **#36826: fix(security): enforce HERMES_WRITE_SAFE_ROOT in terminal/execute_code (#36645)**. 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._
- **#42708: fix(approval): flag model config mutations in DANGEROUS_PATTERNS**. closed without merge · fix tier `suggested`
  - maintainer direction: category `partial_fix`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#8055: fix(security): block file_tools from reading auth.json, mcp-tokens, .env**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#17659`, `#30721`
- **#14157: fix(security): protect Hermes control-plane files from prompt injection #14072**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `1f5219fda`, `#30397`, `#15981`, `#14072`
- **#17659: fix(file-safety): block read_file on HERMES_HOME credential stores (#17656)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#30721`, `#8055`, `#17656`
- **#39004: feat(security): add opt-in execution write scope (#36645)**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `e078c8c6e`, `e078c8c6e`, `#70688`
  - files: `agent/prompt_builder.py`, `cli.py`, `gateway/run.py`, `hermes_cli/config.py`, `hermes_cli/config_defaults.py`, `tests/integration/test_execution_write_confinement.py`, `tests/tools/test_code_execution.py`, `tests/tools/test_docker_environment.py`, `tests/tools/test_execution_write_policy.py`, `tests/tools/test_file_tools.py`, `tests/tools/test_terminal_task_cwd.py`, `tests/tools/test_terminal_tool.py`, `tools/code_execution_tool.py`, `tools/environments/base.py`, `tools/environments/docker.py`, `tools/environments/execution_policy.py`, `tools/file_tools.py`, `tools/terminal_tool.py`, `website/docs/reference/environment-variables.md`, `website/docs/user-guide/configuration.md` (+1 more)
- **#45692: fix(file): route Hermes config writes through approvals**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#48552`
- **#59337: fix(approval): gate security config CLI mutations**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `914d10be3355e65fc1a4dbe56a839eb0f8302149`, `130b8f2c5dbca93a81aa396dd2ba44420d78f6f0`, `#59293`, `#104059`, `#81108`, `#121758`, `#81108`, `#121758`
  - files: `tests/tools/test_approval.py`, `tests/tools/test_command_guards.py`, `tools/approval.py`
- **#65592: fix(security): prevent approval-dialog bypass via execute_code and tool retry**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `fdfe26304a`, `69d126be9f`, `#113450`, `#113458`, `#113450`, `#113458`, `#113459`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#49578`, `#113450`, `#113458`, `#113459`, `#31683`, `#46900`, `#97657`, `#94647`, `#65592`, `#49578`, `#113450`, `#113458`, `#113459`
  - files: `agent/turn_tool_round.py`, `tests/tools/test_approval.py`, `tests/tools/test_code_kernel.py`, `tests/tools/test_exec_code_guard.py`, `tests/tools/test_exec_code_guard_adversarial.py`, `tests/tools/test_exec_code_guard_hermes_home.py`, `tools/approval.py`, `tools/approval_detection.py`, `tools/exec_code_policy.py`
- **#104110: docs(security): approvals.deny recipe for hermes config set/unset**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `5ba559c9e4b1c8397df7788e319ab634144ca728`, `21b1a043d86742076834ea8de54b4f323eece36d`, `#59293`
  - files: `website/docs/user-guide/security.md`
- **#121758: feat(terminal): add opt-in local execution broker**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `tests/scripts/test_local_exec_broker.py`, `tests/tools/test_code_kernel.py`, `tests/tools/test_process_registry.py`, `tools/code_kernel.py`, `tools/environments/local.py`, `tools/local_exec_broker.py`, `tools/process_registry.py`

## Fix status

- Merged to main in PR #30397 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-23`.
- Subsystem: `agent`
