fix(cli): persist YOLO mode across --resume

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.
This commit is contained in:
Teknium
2026-08-02 18:56:38 -07:00
parent aac74be2f1
commit ef9f6effaf
7 changed files with 412 additions and 1 deletions
+11
View File
@@ -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 <id>` 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
+70
View File
@@ -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 <new_id>` 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
+2
View File
@@ -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(
+6
View File
@@ -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|<id_or_title>] — browse or resume previous sessions.
+58
View File
@@ -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,
+14 -1
View File
@@ -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,
@@ -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