mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-02 07:11:49 +00:00
read_file_tool resolves relative paths against TERMINAL_CWD (or the task's live terminal cwd), but the prior call passed the original unresolved string to get_read_block_error. That function's own resolve() is anchored at the Python process cwd, so when a task's TERMINAL_CWD pointed at HERMES_HOME and the agent issued read_file on the relative path "auth.json", the credential-store denylist was never reached and the file was read normally. Pass the already-resolved absolute path string at the file_tools call site, document the contract on get_read_block_error, and add a read_file_tool-level regression test that pins the relative-path case under TERMINAL_CWD == HERMES_HOME. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
7 KiB
Python
189 lines
7 KiB
Python
"""Shared file safety rules used by both tools and ACP shims."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
def _hermes_home_path() -> Path:
|
|
"""Resolve the active HERMES_HOME (profile-aware) without circular imports."""
|
|
try:
|
|
from hermes_constants import get_hermes_home # local import to avoid cycles
|
|
return get_hermes_home()
|
|
except Exception:
|
|
return Path(os.path.expanduser("~/.hermes"))
|
|
|
|
|
|
def _hermes_root_path() -> Path:
|
|
"""Resolve the Hermes root dir (always the parent of any profile, never per-profile)."""
|
|
try:
|
|
from hermes_constants import get_default_hermes_root # local import to avoid cycles
|
|
return get_default_hermes_root()
|
|
except Exception:
|
|
return Path(os.path.expanduser("~/.hermes"))
|
|
|
|
|
|
def build_write_denied_paths(home: str) -> set[str]:
|
|
"""Return exact sensitive paths that must never be written."""
|
|
hermes_home = _hermes_home_path()
|
|
hermes_root = _hermes_root_path()
|
|
return {
|
|
os.path.realpath(p)
|
|
for p in [
|
|
os.path.join(home, ".ssh", "authorized_keys"),
|
|
os.path.join(home, ".ssh", "id_rsa"),
|
|
os.path.join(home, ".ssh", "id_ed25519"),
|
|
os.path.join(home, ".ssh", "config"),
|
|
# Active profile .env (or top-level .env when not in profile mode).
|
|
str(hermes_home / ".env"),
|
|
# Top-level .env, even when running under a profile — overwriting it
|
|
# leaks credentials across every profile that inherits from root (#15981).
|
|
str(hermes_root / ".env"),
|
|
os.path.join(home, ".bashrc"),
|
|
os.path.join(home, ".zshrc"),
|
|
os.path.join(home, ".profile"),
|
|
os.path.join(home, ".bash_profile"),
|
|
os.path.join(home, ".zprofile"),
|
|
os.path.join(home, ".netrc"),
|
|
os.path.join(home, ".pgpass"),
|
|
os.path.join(home, ".npmrc"),
|
|
os.path.join(home, ".pypirc"),
|
|
"/etc/sudoers",
|
|
"/etc/passwd",
|
|
"/etc/shadow",
|
|
]
|
|
}
|
|
|
|
|
|
def build_write_denied_prefixes(home: str) -> list[str]:
|
|
"""Return sensitive directory prefixes that must never be written."""
|
|
return [
|
|
os.path.realpath(p) + os.sep
|
|
for p in [
|
|
os.path.join(home, ".ssh"),
|
|
os.path.join(home, ".aws"),
|
|
os.path.join(home, ".gnupg"),
|
|
os.path.join(home, ".kube"),
|
|
"/etc/sudoers.d",
|
|
"/etc/systemd",
|
|
os.path.join(home, ".docker"),
|
|
os.path.join(home, ".azure"),
|
|
os.path.join(home, ".config", "gh"),
|
|
]
|
|
]
|
|
|
|
|
|
def get_safe_write_root() -> Optional[str]:
|
|
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
|
|
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
|
|
if not root:
|
|
return None
|
|
try:
|
|
return os.path.realpath(os.path.expanduser(root))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def is_write_denied(path: str) -> bool:
|
|
"""Return True if path is blocked by the write denylist or safe root."""
|
|
home = os.path.realpath(os.path.expanduser("~"))
|
|
resolved = os.path.realpath(os.path.expanduser(str(path)))
|
|
|
|
if resolved in build_write_denied_paths(home):
|
|
return True
|
|
for prefix in build_write_denied_prefixes(home):
|
|
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 = "mcp-tokens"
|
|
|
|
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
|
|
|
|
return False
|
|
|
|
|
|
def get_read_block_error(path: str) -> Optional[str]:
|
|
"""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 stores at the top of ``HERMES_HOME`` (``auth.json``,
|
|
``auth.lock``, ``.anthropic_oauth.json``) — plaintext provider
|
|
keys / OAuth tokens that the agent never needs to read directly.
|
|
|
|
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:
|
|
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 stores under HERMES_HOME hold plaintext provider keys
|
|
# and OAuth tokens. The agent never needs to read these directly —
|
|
# auxiliary_client / credential_pool consume them through process
|
|
# env / OAuth flows that bypass read_file. Block read access so a
|
|
# prompt-injection reaching read_file can't exfiltrate them.
|
|
blocked_credential_files = {
|
|
hermes_home / "auth.json",
|
|
hermes_home / "auth.lock",
|
|
hermes_home / ".anthropic_oauth.json",
|
|
}
|
|
if resolved in blocked_credential_files:
|
|
return (
|
|
f"Access denied: {path} is a Hermes credential store "
|
|
"and cannot be read directly. Provider tools consume these "
|
|
"credentials through internal channels."
|
|
)
|
|
return None
|