From ef9f6effaffc5c454598a072c7ffba12048783f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:56:38 -0700 Subject: [PATCH] fix(cli): persist YOLO mode across --resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session's YOLO bypass lived only in the in-memory tools.approval._session_yolo set (or the process-frozen --yolo env var), so resuming a session in a fresh process silently reverted the user's /yolo ON — dangerous commands started prompting again. Persist a yolo_mode flag in the session row's model_config JSON and restore it on every CLI resume path: - SessionDB.set_session_yolo() merges the flag into model_config (same lineage-preserving merge as update_session_runtime_lock); SessionDB.session_yolo_enabled() reads it back, false on any parse failure. - /yolo toggle persists ON and OFF through the new helper; the compression/branch session-id rotation carries the flag onto the continuation row. - --yolo launches record the flag at session creation (agent_init), and a /yolo toggled before the lazily-created row exists is carried into the creation-time model_config (_ensure_db_session). - HermesCLI._restore_session_yolo() re-enables the bypass on startup --resume/-c, the deferred init path, and mid-chat /resume, with a visible '⚡ YOLO mode restored from session' notice. No-op under a frozen process-wide --yolo and never enables on absent/garbage flags. --- agent/agent_init.py | 11 + cli.py | 70 +++++ hermes_cli/cli_agent_setup_mixin.py | 2 + hermes_cli/cli_commands_mixin.py | 6 + hermes_state.py | 58 ++++ run_agent.py | 15 +- tests/cli/test_cli_yolo_resume_persistence.py | 251 ++++++++++++++++++ 7 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_cli_yolo_resume_persistence.py diff --git a/agent/agent_init.py b/agent/agent_init.py index db8f026f63..ad6b0eb00a 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1580,6 +1580,17 @@ def init_agent( "reasoning_config": reasoning_config, "max_tokens": max_tokens, } + # Persist a process-scoped --yolo launch into the session row so a later + # `hermes --resume ` can restore the bypass (CLI resume paths read + # model_config.yolo_mode back via SessionDB.session_yolo_enabled). + # Session-scoped /yolo toggles persist separately through + # SessionDB.set_session_yolo at toggle time. + try: + from tools.approval import _YOLO_MODE_FROZEN + if _YOLO_MODE_FROZEN: + agent._session_init_model_config["yolo_mode"] = True + except Exception: + pass # In-memory todo list for task planning (one per agent/session) from tools.todo_tool import TodoStore diff --git a/cli.py b/cli.py index 55b94c146a..aed3992922 100644 --- a/cli.py +++ b/cli.py @@ -7334,6 +7334,44 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): else: self._console_print(f"[dim]{_escape(msg)}[/dim]") + def _restore_session_yolo(self, session_meta: dict, *, quiet: bool = False) -> None: + """Re-enable YOLO bypass on resume when the session had it on. + + Companion to ``_restore_session_cwd`` — called from every resume path + (startup ``--resume``/``-c`` and mid-chat ``/resume``). The persisted + flag lives in the session row's ``model_config.yolo_mode`` (written by + ``/yolo`` toggles and ``--yolo`` launches); without this restore the + in-memory ``tools.approval._session_yolo`` set starts empty in a fresh + process and the user's bypass silently reverts. + + No-op when the flag is absent/false, when YOLO is already active for + this session (idempotent across repeated resume paths), or when the + process was itself launched with ``--yolo`` (frozen bypass already + covers everything). + """ + try: + from hermes_state import SessionDB + from tools.approval import ( + _YOLO_MODE_FROZEN, + enable_session_yolo, + is_session_yolo_enabled, + ) + except Exception: + return + if _YOLO_MODE_FROZEN: + return + if not SessionDB.session_yolo_enabled(session_meta): + return + session_key = self.session_id or "default" + if is_session_yolo_enabled(session_key): + return + enable_session_yolo(session_key) + msg = "⚡ YOLO mode restored from session — all commands auto-approved. /yolo to turn off." + if quiet: + print(msg, file=sys.stderr) + else: + self._console_print(f"[dim]{_escape(msg)}[/dim]") + def _render_resume_history_panel_lines(self, panel) -> list[str]: @@ -10820,6 +10858,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if is_session_yolo_enabled(old_session_id): enable_session_yolo(new_session_id) disable_session_yolo(old_session_id) + # Carry the persisted flag onto the continuation row so a later + # `hermes --resume ` restores the bypass too. getattr + # guard: tests call this unbound against a minimal stand-in. + _persist = getattr(self, "_persist_session_yolo", None) + if _persist: + _persist(new_session_id, True) def _is_session_yolo_active(self) -> bool: """Whether YOLO bypass is currently enabled for this CLI session. @@ -10871,19 +10915,45 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ) session_key = self.session_id or "default" + # ``getattr`` guard: tests exercise this method unbound against a + # minimal stand-in object (see tests/cli/test_cli_yolo_toggle.py); + # persistence is best-effort either way. + _persist = getattr(self, "_persist_session_yolo", None) if is_session_yolo_enabled(session_key): disable_session_yolo(session_key) + if _persist: + _persist(session_key, False) _cprint( f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}" " — dangerous commands will require approval." ) else: enable_session_yolo(session_key) + if _persist: + _persist(session_key, True) _cprint( f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}" " — all commands auto-approved. Use with caution." ) + def _persist_session_yolo(self, session_key: str, enabled: bool) -> None: + """Persist the YOLO flag to the session row so --resume restores it. + + Best-effort: the in-memory toggle is authoritative for this process; + persistence only affects a future ``hermes --resume``. Skipped when the + session store is unavailable or the row doesn't exist yet (the row is + created lazily on the first turn — ``_toggle_yolo`` before any chat + writes nothing, and the launch-time ``--yolo`` flag is carried into the + creation-time model_config instead). + """ + db = getattr(self, "_session_db", None) + if db is None or not session_key or session_key == "default": + return + try: + db.set_session_yolo(session_key, enabled) + except Exception: + pass + diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 050a60c2ff..57232dde27 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -429,6 +429,7 @@ class CLIAgentSetupMixin: f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)" ) self._restore_session_cwd(session_meta, quiet=_quiet_mode) + self._restore_session_yolo(session_meta, quiet=_quiet_mode) else: if _quiet_mode: print( @@ -640,6 +641,7 @@ class CLIAgentSetupMixin: f"{len(restored)} total messages)[/]" ) self._restore_session_cwd(session_meta) + self._restore_session_yolo(session_meta) else: accent_color = _accent_hex() self._console_print( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 105d7c256b..69b5323b56 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1055,6 +1055,12 @@ class CLICommandsMixin: # and a no-op when the session recorded no cwd. See #38562. self._restore_session_cwd(session_meta) + # Restore the target session's persisted YOLO bypass. Any bypass the + # PREVIOUS session had toggled on stops applying automatically because + # the approval session key just changed. Same contract as a startup + # --resume. + self._restore_session_yolo(session_meta) + def _handle_sessions_command(self, cmd_original: str) -> None: """Handle /sessions [list|] — browse or resume previous sessions. diff --git a/hermes_state.py b/hermes_state.py index e7fc109980..f14f1dde77 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4220,6 +4220,64 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) ) self._execute_write(_do) + def set_session_yolo(self, session_id: str, enabled: bool) -> None: + """Persist the per-session YOLO bypass flag into ``model_config``. + + Merges ``yolo_mode`` into the existing ``model_config`` JSON (same + merge discipline as ``update_session_runtime_lock`` so lineage + markers like ``_branched_from`` / ``_delegate_from`` survive). The + CLI resume paths read this flag back so a ``/yolo ON`` toggle — or a + ``--yolo`` launch — survives ``hermes --resume`` into a fresh + process. No-op when the session row doesn't exist yet; the + creation-time ``model_config`` carries the flag for ``--yolo`` + launches. + """ + if not session_id: + return + + def _do(conn): + row = conn.execute( + "SELECT model_config FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + if row is None: + return + raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0] + config: Dict[str, Any] = {} + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + config = parsed + except Exception: + config = {} + elif isinstance(raw, dict): + config = dict(raw) + config["yolo_mode"] = bool(enabled) + conn.execute( + "UPDATE sessions SET model_config = ? WHERE id = ?", + (json.dumps(config), session_id), + ) + self._execute_write(_do) + + @staticmethod + def session_yolo_enabled(session_meta: Optional[Dict[str, Any]]) -> bool: + """Read the persisted YOLO flag off a session row dict. + + Accepts the dict returned by ``get_session`` (``model_config`` is a + JSON string) or an already-parsed dict. Returns False on any parse + failure — resume must never enable the bypass by accident. + """ + raw = (session_meta or {}).get("model_config") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except Exception: + return False + if not isinstance(raw, dict): + return False + return bool(raw.get("yolo_mode")) + def update_session_billing_route( self, session_id: str, diff --git a/run_agent.py b/run_agent.py index 39a1689bad..c00fcf60de 100644 --- a/run_agent.py +++ b/run_agent.py @@ -630,11 +630,24 @@ class AIAgent: _profile_for_session = None except Exception: _profile_for_session = None + # Carry the live YOLO bypass into the creation-time model_config so + # a session whose /yolo was toggled BEFORE the row existed (the row + # is created lazily on the first turn) still persists the flag for + # `hermes --resume`. set_session_yolo() no-ops on a missing row, so + # this is the only chance to record a pre-first-turn toggle. + _init_model_config = self._session_init_model_config + try: + from tools.approval import is_session_yolo_enabled + if is_session_yolo_enabled(self.session_id): + _init_model_config = dict(_init_model_config or {}) + _init_model_config["yolo_mode"] = True + except Exception: + pass self._session_db.create_session( session_id=self.session_id, source=source, model=self.model, - model_config=self._session_init_model_config, + model_config=_init_model_config, system_prompt=self._cached_system_prompt, user_id=None, parent_session_id=self._parent_session_id, diff --git a/tests/cli/test_cli_yolo_resume_persistence.py b/tests/cli/test_cli_yolo_resume_persistence.py new file mode 100644 index 0000000000..ce771d4d8d --- /dev/null +++ b/tests/cli/test_cli_yolo_resume_persistence.py @@ -0,0 +1,251 @@ +"""Regression tests: YOLO mode persists across ``hermes --resume``. + +Pre-fix bug: the ``/yolo`` toggle (and the process-start ``--yolo`` flag) +lived only in the in-memory ``tools.approval._session_yolo`` set / the +frozen env var. Resuming a session in a fresh process silently reverted the +bypass — dangerous commands started prompting again even though the user had +YOLO on for that session. + +The fix persists a ``yolo_mode`` flag inside the session row's +``model_config`` JSON: + +- ``SessionDB.set_session_yolo`` merges the flag (preserving lineage markers + like ``_branched_from``), written by the CLI ``/yolo`` toggle. +- ``AIAgent._ensure_db_session`` carries a live session bypass (or a frozen + ``--yolo`` launch, via agent_init) into the creation-time model_config. +- ``HermesCLI._restore_session_yolo`` reads the flag on every resume path + and re-enables the in-memory bypass. +""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import tools.approval as approval_module +from cli import HermesCLI +from hermes_state import SessionDB + + +SESSION_ID = "yolo_persist_session" + + +@pytest.fixture(autouse=True) +def _hermetic_yolo(monkeypatch): + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + approval_module.clear_session(SESSION_ID) + yield + approval_module.clear_session(SESSION_ID) + + +@pytest.fixture +def db(tmp_path): + d = SessionDB(db_path=tmp_path / "state.db") + yield d + try: + d.close() + except Exception: + pass + + +class TestSessionDbYoloFlag: + def test_set_and_read_round_trip(self, db): + db.create_session(session_id=SESSION_ID, source="cli", model="m") + db.set_session_yolo(SESSION_ID, True) + meta = db.get_session(SESSION_ID) + assert SessionDB.session_yolo_enabled(meta) is True + + db.set_session_yolo(SESSION_ID, False) + meta = db.get_session(SESSION_ID) + assert SessionDB.session_yolo_enabled(meta) is False + + def test_merge_preserves_existing_model_config_keys(self, db): + db.create_session( + session_id=SESSION_ID, + source="cli", + model="m", + model_config={"max_iterations": 42, "_branched_from": "parent_x"}, + ) + db.set_session_yolo(SESSION_ID, True) + meta = db.get_session(SESSION_ID) + config = json.loads(meta["model_config"]) + assert config["yolo_mode"] is True + assert config["max_iterations"] == 42 + assert config["_branched_from"] == "parent_x" + + def test_missing_row_is_noop(self, db): + # Row doesn't exist yet (lazy creation) — must not raise or create. + db.set_session_yolo("does_not_exist", True) + assert db.get_session("does_not_exist") is None + + def test_creation_time_model_config_flag_reads_back(self, db): + db.create_session( + session_id=SESSION_ID, + source="cli", + model="m", + model_config={"yolo_mode": True}, + ) + meta = db.get_session(SESSION_ID) + assert SessionDB.session_yolo_enabled(meta) is True + + def test_reader_is_false_on_garbage(self): + assert SessionDB.session_yolo_enabled(None) is False + assert SessionDB.session_yolo_enabled({}) is False + assert SessionDB.session_yolo_enabled({"model_config": None}) is False + assert SessionDB.session_yolo_enabled({"model_config": "not json {"}) is False + assert SessionDB.session_yolo_enabled({"model_config": "[1,2]"}) is False + assert ( + SessionDB.session_yolo_enabled({"model_config": '{"yolo_mode": false}'}) + is False + ) + + +def _stand_in(session_id=SESSION_ID, session_db=None): + return SimpleNamespace( + session_id=session_id, + _session_db=session_db, + _console_print=lambda *a, **k: None, + ) + + +class TestRestoreSessionYolo: + def test_restore_enables_bypass_when_flag_set(self): + stand_in = _stand_in() + meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'} + + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False + HermesCLI._restore_session_yolo(stand_in, meta) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + def test_restore_noop_when_flag_absent(self): + stand_in = _stand_in() + meta = {"id": SESSION_ID, "model_config": '{"max_iterations": 10}'} + + HermesCLI._restore_session_yolo(stand_in, meta) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False + + def test_restore_noop_when_meta_empty(self): + stand_in = _stand_in() + HermesCLI._restore_session_yolo(stand_in, {}) + HermesCLI._restore_session_yolo(stand_in, None) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False + + def test_restore_idempotent_when_already_enabled(self): + stand_in = _stand_in() + approval_module.enable_session_yolo(SESSION_ID) + meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'} + # Should not raise or print duplicate banners; state stays enabled. + HermesCLI._restore_session_yolo(stand_in, meta) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + def test_restore_skipped_under_frozen_process_yolo(self): + stand_in = _stand_in() + meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'} + with patch.object(approval_module, "_YOLO_MODE_FROZEN", True): + HermesCLI._restore_session_yolo(stand_in, meta) + # Frozen bypass already covers everything — the session set is + # untouched (avoids persisting a session-scoped bypass the user + # only asked for at process scope). + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False + + +class TestToggleYoloPersists: + def test_toggle_writes_flag_through_session_db(self): + db = MagicMock() + stand_in = SimpleNamespace(session_id=SESSION_ID, _session_db=db) + # Bind the real persist helper so the toggle's getattr finds it. + stand_in._persist_session_yolo = ( + lambda key, enabled: HermesCLI._persist_session_yolo( + stand_in, key, enabled + ) + ) + + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) # ON + db.set_session_yolo.assert_called_once_with(SESSION_ID, True) + + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) # OFF + db.set_session_yolo.assert_called_with(SESSION_ID, False) + + def test_toggle_survives_missing_session_db(self): + stand_in = SimpleNamespace(session_id=SESSION_ID, _session_db=None) + stand_in._persist_session_yolo = ( + lambda key, enabled: HermesCLI._persist_session_yolo( + stand_in, key, enabled + ) + ) + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) # must not raise + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + def test_toggle_still_works_without_persist_helper(self): + # Back-compat with the minimal stand-in used by older tests. + stand_in = SimpleNamespace(session_id=SESSION_ID) + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + +class TestEndToEndPersistAndRestore: + def test_full_round_trip_through_real_db(self, db): + """Toggle ON in 'process 1', restore in 'process 2' (fresh in-memory + approval state), and verify a dangerous command auto-approves.""" + db.create_session(session_id=SESSION_ID, source="cli", model="m") + + # Process 1: user toggles /yolo ON — persisted to the row. + cli_one = SimpleNamespace(session_id=SESSION_ID, _session_db=db) + cli_one._persist_session_yolo = ( + lambda key, enabled: HermesCLI._persist_session_yolo( + cli_one, key, enabled + ) + ) + with patch("cli._cprint"): + HermesCLI._toggle_yolo(cli_one) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + # Simulate process exit: in-memory approval state is gone. + approval_module.clear_session(SESSION_ID) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False + + # Process 2: --resume reads the row and restores the bypass. + meta = db.get_session(SESSION_ID) + cli_two = _stand_in(session_db=db) + HermesCLI._restore_session_yolo(cli_two, meta) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is True + + token = approval_module.set_current_session_key(SESSION_ID) + try: + result = approval_module.check_all_command_guards( + "rm -rf /tmp/scratch-xyzzy", "local", + ) + assert result["approved"] is True + finally: + approval_module.reset_current_session_key(token) + + def test_toggle_off_round_trip(self, db): + """OFF must persist too — a resumed session must not resurrect a + bypass the user explicitly turned off.""" + db.create_session( + session_id=SESSION_ID, + source="cli", + model="m", + model_config={"yolo_mode": True}, + ) + cli_one = SimpleNamespace(session_id=SESSION_ID, _session_db=db) + cli_one._persist_session_yolo = ( + lambda key, enabled: HermesCLI._persist_session_yolo( + cli_one, key, enabled + ) + ) + approval_module.enable_session_yolo(SESSION_ID) + with patch("cli._cprint"): + HermesCLI._toggle_yolo(cli_one) # OFF + approval_module.clear_session(SESSION_ID) + + meta = db.get_session(SESSION_ID) + cli_two = _stand_in(session_db=db) + HermesCLI._restore_session_yolo(cli_two, meta) + assert approval_module.is_session_yolo_enabled(SESSION_ID) is False