Hermes file and execution security boundary issues

NousResearch/hermes-agent · cluster 8035 · agent

Data as of

9 open items. Fix merged in #30397. Check it covers your case.

The short answer

4open issues
5open PRs
23items grouped
Known?
Yes. 23 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #30397. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #30397, #30721. That is separate from the fix event.
Still open
4 issues and 5 PRs.

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.

Open issues4

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

Open PRs5

  • PR#39004feat(security): add opt-in execution write scope (#36645)
  • PR#59337fix(approval): gate security config CLI mutations
  • PR#65592fix(security): prevent approval-dialog bypass via execute_code and tool retry
  • PR#104110docs(security): approvals.deny recipe for hermes config set/unset
  • PR#121758feat(terminal): add opt-in local execution broker

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #30397 fix(security): protect Hermes control-plane files from prompt injection (#14157)

    merged to main

    files: agent/file_safety.py, infographic/pr-14157-control-plane-write-deny/infographic.png, tests/tools/test_file_operations.py

    Show the diff
    • agent/file_safety.py
      @@ -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
      @@ -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

    files: agent/file_safety.py, infographic/pr-17659-read-deny-credentials/infographic.png, tests/agent/test_file_safety_credentials.py, tools/file_tools.py

    Show the diff
    • agent/file_safety.py
      @@ -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
      @@ -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
      @@ -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})
       

6 PRs closed without merging

From the close comments: merged via another change 3 · done elsewhere 1 · partial fix 1 · other 1

  • #8055 fix(security): block file_tools from reading auth.json, mcp-tokens, .env · merged via another change → #17659, #30721
  • #14157 fix(security): protect Hermes control-plane files from prompt injection #14072 · merged via another change → 1f5219f, #30397, #15981, #14072
  • #17659 fix(file-safety): block read_file on HERMES_HOME credential stores (#17656) · merged via another change → #30721, #8055, #17656
  • #36826 fix(security): enforce HERMES_WRITE_SAFE_ROOT in terminal/execute_code (#36645) · other
  • #42708 fix(approval): flag model config mutations in DANGEROUS_PATTERNS · partial fix
Show 1 more
  • #45692 fix(file): route Hermes config writes through approvals · done elsewhere → #48552
Closed issues · 6
  • Closed#8035Security: file_tools read path does not block ~/.hermes/auth.json and mcp-tokens/
  • Closed#14072Security: write_file/patch can modify ~/.hermes control-plane files (auth.json, config.yaml, webhook subscriptions)
  • Closed#17656Security: read_file can exfiltrate credentials from auth.json and .anthropic_oauth.json
  • Closed#42701fix(approval): smart mode blind to config mutations that break gateway connectivity
  • Closed#42702[Bug]: smart approval mode silently allows config mutations that break gateway connectivity
  • Closed#49576Security: execute_code (Python) bypasses agent file edit restrictions
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#30397, #30721
Closed issues
6
Attempts
6 PRs closed without merging
Fix link checked
23 May 2026

For your agent

The same facts, typed.