NousResearch/hermes-agent

Per-profile HOME directory isolation fixes

Cluster 4426 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #7357 on 10 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #7357. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #7357. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address a bug where profiles share a common HOME directory (e.g. /root), causing system credentials and tool configuration to leak across profiles rather than being isolated per profile.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #7357 fix: per-profile subprocess HOME isolation (#4426)

    merged to main

    files: docker/entrypoint.sh, hermes_cli/profiles.py, hermes_constants.py, tests/test_subprocess_home_isolation.py, tools/code_execution_tool.py, tools/environments/local.py

    Show the diff
    • docker/entrypoint.sh
      @@ -9,7 +9,10 @@ INSTALL_DIR="/opt/hermes"
       # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
       # demand by the application — don't pre-create them here so new installs
       # get the consolidated layout from get_hermes_dir().
      -mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills}
      +# The "home/" subdirectory is a per-profile HOME for subprocesses (git,
      +# ssh, gh, npm …).  Without it those tools write to /root which is
      +# ephemeral and shared across profiles.  See issue #4426.
      +mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,home}
       
       # .env
       if [ ! -f "$HERMES_HOME/.env" ]; then
    • hermes_cli/profiles.py
      @@ -42,6 +42,11 @@
           "plans",
           "workspace",
           "cron",
      +    # Per-profile HOME for subprocesses: isolates system tool configs (git,
      +    # ssh, gh, npm …) so credentials don't bleed between profiles.  In Docker
      +    # this also ensures tool configs land inside the persistent volume.
      +    # See hermes_constants.get_subprocess_home() and issue #4426.
      +    "home",
       ]
       
       # Files copied during --clone (if they exist in the source)
    • hermes_constants.py
      @@ -111,6 +111,32 @@ def display_hermes_home() -> str:
               return str(home)
       
       
      +def get_subprocess_home() -> str | None:
      +    """Return a per-profile HOME directory for subprocesses, or None.
      +
      +    When ``{HERMES_HOME}/home/`` exists on disk, subprocesses should use it
      +    as ``HOME`` so system tools (git, ssh, gh, npm …) write their configs
      +    inside the Hermes data directory instead of the OS-level ``/root`` or
      +    ``~/``.  This provides:
      +
      +    * **Docker persistence** — tool configs land inside the persistent volume.
      +    * **Profile isolation** — each profile gets its own git identity, SSH
      +      keys, gh tokens, etc.
      +
      +    The Python process's own ``os.environ["HOME"]`` and ``Path.home()`` are
      +    **never** modified — only subprocess environments should inject this value.
      +    Activation is directory-based: if the ``home/`` subdirectory doesn't
      +    exist, returns ``None`` and behavior is unchanged.
      +    """
      +    hermes_home = os.getenv("HERMES_HOME")
      +    if not hermes_home:
      +        return None
      +    profile_home = os.path.join(hermes_home, "home")
      +    if os.path.isdir(profile_home):
      +        return profile_home
      +    return None
      +
      +
       VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
       
       
    • tests/test_subprocess_home_isolation.py
      @@ -0,0 +1,198 @@
      +"""Tests for per-profile subprocess HOME isolation (#4426).
      +
      +Verifies that subprocesses (terminal, execute_code, background processes)
      +receive a per-profile HOME directory while the Python process's own HOME
      +and Path.home() remain unchanged.
      +
      +See: https://github.com/NousResearch/hermes-agent/issues/4426
      +"""
      +
      +import os
      +from pathlib import Path
      +from unittest.mock import patch
      +
      +import pytest
      +
      +
      +# ---------------------------------------------------------------------------
      +# get_subprocess_home()
      +# ---------------------------------------------------------------------------
      +
      +class TestGetSubprocessHome:
      +    """Unit tests for hermes_constants.get_subprocess_home()."""
      +
      +    def test_returns_none_when_hermes_home_unset(self, monkeypatch):
      +        monkeypatch.delenv("HERMES_HOME", raising=False)
      +        from hermes_constants import get_subprocess_home
      +        assert get_subprocess_home() is None
      +
      +    def test_returns_none_when_home_dir_missing(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +        # No home/ subdirectory created
      +        from hermes_constants import get_subprocess_home
      +        assert get_subprocess_home() is None
      +
      +    def test_returns_path_when_home_dir_exists(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        profile_home = hermes_home / "home"
      +        profile_home.mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +        from hermes_constants import get_subprocess_home
      +        assert get_subprocess_home() == str(profile_home)
      +
      +    def test_returns_profile_specific_path(self, tmp_path, monkeypatch):
      +        """Named profiles get their own isolated HOME."""
      +        profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
      +        profile_dir.mkdir(parents=True)
      +        profile_home = profile_dir / "home"
      +        profile_home.mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(profile_dir))
      +        from hermes_constants import get_subprocess_home
      +        assert get_subprocess_home() == str(profile_home)
      +
      +    def test_two_profiles_get_different_homes(self, tmp_path, monkeypatch):
      +        base = tmp_path / ".hermes" / "profiles"
      +        for name in ("alpha", "beta"):
      +            p = base / name
      +            p.mkdir(parents=True)
      +            (p / "home").mkdir()
      +
      +        from hermes_constants import get_subprocess_home
      +
      +        monkeypatch.setenv("HERMES_HOME", str(base / "alpha"))
      +        home_a = get_subprocess_home()
      +
      +        monkeypatch.setenv("HERMES_HOME", str(base / "beta"))
      +        home_b = get_subprocess_home()
      +
      +        assert home_a != home_b
      +        assert home_a.endswith("alpha/home")
      +        assert home_b.endswith("beta/home")
      +
      +
      +# ---------------------------------------------------------------------------
      +# _make_run_env() injection
      +# ---------------------------------------------------------------------------
      +
      +class TestMakeRunEnvHomeInjection:
      +    """Verify _make_run_env() injects HOME into subprocess envs."""
      +
      +    def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "home").mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +        monkeypatch.setenv("HOME", "/root")
      +        monkeypatch.setenv("PATH", "/usr/bin:/bin")
      +
      +        from tools.environments.local import _make_run_env
      +        result = _make_run_env({})
      +
      +        assert result["HOME"] == str(hermes_home / "home")
      +
      +    def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +        # No home/ subdirectory
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +        monkeypatch.setenv("HOME", "/root")
      +        monkeypatch.setenv("PATH", "/usr/bin:/bin")
      +
      +        from tools.environments.local import _make_run_env
      +        result = _make_run_env({})
      +
      +        assert result["HOME"] == "/root"
      +
      +    def test_no_injection_when_hermes_home_unset(self, monkeypatch):
      +        monkeypatch.delenv("HERMES_HOME", raising=False)
      +        monkeypatch.setenv("HOME", "/home/user")
      +        monkeypatch.setenv("PATH", "/usr/bin:/bin")
      +
      +        from tools.environments.local import _make_run_env
      +        result = _make_run_env({})
      +
      +        assert result["HOME"] == "/home/user"
      +
      +
      +# ---------------------------------------------------------------------------
      +# _sanitize_subprocess_env() injection
      +# ---------------------------------------------------------------------------
      +
      +class TestSanitizeSubprocessEnvHomeInjection:
      +    """Verify _sanitize_subprocess_env() injects HOME for background procs."""
      +
      +    def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "home").mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        base_env = {"HOME": "/root", "PATH": "/usr/bin", "USER": "root"}
      +        from tools.environments.local import _sanitize_subprocess_env
      +        result = _sanitize_subprocess_env(base_env)
      +
      +        assert result["HOME"] == str(hermes_home / "home")
      +
      +    def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        base_env = {"HOME": "/root", "PATH": "/usr/bin"}
      +        from tools.environments.local import _sanitize_subprocess_env
      +        result = _sanitize_subprocess_env(base_env)
      +
      +        assert result["HOME"] == "/root"
      +
      +
      +# ---------------------------------------------------------------------------
      +# Profile bootstrap
      +# ---------------------------------------------------------------------------
      +
      +class TestProfileBootstrap:
      +    """Verify new profiles get a home/ subdirectory."""
      +
      +    def test_profile_dirs_includes_home(self):
      +        from hermes_cli.profiles import _PROFILE_DIRS
      +        assert "home" in _PROFILE_DIRS
      +
      +    def test_create_profile_bootstraps_home_dir(self, tmp_path, monkeypatch):
      +        """create_profile() should create home/ inside the profile dir."""
      +        home = tmp_path / ".hermes"
      +        home.mkdir()
      +        monkeypatch.setattr(Path, "home", lambda: tmp_path)
      +        monkeypatch.setenv("HERMES_HOME", str(home))
      +
      +        from hermes_cli.profiles import create_profile
      +        profile_dir = create_profile("testbot", no_alias=True)
      +        assert (profile_dir / "home").is_dir()
      +
      +
      +# ---------------------------------------------------------------------------
      +# Python process HOME unchanged
      +# ---------------------------------------------------------------------------
      +
      +class TestPythonProcessUnchanged:
      +    """Confirm the Python process's own HOME is never modified."""
      +
      +    def test_path_home_unchanged_after_subprocess_home_resolved(
      +        self, tmp_path, monkeypatch
      +    ):
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "home").mkdir()
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        original_home = os.environ.get("HOME")
      +        original_path_home = str(Path.home())
      +
      +        from hermes_constants import get_subprocess_home
      +        sub_home = get_subprocess_home()
      +
      +        # Subprocess home is set but Python HOME stays the same
      +        assert sub_home is not None
      +        assert os.environ.get("HOME") == original_home
      +        assert str(Path.home()) == original_path_home
    • tools/code_execution_tool.py
      @@ -1020,6 +1020,13 @@ def execute_code(
               if _tz_name:
                   child_env["TZ"] = _tz_name
       
      +        # Per-profile HOME isolation: redirect system tool configs into
      +        # {HERMES_HOME}/home/ when that directory exists.
      +        from hermes_constants import get_subprocess_home
      +        _profile_home = get_subprocess_home()
      +        if _profile_home:
      +            child_env["HOME"] = _profile_home
      +
               proc = subprocess.Popen(
                   [sys.executable, "script.py"],
                   cwd=tmpdir,
    • tools/environments/local.py
      @@ -129,6 +129,12 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
               elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key):
                   sanitized[key] = value
       
      +    # Per-profile HOME isolation for background processes (same as _make_run_env).
      +    from hermes_constants import get_subprocess_home
      +    _profile_home = get_subprocess_home()
      +    if _profile_home:
      +        sanitized["HOME"] = _profile_home
      +
           return sanitized
       
       
      @@ -195,6 +201,15 @@ def _make_run_env(env: dict) -> dict:
           existing_path = run_env.get("PATH", "")
           if "/usr/bin" not in existing_path.split(":"):
               run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH
      +
      +    # Per-profile HOME isolation: redirect system tool configs (git, ssh, gh,
      +    # npm …) into {HERMES_HOME}/home/ when that directory exists.  Only the
      +    # subprocess sees the override — the Python process keeps the real HOME.
      +    from hermes_constants import get_subprocess_home
      +    _profile_home = get_subprocess_home()
      +    if _profile_home:
      +        run_env["HOME"] = _profile_home
      +
           return run_env
       
       

2 PRs closed without merging

From the close comments: superseded by another PR 1 · won't fix / by design 1

  • #4437 fix(core): isolate HOME per profile to persist system credentials and… · won't fix / by design → #4426
  • #4685 fix: per-profile HOME isolation (#4426) · superseded by another PR → #7357
Closed issues · 1
  • Closed#4426Bug: profiles do not isolate system credentials (/root shared across profiles) and Docker setup does not persist tool configuration outside /opt/data
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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

At a glance

Subsystem
hermes cli
Merged PRs
#7357
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
10 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.