From 889eddcd867f79bee72eecfc448b14bfa293d206 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:58:30 -0700 Subject: [PATCH] fix(gateway): /yolo survives a gateway restart /yolo on a messaging platform only flipped tools.approval's in-memory set, so any gateway restart (hermes update, crash, systemd restart, Desktop reopen) silently put the chat back on manual approvals while the user still believed YOLO was on. The flag now lives on the chat's routing entry (SessionEntry.yolo, persisted in gateway_routing like the /model override). /yolo writes it before flipping the live flag, materializing the entry for a first-message toggle; every turn re-arms the live flag from the entry it already resolved; switch_session carries it across compression-tip and binding repoints; the conversation-boundary security funnel (/new, /resume, /branch, auto-reset) clears the persisted copy with the in-memory one so a restart can never revive a bypass the user dropped. Also corrects the security docs, which still claimed /yolo sets HERMES_YOLO_MODE. --- gateway/run_agent_cache.py | 19 ++++++++++++++++++ gateway/run_turn.py | 2 +- gateway/session.py | 14 ++++++++++++- gateway/slash_commands.py | 20 +++++++++++++------ tests/gateway/test_yolo_command.py | 31 +++++++++++++++++++++++++++++ website/docs/user-guide/security.md | 2 +- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 5be523d3fc..c4945b6159 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -433,6 +433,25 @@ class GatewayAgentCacheMixin: clear(session_key) except Exception as e: logger.debug("Failed to clear %s state for session boundary %s: %s", what, session_key, e) + # The persisted /yolo copy dies with the in-memory one, or the next turn's restore revives it. + store = getattr(self, "session_store", None) + if store is not None: + try: + store.set_session_yolo(session_key, False) + except Exception: + # Never fails the boundary itself, but a surviving ON would come back after a restart. + logger.warning("Failed to clear persisted yolo for session boundary %s", session_key, exc_info=True) + + @staticmethod + def _restore_session_yolo(session_key: str, session_entry) -> None: + """Re-arm a persisted /yolo bypass after a gateway restart (the in-memory approval set starts + empty). Restore-only: ``_handle_yolo_command`` is the one writer and persists before it flips + the live flag, so a turn never sees a stale ON that it would wrongly revive.""" + if not session_key or getattr(session_entry, "yolo", False) is not True: + return + from tools.approval import enable_session_yolo, is_session_yolo_enabled + if not is_session_yolo_enabled(session_key): + enable_session_yolo(session_key) def _begin_session_run_generation(self, session_key: str) -> int: """Claim a fresh, monotonically increasing run generation token (NEVER reset): a late result diff --git a/gateway/run_turn.py b/gateway/run_turn.py index c8e6f037f2..fbce3294cb 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -512,7 +512,6 @@ class GatewayTurnMixin: async def _hmwa_open_session(self, session_entry, session_key, source): """Consume auto-reset / fresh-reset flags and emit ``session:start`` for new sessions. Returns ``(_was_auto_reset, _is_new_session)``.""" - # Consume was_auto_reset immediately so it cannot re-fire and wipe overrides set between turns. # Capture and immediately consume was_auto_reset so it does not re-fire on subsequent messages — # preventing the cleanup from wiping model/reasoning overrides set between turns (Closes #48031). _was_auto_reset = getattr(session_entry, "was_auto_reset", False) @@ -2051,6 +2050,7 @@ class GatewayTurnMixin: running (history unreadable); ``None`` drops the turn (inbound text rejected).""" from gateway.run import _load_gateway_config _was_auto_reset, _is_new_session = await self._hmwa_open_session(session_entry, session_key, source) + self._restore_session_yolo(session_key, session_entry) context = build_session_context(source, self.config, session_entry) # Session context variables for tools (task-local, concurrency-safe) _session_env_tokens = self._set_session_env(context) diff --git a/gateway/session.py b/gateway/session.py index 3645a88583..3e4776e943 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -537,13 +537,16 @@ class SessionEntry: # Exact session-context/channel inputs from the last human turn. Append-only dataclass field so # older positional construction of transport_profile keeps its meaning. prompt_pin: Optional[Dict[str, Any]] = None + # Gateway ``/yolo`` bypass for this lane, mirrored from ``tools.approval``'s in-memory set so a restart + # keeps it; cleared with it at every conversation boundary (``_clear_session_boundary_security_state``). + yolo: bool = False # Fields (de)serialized verbatim, in wire order (``from_dict`` reads them with # ``data.get(name, )``), split around the three ISO-datetime/token keys. _PLAIN_FIELDS = ( "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "total_tokens", "last_prompt_tokens", "estimated_cost_usd", "cost_status", - "expiry_finalized", "suspended", "resume_pending", "resume_reason", + "expiry_finalized", "suspended", "resume_pending", "resume_reason", "yolo", ) _RESET_FIELDS = ( "is_fresh_reset", "was_auto_reset", "auto_reset_reason", "reset_had_activity", @@ -1114,6 +1117,14 @@ class SessionStore( self._persist_routing_data(data, generation) entry.model_override = cleaned + def set_session_yolo(self, session_key: str, enabled: bool) -> bool: + """Persist the lane's ``/yolo`` bypass; False when the key has no entry yet or it is unchanged.""" + def _apply(entry: SessionEntry): + if entry.yolo is enabled: + return False + entry.yolo = enabled + return self._update_entry(session_key, _apply) + def get_model_override(self, session_key: str) -> Optional[Dict[str, str]]: """Return the persisted /model override for *session_key*, if any.""" with self._lock: @@ -1260,6 +1271,7 @@ class SessionStore( new_entry = self._replace_route_locked( session_key, old_entry, target_session_id, _now(), display_name=old_entry.display_name, model_override=old_entry.model_override, + yolo=old_entry.yolo, prompt_pin=( dict(old_entry.prompt_pin) if preserve_prompt_pin and old_entry.prompt_pin is not None else None diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index c9d377d9ac..6ba9ae9145 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -935,14 +935,22 @@ class GatewaySlashCommandsMixin( return run_approval_mode_command(requested).message async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: - """Handle /yolo — toggle dangerous command approval bypass for this session only.""" + """Handle /yolo — toggle dangerous command approval bypass for this session only. The flag is + persisted on the routing entry so it survives a gateway restart.""" from tools.approval import disable_session_yolo, enable_session_yolo, is_session_yolo_enabled session_key = self._session_key_for_source(event.source) - if is_session_yolo_enabled(session_key): - disable_session_yolo(session_key) - return EphemeralReply(t("gateway.yolo.disabled")) - enable_session_yolo(session_key) - return EphemeralReply(t("gateway.yolo.enabled")) + if self.session_store is not None: + # A first-message /yolo has no routing entry yet; materialize it so the flag has a home. + entry = await self.async_session_store.get_or_create_session(event.source) + # After a restart only the persisted copy is set: the user still sees it ON. + enable = not (entry.yolo is True or is_session_yolo_enabled(session_key)) + # Persist BEFORE flipping the live flag: a turn restoring in between then never revives a + # bypass that is being switched off. + await self.async_session_store.set_session_yolo(session_key, enable) + else: + enable = not is_session_yolo_enabled(session_key) + (enable_session_yolo if enable else disable_session_yolo)(session_key) + return EphemeralReply(t("gateway.yolo.enabled" if enable else "gateway.yolo.disabled")) async def _handle_verbose_command(self, event: MessageEvent) -> str: """Handle /verbose — cycle tool progress display mode (off → new → all → verbose → log) per diff --git a/tests/gateway/test_yolo_command.py b/tests/gateway/test_yolo_command.py index 560e2a9ee4..3409d226d3 100644 --- a/tests/gateway/test_yolo_command.py +++ b/tests/gateway/test_yolo_command.py @@ -58,3 +58,34 @@ async def test_yolo_command_toggles_only_current_session(monkeypatch): assert is_session_yolo_enabled(session_a) is False assert os.environ.get("HERMES_YOLO_MODE") is None + + +@pytest.mark.asyncio +async def test_yolo_survives_gateway_restart_and_dies_at_session_boundary(tmp_path): + """/yolo is persisted on the routing entry: a fresh process re-arms it on the next turn, and a + conversation boundary (/new, /resume) clears both copies so a restart cannot revive it.""" + from gateway.config import GatewayConfig + from gateway.session import SessionStore + + def _runner(): + runner = _make_runner() + runner.session_store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + runner.session_store._db = None # JSON routing index: the restart reads it back from disk + return runner + + event = _make_event("chat-a") + first = _runner() + key = first._session_key_for_source(event.source) + await first._handle_yolo_command(event) + assert is_session_yolo_enabled(key) is True + + disable_session_yolo(key) # a new process starts with an empty in-memory approval set + second = _runner() + second._restore_session_yolo(key, second.session_store.get_or_create_session(event.source)) + assert is_session_yolo_enabled(key) is True + + second._clear_session_boundary_security_state(key) + assert is_session_yolo_enabled(key) is False + third = _runner() + third._restore_session_yolo(key, third.session_store.get_or_create_session(event.source)) + assert is_session_yolo_enabled(key) is False diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index bd090ab795..322bc7efd6 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -80,7 +80,7 @@ The `/yolo` command is a **toggle** — each use flips the mode on or off: ⚠ YOLO mode OFF — dangerous commands will require approval. ``` -YOLO mode is available in both CLI and gateway sessions. Internally, it sets the `HERMES_YOLO_MODE` environment variable which is checked before every command execution. +YOLO mode is available in both CLI and gateway sessions. `/yolo` applies only to the session it was typed in. On a messaging platform it is saved with the chat's session, so it survives a gateway restart; `/new` and `/resume` start the next conversation with approvals back on. When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed: