mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
* fix(profiles): cross-profile soft guard on file-write tools + system-prompt hint
Adds a soft guard so an agent running under one Hermes profile cannot
silently edit a different profile's skills/plugins/cron/memories.
Three layers:
A. agent/file_safety.classify_cross_profile_target
Classifies a write target against the active HERMES_HOME. Returns
a {active_profile, target_profile, area, target_path} dict when the
path lands in another profile's scoped area. PROFILE_SCOPED_AREAS =
(skills, plugins, cron, memories). get_cross_profile_warning()
wraps it into a model-facing error string that names both profiles,
names the area, and points at the cross_profile=True bypass.
Defense-in-depth, NOT a security boundary — the terminal tool runs
as the same OS user and can write any of these paths directly. The
guard exists to prevent confused-agent corruption, not to stop a
determined attacker. SECURITY.md §3.2 (terminal-bypass posture)
still applies.
Wired into tools/file_tools.write_file_tool and patch_tool with a
cross_profile=False kwarg. WRITE_FILE_SCHEMA and PATCH_SCHEMA both
advertise cross_profile so the model can pass it after explicit
user direction. patch_tool extracts target paths from V4A patch
bodies before checking (same shape as the existing sensitive-path
check).
skill_manage is already scoped to the active profile's SKILLS_DIR
by construction, so no extra guard wiring is needed there. The
D-side error message (below) still names other profiles when the
skill exists elsewhere.
B. agent/system_prompt
One deterministic line near the environment-hints block names the
active profile and tells the model not to modify another profile's
skills/plugins/cron/memories without explicit direction. Profile
name is stable for the lifetime of the AIAgent, so the line is
prompt-cache-safe.
D. tools/skill_manager_tool._skill_not_found_error
Replaces the bare "Skill 'X' not found." with a message that:
- names the active profile,
- searches OTHER profiles' skills dirs for the same name,
- names the profile(s) where the skill exists and the path,
- suggests `hermes -p <name>` to switch profiles, or
cross_profile=True for an explicit edit.
All 5 "not found" sites in skill_manager_tool (edit, patch, delete,
write_file, remove_file) now go through the helper.
Reference incident (May 2026): a hermes-security profile session
edited skills under both ~/.hermes/profiles/hermes-security/skills/
AND ~/.hermes/skills/ (the default profile's skills) without
realizing the second path belonged to a different profile. Three of
the four skill files needed manual restoration afterward.
What this PR does NOT do:
* No hard block. The terminal tool can still touch any of these
paths with no guard — same posture as the dangerous-command
approval flow. SECURITY.md §3.2 applies.
* No regex sweep on terminal commands for cross-profile paths.
That direction is a Skills-Guard-style arms race (cd + relative
paths, base64, etc.) and would false-positive on legitimate
cross-profile reads. Filed as a follow-up.
* No on-disk path migration. ~/.hermes/skills/ remains the
default profile's skills dir; this PR is about telling the
agent about that boundary, not changing the layout.
Tests:
tests/agent/test_file_safety_cross_profile.py (16 tests)
- _resolve_active_profile_name covers default/named/failure paths
- classify_cross_profile_target covers all four scoped areas,
both directions (default → named, named → default, named → named),
non-Hermes paths, and root-level config files
- get_cross_profile_warning covers in-profile no-op, cross-profile
message shape, and the defense-in-depth self-documentation
tests/tools/test_cross_profile_guard.py (12 tests)
- write_file: in-profile allow, cross-profile block, cross_profile=True
bypass, non-Hermes pass-through
- patch: replace-mode block, cross_profile=True bypass, V4A patch
path extraction
- skill_manage: error names the other profile (single + multiple),
missing-everywhere falls back to skills_list hint
- system prompt: contract-level checks (both branches present,
cross_profile=True mentioned, ~/.hermes/profiles/ referenced)
All 207 existing tests in file_safety/file_operations/skill_manager
still pass. 10 system-prompt tests still pass.
E2E verified: the exact incident scenario (security profile editing
default's hermes-agent-dev skill) is now blocked with the warning
message; cross_profile=True unblocks.
* fix(code_execution): add cross_profile to write_file/patch stubs
The cross_profile kwarg added to write_file_tool/patch_tool needs to
flow through the execute_code sandbox stubs in _TOOL_STUBS so the
test_stubs_cover_all_schema_params drift test passes. Without this,
scripts running inside execute_code couldn't pass cross_profile=True
through hermes_tools.write_file().
Caught by CI on PR #31290.
401 lines
15 KiB
Python
401 lines
15 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 / 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()
|
|
|
|
# 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:
|
|
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 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-profile write guard (#TBD)
|
|
#
|
|
# Hermes profiles are separate HERMES_HOME dirs under
|
|
# ``<root>/profiles/<name>/``. Each profile has its own skills/, plugins/,
|
|
# cron/, memories/. When an agent runs under one profile, writing into
|
|
# ANOTHER profile's directories is almost always wrong — those skills /
|
|
# plugins / cron jobs / memories affect a different session the user runs
|
|
# from a different shell.
|
|
#
|
|
# Soft guard, NOT a security boundary: the agent runs as the same OS user
|
|
# and has unrestricted terminal access, so this returns a warning the model
|
|
# can choose to honor or override with ``cross_profile=True``. Same shape
|
|
# as the dangerous-command approval flow — the agent is told the boundary
|
|
# exists, and explicit user direction is required to cross it.
|
|
#
|
|
# Reference: May 2026 incident where a hermes-security profile session
|
|
# edited skills under both ``~/.hermes/profiles/hermes-security/skills/``
|
|
# AND ``~/.hermes/skills/`` (the default profile's skills) without realizing
|
|
# the second path belonged to a different profile.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Profile-scoped directories under HERMES_HOME / <root> / <root>/profiles/<X>/
|
|
# that should be guarded. Adding a new area here extends the guard with no
|
|
# other code change.
|
|
PROFILE_SCOPED_AREAS = ("skills", "plugins", "cron", "memories")
|
|
|
|
|
|
def _resolve_active_profile_name() -> str:
|
|
"""Return the active profile name derived from HERMES_HOME.
|
|
|
|
``~/.hermes`` -> ``"default"``
|
|
``~/.hermes/profiles/X`` -> ``"X"``
|
|
|
|
Falls back to ``"default"`` on any resolution failure so the guard
|
|
never raises into the tool path.
|
|
"""
|
|
try:
|
|
home_real = _hermes_home_path().resolve()
|
|
root_real = _hermes_root_path().resolve()
|
|
except (OSError, RuntimeError):
|
|
return "default"
|
|
profiles_dir = root_real / "profiles"
|
|
try:
|
|
rel = home_real.relative_to(profiles_dir)
|
|
parts = rel.parts
|
|
if len(parts) >= 1:
|
|
return parts[0]
|
|
except ValueError:
|
|
pass
|
|
return "default"
|
|
|
|
|
|
def classify_cross_profile_target(path: str) -> Optional[dict]:
|
|
"""Classify a write target as cross-profile if it lands in another
|
|
profile's scoped area (skills/plugins/cron/memories).
|
|
|
|
Returns ``None`` when the target is outside Hermes scope, or is inside
|
|
the ACTIVE profile, or doesn't hit a profile-scoped area. Otherwise
|
|
returns a dict with:
|
|
|
|
* ``active_profile``: name of the profile the agent is running as
|
|
* ``target_profile``: name of the profile the path belongs to
|
|
* ``area``: which scoped area (``"skills"``, ``"plugins"``, etc.)
|
|
* ``target_path``: the resolved path string
|
|
|
|
The caller decides what to do with the result — surface a warning to
|
|
the model, prompt the user, or (with explicit consent /
|
|
``cross_profile=True``) proceed anyway.
|
|
"""
|
|
try:
|
|
target = Path(os.path.expanduser(str(path))).resolve()
|
|
root_real = _hermes_root_path().resolve()
|
|
except (OSError, RuntimeError):
|
|
return None
|
|
|
|
target_profile: Optional[str] = None
|
|
area: Optional[str] = None
|
|
|
|
try:
|
|
rel = target.relative_to(root_real)
|
|
except ValueError:
|
|
return None
|
|
|
|
parts = rel.parts
|
|
if not parts:
|
|
return None
|
|
|
|
if parts[0] in PROFILE_SCOPED_AREAS:
|
|
# ``<root>/<area>/...`` → default profile.
|
|
target_profile = "default"
|
|
area = parts[0]
|
|
elif (
|
|
parts[0] == "profiles"
|
|
and len(parts) >= 3
|
|
and parts[2] in PROFILE_SCOPED_AREAS
|
|
):
|
|
# ``<root>/profiles/<name>/<area>/...`` → named profile.
|
|
target_profile = parts[1]
|
|
area = parts[2]
|
|
else:
|
|
return None
|
|
|
|
active_profile = _resolve_active_profile_name()
|
|
if target_profile == active_profile:
|
|
# In-profile write — not a cross-profile event.
|
|
return None
|
|
|
|
return {
|
|
"active_profile": active_profile,
|
|
"target_profile": target_profile,
|
|
"area": area,
|
|
"target_path": str(target),
|
|
}
|
|
|
|
|
|
def get_cross_profile_warning(path: str) -> Optional[str]:
|
|
"""Return a model-facing warning string when ``path`` is cross-profile.
|
|
|
|
Returns ``None`` when the write is in-scope (same profile) or outside
|
|
Hermes entirely. Caller is expected to surface the warning to the
|
|
agent as a tool-result error, NOT to silently allow the write — the
|
|
agent must either get explicit user direction to proceed, or pass
|
|
``cross_profile=True`` to its write tool.
|
|
|
|
This is defense-in-depth: the terminal tool runs as the same OS user
|
|
and can write any of these paths without going through this guard.
|
|
Treat the guard as a confusion-reducer, not a security boundary.
|
|
"""
|
|
info = classify_cross_profile_target(path)
|
|
if info is None:
|
|
return None
|
|
return (
|
|
f"Cross-profile write blocked by soft guard: {info['target_path']} "
|
|
f"belongs to Hermes profile {info['target_profile']!r}, but the "
|
|
f"agent is running under profile {info['active_profile']!r}. "
|
|
f"Editing another profile's {info['area']}/ will affect that "
|
|
f"profile's future sessions, not the one you are currently in. "
|
|
f"Confirm with the user before proceeding. To bypass this guard "
|
|
f"after explicit user direction, retry the call with "
|
|
f"``cross_profile=True``. (Defense-in-depth — not a security "
|
|
f"boundary; the terminal tool can still bypass.)"
|
|
)
|