Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,83 @@ def get_subprocess_home() -> str | None:
return None


def is_profiled_mode() -> bool:
"""Return True if the current process is running under a named profile.

In profile mode, ``HERMES_HOME`` is ``<root>/profiles/<name>`` and the
agent should be restricted to its own data directory to prevent
cross-profile data leakage (reading another profile's memory, sessions,
or config).

The default profile (``HERMES_HOME == ~/.hermes``) returns ``False``
because the default profile is the admin — it needs access to all
profiles for management tasks (``profile list``, skill syncing, etc.).
"""
env_home = os.environ.get("HERMES_HOME", "")
if not env_home:
return False
env_path = Path(env_home)
# Check if HERMES_HOME is <something>/profiles/<name>
return env_path.parent.name == "profiles"


def get_profile_boundary() -> Path | None:
"""Return the allowed root path for file operations, or None if unrestricted.

- Default profile (admin): returns ``None`` — full filesystem access.
- Named profile: returns ``HERMES_HOME`` (e.g. ``~/.hermes/profiles/frog/``).
"""
if is_profiled_mode():
return get_hermes_home()
return None


def is_within_profile_boundary(path: str) -> tuple[bool, str]:
"""Check whether *path* is within the current profile's boundary.

Returns ``(allowed, reason)``. When *allowed* is ``False``, *reason*
explains why (for the tool's error response).

The policy is **cross-profile isolation only** — we block access to
*other profiles' data directories* but allow everything else (home
directory projects, /tmp, system paths, installed packages, etc.).

This is intentionally NOT a full sandbox. Named profiles still need
to read/write project code, system configs, and installed tools.
The goal is solely to prevent one profile from reading another
profile's memory, sessions, or config.
"""
boundary = get_profile_boundary()
if boundary is None:
return True, ""

try:
resolved = Path(path).expanduser().resolve()
except (OSError, ValueError):
return True, "" # Unresolvable paths are not our concern

# Get the parent profiles root: ~/.hermes/profiles/
profiles_root = boundary.resolve().parent # <root>/profiles/

# If the path is NOT under the profiles root at all, allow it freely.
# This covers /tmp, ~/projects, /usr, etc.
try:
resolved.relative_to(profiles_root)
except ValueError:
return True, ""

# Path IS under ~/.hermes/profiles/ — check it's within OUR profile
try:
resolved.relative_to(boundary.resolve())
return True, ""
except ValueError:
return False, (
f"Access denied: '{path}' is in another profile's data directory. "
f"This agent is restricted to {display_hermes_home()}/ to prevent "
f"cross-profile data leakage."
)


VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")


Expand Down
247 changes: 247 additions & 0 deletions tests/test_profile_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""Tests for profile boundary enforcement in hermes_constants.

Covers: is_profiled_mode(), get_profile_boundary(), is_within_profile_boundary().
These functions enforce cross-profile isolation — a named profile must not
access another profile's data directory, but should still have full access
to the filesystem for project work.
"""

from pathlib import Path
from unittest.mock import patch

import pytest

from hermes_constants import (
is_profiled_mode,
get_profile_boundary,
is_within_profile_boundary,
)


@pytest.fixture()
def default_env(tmp_path, monkeypatch):
"""Default profile (admin) — unrestricted, full filesystem access."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home


@pytest.fixture()
def named_profile_env(tmp_path, monkeypatch):
"""Named profile (e.g. 'frog') — restricted from other profiles."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir()
profile_dir = hermes_root / "profiles" / "frog"
profile_dir.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
return profile_dir


@pytest.fixture()
def multi_profile_env(tmp_path, monkeypatch):
"""Two named profiles to test cross-profile blocking."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir()
frog_dir = hermes_root / "profiles" / "frog"
xiaoge_dir = hermes_root / "profiles" / "xiaoge"
frog_dir.mkdir(parents=True)
xiaoge_dir.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(frog_dir))
return {
"root": hermes_root,
"frog": frog_dir,
"xiaoge": xiaoge_dir,
}


# ===================================================================
# is_profiled_mode
# ===================================================================


class TestIsProfiledMode:
def test_default_profile_returns_false(self, default_env):
assert is_profiled_mode() is False

def test_named_profile_returns_true(self, named_profile_env):
assert is_profiled_mode() is True

def test_no_hermes_home_returns_false(self, monkeypatch):
monkeypatch.delenv("HERMES_HOME", raising=False)
assert is_profiled_mode() is False

