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: