fix(file-safety): write-deny pairing/ directory to prevent approved-list injection

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.
This commit is contained in:
AhmetArif0 2026-05-22 15:07:37 +03:00 committed by Teknium
parent 6c44d537cc
commit 4f4e337c47
2 changed files with 34 additions and 1 deletions

View file

@ -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)):

View file

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