def test_custom_path_not_in_profiles_returns_false(self, tmp_path, monkeypatch):
"""A custom HERMES_HOME like /opt/hermes is NOT a profile."""
custom = tmp_path / "opt" / "hermes"
custom.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(custom))
assert is_profiled_mode() is False

def test_profiles_in_other_path_components(self, tmp_path, monkeypatch):
"""HERMES_HOME=/tmp/profiles/work → profiled."""
p = tmp_path / "tmp" / "profiles" / "work"
p.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(p))
assert is_profiled_mode() is True

def test_profiles_dir_name_but_not_structure(self, tmp_path, monkeypatch):
"""HERMES_HOME=.../profiles → not profiled (parent is not 'profiles')."""
p = tmp_path / "profiles"
p.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(p))
assert is_profiled_mode() is False


# ===================================================================
# get_profile_boundary
# ===================================================================


class TestGetProfileBoundary:
def test_default_profile_no_boundary(self, default_env):
assert get_profile_boundary() is None

def test_named_profile_returns_hermes_home(self, named_profile_env):
boundary = get_profile_boundary()
assert boundary is not None
assert boundary == named_profile_env

def test_no_hermes_home_no_boundary(self, monkeypatch):
monkeypatch.delenv("HERMES_HOME", raising=False)
assert get_profile_boundary() is None


# ===================================================================
# is_within_profile_boundary — DEFAULT profile (unrestricted)
# ===================================================================


class TestBoundaryDefaultProfile:
"""Default profile should allow ALL paths."""

def test_allows_any_path(self, default_env):
for path in ["/etc/passwd", "/tmp/foo", "~/bar", "/any/thing"]:
ok, reason = is_within_profile_boundary(path)
assert ok is True, f"Should allow {path}, got: {reason}"

def test_allows_other_profiles(self, default_env, tmp_path):
"""Admin profile can access other profiles' data."""
other = tmp_path / ".hermes" / "profiles" / "other" / "secret.md"
ok, reason = is_within_profile_boundary(str(other))
assert ok is True


# ===================================================================
# is_within_profile_boundary — NAMED profile (cross-profile isolation)
# ===================================================================


class TestBoundaryNamedProfile:
"""Named profile: block other profiles, allow everything else."""

def test_allows_own_profile_files(self, named_profile_env):
own_file = named_profile_env / "skills" / "test.md"
ok, reason = is_within_profile_boundary(str(own_file))
assert ok is True

def test_allows_own_profile_nested(self, named_profile_env):
deep = named_profile_env / "sessions" / "sub" / "dir" / "file.json"
ok, reason = is_within_profile_boundary(str(deep))
assert ok is True

def test_blocks_other_profile_file(self, multi_profile_env):
other = multi_profile_env["xiaoge"] / "memory.md"
ok, reason = is_within_profile_boundary(str(other))
assert ok is False
assert "other profile" in reason

def test_blocks_other_profile_nested(self, multi_profile_env):
other = multi_profile_env["xiaoge"] / "sessions" / "deep" / "data.json"
ok, reason = is_within_profile_boundary(str(other))
assert ok is False

def test_blocks_other_profile_root(self, multi_profile_env):
"""Even listing the other profile's root dir should be blocked."""
ok, reason = is_within_profile_boundary(str(multi_profile_env["xiaoge"]))
assert ok is False

def test_allows_system_files(self, named_profile_env):
ok, reason = is_within_profile_boundary("/etc/passwd")
assert ok is True, reason

def test_allows_tmp(self, named_profile_env):
ok, reason = is_within_profile_boundary("/tmp/test.txt")
assert ok is True, reason

def test_allows_home_projects(self, named_profile_env, tmp_path):
project = tmp_path / "projects" / "my-app" / "main.py"
ok, reason = is_within_profile_boundary(str(project))
assert ok is True, reason

def test_allows_default_hermes_config(self, multi_profile_env):
"""~/.hermes/config.yaml is outside profiles/, so it's allowed."""
config = multi_profile_env["root"] / "config.yaml"
ok, reason = is_within_profile_boundary(str(config))
assert ok is True, reason

def test_allows_path_outside_hermes_entirely(self, named_profile_env):
ok, reason = is_within_profile_boundary("/usr/lib/python3/site.py")
assert ok is True, reason

def test_tilde_expansion(self, named_profile_env, tmp_path, monkeypatch):
"""~ should expand correctly."""
monkeypatch.setenv("HOME", str(tmp_path))
ok, reason = is_within_profile_boundary("~/file.txt")
# ~/file.txt is not under profiles/, so allowed
assert ok is True, reason

def test_symlink_inside_own_profile(self, named_profile_env):
"""A symlink pointing inside the profile should be allowed."""
target = named_profile_env / "real_file.txt"
target.touch()
link = named_profile_env / "link.txt"
link.symlink_to(target)
ok, reason = is_within_profile_boundary(str(link))
assert ok is True, reason

def test_symlink_to_other_profile(self, multi_profile_env):
"""A symlink pointing to another profile should be BLOCKED."""
target = multi_profile_env["xiaoge"] / "secret.txt"
target.touch()
link = multi_profile_env["frog"] / "sneaky_link.txt"
link.symlink_to(target)
ok, reason = is_within_profile_boundary(str(link))
# resolve() follows symlinks → ends up in xiaoge → blocked
assert ok is False, "Symlink to other profile should be blocked"

def test_nonexistent_path_outside_profiles(self, named_profile_env):
"""Nonexistent paths outside profiles/ should be allowed."""
ok, reason = is_within_profile_boundary("/nonexistent/path/file.txt")
assert ok is True, reason

def test_nonexistent_path_in_other_profile(self, multi_profile_env):
"""Nonexistent paths in other profiles should still be blocked."""
other = multi_profile_env["xiaoge"] / "does_not_exist" / "file.txt"
ok, reason = is_within_profile_boundary(str(other))
assert ok is False

def test_error_message_mentions_profile_name(self, multi_profile_env):
other = multi_profile_env["xiaoge"] / "secret.md"
_, reason = is_within_profile_boundary(str(other))
# Should mention "frog" (current profile) in the message
assert "frog" in reason

def test_blocks_sibling_profile_same_depth(self, multi_profile_env):
"""A file at the same nesting level but in another profile."""
frog_file = multi_profile_env["frog"] / "data.json"
xiaoge_file = multi_profile_env["xiaoge"] / "data.json"
# Make sure frog_file is allowed
ok, _ = is_within_profile_boundary(str(frog_file))
assert ok is True
# But xiaoge_file is blocked
ok, reason = is_within_profile_boundary(str(xiaoge_file))
assert ok is False
26 changes: 26 additions & 0 deletions tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from tools.binary_extensions import has_binary_extension
from tools.file_operations import ShellFileOperations
from agent.redact import redact_sensitive_text
from hermes_constants import is_within_profile_boundary

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -281,6 +282,11 @@ def clear_file_ops_cache(task_id: str = None):

def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = "default") -> str:
"""Read a file with pagination and line numbers."""
# ── Profile boundary guard ────────────────────────────────────
allowed, reason = is_within_profile_boundary(path)
if not allowed:
return json.dumps({"error": reason})

try:
# ── Device path guard ─────────────────────────────────────────
# Block paths that would hang the process (infinite output,
Expand Down Expand Up @@ -540,6 +546,11 @@ def _check_file_staleness(filepath: str, task_id: str) -> str | None:

def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
"""Write content to a file."""
# ── Profile boundary guard ────────────────────────────────────
allowed, reason = is_within_profile_boundary(path)
if not allowed:
return json.dumps({"error": reason})

sensitive_err = _check_sensitive_path(path)
if sensitive_err:
return tool_error(sensitive_err)
Expand All @@ -566,6 +577,16 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
new_string: str = None, replace_all: bool = False, patch: str = None,
task_id: str = "default") -> str:
"""Patch a file using replace mode or V4A patch format."""
# ── Profile boundary guard ────────────────────────────────────
# Check explicit path first, then extract paths from V4A patch content
_paths_to_check = []
if path:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For mode="patch", callers normally omit path, so this list remains empty and the *** Update File: target is never boundary-checked. Extract every V4A Update/Add/Delete/Move target before this loop; current main's equivalent handling is at tools/file_tools.py:1731-1783.

_paths_to_check.append(path)
for _p in _paths_to_check:
allowed, reason = is_within_profile_boundary(_p)
if not allowed:
return json.dumps({"error": reason})

# Check sensitive paths for both replace (explicit path) and V4A patch (extract paths)
_paths_to_check = []
if path:
Expand Down Expand Up @@ -624,6 +645,11 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
output_mode: str = "content", context: int = 0,
task_id: str = "default") -> str:
"""Search for content or files."""
# ── Profile boundary guard ────────────────────────────────────
allowed, reason = is_within_profile_boundary(path)
if not allowed:
return json.dumps({"error": reason})

try:
# Track searches to detect *consecutive* repeated search loops.
# Include pagination args so users can page through truncated
Expand Down