{"repo":"NousResearch/hermes-agent","public_id":"33925","display_label":"CLI yolo mode approval bypass not working correctly","summary":"Multiple bug reports and fix attempts addressing the --yolo flag and /yolo chat command failing to bypass dangerous command approvals, due to environment variable timing issues relative to plugin discovery and missing session-level enable calls.","subsystem":"hermes_cli","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33931},"resolution":{"state":"resolved","by":"pr-33931,pr-61715"},"freshness":{"state":"verified","last_verified_at":"2026-07-09T23:58:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":33925,"pr_number":33931,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":60328,"pr_number":61715,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":33925,"title":"CLI /yolo (in-chat) does not bypass dangerous command approvals — env var freeze + missing enable_session_yolo call","url":null,"state":"closed"},{"kind":"issue","number":60328,"title":"[Bug]: hermes --yolo broken — approval bypass silently ignored since v0.11.0","url":null,"state":"closed"},{"kind":"pr","number":33931,"title":"fix(cli): /yolo in chat must enable session bypass, not just set env var","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_yolo_toggle.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -168,7 +168,7 @@ def realign_markdown_tables(*args, **kwargs):\n     try_launch_chrome_debug,\n )\n from hermes_cli.env_loader import load_hermes_dotenv\n-from utils import base_url_host_matches, is_truthy_value\n+from utils import base_url_host_matches\n \n _hermes_home = get_hermes_home()\n _project_env = Path(__file__).parent / '.env'","@@ -3747,7 +3747,7 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str:\n             percent_label = f\"{percent}%\" if percent is not None else \"--\"\n             duration_label = snapshot[\"duration\"]\n \n-            yolo_active = bool(os.getenv(\"HERMES_YOLO_MODE\"))\n+            yolo_active = self._is_session_yolo_active()\n             if width < 52:\n                 text = f\"⚕ {snapshot['model_short']} · {duration_label}\"\n                 if yolo_active:","@@ -3808,7 +3808,7 @@ def _get_status_bar_fragments(self):\n             # line and produce duplicated status bar rows over long sessions.\n             width = self._get_tui_terminal_width()\n             duration_label = snapshot[\"duration\"]\n-            yolo_active = bool(os.getenv(\"HERMES_YOLO_MODE\"))\n+            yolo_active = self._is_session_yolo_active()\n \n             if width < 52:\n                 frags = [","@@ -6907,6 +6907,7 @@ def _handle_branch_command(self, cmd_original: str) -> None:\n             pass\n \n         # Switch to the new session\n+        self._transfer_session_yolo(self.session_id, new_session_id)\n         self.session_id = new_session_id\n         self.session_start = now\n         self._pending_title = None","@@ -9607,20 +9608,92 @@ def _toggle_verbose(self):\n         }\n         _cprint(labels.get(self.tool_progress_mode, \"\"))\n \n+    def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None:\n+        \"\"\"Move YOLO bypass state from an old session key to a new one.\n+\n+        Called whenever ``self.session_id`` is reassigned mid-run — ``/branch``\n+        forks into a new session, and auto-compression rotates the agent's\n+        session id into a fresh continuation session. Without this transfer\n+        the user's ``/yolo ON`` toggle would silently revert on the very next\n+        turn (the same UX failure mode that motivated this entire fix), since\n+        ``_session_yolo`` is keyed by session id.\n+\n+        Mirrors ``tui_gateway/server.py`` (~line 1297-1305) which performs the\n+        same transfer for the TUI's session-rename path. No-op when YOLO\n+        wasn't enabled or when the ids match.\n+        \"\"\"\n+        if not old_session_id or not new_session_id or old_session_id == new_session_id:\n+            return\n+        try:\n+            from tools.approval import (\n+                disable_session_yolo,\n+                enable_session_yolo,\n+                is_session_yolo_enabled,\n+            )\n+        except Exception:\n+            return\n+        if is_session_yolo_enabled(old_session_id):\n+            enable_session_yolo(new_session_id)\n+            disable_session_yolo(old_session_id)\n+\n+    def _is_session_yolo_active(self) -> bool:\n+        \"\"\"Whether YOLO bypass is currently enabled for this CLI session.\n+\n+        Reads from ``tools.approval._session_yolo`` (the same set that\n+        ``enable_session_yolo`` / ``disable_session_yolo`` write to) so the\n+        status bar reflects the actual bypass state instead of a stale env\n+        var. Also honors the process-start ``--yolo`` flag, which freezes\n+        ``HERMES_YOLO_MODE`` into ``_YOLO_MODE_FROZEN`` before tool imports\n+        happen.\n+        \"\"\"\n+        try:\n+            from tools.approval import (\n+                _YOLO_MODE_FROZEN,\n+                is_session_yolo_enabled,\n+            )\n+        except Exception:\n+            return False\n+        if _YOLO_MODE_FROZEN:\n+            return True\n+        # Use ``getattr`` so test fixtures that build a CLI via ``__new__``\n+        # (skipping ``__init__``) don't trip an AttributeError here; the\n+        # status-bar builders swallow exceptions silently but lose every\n+        # field after the failure.\n+        session_key = getattr(self, \"session_id\", None) or \"default\"\n+        return is_session_yolo_enabled(session_key)\n+\n     def _toggle_yolo(self):\n-        \"\"\"Toggle YOLO mode — skip all dangerous command approval prompts.\"\"\"\n-        import os\n+        \"\"\"Toggle YOLO mode — skip all dangerous command approval prompts.\n+\n+        Per-session toggle that mirrors the gateway and TUI ``/yolo`` handlers\n+        (see ``gateway/run.py:_handle_yolo_command`` and\n+        ``tui_gateway/server.py`` key==\"yolo\"). We deliberately do NOT mutate\n+        ``HERMES_YOLO_MODE`` here — that env var is read once at module import\n+        time into ``tools.approval._YOLO_MODE_FROZEN`` to keep prompt-injected\n+        skills from flipping the bypass mid-session, so setting it after CLI\n+        startup is a silent no-op. Routing through ``enable_session_yolo`` /\n+        ``disable_session_yolo`` gives the same auditable, per-session bypass\n+        the other surfaces have. ``run_conversation`` binds\n+        ``self.session_id`` as the active approval session key via\n+        ``set_current_session_key`` so the bypass takes effect on the very\n+        next dangerous command in this run.\n+        \"\"\"\n         from hermes_cli.colors import Colors as _Colors\n+        from tools.approval import (\n+            disable_session_yolo,\n+            enable_session_yolo,\n+            is_session_yolo_enabled,\n+        )\n \n-        current = is_truthy_value(os.environ.get(\"HERMES_YOLO_MODE\"))\n-        if current:\n-            os.environ.pop(\"HERMES_YOLO_MODE\", None)\n+        session_key = self.session_id or \"default\"\n+        if is_session_yolo_enabled(session_key):\n+            disable_session_yolo(session_key)\n             _cprint(\n                 f\"  ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}\"\n                 \" — dangerous commands will require approval.\"\n             )\n         else:\n-            os.environ[\"HERMES_YOLO_MODE\"] = \"1\"\n+            enable_session_yolo(session_key)\n             _cprint(\n                 f\"  ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}\"\n                 \" — all commands auto-approved. Use with caution.\"","@@ -11757,6 +11830,23 @@ def run_agent():\n                     set_secret_capture_callback(self._secret_capture_callback)\n                 except Exception:\n                     pass\n+                # Bind this turn's approval session key into the contextvar so\n+                # ``tools.approval.is_current_session_yolo_enabled()`` resolves\n+                # against the same key that ``/yolo`` toggles under (see\n+                # ``_toggle_yolo`` → ``enable_session_yolo(self.session_id)``).\n+                # Mirrors ``tui_gateway/server.py`` and ``gateway/run.py`` which\n+                # bind the same contextvar before invoking the agent.\n+                try:\n+                    from tools.approval import (\n+                        reset_current_session_key,\n+                        set_current_session_key,\n+                    )\n+                    _approval_session_token = [redacted:secret-assignment]\n+                        self.session_id or \"default\"\n+                    )\n+                except Exception:\n+                    reset_current_session_key = None  # type: ignore[assignment]\n+                    _approval_session_token = None\n                 agent_message = _voice_prefix + message if _voice_prefix else message\n                 # Prepend pending model switch note so the model knows about the switch\n                 _msn = getattr(self, '_pending_model_switch_note', None)","@@ -11798,6 +11888,15 @@ def run_agent():\n                         set_secret_capture_callback(None)\n                     except Exception:\n                         pass\n+                    # Release the per-turn approval session key. ``_session_yolo``\n+                    # state itself is preserved across turns (so /yolo persists\n+                    # for the whole CLI run); we just unbind the contextvar so a\n+                    # reused thread doesn't see stale identity on its next run.\n+                    if _approval_session_token is not None and reset_current_session_key is not None:\n+                        try:\n+                            reset_current_session_key(_approval_session_token)\n+                        except Exception:\n+                            pass\n \n             # Start agent in background thread (daemon so it cannot keep the\n             # process alive when the user closes the terminal tab — SIGHUP","@@ -11928,6 +12027,7 @@ def run_agent():\n                 and getattr(self.agent, \"session_id\", None)\n                 and self.agent.session_id != self.session_id\n             ):\n+                self._transfer_session_yolo(self.session_id, self.agent.session_id)\n                 self.session_id = self.agent.session_id\n                 self._pending_title = None\n "]},{"file":"tests/cli/test_cli_yolo_toggle.py","hunks":["@@ -0,0 +1,244 @@\n+\"\"\"Regression tests for the CLI ``/yolo`` in-chat toggle.\n+\n+Pre-fix bug (issue #33925): ``cli.HermesCLI._toggle_yolo`` mutated only\n+``os.environ[\"HERMES_YOLO_MODE\"]``. That env var is captured once at\n+module-import time into ``tools.approval._YOLO_MODE_FROZEN`` (security\n+hardening: stops prompt-injected skills from flipping the bypass mid-run),\n+so the post-startup toggle was a silent no-op. ``/yolo`` advertised \"YOLO ON\"\n+in the status bar while every dangerous command still hit the approval\n+prompt. Only ``hermes --yolo`` (process-start env), ``HERMES_YOLO_MODE=1``,\n+and ``hermes config set approvals.mode off`` actually bypassed.\n+\n+The fix routes the CLI toggle through ``enable_session_yolo`` /\n+``disable_session_yolo`` (matching the gateway and TUI ``/yolo`` paths) and\n+binds ``self.session_id`` as the active approval session key around each\n+``run_conversation`` call so ``is_current_session_yolo_enabled()`` resolves\n+against the same key the toggle writes under.\n+\n+We test ``_toggle_yolo`` and ``_is_session_yolo_active`` as unbound methods\n+against a minimal stand-in object that exposes only the attribute they\n+read (``session_id``). This avoids the heavy ``HermesCLI`` construction\n+path used in ``test_cli_init.py``, which is incompatible with this test\n+file's path layout — ``HermesCLI.__init__`` imports a lot of optional\n+state we don't need here.\n+\"\"\"\n+\n+import os\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+import tools.approval as approval_module\n+from cli import HermesCLI\n+\n+\n+SESSION_KEY = \"test-cli-yolo-session\"\n+\n+\n[redacted:email](autouse=True)\n+def _clear_approval_state(monkeypatch):\n+    \"\"\"Clear the YOLO bypass + env var around every test so cases are independent.\"\"\"\n+    monkeypatch.delenv(\"HERMES_YOLO_MODE\", raising=False)\n+    approval_module.clear_session(SESSION_KEY)\n+    approval_module.clear_session(\"default\")\n+    yield\n+    approval_module.clear_session(SESSION_KEY)\n+    approval_module.clear_session(\"default\")\n+\n+\n+def _make_stand_in(session_id: str = SESSION_KEY) -> SimpleNamespace:\n+    \"\"\"Minimal stand-in exposing only ``session_id``.\n+\n+    ``_toggle_yolo`` and ``_is_session_yolo_active`` are both pure methods\n+    that only read ``self.session_id`` — no other CLI state is touched.\n+    Calling them as unbound functions against this stand-in is equivalent\n+    to invoking them on a fully-constructed ``HermesCLI`` for the\n+    behaviour under test, and avoids the brittle prompt_toolkit / config\n+    stubbing required to instantiate ``HermesCLI`` from this test file.\n+    \"\"\"\n+    return SimpleNamespace(session_id=session_id)\n+\n+\n+class TestToggleYoloIsSessionScoped:\n+    \"\"\"The CLI /yolo handler must mutate the session-yolo set, not the env var.\n+\n+    The env var path is dead-on-arrival because ``_YOLO_MODE_FROZEN`` is\n+    captured once at module import, long before the CLI's ``/yolo`` command\n+    can run.\n+    \"\"\"\n+\n+    def test_toggle_yolo_enables_session_bypass(self):\n+        stand_in = _make_stand_in()\n+\n+        assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False\n+\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)\n+\n+        assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True\n+\n+    def test_toggle_yolo_disables_session_bypass_on_second_call(self):\n+        stand_in = _make_stand_in()\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)  # ON\n+            assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True\n+            HermesCLI._toggle_yolo(stand_in)  # OFF\n+            assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False\n+\n+    def test_toggle_yolo_does_not_mutate_env_var(self):\n+        \"\"\"Toggling /yolo must not write ``HERMES_YOLO_MODE`` — that path is\n+        frozen at import time and would mislead anyone reading the env later\n+        (subprocesses, status bars wired to the env, the relaunch flag list).\"\"\"\n+        stand_in = _make_stand_in()\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)\n+\n+        assert os.environ.get(\"HERMES_YOLO_MODE\") is None\n+\n+    def test_toggle_yolo_falls_back_to_default_when_session_id_missing(self):\n+        \"\"\"An edge case during CLI bootstrap: a ``/yolo`` triggered before the\n+        session id is set should not blow up, and should land under the\n+        ``default`` session key so the bypass still takes effect for any code\n+        that resolves against the default key.\"\"\"\n+        stand_in = _make_stand_in(session_id=\"\")\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)\n+\n+        assert approval_module.is_session_yolo_enabled(\"default\") is True\n+\n+    def test_two_independent_sessions_are_isolated(self):\n+        \"\"\"``/yolo`` toggled in one session must not bypass approvals in\n+        another session — mirrors the gateway-side invariant.\"\"\"\n+        cli_a = _make_stand_in(session_id=\"session-yolo-a\")\n+        cli_b = _make_stand_in(session_id=\"session-yolo-b\")\n+\n+        try:\n+            with patch(\"cli._cprint\"):\n+                HermesCLI._toggle_yolo(cli_a)\n+\n+            assert approval_module.is_session_yolo_enabled(\"session-yolo-a\") is True\n+            assert approval_module.is_session_yolo_enabled(\"session-yolo-b\") is False\n+        finally:\n+            approval_module.clear_session(\"session-yolo-a\")\n+            approval_module.clear_session(\"session-yolo-b\")\n+\n+\n+class TestIsSessionYoloActiveHelper:\n+    \"\"\"The status-bar helper must read the live session-yolo state, not the\n+    env var (which is the bug class this PR fixes).\"\"\"\n+\n+    def test_helper_reflects_toggle(self):\n+        stand_in = _make_stand_in()\n+\n+        assert HermesCLI._is_session_yolo_active(stand_in) is False\n+\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)\n+\n+        assert HermesCLI._is_session_yolo_active(stand_in) is True\n+\n+        with patch(\"cli._cprint\"):\n+            HermesCLI._toggle_yolo(stand_in)\n+\n+        assert HermesCLI._is_session_yolo_active(stand_in) is False\n+\n+    def test_helper_honors_frozen_yolo_mode(self):\n+        \"\"\"``hermes --yolo`` sets ``HERMES_YOLO_MODE`` before tool imports, so\n+        ``_YOLO_MODE_FROZEN`` ends up True. The status bar should still\n+        reflect YOLO on in that case even when the session toggle is off.\"\"\"\n+        stand_in = _make_stand_in()\n+\n+        with patch.object(approval_module, \"_YOLO_MODE_FROZEN\", True):\n+            assert HermesCLI._is_session_yolo_active(stand_in) is True\n+\n+\n+class TestToggleYoloEndToEnd:\n+    \"\"\"End-to-end: a dangerous command must auto-approve through the same\n+    ``check_all_command_guards`` path the terminal tool uses.\"\"\"\n+\n+    def test_toggle_yolo_bypasses_dangerous_command_check(self):\n+        stand_in = _make_stand_in()\n+\n+        token = [redacted:secret-assignment]\n+        try:\n+            with patch(\"cli._cprint\"):\n+                HermesCLI._toggle_yolo(stand_in)  # YOLO ON\n+\n+            result = approval_module.check_all_command_guards(\n+                \"rm -rf /tmp/scratch-xyzzy\", \"local\",\n+            )\n+            assert result[\"approved\"] is True, (\n+                f\"YOLO toggle should auto-approve dangerous commands, got: {result}\"\n+            )\n+        finally:\n+            approval_module.reset_current_session_key(token)\n+\n+\n+class TestIsSessionYoloActiveAttrSafety:\n+    \"\"\"The status-bar helper runs against partially-constructed CLI fixtures\n+    (tests use ``HermesCLI.__new__(HermesCLI)`` to skip ``__init__``). It must\n+    not raise ``AttributeError`` when ``session_id`` is absent — the\n+    status-bar builders swallow exceptions silently and lose every field\n+    after the failure, producing a regression that's hard to track back to\n+    the helper.\"\"\"\n+\n+    def test_helper_survives_missing_session_id_attr(self):\n+        # SimpleNamespace WITHOUT session_id mimics __new__-built fixtures.\n+        from types import SimpleNamespace\n+        no_attr = SimpleNamespace()\n+        # Must return False, not raise.\n+        assert HermesCLI._is_session_yolo_active(no_attr) is False\n+\n+\n+class TestSessionRotationTransfersYolo:\n+    \"\"\"When the CLI's ``session_id`` rotates mid-run (``/branch``, auto\n+    compression continuation), YOLO state keyed under the old id must move\n+    to the new id. Otherwise the user's ``/yolo ON`` silently reverts on\n+    the next turn — the same UX failure mode this PR set out to fix.\n+    Mirrors ``tui_gateway/server.py`` ~line 1297-1305.\"\"\"\n+\n+    def test_transfer_moves_yolo_to_new_session(self):\n+        stand_in = _make_stand_in(session_id=\"old-id\")\n+        try:\n+            approval_module.enable_session_yolo(\"old-id\")\n+            assert approval_module.is_session_yolo_enabled(\"old-id\") is True\n+\n+            HermesCLI._transfer_session_yolo(stand_in, \"old-id\", \"new-id\")\n+\n+            assert approval_module.is_session_yolo_enabled(\"new-id\") is True\n+            assert approval_module.is_session_yolo_enabled(\"old-id\") is False\n+        finally:\n+            approval_module.clear_session(\"old-id\")\n+            approval_module.clear_session(\"new-id\")\n+\n+    def test_transfer_is_noop_when_yolo_was_off(self):\n+        stand_in = _make_stand_in(session_id=\"old-id\")\n+        try:\n+            HermesCLI._transfer_session_yolo(stand_in, \"old-id\", \"new-id\")\n+            assert approval_module.is_session_yolo_enabled(\"new-id\") is False\n+            assert approval_module.is_session_yolo_enabled(\"old-id\") is False\n+        finally:\n+            approval_module.clear_session(\"old-id\")\n+            approval_module.clear_session(\"new-id\")\n+\n+    def test_transfer_is_noop_when_ids_match(self):\n+        stand_in = _make_stand_in(session_id=\"same-id\")\n+        try:\n+            approval_module.enable_session_yolo(\"same-id\")\n+            HermesCLI._transfer_session_yolo(stand_in, \"same-id\", \"same-id\")\n+            # Must NOT have been disabled — same-id == same-id is a no-op,\n+            # not a \"disable then re-enable\" round-trip.\n+            assert approval_module.is_session_yolo_enabled(\"same-id\") is True\n+        finally:\n+            approval_module.clear_session(\"same-id\")\n+\n+    def test_transfer_handles_empty_inputs_safely(self):\n+        stand_in = _make_stand_in(session_id=\"x\")\n+        # Both directions of empty input should be safe no-ops; nothing\n+        # to transfer from \"\" / to \"\".\n+        HermesCLI._transfer_session_yolo(stand_in, \"\", \"new\")\n+        HermesCLI._transfer_session_yolo(stand_in, \"old\", \"\")\n+        # Neither key should have been touched.\n+        assert approval_module.is_session_yolo_enabled(\"new\") is False\n+        assert approval_module.is_session_yolo_enabled(\"old\") is False\n"]}]},{"kind":"pr","number":34032,"title":"fix(cli): use session-scoped yolo bypass in /yolo command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5cbc3fbdcc13c3a5d6d0f565a1d1929d0e5e47ff","number":0},{"kind":"pr","value":"#33931","number":33931}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60349,"title":"fix(cli): set HERMES_YOLO_MODE before _prepare_agent_startup() imports (#60328)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60360","number":60360},{"kind":"pr","value":"#60360","number":60360},{"kind":"pr","value":"#61715","number":61715}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":60360,"title":"fix(cli): set HERMES_YOLO_MODE before plugin discovery at startup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61715","number":61715},{"kind":"pr","value":"#60328","number":60328}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60963,"title":"fix(cli): set HERMES_YOLO_MODE before plugin discovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61715","number":61715},{"kind":"pr","value":"#60360","number":60360}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61715,"title":"fix(cli): set HERMES_YOLO_MODE before plugin discovery freezes the approval bypass (#60328)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","scripts/release.py","tests/hermes_cli/test_yolo_startup_order.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -2358,7 +2358,11 @@ def cmd_chat(args):\n     except Exception:\n         pass\n \n-    # --yolo: bypass all dangerous command approvals\n+    # --yolo: bypass all dangerous command approvals.\n+    # Also set in main() before _prepare_agent_startup() — that is the\n+    # authoritative site because it runs before tool imports freeze\n+    # _YOLO_MODE_FROZEN.  This redundant set is a safety net for callers\n+    # that invoke cmd_chat directly (e.g. subcommand dispatch).\n     if getattr(args, \"yolo\", False):\n         os.environ[\"HERMES_YOLO_MODE\"] = \"1\"\n ","@@ -12410,6 +12414,15 @@ def _should_background_mcp_startup(args) -> bool:\n \n def _prepare_agent_startup(args) -> None:\n     \"\"\"Discover plugins/MCP/hooks for commands that can run an agent turn.\"\"\"\n+    # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY\n+    # plugin/tool discovery below imports tools.approval, which freezes\n+    # _YOLO_MODE_FROZEN at import time (PR #7994 security design).  main()'s\n+    # dispatch path also sets this earlier, but _prepare_agent_startup() is\n+    # reachable from other launchers too (e.g. the Termux fast-CLI path),\n+    # so the guarantee lives here where the import is actually triggered\n+    # (#60328).\n+    if getattr(args, \"yolo\", False):\n+        os.environ[\"HERMES_YOLO_MODE\"] = \"1\"\n     _apply_safe_mode(args)\n \n     _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))","@@ -14627,6 +14640,15 @@ def _export_one(session_id: str):\n         cmd_version(args)\n         return\n \n+    # --yolo: set HERMES_YOLO_MODE *before* plugin discovery.  The call to\n+    # _prepare_agent_startup() below triggers discover_plugins() → tool\n+    # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module\n+    # import time (PR #7994, security hardening against prompt-injection).\n+    # If the env var is set only later (e.g. inside cmd_chat), the frozen\n+    # value is already False and --yolo silently does nothing.\n+    if getattr(args, \"yolo\", False):\n+        os.environ[\"HERMES_YOLO_MODE\"] = \"1\"\n+\n     # Discover Python plugins and register shell hooks once, before any\n     # command that can fire lifecycle hooks.  Both are idempotent; gated\n     # so introspection/management commands (hermes hooks list, cron"]},{"file":"scripts/release.py","hunks":["@@ -299,6 +299,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"bytesnail\",  # PR #60360 salvage (--yolo startup ordering; #60328)\n     \"[redacted:email]\": \"nullptr0807\",  # PR #60956 salvage (gateway hygiene in-place compaction; #60947)\n     \"[redacted:email]\": \"embwl0x\",  # PR #60810 salvage (channel directory offload)\n     \"[redacted:email]\": \"doncazper\","]},{"file":"tests/hermes_cli/test_yolo_startup_order.py","hunks":["@@ -0,0 +1,77 @@\n+\"\"\"Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in\n+main() before _prepare_agent_startup() triggers tool imports.\n+\n+The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct\n+by design (PR #7994). The bug was that main() set the env var inside\n+cmd_chat(), which runs *after* _prepare_agent_startup() has already\n+imported tools.approval and frozen the constant to False.\n+\n+These tests verify the ordering in main() itself: the env var must\n+already be set at the moment _prepare_agent_startup() is called.\n+If someone moves the assignment back into cmd_chat(), these tests\n+fail — catching the exact #60328 regression.\n+\"\"\"\n+\n+import os\n+import sys\n+\n+\n+def _run_main_and_capture_yolo_at_startup(monkeypatch, argv):\n+    \"\"\"Run main() with *argv*, capturing HERMES_YOLO_MODE at the\n+    moment _prepare_agent_startup is called.\n+\n+    Returns the captured env var value (or None if unset).\n+    \"\"\"\n+    yolo_at_startup = {}\n+\n+    def spy_prepare_startup(args):\n+        yolo_at_startup[\"value\"] = os.environ.get(\"HERMES_YOLO_MODE\")\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.main._prepare_agent_startup\", spy_prepare_startup\n+    )\n+    # Stub cmd_chat so main() returns cleanly without entering chat.\n+    monkeypatch.setattr(\"hermes_cli.main.cmd_chat\", lambda args: None)\n+    monkeypatch.delenv(\"HERMES_YOLO_MODE\", raising=False)\n+    monkeypatch.setattr(sys, \"argv\", argv)\n+\n+    from hermes_cli.main import main as cli_main\n+\n+    cli_main()\n+\n+    return yolo_at_startup.get(\"value\")\n+\n+\n+def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch):\n+    \"\"\"hermes --yolo must set HERMES_YOLO_MODE before\n+    _prepare_agent_startup imports tools.approval.\"\"\"\n+    result = _run_main_and_capture_yolo_at_startup(\n+        monkeypatch, [\"hermes\", \"--yolo\"]\n+    )\n+    assert result == \"1\", (\n+        \"HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was \"\n+        \"called from main() with --yolo. This is the #60328 regression: \"\n+        \"the env var is set too late (inside cmd_chat, after tool imports).\"\n+    )\n+\n+\n+def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch):\n+    \"\"\"hermes chat --yolo must also set HERMES_YOLO_MODE before\n+    _prepare_agent_startup.\"\"\"\n+    result = _run_main_and_capture_yolo_at_startup(\n+        monkeypatch, [\"hermes\", \"chat\", \"--yolo\"]\n+    )\n+    assert result == \"1\", (\n+        \"HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was \"\n+        \"called from main() with 'chat --yolo'.\"\n+    )\n+\n+\n+def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch):\n+    \"\"\"Without --yolo, HERMES_YOLO_MODE must not be set at startup.\"\"\"\n+    result = _run_main_and_capture_yolo_at_startup(\n+        monkeypatch, [\"hermes\"]\n+    )\n+    assert result is None, (\n+        \"HERMES_YOLO_MODE was unexpectedly set at startup without --yolo.\"\n+    )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}