{"repo":"NousResearch/hermes-agent","public_id":"38964","display_label":"Windows drive-letter paths missing from dangerous command detection","summary":"A bug was identified where dangerous command detection did not recognize Windows-style absolute paths (drive letters like X:/ or X:\\) as sensitive. Multiple patches address adding Windows path patterns to the approval and detection logic.","subsystem":"agent","counts":{"members":8,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52768},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-26T00:49:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":49509,"pr_number":52768,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":38964,"title":"[Bug]: [Windows] Dangerous command approval doesn't catch paths with drive letters (X:/...)","url":null,"state":"open"},{"kind":"issue","number":49509,"title":"detect_dangerous_command misses Windows absolute paths for sensitive file mutations","url":null,"state":"closed"},{"kind":"pr","number":38977,"title":"fix(approval): catch Windows drive-letter paths (X:/, X:\\) as dangerous (#38964)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#38964","number":38964},{"kind":"pr","value":"#38979","number":38979}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":38979,"title":"fix(approval): detect Windows drive-letter paths in dangerous command patterns","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":38980,"title":"fix(approval): add Windows drive-letter path patterns to dangerous command detection (fixes #38964)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":49531,"title":"fix(approval): detect Windows absolute sensitive paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b8fc8c908bb78db138458988042bfca61de5f4e4","number":0},{"kind":"pr","value":"#52768","number":52768}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52768,"title":"fix(approval): fold Windows absolute home paths in dangerous-command detection","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_approval.py","tools/approval.py"],"fix_hunks":[{"file":"tests/tools/test_approval.py","hunks":["@@ -738,6 +738,64 @@ def test_sed_in_place_regular_file_safe(self):\n         assert key is None\n \n \n+class TestWindowsAbsolutePathFolding:\n+    \"\"\"Windows absolute home / Hermes-home prefixes must fold to ~/ and\n+    ~/.hermes/ in dangerous-command detection.\n+\n+    Regression: on native Windows the home prefix uses backslash separators\n+    (``C:\\\\Users\\\\alice\\\\.ssh\\\\authorized_keys``). Detection stripped backslash\n+    escapes *before* folding, dissolving those separators, so writes to startup,\n+    SSH, and Hermes config/env files returned \"safe\" without an approval prompt.\n+    The OS-specific ``Path.home()`` / ``get_hermes_home()`` tests above only\n+    exercise this branch on a Windows host; these monkeypatch a Windows-style\n+    HOME/HERMES_HOME so the fold is verified on the POSIX CI runner too.\"\"\"\n+\n+    def test_windows_home_bashrc_folds(self, monkeypatch):\n+        monkeypatch.setenv(\"HOME\", r\"C:\\Users\\tester\")\n+        dangerous, key, _ = detect_dangerous_command(\n+            r\"echo 'pwned' > C:\\Users\\tester\\.bashrc\"\n+        )\n+        assert dangerous is True\n+        assert key is not None\n+\n+    def test_windows_home_ssh_authorized_keys_multiseg_folds(self, monkeypatch):\n+        # The multi-segment suffix (\\.ssh\\authorized_keys) must also have its\n+        # separators normalized, not just the home prefix.\n+        monkeypatch.setenv(\"HOME\", r\"C:\\Users\\tester\")\n+        dangerous, key, _ = detect_dangerous_command(\n+            r\"cat key >> C:\\Users\\tester\\.ssh\\authorized_keys\"\n+        )\n+        assert dangerous is True\n+        assert key is not None\n+\n+    def test_windows_home_forward_slash_folds(self, monkeypatch):\n+        monkeypatch.setenv(\"HOME\", r\"C:\\Users\\tester\")\n+        dangerous, key, _ = detect_dangerous_command(\n+            \"cat key >> C:/Users/tester/.ssh/authorized_keys\"\n+        )\n+        assert dangerous is True\n+        assert key is not None\n+\n+    def test_windows_hermes_home_config_folds(self, monkeypatch):\n+        # Hermes home nests under the user home on Windows; it must fold before\n+        # the user-home rewrite eats its prefix.\n+        monkeypatch.setenv(\"HOME\", r\"C:\\Users\\tester\")\n+        monkeypatch.setenv(\"HERMES_HOME\", r\"C:\\Users\\tester\\.hermes\")\n+        dangerous, key, _ = detect_dangerous_command(\n+            r\"sed -i 's/manual/off/' C:\\Users\\tester\\.hermes\\config.yaml\"\n+        )\n+        assert dangerous is True\n+        assert key is not None\n+\n+    def test_windows_unrelated_path_not_flagged(self, monkeypatch):\n+        monkeypatch.setenv(\"HOME\", r\"C:\\Users\\tester\")\n+        dangerous, key, _ = detect_dangerous_command(\n+            r\"cp report.txt C:\\Users\\tester\\notes.txt\"\n+        )\n+        assert dangerous is False\n+        assert key is None\n+\n+\n class TestProjectSensitiveTeePattern:\n     def test_tee_to_local_dotenv_requires_approval(self):\n         dangerous, key, desc = detect_dangerous_command(\"printenv | tee .env.local\")"]},{"file":"tools/approval.py","hunks":["@@ -10,6 +10,7 @@\n \n import contextvars\n import fnmatch\n+import functools\n import logging\n import os\n import re","@@ -571,87 +572,136 @@ def _normalize_command_for_detection(command: str) -> str:\n     command = command.replace('\\x00', '')\n     # Normalize Unicode (fullwidth Latin, halfwidth Katakana, etc.)\n     command = unicodedata.normalize('NFKC', command)\n+    # Fold absolute home / active-profile-home prefixes into their canonical\n+    # ~/ and ~/.hermes/ forms so static user-sensitive patterns catch\n+    # /home/alice/.bashrc and C:\\Users\\alice\\.bashrc the same way they catch\n+    # ~/.bashrc. Resolve at detection time (not via an import-time snapshot) so\n+    # it tracks HOME / HERMES_HOME even when those are set after this module is\n+    # imported — as the hermetic test conftest and profile/session launchers do.\n+    #\n+    # This MUST run before the backslash-escape strip below: on Windows the home\n+    # prefix is separated by backslashes (C:\\Users\\alice\\...), which that strip\n+    # would otherwise dissolve (-> C:Usersalice) and make the fold impossible.\n+    # The fold matches either separator, so POSIX paths are unaffected by order.\n+    #\n+    # Fold the (more specific) Hermes home first: on Windows it nests under the\n+    # user home (C:\\Users\\alice\\AppData\\...\\hermes), so folding the user home\n+    # first would eat the prefix the Hermes-home fold needs.\n+    command = _rewrite_resolved_hermes_home(command)\n+    command = _rewrite_resolved_user_home(command)\n     # Strip shell backslash-escapes: r\\m → rm. Prevents \\-injection bypass.\n     command = re.sub(r'\\\\([^\\n])', r'\\1', command)\n     # Strip empty-string literals that split tokens: r''m → rm, r\"\\\"m → rm.\n     command = re.sub(r\"''|\\\"\\\"\", '', command)\n-    # Fold the current user's resolved absolute home path into ~/ at detection\n-    # time so static user-sensitive patterns catch /home/alice/.bashrc the same\n-    # way they catch ~/.bashrc. Do not snapshot this at import time: tests and\n-    # profile/session launchers can set HOME after this module is imported.\n-    command = _rewrite_resolved_user_home(command)\n-    # Fold the resolved absolute active-profile home path into the canonical\n-    # ~/.hermes/ form so the Hermes config/env patterns catch it. In Docker and\n-    # gateway deployments the agent often references the resolved absolute path\n-    # directly (e.g. `sed -i ... /home/hermes/.hermes/config.yaml`) rather than\n-    # ~, $HOME, or $HERMES_HOME. Done at detection time (not via an import-time\n-    # pattern snapshot) so it tracks the live HERMES_HOME even when that is set\n-    # after this module is imported — as the hermetic test conftest does.\n-    command = _rewrite_resolved_hermes_home(command)\n+    return command\n+\n+\n+# Shell metacharacters, quotes, and whitespace that terminate a filesystem\n+# path token on a command line. Used to bound the path tail we normalize.\n+_PATH_TOKEN_STOP = r\"\"\"\\s'\"`;|&<>()\"\"\"\n+# One path segment (no separators, no terminators) preceded by a separator.\n+_PATH_TAIL = r\"(?P<tail>(?:[/\\\\][^/\\\\\" + _PATH_TOKEN_STOP + r\"]*)+)\"\n+\n+\n[redacted:email]_cache(maxsize=64)\n+def _home_prefix_fold_regex(path: str):\n+    \"\"\"Compile a regex matching *path* used as an absolute directory prefix.\n+\n+    The home components are matched with either separator (``/`` or ``\\\\``)\n+    between them, followed by the rest of the path token (the ``tail`` group),\n+    so a Windows native path (``C:\\\\Users\\\\alice\\\\.ssh\\\\authorized_keys``), its\n+    forward-slash form, and mixed-separator forms all fold — and the tail's\n+    backslashes get normalized to ``/`` by the caller so multi-segment static\n+    patterns (``~/.ssh/authorized_keys``) still match. The trailing tail is\n+    required (``+``), so a bare home with no path under it is not folded.\n+\n+    Returns ``None`` for an unset or degenerate path — one with fewer than two\n+    components below the root — so a stray HOME / HERMES_HOME such as ``/``,\n+    ``C:\\\\`` or ``\"\"`` cannot rewrite unrelated filesystem prefixes. Cached\n+    because the resolved home is stable across calls on this hot path.\n+    \"\"\"\n+    if not path:\n+        return None\n+    components = [c for c in re.split(r\"[/\\\\]+\", path) if c]\n+    # Require at least two non-empty components below the root. For POSIX this\n+    # mirrors the historical ``count(\"/\") >= 2`` guard (``/home/alice`` folds,\n+    # ``/home`` does not); for Windows it rejects a bare drive root (``C:\\\\``)\n+    # while accepting a real home (``C:\\\\Users\\\\alice``).\n+    if len(components) < 2:\n+        return None\n+    body = r\"[/\\\\]+\".join(re.escape(c) for c in components)\n+    # Optional leading root separator (POSIX ``/`` or UNC ``\\\\``); a Windows\n+    # drive letter is captured as the first component.\n+    return re.compile(r\"[/\\\\]*\" + body + _PATH_TAIL)\n+\n+\n+def _fold_home_prefixes(command: str, paths, replacement: str) -> str:\n+    \"\"\"Fold each resolved home *path* prefix in *command* to *replacement*.\n+\n+    *replacement* has no trailing separator (``~`` / ``~/.hermes``); the matched\n+    path tail (with its backslashes normalized to ``/``) supplies it. Longest\n+    candidate first so a deeper home (e.g. an explicit HOME under USERPROFILE)\n+    folds before a shorter overlapping one that would otherwise clobber it.\n+    \"\"\"\n+    seen: set[str] = set()\n+    for path in sorted((p for p in paths if p), key=len, reverse=True):\n+        if path in seen:\n+            continue\n+        seen.add(path)\n+        pattern = _home_prefix_fold_regex(path)\n+        if pattern is not None:\n+            command = pattern.sub(\n+                lambda m: replacement + m.group(\"tail\").replace(\"\\\\\", \"/\"),\n+                command,\n+            )\n     return command\n \n \n def _rewrite_resolved_user_home(command: str) -> str:\n     \"\"\"Rewrite the current user's absolute home prefix to ``~/``.\n \n-    Resolves HOME at detection time, including its symlink-resolved form, so\n-    terminal commands targeting absolute home paths are checked by the same\n-    static patterns as tilde and $HOME forms. No-op when HOME is unset or\n-    degenerate.\n+    Resolves the home at detection time — its expanduser form, symlink-resolved\n+    form, and an explicitly set ``HOME`` — so absolute home paths are checked by\n+    the same static patterns as tilde and ``$HOME`` forms. ``HOME`` is consulted\n+    directly because Windows' ``os.path.expanduser`` resolves ``~`` from\n+    ``USERPROFILE`` and ignores ``HOME``, unlike POSIX. Matches both POSIX\n+    (``/home/alice``) and Windows (``C:\\\\Users\\\\alice`` or ``C:/Users/alice``)\n+    separators. No-op when the home is unset or degenerate.\n     \"\"\"\n     try:\n         home = os.path.expanduser(\"~\")\n         candidates = [\n-            home.rstrip(\"/\"),\n-            os.path.realpath(home).rstrip(\"/\"),\n+            home,\n+            os.path.realpath(home),\n+            os.environ.get(\"HOME\", \"\"),\n         ]\n     except Exception:\n         return command\n-    seen: set[str] = set()\n-    for path in candidates:\n-        if not path or path in seen:\n-            continue\n-        seen.add(path)\n-        # Require an absolute path below root so a bad HOME cannot rewrite the\n-        # whole filesystem namespace.\n-        normalized = path.rstrip(\"/\")\n-        if not normalized.startswith(\"/\") or normalized.count(\"/\") < 2:\n-            continue\n-        command = command.replace(normalized + \"/\", \"~/\")\n-    return command\n+    return _fold_home_prefixes(command, candidates, \"~\")\n \n \n def _rewrite_resolved_hermes_home(command: str) -> str:\n     \"\"\"Rewrite the resolved absolute Hermes home prefix to ``~/.hermes/``.\n \n     Resolves the active ``HERMES_HOME`` at call time (and its symlink-resolved\n-    form) and replaces an occurrence of ``<home>/`` in *command* with\n+    form) and folds an occurrence of ``<home>/`` in *command* into\n     ``~/.hermes/`` so the static ``_HERMES_CONFIG_PATH`` / ``_HERMES_ENV_PATH``\n-    patterns match. No-op when the path can't be resolved or doesn't appear.\n+    patterns match. In Docker and gateway deployments the agent often references\n+    the resolved absolute path directly (e.g. ``sed -i ...\n+    /home/hermes/.hermes/config.yaml``) rather than ``~``, ``$HOME``, or\n+    ``$HERMES_HOME``. Matches both POSIX and Windows separators. No-op when the\n+    path can't be resolved or doesn't appear.\n     \"\"\"\n     try:\n         from hermes_constants import get_hermes_home\n         home = get_hermes_home().expanduser()\n         candidates = [\n-            str(home).rstrip(\"/\"),\n-            str(home.resolve(strict=False)).rstrip(\"/\"),\n+            str(home),\n+            str(home.resolve(strict=False)),\n         ]\n     except Exception:\n         return command\n-    seen: set[str] = set()\n-    for path in candidates:\n-        if not path or path in seen:\n-            continue\n-        seen.add(path)\n-        # Guard against a degenerate HERMES_HOME (e.g. \"/\" or \"\") rewriting\n-        # unrelated paths: require an absolute path with at least one non-root\n-        # component. The active profile home is always a real directory like\n-        # /home/hermes/.hermes or a per-test tempdir, never a bare root.\n-        normalized = path.rstrip(\"/\")\n-        if not normalized.startswith(\"/\") or normalized.count(\"/\") < 2:\n-            continue\n-        command = command.replace(normalized + \"/\", \"~/.hermes/\")\n-    return command\n+    return _fold_home_prefixes(command, candidates, \"~/.hermes\")\n \n \n def detect_dangerous_command(command: str) -> tuple:\n"]}]},{"kind":"pr","number":66202,"title":"fix(approval): detect Windows drive-letter paths in dangerous command patterns","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"527c925f4642","number":0},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#66202","number":66202},{"kind":"pr","value":"#38964","number":38964},{"kind":"pr","value":"#39662","number":39662},{"kind":"pr","value":"#39665","number":39665},{"kind":"pr","value":"#39671","number":39671},{"kind":"pr","value":"#39607","number":39607},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#38964","number":38964},{"kind":"pr","value":"#39662","number":39662},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#39607","number":39607},{"kind":"pr","value":"#39665","number":39665},{"kind":"pr","value":"#39671","number":39671},{"kind":"pr","value":"#39671","number":39671},{"kind":"pr","value":"#39607","number":39607},{"kind":"pr","value":"#66202","number":66202},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#66202","number":66202},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#39662","number":39662},{"kind":"pr","value":"#39665","number":39665},{"kind":"pr","value":"#39671","number":39671},{"kind":"pr","value":"#39662","number":39662},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#66202","number":66202},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#66202","number":66202},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#38979","number":38979},{"kind":"pr","value":"#39671","number":39671},{"kind":"pr","value":"#39662","number":39662},{"kind":"pr","value":"#39665","number":39665},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#38964","number":38964},{"kind":"pr","value":"#38977","number":38977},{"kind":"pr","value":"#38980","number":38980},{"kind":"pr","value":"#66202","number":66202}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}