From 4f4e337c47865f74383a370302f91640b771ebfa Mon Sep 17 00:00:00 2001 From: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com> Date: Fri, 22 May 2026 15:07:37 +0300 Subject: [PATCH] fix(file-safety): write-deny pairing/ directory to prevent approved-list injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway pairing directory (~/.hermes/pairing/) stores per-platform access-control files (telegram-approved.json, discord-approved.json, etc.). A prompt-injected agent using write_file could add arbitrary user IDs to an approved file, granting persistent gateway access without going through the pairing code flow — the same threat class that motivated protecting webhook_subscriptions.json (#14157). The pairing directory was not included in the original control-plane protection because it postdates PR #14157. PR #30383 introduced the hashed-pending schema and made the approved files the sole source of truth for gateway access, raising the security sensitivity of the directory. Apply the same mcp-tokens pattern: block writes to pairing/ and any path within it, under both the active hermes_home and the root path (for profile-mode parity with the fix in #30382). Regression tests verify denial for pairing/telegram-approved.json, pairing/discord-pending.json, and the directory itself, in both normal and profile-mode layouts. --- agent/file_safety.py | 6 ++++++ tests/tools/test_file_operations.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/agent/file_safety.py b/agent/file_safety.py index 2e8d31282af..502c3b254a8 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -127,6 +127,12 @@ def is_write_denied(path: str) -> bool: return True except Exception: pass + try: + pairing_real = os.path.realpath(os.path.join(base_real, "pairing")) + if resolved == pairing_real or resolved.startswith(pairing_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)): diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index db4f490f73b..1d3ec8b4a02 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -68,10 +68,14 @@ class TestIsWriteDenied: "webhook_subscriptions.json", "mcp-tokens/token1.json", "mcp-tokens/subdir/token2.json", + "pairing/telegram-approved.json", + "pairing/discord-approved.json", + "pairing/telegram-pending.json", + "pairing", ], ) def test_hermes_control_files_and_mcp_tokens_denied(self, path): - """Hermes control files and mcp-tokens entries must be write-denied.""" + """Hermes control files and mcp-tokens/pairing entries must be write-denied.""" from hermes_constants import get_hermes_home hermes_home = get_hermes_home() full_path = str(hermes_home / path) @@ -140,6 +144,29 @@ class TestIsWriteDenied: # The directory itself must also be denied (not just files inside) assert _is_write_denied(str(root / "mcp-tokens")) is True + def test_pairing_dir_denied(self, tmp_path, monkeypatch): + """Regression: pairing/ must be write-denied under both profile and root. + + PR #30383 introduced ~/.hermes/pairing/{platform}-approved.json as the + gateway access-control list. Without this block, a prompt-injected agent + can write arbitrary user IDs into an approved file, granting persistent + gateway access without going through the pairing code flow — the same + threat class that motivated protecting webhook_subscriptions.json. + """ + root = tmp_path / "hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(profile)) + + # Active profile pairing entries + assert _is_write_denied(str(profile / "pairing" / "telegram-approved.json")) is True + assert _is_write_denied(str(profile / "pairing" / "discord-pending.json")) is True + # The directory itself + assert _is_write_denied(str(profile / "pairing")) is True + # Root pairing entries (profile mode — same shape as mcp-tokens gap) + assert _is_write_denied(str(root / "pairing" / "telegram-approved.json")) is True + assert _is_write_denied(str(root / "pairing")) is True + # =========================================================================