NousResearch/hermes-agent

Protecting Hermes control-plane files from write_file/patch modification

Cluster 14072. Data as of

Nothing open on GitHub. Fix merged in #30397 on 22 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue 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 resolved, deciding PR #30397. That is separate from the fix event.
Still open
Nothing open on GitHub.

One issue reports that write_file/patch can modify ~/.hermes control-plane files such as auth.json, config.yaml, and webhook subscriptions. Two pull requests with near-identical titles propose protecting those files from prompt injection; the titles alone do not show their status or implementation details.

Open issues0

No open issues.

Open PRs0

No open pull requests.

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
      +
       
       
       # =========================================================================

1 PR closed without merging

From the close comments: merged via another change 1

  • #14157 fix(security): protect Hermes control-plane files from prompt injection #14072 · merged via another change → 1f5219f, #30397, #15981, #14072
Closed issues · 1
  • Closed#14072Security: write_file/patch can modify ~/.hermes control-plane files (auth.json, config.yaml, webhook subscriptions)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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

Merged PRs
#30397
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
22 May 2026

For your agent

The same facts, typed.