{"repo":"NousResearch/hermes-agent","public_id":"1444","display_label":"Agent consent enforcement and Windows install reliability","summary":"Two intertwined themes: fixes ensuring agents require explicit user approval before executing destructive or sensitive actions, and a cluster of Windows-specific installation and launcher issues involving uv trampolines, broken virtual environments, and Hermes startup failures.","subsystem":"agent","counts":{"members":18,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":7,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30879},"resolution":{"state":"partially_resolved","by":"pr-30879,pr-109170,pr-122973"},"freshness":{"state":"verified","last_verified_at":"2026-09-25T21:24:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08418300653594772},"fix_edges":[{"issue_number":10199,"pr_number":109170,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":24912,"pr_number":30879,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58748,"pr_number":122973,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1444,"title":"[Bug]:  Agent executes actions without user approval in Telegram gateway","url":null,"state":"closed"},{"kind":"issue","number":10199,"title":"[Bug] Agent executed destructive command without user confirmation","url":null,"state":"closed"},{"kind":"issue","number":24912,"title":"[Bug]: Agent assumes consent without it was given","url":null,"state":"closed"},{"kind":"issue","number":48414,"title":"[Bug]: `error: uv trampoline failed to canonicalize script path` when executes `hermes` on Windows","url":null,"state":"closed"},{"kind":"issue","number":54919,"title":"[Setup]: hermes not launching","url":null,"state":"closed"},{"kind":"issue","number":58748,"title":"Agent can delete the Python interpreter Hermes's own runtime depends on, breaking startup","url":null,"state":"closed"},{"kind":"issue","number":58749,"title":"Repair install reuses broken uv-trampoline venv instead of detecting missing base Python and rebuilding","url":null,"state":"closed"},{"kind":"issue","number":61557,"title":"Windows OpenSSH: 'uv trampoline failed to spawn Python child process' (os error 2) / WinError 448 untrusted mount point","url":null,"state":"open"},{"kind":"pr","number":1510,"title":"fix: agent executes actions without user approval in Telegram gateway","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1444","number":1444}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":30879,"title":"fix(approval): pin 'silence is not consent' contract on timeout/deny (#24912)","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":["@@ -1,6 +1,9 @@\n \"\"\"Tests for the dangerous command approval module.\"\"\"\n \n import ast\n+import os\n+import threading\n+import time\n from pathlib import Path\n from types import SimpleNamespace\n from unittest.mock import patch as mock_patch","@@ -1305,3 +1308,165 @@ def test_cat_etc_hostname_is_safe(self):\n     def test_grep_etc_passwd_is_safe(self):\n         dangerous, _, _ = detect_dangerous_command(\"grep root /etc/passwd\")\n         assert dangerous is False\n+\n+\n+# =========================================================================\n+# Gateway approval timeout = deny, NOT consent (#24912)\n+#\n+# A Slack user walked away mid-conversation; the agent requested approval\n+# to run `rm -rf .git`; the prompt timed out; the agent ran the command\n+# anyway. Reported by @tofalck on 2026-05-13, corroborated by\n+# @angry-programmer on Telegram. Silence is not consent.\n+#\n+# These tests pin:\n+#   1. Gateway timeout → approved=False, with a message strong enough that\n+#      a downstream agent reading \"BLOCKED: ... Silence is not consent.\"\n+#      treats it as a hard halt, not an invitation to rephrase.\n+#   2. The structured outcome / user_consent fields are present so\n+#      plugins, hooks, and audit pipelines can act on the timeout without\n+#      string-parsing the message.\n+#   3. Explicit /deny carries the same shape (treat-as-not-consented).\n+# =========================================================================\n+\n+\n+class TestApprovalTimeoutIsNotConsent:\n+    \"\"\"The gateway approval contract: silence is not consent (#24912).\"\"\"\n+\n+    SESSION_KEY = \"test-no-consent-session\"\n+\n+    def setup_method(self):\n+        \"\"\"Reset module state and force tight gateway_timeout for fast tests.\"\"\"\n+        from tools import approval as mod\n+        mod._gateway_queues.clear()\n+        mod._gateway_notify_cbs.clear()\n+        mod._session_approved.clear()\n+        mod._permanent_approved.clear()\n+        mod._pending.clear()\n+\n+        self._saved_env = {\n+            k: os.environ.get(k)\n+            for k in (\"HERMES_GATEWAY_SESSION\", \"HERMES_YOLO_MODE\",\n+                      \"HERMES_SESSION_KEY\", \"HERMES_INTERACTIVE\")\n+        }\n+        os.environ.pop(\"HERMES_YOLO_MODE\", None)\n+        os.environ.pop(\"HERMES_INTERACTIVE\", None)\n+        os.environ[\"HERMES_GATEWAY_SESSION\"] = \"1\"\n+        os.environ[\"HERMES_SESSION_KEY\"] = self.SESSION_KEY\n+\n+    def teardown_method(self):\n+        from tools import approval as mod\n+        mod._gateway_queues.clear()\n+        mod._gateway_notify_cbs.clear()\n+        for k, v in self._saved_env.items():\n+            if v is None:\n+                os.environ.pop(k, None)\n+            else:\n+                os.environ[k] = v\n+\n+    def _force_short_timeout(self, monkeypatch, seconds=1):\n+        from tools import approval as mod\n+        monkeypatch.setattr(\n+            mod, \"_get_approval_config\",\n+            lambda: {\"mode\": \"manual\", \"gateway_timeout\": seconds, \"timeout\": seconds},\n+        )\n+\n+    def test_timeout_returns_approved_false_with_no_consent(self, monkeypatch):\n+        \"\"\"The reported #24912 scenario — user never responds, agent must see BLOCKED.\"\"\"\n+        from tools import approval as mod\n+\n+        self._force_short_timeout(monkeypatch, seconds=1)\n+\n+        # Slack-shaped: notify_cb registered, but user doesn't respond.\n+        notified = []\n+        mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data))\n+\n+        result = mod.check_all_command_guards(\"rm -rf .git\", \"local\")\n+\n+        assert result[\"approved\"] is False\n+        assert result.get(\"user_consent\") is False\n+        assert result.get(\"outcome\") == \"timeout\"\n+        # The notify_cb DID fire — we did try to ask the user.\n+        assert len(notified) == 1\n+\n+    def test_timeout_message_is_emphatic_against_retry_and_rephrase(self, monkeypatch):\n+        \"\"\"The BLOCKED message must explicitly tell the agent not to rephrase.\n+\n+        Without this, the agent treats 'Do NOT retry this command' as\n+        permission to try a different command achieving the same outcome.\n+        \"\"\"\n+        from tools import approval as mod\n+        self._force_short_timeout(monkeypatch, seconds=1)\n+        mod.register_gateway_notify(self.SESSION_KEY, lambda data: None)\n+\n+        result = mod.check_all_command_guards(\"rm -rf .git\", \"local\")\n+\n+        msg = result[\"message\"]\n+        # Explicit halt signals — these are the model-facing contract.\n+        assert \"BLOCKED\" in msg\n+        assert \"NOT consented\" in msg\n+        assert \"Silence is not consent\" in msg\n+        # Both forms of evasion must be named:\n+        assert \"do NOT retry\" in msg.lower() or \"Do NOT retry\" in msg\n+        assert \"rephrase\" in msg.lower()\n+        assert \"different command\" in msg.lower()\n+\n+    def test_explicit_deny_carries_same_no_consent_shape(self):\n+        \"\"\"An explicit /deny must produce the same shape as timeout —\n+        the agent should treat both identically.\"\"\"\n+        from tools import approval as mod\n+\n+        notified = []\n+        mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data))\n+\n+        # Spawn the approval wait in a thread, then resolve it with \"deny\".\n+        result_holder = {}\n+        def _check():\n+            result_holder[\"r\"] = mod.check_all_command_guards(\"rm -rf .git\", \"local\")\n+        t = threading.Thread(target=_check)\n+        t.start()\n+\n+        # Wait for the queue entry to appear, then resolve.\n+        for _ in range(50):\n+            if mod._gateway_queues.get(self.SESSION_KEY):\n+                break\n+            time.sleep(0.02)\n+        mod.resolve_gateway_approval(self.SESSION_KEY, \"deny\")\n+        t.join(timeout=5)\n+        assert \"r\" in result_holder, \"approval wait did not return after deny\"\n+\n+        r = result_holder[\"r\"]\n+        assert r[\"approved\"] is False\n+        assert r.get(\"user_consent\") is False\n+        assert r.get(\"outcome\") == \"denied\"\n+        assert \"Silence is not consent\" not in r[\"message\"]  # this one IS denied, not timed-out\n+        assert \"NOT consented\" in r[\"message\"]\n+        assert \"rephrase\" in r[\"message\"].lower()\n+\n+    def test_timeout_emits_post_hook_with_timeout_outcome(self, monkeypatch):\n+        \"\"\"Plugins must be able to distinguish timeout from explicit deny.\n+\n+        This is what an audit / notification plugin needs to alert\n+        operators on 'agent asked, user never replied' incidents like #24912.\n+        \"\"\"\n+        from tools import approval as mod\n+        self._force_short_timeout(monkeypatch, seconds=1)\n+        mod.register_gateway_notify(self.SESSION_KEY, lambda data: None)\n+\n+        hook_calls = []\n+        original_fire = mod._fire_approval_hook\n+\n+        def _capture(event_name, **kwargs):\n+            hook_calls.append((event_name, kwargs))\n+            return original_fire(event_name, **kwargs)\n+\n+        monkeypatch.setattr(mod, \"_fire_approval_hook\", _capture)\n+\n+        mod.check_all_command_guards(\"rm -rf .git\", \"local\")\n+\n+        # post_approval_response must be in the hook log with choice=timeout\n+        posts = [c for c in hook_calls if c[0] == \"post_approval_response\"]\n+        assert posts, \"post_approval_response hook did not fire\"\n+        last_post = posts[-1][1]\n+        assert last_post.get(\"choice\") == \"timeout\", (\n+            f\"hook choice should be 'timeout' on no-response, got {last_post.get('choice')!r}\"\n+        )"]},{"file":"tools/approval.py","hunks":["@@ -1299,12 +1299,34 @@ def check_all_command_guards(command: str, env_type: str,\n             )\n \n             if not resolved or choice is None or choice == \"deny\":\n-                reason = \"timed out\" if not resolved else \"denied by user\"\n+                # Consent contract: silence is NOT consent, and an explicit\n+                # deny is also a hard halt — both produce a BLOCKED outcome\n+                # that names the agent's most common evasion paths (retry,\n+                # rephrase, achieve the same outcome via a different command).\n+                # See issue #24912 for the original incident.\n+                if not resolved:\n+                    reason = \"timed out without user response\"\n+                    timeout_addendum = \" Silence is not consent.\"\n+                    outcome = \"timeout\"\n+                else:\n+                    reason = \"denied by user\"\n+                    timeout_addendum = \"\"\n+                    outcome = \"denied\"\n                 return {\n                     \"approved\": False,\n-                    \"message\": f\"BLOCKED: Command {reason}. Do NOT retry this command.\",\n+                    \"message\": (\n+                        f\"BLOCKED: Command {reason}. The user has NOT consented \"\n+                        f\"to this action. Do NOT retry this command, do NOT \"\n+                        f\"rephrase it, and do NOT attempt the same outcome via \"\n+                        f\"a different command. Stop the current workflow and \"\n+                        f\"wait for the user to respond before taking any \"\n+                        f\"further destructive or irreversible action.\"\n+                        f\"{timeout_addendum}\"\n+                    ),\n                     \"pattern_key\": primary_key,\n                     \"description\": combined_desc,\n+                    \"outcome\": outcome,\n+                    \"user_consent\": False,\n                 }\n \n             # User approved — persist based on scope (same logic as CLI)","@@ -1369,9 +1391,18 @@ def check_all_command_guards(command: str, env_type: str,\n     if choice == \"deny\":\n         return {\n             \"approved\": False,\n-            \"message\": \"BLOCKED: User denied. Do NOT retry.\",\n+            \"message\": (\n+                \"BLOCKED: User denied this command. The user has NOT consented \"\n+                \"to this action. Do NOT retry this command, do NOT rephrase \"\n+                \"it, and do NOT attempt the same outcome via a different \"\n+                \"command. Stop the current workflow and wait for the user \"\n+                \"to respond before taking any further destructive or \"\n+                \"irreversible action.\"\n+            ),\n             \"pattern_key\": primary_key,\n             \"description\": combined_desc,\n+            \"outcome\": \"denied\",\n+            \"user_consent\": False,\n         }\n \n     # Persist approval for each warning individually\n"]}]},{"kind":"pr","number":48763,"title":"Fix broken Windows hermes launchers after uv installs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54979","number":54979}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54979,"title":"fix: add Windows entry-point smoke test to catch uv trampoline failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54919","number":54919}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55860,"title":"fix(install): prefer active Hermes shim on Windows","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":58836,"title":"fix(desktop): rebuild when uv venv base interpreter is missing","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#62191","number":62191}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61564,"title":"fix(cli): resolve junctions before TUI ink check for Windows OpenSSH","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":64175,"title":"fix(approval): require confirmation for package uninstalls","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ce0b10cb21","number":0},{"kind":"pr","value":"#109170","number":109170}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":109170,"title":"Package-manager uninstalls now require approval (#10199, salvage #64175)","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_detection.py"]},{"kind":"pr","number":122973,"title":"fix(agent): protect the runtime's own interpreter from agent deletes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/file_safety.py","agent/runtime_self_protection.py","tests/agent/test_runtime_self_protection.py","tools/approval.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}