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.
This commit is contained in:
Teknium committed 2026-10-07 16:15:35 -07:00
1 parent 3b288d8562
commit 889eddcd86
6 files changed
+79 -9

No files matched your search

+19
View File
@@ -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
+1 -1
View File
@@ -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)
+13 -1
View File
@@ -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, <dataclass default>)``), 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
+14 -6
View File
@@ -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
+31
View File
@@ -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
+1 -1
View File
@@ -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: