{"repo":"NousResearch/hermes-agent","public_id":"4426","display_label":"Per-profile HOME directory isolation fixes","summary":"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.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7357,"merged_at":"2026-04-10T20:37:45+00:00"},"resolution":{"state":"resolved","by":"pr-7357"},"freshness":{"state":"stale","last_verified_at":"2026-04-10T20:37:45+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":4426,"pr_number":7357,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-10T20:37:45+00:00"}],"members":[{"kind":"issue","number":4426,"title":"Bug: profiles do not isolate system credentials (/root shared across profiles) and Docker setup does not persist tool configuration outside /opt/data","url":null,"state":"closed"},{"kind":"pr","number":4437,"title":"fix(core): isolate HOME per profile to persist system credentials and…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4426","number":4426}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":4685,"title":"fix: per-profile HOME isolation (#4426)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7357","number":7357}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":7357,"title":"fix: per-profile subprocess HOME isolation (#4426)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"docker/entrypoint.sh","hunks":["@@ -9,7 +9,10 @@ INSTALL_DIR=\"/opt/hermes\"\n # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on\n # demand by the application — don't pre-create them here so new installs\n # get the consolidated layout from get_hermes_dir().\n-mkdir -p \"$HERMES_HOME\"/{cron,sessions,logs,hooks,memories,skills}\n+# The \"home/\" subdirectory is a per-profile HOME for subprocesses (git,\n+# ssh, gh, npm …).  Without it those tools write to /root which is\n+# ephemeral and shared across profiles.  See issue #4426.\n+mkdir -p \"$HERMES_HOME\"/{cron,sessions,logs,hooks,memories,skills,home}\n \n # .env\n if [ ! -f \"$HERMES_HOME/.env\" ]; then"]},{"file":"hermes_cli/profiles.py","hunks":["@@ -42,6 +42,11 @@\n     \"plans\",\n     \"workspace\",\n     \"cron\",\n+    # Per-profile HOME for subprocesses: isolates system tool configs (git,\n+    # ssh, gh, npm …) so credentials don't bleed between profiles.  In Docker\n+    # this also ensures tool configs land inside the persistent volume.\n+    # See hermes_constants.get_subprocess_home() and issue #4426.\n+    \"home\",\n ]\n \n # Files copied during --clone (if they exist in the source)"]},{"file":"hermes_constants.py","hunks":["@@ -111,6 +111,32 @@ def display_hermes_home() -> str:\n         return str(home)\n \n \n+def get_subprocess_home() -> str | None:\n+    \"\"\"Return a per-profile HOME directory for subprocesses, or None.\n+\n+    When ``{HERMES_HOME}/home/`` exists on disk, subprocesses should use it\n+    as ``HOME`` so system tools (git, ssh, gh, npm …) write their configs\n+    inside the Hermes data directory instead of the OS-level ``/root`` or\n+    ``~/``.  This provides:\n+\n+    * **Docker persistence** — tool configs land inside the persistent volume.\n+    * **Profile isolation** — each profile gets its own git identity, SSH\n+      keys, gh tokens, etc.\n+\n+    The Python process's own ``os.environ[\"HOME\"]`` and ``Path.home()`` are\n+    **never** modified — only subprocess environments should inject this value.\n+    Activation is directory-based: if the ``home/`` subdirectory doesn't\n+    exist, returns ``None`` and behavior is unchanged.\n+    \"\"\"\n+    hermes_home = os.getenv(\"HERMES_HOME\")\n+    if not hermes_home:\n+        return None\n+    profile_home = os.path.join(hermes_home, \"home\")\n+    if os.path.isdir(profile_home):\n+        return profile_home\n+    return None\n+\n+\n VALID_REASONING_EFFORTS = (\"minimal\", \"low\", \"medium\", \"high\", \"xhigh\")\n \n "]},{"file":"tests/test_subprocess_home_isolation.py","hunks":["@@ -0,0 +1,198 @@\n+\"\"\"Tests for per-profile subprocess HOME isolation (#4426).\n+\n+Verifies that subprocesses (terminal, execute_code, background processes)\n+receive a per-profile HOME directory while the Python process's own HOME\n+and Path.home() remain unchanged.\n+\n+See: https://github.com/NousResearch/hermes-agent/issues/4426\n+\"\"\"\n+\n+import os\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# get_subprocess_home()\n+# ---------------------------------------------------------------------------\n+\n+class TestGetSubprocessHome:\n+    \"\"\"Unit tests for hermes_constants.get_subprocess_home().\"\"\"\n+\n+    def test_returns_none_when_hermes_home_unset(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_HOME\", raising=False)\n+        from hermes_constants import get_subprocess_home\n+        assert get_subprocess_home() is None\n+\n+    def test_returns_none_when_home_dir_missing(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        # No home/ subdirectory created\n+        from hermes_constants import get_subprocess_home\n+        assert get_subprocess_home() is None\n+\n+    def test_returns_path_when_home_dir_exists(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        profile_home = hermes_home / \"home\"\n+        profile_home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        from hermes_constants import get_subprocess_home\n+        assert get_subprocess_home() == str(profile_home)\n+\n+    def test_returns_profile_specific_path(self, tmp_path, monkeypatch):\n+        \"\"\"Named profiles get their own isolated HOME.\"\"\"\n+        profile_dir = tmp_path / \".hermes\" / \"profiles\" / \"coder\"\n+        profile_dir.mkdir(parents=True)\n+        profile_home = profile_dir / \"home\"\n+        profile_home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(profile_dir))\n+        from hermes_constants import get_subprocess_home\n+        assert get_subprocess_home() == str(profile_home)\n+\n+    def test_two_profiles_get_different_homes(self, tmp_path, monkeypatch):\n+        base = tmp_path / \".hermes\" / \"profiles\"\n+        for name in (\"alpha\", \"beta\"):\n+            p = base / name\n+            p.mkdir(parents=True)\n+            (p / \"home\").mkdir()\n+\n+        from hermes_constants import get_subprocess_home\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(base / \"alpha\"))\n+        home_a = get_subprocess_home()\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(base / \"beta\"))\n+        home_b = get_subprocess_home()\n+\n+        assert home_a != home_b\n+        assert home_a.endswith(\"alpha/home\")\n+        assert home_b.endswith(\"beta/home\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# _make_run_env() injection\n+# ---------------------------------------------------------------------------\n+\n+class TestMakeRunEnvHomeInjection:\n+    \"\"\"Verify _make_run_env() injects HOME into subprocess envs.\"\"\"\n+\n+    def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"home\").mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setenv(\"HOME\", \"/root\")\n+        monkeypatch.setenv(\"PATH\", \"/usr/bin:/bin\")\n+\n+        from tools.environments.local import _make_run_env\n+        result = _make_run_env({})\n+\n+        assert result[\"HOME\"] == str(hermes_home / \"home\")\n+\n+    def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        # No home/ subdirectory\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setenv(\"HOME\", \"/root\")\n+        monkeypatch.setenv(\"PATH\", \"/usr/bin:/bin\")\n+\n+        from tools.environments.local import _make_run_env\n+        result = _make_run_env({})\n+\n+        assert result[\"HOME\"] == \"/root\"\n+\n+    def test_no_injection_when_hermes_home_unset(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_HOME\", raising=False)\n+        monkeypatch.setenv(\"HOME\", \"/home/user\")\n+        monkeypatch.setenv(\"PATH\", \"/usr/bin:/bin\")\n+\n+        from tools.environments.local import _make_run_env\n+        result = _make_run_env({})\n+\n+        assert result[\"HOME\"] == \"/home/user\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _sanitize_subprocess_env() injection\n+# ---------------------------------------------------------------------------\n+\n+class TestSanitizeSubprocessEnvHomeInjection:\n+    \"\"\"Verify _sanitize_subprocess_env() injects HOME for background procs.\"\"\"\n+\n+    def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"home\").mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        base_env = {\"HOME\": \"/root\", \"PATH\": \"/usr/bin\", \"USER\": \"root\"}\n+        from tools.environments.local import _sanitize_subprocess_env\n+        result = _sanitize_subprocess_env(base_env)\n+\n+        assert result[\"HOME\"] == str(hermes_home / \"home\")\n+\n+    def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        base_env = {\"HOME\": \"/root\", \"PATH\": \"/usr/bin\"}\n+        from tools.environments.local import _sanitize_subprocess_env\n+        result = _sanitize_subprocess_env(base_env)\n+\n+        assert result[\"HOME\"] == \"/root\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Profile bootstrap\n+# ---------------------------------------------------------------------------\n+\n+class TestProfileBootstrap:\n+    \"\"\"Verify new profiles get a home/ subdirectory.\"\"\"\n+\n+    def test_profile_dirs_includes_home(self):\n+        from hermes_cli.profiles import _PROFILE_DIRS\n+        assert \"home\" in _PROFILE_DIRS\n+\n+    def test_create_profile_bootstraps_home_dir(self, tmp_path, monkeypatch):\n+        \"\"\"create_profile() should create home/ inside the profile dir.\"\"\"\n+        home = tmp_path / \".hermes\"\n+        home.mkdir()\n+        monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+        monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+        from hermes_cli.profiles import create_profile\n+        profile_dir = create_profile(\"testbot\", no_alias=True)\n+        assert (profile_dir / \"home\").is_dir()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Python process HOME unchanged\n+# ---------------------------------------------------------------------------\n+\n+class TestPythonProcessUnchanged:\n+    \"\"\"Confirm the Python process's own HOME is never modified.\"\"\"\n+\n+    def test_path_home_unchanged_after_subprocess_home_resolved(\n+        self, tmp_path, monkeypatch\n+    ):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"home\").mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        original_home = os.environ.get(\"HOME\")\n+        original_path_home = str(Path.home())\n+\n+        from hermes_constants import get_subprocess_home\n+        sub_home = get_subprocess_home()\n+\n+        # Subprocess home is set but Python HOME stays the same\n+        assert sub_home is not None\n+        assert os.environ.get(\"HOME\") == original_home\n+        assert str(Path.home()) == original_path_home"]},{"file":"tools/code_execution_tool.py","hunks":["@@ -1020,6 +1020,13 @@ def execute_code(\n         if _tz_name:\n             child_env[\"TZ\"] = _tz_name\n \n+        # Per-profile HOME isolation: redirect system tool configs into\n+        # {HERMES_HOME}/home/ when that directory exists.\n+        from hermes_constants import get_subprocess_home\n+        _profile_home = get_subprocess_home()\n+        if _profile_home:\n+            child_env[\"HOME\"] = _profile_home\n+\n         proc = subprocess.Popen(\n             [sys.executable, \"script.py\"],\n             cwd=tmpdir,"]},{"file":"tools/environments/local.py","hunks":["@@ -129,6 +129,12 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non\n         elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key):\n             sanitized[key] = value\n \n+    # Per-profile HOME isolation for background processes (same as _make_run_env).\n+    from hermes_constants import get_subprocess_home\n+    _profile_home = get_subprocess_home()\n+    if _profile_home:\n+        sanitized[\"HOME\"] = _profile_home\n+\n     return sanitized\n \n ","@@ -195,6 +201,15 @@ def _make_run_env(env: dict) -> dict:\n     existing_path = run_env.get(\"PATH\", \"\")\n     if \"/usr/bin\" not in existing_path.split(\":\"):\n         run_env[\"PATH\"] = f\"{existing_path}:{_SANE_PATH}\" if existing_path else _SANE_PATH\n+\n+    # Per-profile HOME isolation: redirect system tool configs (git, ssh, gh,\n+    # npm …) into {HERMES_HOME}/home/ when that directory exists.  Only the\n+    # subprocess sees the override — the Python process keeps the real HOME.\n+    from hermes_constants import get_subprocess_home\n+    _profile_home = get_subprocess_home()\n+    if _profile_home:\n+        run_env[\"HOME\"] = _profile_home\n+\n     return run_env\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}