{"repo":"NousResearch/hermes-agent","public_id":"65583","display_label":"Terminal tool handling of inaccessible or unenterable working directory","summary":"Members appear to center on the terminal tool's behavior when its configured working directory exists but cannot be entered, such as a permission-denied error on '/root'. One member is a bug report involving cron jobs after mixed root and non-root sessions, and the others are pull requests proposing fallback or recovery behavior, with issue #65583 referenced.","subsystem":"environments","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":66306,"merged_at":"2026-07-17T13:54:51+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-17T13:54:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":65583,"pr_number":66306,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-17T13:54:51+00:00"}],"members":[{"kind":"issue","number":65583,"title":"[Bug]: Terminal/file tools fail with \"Permission denied: '/root'\" for all cron jobs after mixing root-launched and hermes-user-launched CLI sessions","url":null,"state":"closed"},{"kind":"pr","number":65623,"title":"fix(terminal): recover from inaccessible cwd instead of wedging (#65583)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"71252f0dcb92b957b45c371d062aa572b8dc4785","number":0},{"kind":"pr","value":"#66306","number":66306}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":66164,"title":"fix(tools): recover terminal cwd that exists but the user can't enter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65623","number":65623},{"kind":"pr","value":"#65583","number":65583}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66306,"title":"fix(terminal): fall back when the configured cwd is unenterable, not just missing (#65583)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_local_cwd_permission_fallback.py","tools/environments/local.py"],"fix_hunks":[{"file":"tests/tools/test_local_cwd_permission_fallback.py","hunks":["@@ -0,0 +1,83 @@\n+\"\"\"Regression tests for inaccessible-cwd fallback (#65583).\n+\n+``/root`` leaking into a non-root gateway/cron process's terminal cwd used to\n+kill every command with ``PermissionError: [Errno 13] Permission denied:\n+'/root'`` — ``os.path.isdir('/root')`` is True for a non-root user (stat only\n+needs search permission on ``/``), so the old existence-only check in\n+``_resolve_safe_cwd`` happily returned a directory ``subprocess.Popen`` could\n+not enter. The fix checks X_OK and falls back to the nearest usable ancestor.\n+\"\"\"\n+\n+import os\n+import sys\n+import tempfile\n+\n+import pytest\n+\n+from tools.environments.local import LocalEnvironment, _cwd_usable, _resolve_safe_cwd\n+\n+\n[redacted:email]\n+def denied_dir(tmp_path):\n+    \"\"\"A directory that exists but cannot be entered (simulates /root).\"\"\"\n+    d = tmp_path / \"rootlike\"\n+    d.mkdir()\n+    d.chmod(0)\n+    yield d\n+    d.chmod(0o755)  # so pytest can clean up\n+\n+\n+needs_posix_perms = pytest.mark.skipif(\n+    sys.platform == \"win32\" or os.geteuid() == 0,\n+    reason=\"chmod-based access denial needs POSIX + non-root\",\n+)\n+\n+\n+@needs_posix_perms\n+class TestInaccessibleCwdFallback:\n+    def test_cwd_usable_rejects_unenterable_directory(self, denied_dir):\n+        assert os.path.isdir(denied_dir)  # the trap: stat succeeds\n+        assert _cwd_usable(str(denied_dir)) is False\n+\n+    def test_resolve_safe_cwd_falls_back_from_denied_dir(self, denied_dir, tmp_path):\n+        resolved = _resolve_safe_cwd(str(denied_dir))\n+        assert resolved != str(denied_dir)\n+        assert os.access(resolved, os.X_OK)\n+        # Nearest usable ancestor is the tmp_path parent, not a random tempdir.\n+        assert resolved == str(tmp_path)\n+\n+    def test_resolve_safe_cwd_climbs_past_denied_ancestor(self, denied_dir, tmp_path):\n+        missing_child = str(denied_dir / \"sub\" / \"dir\")\n+        resolved = _resolve_safe_cwd(missing_child)\n+        assert os.access(resolved, os.X_OK)\n+        assert resolved == str(tmp_path)\n+\n+    def test_local_environment_survives_denied_cwd(self, denied_dir):\n+        \"\"\"The #65583 shape: env constructed with an unenterable cwd must\n+        still execute commands instead of raising PermissionError.\"\"\"\n+        env = LocalEnvironment(cwd=str(denied_dir), timeout=30)\n+        try:\n+            handle = env._run_bash(\"echo ALIVE\")\n+            out = handle.stdout.read() if handle.stdout else b\"\"\n+            rc = handle.wait(timeout=15)\n+            assert rc == 0\n+            assert b\"ALIVE\" in (out if isinstance(out, bytes) else out.encode())\n+        finally:\n+            try:\n+                env.cleanup()\n+            except Exception:\n+                pass\n+\n+\n+class TestUsableCwdBehaviorUnchanged:\n+    def test_existing_accessible_cwd_returned_verbatim(self, tmp_path):\n+        assert _resolve_safe_cwd(str(tmp_path)) == str(tmp_path)\n+\n+    def test_missing_cwd_still_climbs_to_existing_ancestor(self, tmp_path):\n+        missing = str(tmp_path / \"gone\" / \"deeper\")\n+        assert _resolve_safe_cwd(missing) == str(tmp_path)\n+\n+    def test_hopeless_path_falls_back_to_tempdir(self):\n+        # A path whose every component is missing outside any real tree.\n+        resolved = _resolve_safe_cwd(\"\")\n+        assert resolved == tempfile.gettempdir()"]},{"file":"tools/environments/local.py","hunks":["@@ -136,11 +136,27 @@ def _quote_bash_path(path: str) -> str:\n     return shlex.quote(_bash_safe_path(path))\n \n \n+def _cwd_usable(path: str) -> bool:\n+    \"\"\"True when *path* is a directory this process can actually chdir into.\n+\n+    ``os.path.isdir`` alone is not enough: stat() on ``/root`` succeeds for a\n+    non-root user (only ``/`` needs search permission), but\n+    ``subprocess.Popen(cwd='/root')`` then dies with ``PermissionError:\n+    [Errno 13] Permission denied: '/root'``. Seen in the wild when a\n+    root-launched CLI session leaks ``/root`` into shared state that a\n+    non-root gateway/cron process later reads (#65583) — every cron job's\n+    terminal/file tool then fails on every command, forever. Checking\n+    X_OK up front lets the caller fall back instead.\n+    \"\"\"\n+    return os.path.isdir(path) and os.access(path, os.X_OK)\n+\n+\n def _resolve_safe_cwd(cwd: str) -> str:\n-    \"\"\"Return ``cwd`` if it exists as a directory, else the nearest existing\n-    ancestor.  Falls back to ``tempfile.gettempdir()`` only if walking up the\n-    path can't find any existing directory (effectively never on a healthy\n-    filesystem, but cheap belt-and-braces).\n+    \"\"\"Return ``cwd`` if it exists as a directory this process can enter,\n+    else the nearest existing accessible ancestor.  Falls back to\n+    ``tempfile.gettempdir()`` only if walking up the path can't find any\n+    usable directory (effectively never on a healthy filesystem, but cheap\n+    belt-and-braces).\n \n     On Windows, also normalizes Git Bash / MSYS-style POSIX paths\n     (``/c/Users/x``) to native Windows form before the isdir check so a","@@ -149,16 +165,27 @@ def _resolve_safe_cwd(cwd: str) -> str:\n \n     Used by ``_run_bash`` to recover when the configured cwd is gone — most\n     commonly because a previous tool call deleted its own working directory\n-    (issue #17558).  Without this guard, ``subprocess.Popen(..., cwd=...)``\n-    raises ``FileNotFoundError`` before bash starts, wedging every subsequent\n-    terminal call until the gateway restarts.\n+    (issue #17558) — or inaccessible to this user, e.g. ``/root`` leaking\n+    from a root-launched CLI session into a non-root gateway's cron jobs\n+    (issue #65583).  Without this guard, ``subprocess.Popen(..., cwd=...)``\n+    raises ``FileNotFoundError``/``PermissionError`` before bash starts,\n+    wedging every subsequent terminal call until the gateway restarts.\n     \"\"\"\n     cwd = _msys_to_windows_path(cwd) if _IS_WINDOWS else cwd\n-    if cwd and os.path.isdir(cwd):\n+    if cwd and _cwd_usable(cwd):\n         return cwd\n+    if cwd and os.path.isdir(cwd):\n+        logger.warning(\n+            \"Configured terminal cwd %r exists but is not accessible to \"\n+            \"this user (uid=%s) — falling back to the nearest usable \"\n+            \"directory. If this is a gateway/cron process, check for \"\n+            \"root-owned paths leaking into terminal.cwd / TERMINAL_CWD \"\n+            \"(#65583).\",\n+            cwd, getattr(os, \"getuid\", lambda: \"?\")(),\n+        )\n     parent = os.path.dirname(cwd) if cwd else \"\"\n     while parent:\n-        if os.path.isdir(parent):\n+        if _cwd_usable(parent):\n             return parent\n         next_parent = os.path.dirname(parent)\n         if next_parent == parent:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}