# NousResearch/hermes-agent · Protecting Hermes control-plane files from write_file/patch modification

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 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: `resolved` · deciding PR(s): `pr-30397`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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.

## Closed / duplicate issues

- #14072: Security: write_file/patch can modify ~/.hermes control-plane files (auth.json, config.yaml, webhook subscriptions)

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

## Fix status

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