mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
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:
1 parent
3b288d8562
commit
889eddcd86
6 files changed
+79
-9
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user