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
- 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.
Next step: read #30397, then check whether your report is one of the 4 still open.
Read #30397Pick up a Lab taskThese 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
-
#30397 fix(security): protect Hermes control-plane files from prompt injection (#14157)
files:
agent/file_safety.py,infographic/pr-14157-control-plane-write-deny/infographic.png,tests/tools/test_file_operations.pyShow 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)
files:
agent/file_safety.py,infographic/pr-17659-read-deny-credentials/infographic.png,tests/agent/test_file_safety_credentials.py,tools/file_tools.pyShow 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.00means 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