CLI yolo mode approval bypass not working correctly

NousResearch/hermes-agent · cluster 33925 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #33931. Check it covers your case.

The short answer

0open issues
0open PRs
8items grouped
Known?
Yes. 8 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #33931. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #33931, #61715. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #33931 fix(cli): /yolo in chat must enable session bypass, not just set env var

    merged to main

    files: cli.py, tests/cli/test_cli_yolo_toggle.py

    Show the diff
    • cli.py
      @@ -168,7 +168,7 @@ def realign_markdown_tables(*args, **kwargs):
           try_launch_chrome_debug,
       )
       from hermes_cli.env_loader import load_hermes_dotenv
      -from utils import base_url_host_matches, is_truthy_value
      +from utils import base_url_host_matches
       
       _hermes_home = get_hermes_home()
       _project_env = Path(__file__).parent / '.env'
      @@ -3747,7 +3747,7 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str:
                   percent_label = f"{percent}%" if percent is not None else "--"
                   duration_label = snapshot["duration"]
       
      -            yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
      +            yolo_active = self._is_session_yolo_active()
                   if width < 52:
                       text = f"⚕ {snapshot['model_short']} · {duration_label}"
                       if yolo_active:
      @@ -3808,7 +3808,7 @@ def _get_status_bar_fragments(self):
                   # line and produce duplicated status bar rows over long sessions.
                   width = self._get_tui_terminal_width()
                   duration_label = snapshot["duration"]
      -            yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
      +            yolo_active = self._is_session_yolo_active()
       
                   if width < 52:
                       frags = [
      @@ -6907,6 +6907,7 @@ def _handle_branch_command(self, cmd_original: str) -> None:
                   pass
       
               # Switch to the new session
      +        self._transfer_session_yolo(self.session_id, new_session_id)
               self.session_id = new_session_id
               self.session_start = now
               self._pending_title = None
      @@ -9607,20 +9608,92 @@ def _toggle_verbose(self):
               }
               _cprint(labels.get(self.tool_progress_mode, ""))
       
      +    def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None:
      +        """Move YOLO bypass state from an old session key to a new one.
      +
      +        Called whenever ``self.session_id`` is reassigned mid-run — ``/branch``
      +        forks into a new session, and auto-compression rotates the agent's
      +        session id into a fresh continuation session. Without this transfer
      +        the user's ``/yolo ON`` toggle would silently revert on the very next
      +        turn (the same UX failure mode that motivated this entire fix), since
      +        ``_session_yolo`` is keyed by session id.
      +
      +        Mirrors ``tui_gateway/server.py`` (~line 1297-1305) which performs the
      +        same transfer for the TUI's session-rename path. No-op when YOLO
      +        wasn't enabled or when the ids match.
      +        """
      +        if not old_session_id or not new_session_id or old_session_id == new_session_id:
      +            return
      +        try:
      +            from tools.approval import (
      +                disable_session_yolo,
      +                enable_session_yolo,
      +                is_session_yolo_enabled,
      +            )
      +        except Exception:
      +            return
      +        if is_session_yolo_enabled(old_session_id):
      +            enable_session_yolo(new_session_id)
      +            disable_session_yolo(old_session_id)
      +
      +    def _is_session_yolo_active(self) -> bool:
      +        """Whether YOLO bypass is currently enabled for this CLI session.
      +
      +        Reads from ``tools.approval._session_yolo`` (the same set that
      +        ``enable_session_yolo`` / ``disable_session_yolo`` write to) so the
      +        status bar reflects the actual bypass state instead of a stale env
      +        var. Also honors the process-start ``--yolo`` flag, which freezes
      +        ``HERMES_YOLO_MODE`` into ``_YOLO_MODE_FROZEN`` before tool imports
      +        happen.
      +        """
      +        try:
      +            from tools.approval import (
      +                _YOLO_MODE_FROZEN,
      +                is_session_yolo_enabled,
      +            )
      +        except Exception:
      +            return False
      +        if _YOLO_MODE_FROZEN:
      +            return True
      +        # Use ``getattr`` so test fixtures that build a CLI via ``__new__``
      +        # (skipping ``__init__``) don't trip an AttributeError here; the
      +        # status-bar builders swallow exceptions silently but lose every
      +        # field after the failure.
      +        session_key = getattr(self, "session_id", None) or "default"
      +        return is_session_yolo_enabled(session_key)
      +
           def _toggle_yolo(self):
      -        """Toggle YOLO mode — skip all dangerous command approval prompts."""
      -        import os
      +        """Toggle YOLO mode — skip all dangerous command approval prompts.
      +
      +        Per-session toggle that mirrors the gateway and TUI ``/yolo`` handlers
      +        (see ``gateway/run.py:_handle_yolo_command`` and
      +        ``tui_gateway/server.py`` key=="yolo"). We deliberately do NOT mutate
      +        ``HERMES_YOLO_MODE`` here — that env var is read once at module import
      +        time into ``tools.approval._YOLO_MODE_FROZEN`` to keep prompt-injected
      +        skills from flipping the bypass mid-session, so setting it after CLI
      +        startup is a silent no-op. Routing through ``enable_session_yolo`` /
      +        ``disable_session_yolo`` gives the same auditable, per-session bypass
      +        the other surfaces have. ``run_conversation`` binds
      +        ``self.session_id`` as the active approval session key via
      +        ``set_current_session_key`` so the bypass takes effect on the very
      +        next dangerous command in this run.
      +        """
               from hermes_cli.colors import Colors as _Colors
      +        from tools.approval import (
      +            disable_session_yolo,
      +            enable_session_yolo,
      +            is_session_yolo_enabled,
      +        )
       
      -        current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
      -        if current:
      -            os.environ.pop("HERMES_YOLO_MODE", None)
      +        session_key = self.session_id or "default"
      +        if is_session_yolo_enabled(session_key):
      +            disable_session_yolo(session_key)
                   _cprint(
                       f"  ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
                       " — dangerous commands will require approval."
                   )
               else:
      -            os.environ["HERMES_YOLO_MODE"] = "1"
      +            enable_session_yolo(session_key)
                   _cprint(
                       f"  ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
                       " — all commands auto-approved. Use with caution."
      @@ -11757,6 +11830,23 @@ def run_agent():
                           set_secret_capture_callback(self._secret_capture_callback)
                       except Exception:
                           pass
      +                # Bind this turn's approval session key into the contextvar so
      +                # ``tools.approval.is_current_session_yolo_enabled()`` resolves
      +                # against the same key that ``/yolo`` toggles under (see
      +                # ``_toggle_yolo`` → ``enable_session_yolo(self.session_id)``).
      +                # Mirrors ``tui_gateway/server.py`` and ``gateway/run.py`` which
      +                # bind the same contextvar before invoking the agent.
      +                try:
      +                    from tools.approval import (
      +                        reset_current_session_key,
      +                        set_current_session_key,
      +                    )
      +                    _approval_session_token = [redacted:secret-assignment]
      +                        self.session_id or "default"
      +                    )
      +                except Exception:
      +                    reset_current_session_key = None  # type: ignore[assignment]
      +                    _approval_session_token = None
                       agent_message = _voice_prefix + message if _voice_prefix else message
                       # Prepend pending model switch note so the model knows about the switch
                       _msn = getattr(self, '_pending_model_switch_note', None)
      @@ -11798,6 +11888,15 @@ def run_agent():
                               set_secret_capture_callback(None)
                           except Exception:
                               pass
      +                    # Release the per-turn approval session key. ``_session_yolo``
      +                    # state itself is preserved across turns (so /yolo persists
      +                    # for the whole CLI run); we just unbind the contextvar so a
      +                    # reused thread doesn't see stale identity on its next run.
      +                    if _approval_session_token is not None and reset_current_session_key is not None:
      +                        try:
      +                            reset_current_session_key(_approval_session_token)
      +                        except Exception:
      +                            pass
       
                   # Start agent in background thread (daemon so it cannot keep the
                   # process alive when the user closes the terminal tab — SIGHUP
      @@ -11928,6 +12027,7 @@ def run_agent():
                       and getattr(self.agent, "session_id", None)
                       and self.agent.session_id != self.session_id
                   ):
      +                self._transfer_session_yolo(self.session_id, self.agent.session_id)
                       self.session_id = self.agent.session_id
                       self._pending_title = None
       
    • tests/cli/test_cli_yolo_toggle.py
      @@ -0,0 +1,244 @@
      +"""Regression tests for the CLI ``/yolo`` in-chat toggle.
      +
      +Pre-fix bug (issue #33925): ``cli.HermesCLI._toggle_yolo`` mutated only
      +``os.environ["HERMES_YOLO_MODE"]``. That env var is captured once at
      +module-import time into ``tools.approval._YOLO_MODE_FROZEN`` (security
      +hardening: stops prompt-injected skills from flipping the bypass mid-run),
      +so the post-startup toggle was a silent no-op. ``/yolo`` advertised "YOLO ON"
      +in the status bar while every dangerous command still hit the approval
      +prompt. Only ``hermes --yolo`` (process-start env), ``HERMES_YOLO_MODE=1``,
      +and ``hermes config set approvals.mode off`` actually bypassed.
      +
      +The fix routes the CLI toggle through ``enable_session_yolo`` /
      +``disable_session_yolo`` (matching the gateway and TUI ``/yolo`` paths) and
      +binds ``self.session_id`` as the active approval session key around each
      +``run_conversation`` call so ``is_current_session_yolo_enabled()`` resolves
      +against the same key the toggle writes under.
      +
      +We test ``_toggle_yolo`` and ``_is_session_yolo_active`` as unbound methods
      +against a minimal stand-in object that exposes only the attribute they
      +read (``session_id``). This avoids the heavy ``HermesCLI`` construction
      +path used in ``test_cli_init.py``, which is incompatible with this test
      +file's path layout — ``HermesCLI.__init__`` imports a lot of optional
      +state we don't need here.
      +"""
      +
      +import os
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +import pytest
      +
      +import tools.approval as approval_module
      +from cli import HermesCLI
      +
      +
      +SESSION_KEY = "test-cli-yolo-session"
      +
      +
      [redacted:email](autouse=True)
      +def _clear_approval_state(monkeypatch):
      +    """Clear the YOLO bypass + env var around every test so cases are independent."""
      +    monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
      +    approval_module.clear_session(SESSION_KEY)
      +    approval_module.clear_session("default")
      +    yield
      +    approval_module.clear_session(SESSION_KEY)
      +    approval_module.clear_session("default")
      +
      +
      +def _make_stand_in(session_id: str = SESSION_KEY) -> SimpleNamespace:
      +    """Minimal stand-in exposing only ``session_id``.
      +
      +    ``_toggle_yolo`` and ``_is_session_yolo_active`` are both pure methods
      +    that only read ``self.session_id`` — no other CLI state is touched.
      +    Calling them as unbound functions against this stand-in is equivalent
      +    to invoking them on a fully-constructed ``HermesCLI`` for the
      +    behaviour under test, and avoids the brittle prompt_toolkit / config
      +    stubbing required to instantiate ``HermesCLI`` from this test file.
      +    """
      +    return SimpleNamespace(session_id=session_id)
      +
      +
      +class TestToggleYoloIsSessionScoped:
      +    """The CLI /yolo handler must mutate the session-yolo set, not the env var.
      +
      +    The env var path is dead-on-arrival because ``_YOLO_MODE_FROZEN`` is
      +    captured once at module import, long before the CLI's ``/yolo`` command
      +    can run.
      +    """
      +
      +    def test_toggle_yolo_enables_session_bypass(self):
      +        stand_in = _make_stand_in()
      +
      +        assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
      +
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)
      +
      +        assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
      +
      +    def test_toggle_yolo_disables_session_bypass_on_second_call(self):
      +        stand_in = _make_stand_in()
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)  # ON
      +            assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
      +            HermesCLI._toggle_yolo(stand_in)  # OFF
      +            assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
      +
      +    def test_toggle_yolo_does_not_mutate_env_var(self):
      +        """Toggling /yolo must not write ``HERMES_YOLO_MODE`` — that path is
      +        frozen at import time and would mislead anyone reading the env later
      +        (subprocesses, status bars wired to the env, the relaunch flag list)."""
      +        stand_in = _make_stand_in()
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)
      +
      +        assert os.environ.get("HERMES_YOLO_MODE") is None
      +
      +    def test_toggle_yolo_falls_back_to_default_when_session_id_missing(self):
      +        """An edge case during CLI bootstrap: a ``/yolo`` triggered before the
      +        session id is set should not blow up, and should land under the
      +        ``default`` session key so the bypass still takes effect for any code
      +        that resolves against the default key."""
      +        stand_in = _make_stand_in(session_id="")
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)
      +
      +        assert approval_module.is_session_yolo_enabled("default") is True
      +
      +    def test_two_independent_sessions_are_isolated(self):
      +        """``/yolo`` toggled in one session must not bypass approvals in
      +        another session — mirrors the gateway-side invariant."""
      +        cli_a = _make_stand_in(session_id="session-yolo-a")
      +        cli_b = _make_stand_in(session_id="session-yolo-b")
      +
      +        try:
      +            with patch("cli._cprint"):
      +                HermesCLI._toggle_yolo(cli_a)
      +
      +            assert approval_module.is_session_yolo_enabled("session-yolo-a") is True
      +            assert approval_module.is_session_yolo_enabled("session-yolo-b") is False
      +        finally:
      +            approval_module.clear_session("session-yolo-a")
      +            approval_module.clear_session("session-yolo-b")
      +
      +
      +class TestIsSessionYoloActiveHelper:
      +    """The status-bar helper must read the live session-yolo state, not the
      +    env var (which is the bug class this PR fixes)."""
      +
      +    def test_helper_reflects_toggle(self):
      +        stand_in = _make_stand_in()
      +
      +        assert HermesCLI._is_session_yolo_active(stand_in) is False
      +
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)
      +
      +        assert HermesCLI._is_session_yolo_active(stand_in) is True
      +
      +        with patch("cli._cprint"):
      +            HermesCLI._toggle_yolo(stand_in)
      +
      +        assert HermesCLI._is_session_yolo_active(stand_in) is False
      +
      +    def test_helper_honors_frozen_yolo_mode(self):
      +        """``hermes --yolo`` sets ``HERMES_YOLO_MODE`` before tool imports, so
      +        ``_YOLO_MODE_FROZEN`` ends up True. The status bar should still
      +        reflect YOLO on in that case even when the session toggle is off."""
      +        stand_in = _make_stand_in()
      +
      +        with patch.object(approval_module, "_YOLO_MODE_FROZEN", True):
      +            assert HermesCLI._is_session_yolo_active(stand_in) is True
      +
      +
      +class TestToggleYoloEndToEnd:
      +    """End-to-end: a dangerous command must auto-approve through the same
      +    ``check_all_command_guards`` path the terminal tool uses."""
      +
      +    def test_toggle_yolo_bypasses_dangerous_command_check(self):
      +        stand_in = _make_stand_in()
      +
      +        token = [redacted:secret-assignment]
      +        try:
      +            with patch("cli._cprint"):
      +                HermesCLI._toggle_yolo(stand_in)  # YOLO ON
      +
      +            result = approval_module.check_all_command_guards(
      +                "rm -rf /tmp/scratch-xyzzy", "local",
      +            )
      +            assert result["approved"] is True, (
      +                f"YOLO toggle should auto-approve dangerous commands, got: {result}"
      +            )
      +        finally:
      +            approval_module.reset_current_session_key(token)
      +
      +
      +class TestIsSessionYoloActiveAttrSafety:
      +    """The status-bar helper runs against partially-constructed CLI fixtures
      +    (tests use ``HermesCLI.__new__(HermesCLI)`` to skip ``__init__``). It must
      +    not raise ``AttributeError`` when ``session_id`` is absent — the
      +    status-bar builders swallow exceptions silently and lose every field
      +    after the failure, producing a regression that's hard to track back to
      +    the helper."""
      +
      +    def test_helper_survives_missing_session_id_attr(self):
      +        # SimpleNamespace WITHOUT session_id mimics __new__-built fixtures.
      +        from types import SimpleNamespace
      +        no_attr = SimpleNamespace()
      +        # Must return False, not raise.
      +        assert HermesCLI._is_session_yolo_active(no_attr) is False
      +
      +
      +class TestSessionRotationTransfersYolo:
      +    """When the CLI's ``session_id`` rotates mid-run (``/branch``, auto
      +    compression continuation), YOLO state keyed under the old id must move
      +    to the new id. Otherwise the user's ``/yolo ON`` silently reverts on
      +    the next turn — the same UX failure mode this PR set out to fix.
      +    Mirrors ``tui_gateway/server.py`` ~line 1297-1305."""
      +
      +    def test_transfer_moves_yolo_to_new_session(self):
      +        stand_in = _make_stand_in(session_id="old-id")
      +        try:
      +            approval_module.enable_session_yolo("old-id")
      +            assert approval_module.is_session_yolo_enabled("old-id") is True
      +
      +            HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id")
      +
      +            assert approval_module.is_session_yolo_enabled("new-id") is True
      +            assert approval_module.is_session_yolo_enabled("old-id") is False
      +        finally:
      +            approval_module.clear_session("old-id")
      +            approval_module.clear_session("new-id")
      +
      +    def test_transfer_is_noop_when_yolo_was_off(self):
      +        stand_in = _make_stand_in(session_id="old-id")
      +        try:
      +            HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id")
      +            assert approval_module.is_session_yolo_enabled("new-id") is False
      +            assert approval_module.is_session_yolo_enabled("old-id") is False
      +        finally:
      +            approval_module.clear_session("old-id")
      +            approval_module.clear_session("new-id")
      +
      +    def test_transfer_is_noop_when_ids_match(self):
      +        stand_in = _make_stand_in(session_id="same-id")
      +        try:
      +            approval_module.enable_session_yolo("same-id")
      +            HermesCLI._transfer_session_yolo(stand_in, "same-id", "same-id")
      +            # Must NOT have been disabled — same-id == same-id is a no-op,
      +            # not a "disable then re-enable" round-trip.
      +            assert approval_module.is_session_yolo_enabled("same-id") is True
      +        finally:
      +            approval_module.clear_session("same-id")
      +
      +    def test_transfer_handles_empty_inputs_safely(self):
      +        stand_in = _make_stand_in(session_id="x")
      +        # Both directions of empty input should be safe no-ops; nothing
      +        # to transfer from "" / to "".
      +        HermesCLI._transfer_session_yolo(stand_in, "", "new")
      +        HermesCLI._transfer_session_yolo(stand_in, "old", "")
      +        # Neither key should have been touched.
      +        assert approval_module.is_session_yolo_enabled("new") is False
      +        assert approval_module.is_session_yolo_enabled("old") is False
  • #61715 fix(cli): set HERMES_YOLO_MODE before plugin discovery freezes the approval bypass (#60328)

    merged to main

    files: hermes_cli/main.py, scripts/release.py, tests/hermes_cli/test_yolo_startup_order.py

    Show the diff
    • hermes_cli/main.py
      @@ -2358,7 +2358,11 @@ def cmd_chat(args):
           except Exception:
               pass
       
      -    # --yolo: bypass all dangerous command approvals
      +    # --yolo: bypass all dangerous command approvals.
      +    # Also set in main() before _prepare_agent_startup() — that is the
      +    # authoritative site because it runs before tool imports freeze
      +    # _YOLO_MODE_FROZEN.  This redundant set is a safety net for callers
      +    # that invoke cmd_chat directly (e.g. subcommand dispatch).
           if getattr(args, "yolo", False):
               os.environ["HERMES_YOLO_MODE"] = "1"
       
      @@ -12410,6 +12414,15 @@ def _should_background_mcp_startup(args) -> bool:
       
       def _prepare_agent_startup(args) -> None:
           """Discover plugins/MCP/hooks for commands that can run an agent turn."""
      +    # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY
      +    # plugin/tool discovery below imports tools.approval, which freezes
      +    # _YOLO_MODE_FROZEN at import time (PR #7994 security design).  main()'s
      +    # dispatch path also sets this earlier, but _prepare_agent_startup() is
      +    # reachable from other launchers too (e.g. the Termux fast-CLI path),
      +    # so the guarantee lives here where the import is actually triggered
      +    # (#60328).
      +    if getattr(args, "yolo", False):
      +        os.environ["HERMES_YOLO_MODE"] = "1"
           _apply_safe_mode(args)
       
           _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
      @@ -14627,6 +14640,15 @@ def _export_one(session_id: str):
               cmd_version(args)
               return
       
      +    # --yolo: set HERMES_YOLO_MODE *before* plugin discovery.  The call to
      +    # _prepare_agent_startup() below triggers discover_plugins() → tool
      +    # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module
      +    # import time (PR #7994, security hardening against prompt-injection).
      +    # If the env var is set only later (e.g. inside cmd_chat), the frozen
      +    # value is already False and --yolo silently does nothing.
      +    if getattr(args, "yolo", False):
      +        os.environ["HERMES_YOLO_MODE"] = "1"
      +
           # Discover Python plugins and register shell hooks once, before any
           # command that can fire lifecycle hooks.  Both are idempotent; gated
           # so introspection/management commands (hermes hooks list, cron
    • scripts/release.py
      @@ -299,6 +299,7 @@
           "[redacted:email]": "synapsesx",
           "[redacted:email]": "itsflownium",
           "[redacted:email]": "dirtyren",
      +    "[redacted:email]": "bytesnail",  # PR #60360 salvage (--yolo startup ordering; #60328)
           "[redacted:email]": "nullptr0807",  # PR #60956 salvage (gateway hygiene in-place compaction; #60947)
           "[redacted:email]": "embwl0x",  # PR #60810 salvage (channel directory offload)
           "[redacted:email]": "doncazper",
    • tests/hermes_cli/test_yolo_startup_order.py
      @@ -0,0 +1,77 @@
      +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in
      +main() before _prepare_agent_startup() triggers tool imports.
      +
      +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct
      +by design (PR #7994). The bug was that main() set the env var inside
      +cmd_chat(), which runs *after* _prepare_agent_startup() has already
      +imported tools.approval and frozen the constant to False.
      +
      +These tests verify the ordering in main() itself: the env var must
      +already be set at the moment _prepare_agent_startup() is called.
      +If someone moves the assignment back into cmd_chat(), these tests
      +fail — catching the exact #60328 regression.
      +"""
      +
      +import os
      +import sys
      +
      +
      +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv):
      +    """Run main() with *argv*, capturing HERMES_YOLO_MODE at the
      +    moment _prepare_agent_startup is called.
      +
      +    Returns the captured env var value (or None if unset).
      +    """
      +    yolo_at_startup = {}
      +
      +    def spy_prepare_startup(args):
      +        yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE")
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.main._prepare_agent_startup", spy_prepare_startup
      +    )
      +    # Stub cmd_chat so main() returns cleanly without entering chat.
      +    monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None)
      +    monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
      +    monkeypatch.setattr(sys, "argv", argv)
      +
      +    from hermes_cli.main import main as cli_main
      +
      +    cli_main()
      +
      +    return yolo_at_startup.get("value")
      +
      +
      +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch):
      +    """hermes --yolo must set HERMES_YOLO_MODE before
      +    _prepare_agent_startup imports tools.approval."""
      +    result = _run_main_and_capture_yolo_at_startup(
      +        monkeypatch, ["hermes", "--yolo"]
      +    )
      +    assert result == "1", (
      +        "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was "
      +        "called from main() with --yolo. This is the #60328 regression: "
      +        "the env var is set too late (inside cmd_chat, after tool imports)."
      +    )
      +
      +
      +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch):
      +    """hermes chat --yolo must also set HERMES_YOLO_MODE before
      +    _prepare_agent_startup."""
      +    result = _run_main_and_capture_yolo_at_startup(
      +        monkeypatch, ["hermes", "chat", "--yolo"]
      +    )
      +    assert result == "1", (
      +        "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was "
      +        "called from main() with 'chat --yolo'."
      +    )
      +
      +
      +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch):
      +    """Without --yolo, HERMES_YOLO_MODE must not be set at startup."""
      +    result = _run_main_and_capture_yolo_at_startup(
      +        monkeypatch, ["hermes"]
      +    )
      +    assert result is None, (
      +        "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo."
      +    )

4 PRs closed without merging

From the close comments: done elsewhere 2 · merged via another change 1 · other 1

  • #34032 fix(cli): use session-scoped yolo bypass in /yolo command · done elsewhere → 5cbc3fb, #33931
  • #60349 fix(cli): set HERMES_YOLO_MODE before _prepare_agent_startup() imports (#60328) · other → #60360, #61715
  • #60360 fix(cli): set HERMES_YOLO_MODE before plugin discovery at startup · merged via another change → #61715, #60328
  • #60963 fix(cli): set HERMES_YOLO_MODE before plugin discovery · done elsewhere → #61715, #60360
Closed issues · 2
  • Closed#33925CLI /yolo (in-chat) does not bypass dangerous command approvals — env var freeze + missing enable_session_yolo call
  • Closed#60328[Bug]: hermes --yolo broken — approval bypass silently ignored since v0.11.0
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#33931, #61715
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
9 Jul 2026

For your agent

The same facts, typed.