diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 749455e..cedb2f4 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -292,6 +292,30 @@ clarifications accept free text. --- +## Auto-approve (YOLO) + +YOLO is always conversation-scoped. It skips ordinary dangerous-command +approval prompts for that Hermes session, while Hermes hard deny rules and +hardline safety floors still apply. + +```json +→ { "type": "yolo.current", "conversation_id": "a1b2…", "request_id": "r15" } +← { "type": "yolo.snapshot", "enabled": false, "scope": "conversation", + "conversation_id": "a1b2…", "request_id": "r15" } + +→ { "type": "yolo.set", "conversation_id": "a1b2…", "enabled": true, + "request_id": "r16" } +← { "type": "yolo.changed", "enabled": true, "scope": "conversation", + "conversation_id": "a1b2…", "request_id": "r16" } +// plus broadcast of yolo.changed (without request_id) to all clients +``` + +`enabled` must be a JSON boolean. Unknown conversations return +`conversation_not_found`. The adapter also persists the flag in Hermes session +metadata so it can be restored after the gateway restarts. + +--- + ## Attachments (agent → client deliverables) When the agent produces a file (image, document, audio, video… via Hermes's diff --git a/plugin/pheby/hermes_bridge.py b/plugin/pheby/hermes_bridge.py index 79a1207..fafcfeb 100644 --- a/plugin/pheby/hermes_bridge.py +++ b/plugin/pheby/hermes_bridge.py @@ -686,6 +686,66 @@ async def resolve_clarify(clarify_id: str, response: str) -> bool: return bool(ok) +# ═══════════════════════════════════════════════════════════════════════════ +# Conversation-scoped YOLO / approval bypass +# ═══════════════════════════════════════════════════════════════════════════ +async def yolo_snapshot(conversation_id: str) -> Dict[str, Any]: + """Return the effective dangerous-command approval bypass for one chat.""" + store = _session_store() + session_key = _session_key_for(conversation_id) + session_id = None + if store is not None: + try: + session_id = await asyncio.to_thread(store.peek_session_id, session_key) + except Exception: + logger.debug("[pheby] yolo session lookup failed", exc_info=True) + if not session_id: + return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND, + "message": "Conversation not found"} + + from tools.approval import enable_session_yolo, is_session_yolo_enabled + enabled = is_session_yolo_enabled(session_key) + if not enabled: + db = _session_db() + if db is not None: + try: + meta = await asyncio.to_thread(db.get_session, str(session_id)) + from hermes_state import SessionDB + if SessionDB.session_yolo_enabled(meta): + enable_session_yolo(session_key) + enabled = True + except Exception: + logger.debug("[pheby] persisted yolo read failed", exc_info=True) + return {"ok": True, "enabled": bool(enabled), "scope": "conversation", + "conversation_id": conversation_id} + + +async def set_yolo(conversation_id: str, enabled: bool) -> Dict[str, Any]: + """Set YOLO for one chat and persist it with that Hermes session.""" + store = _session_store() + session_key = _session_key_for(conversation_id) + session_id = None + if store is not None: + try: + session_id = await asyncio.to_thread(store.peek_session_id, session_key) + except Exception: + logger.debug("[pheby] yolo session lookup failed", exc_info=True) + if not session_id: + return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND, + "message": "Conversation not found"} + + from tools.approval import disable_session_yolo, enable_session_yolo + (enable_session_yolo if enabled else disable_session_yolo)(session_key) + db = _session_db() + if db is not None: + try: + await asyncio.to_thread(db.set_session_yolo, str(session_id), bool(enabled)) + except Exception: + logger.warning("[pheby] yolo persistence failed", exc_info=True) + return {"ok": True, "enabled": bool(enabled), "scope": "conversation", + "conversation_id": conversation_id} + + # ═══════════════════════════════════════════════════════════════════════════ # Models & reasoning # ═══════════════════════════════════════════════════════════════════════════ diff --git a/plugin/pheby/protocol.py b/plugin/pheby/protocol.py index afdbdef..1da2722 100644 --- a/plugin/pheby/protocol.py +++ b/plugin/pheby/protocol.py @@ -71,6 +71,8 @@ C_MODEL_SET = "model.set" C_MODEL_CURRENT = "models.current" C_REASONING_SET = "reasoning.set" C_REASONING_CURRENT = "reasoning.current" +C_YOLO_SET = "yolo.set" +C_YOLO_CURRENT = "yolo.current" # ── Server → client message types ──────────────────────────────────────────── S_READY = "ready" @@ -98,6 +100,8 @@ S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current S_MODEL_CHANGED = "model.changed" # after model.set accepted S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted +S_YOLO_SNAPSHOT = "yolo.snapshot" # reply to yolo.current +S_YOLO_CHANGED = "yolo.changed" # after yolo.set accepted # Reasoning effort levels supported by Hermes (hermes_constants). REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") @@ -179,7 +183,7 @@ __all__ = [ "C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE", "C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND", "C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET", - "C_REASONING_CURRENT", + "C_REASONING_CURRENT", "C_YOLO_SET", "C_YOLO_CURRENT", "S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT", "S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED", "S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED", @@ -188,7 +192,7 @@ __all__ = [ "S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED", "S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT", "S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED", - "REASONING_EFFORTS", + "S_YOLO_SNAPSHOT", "S_YOLO_CHANGED", "REASONING_EFFORTS", "now_iso", "new_id", "is_valid_attachment_id", "encode_message", "decode_message", "error_event", "safe_str", ] diff --git a/plugin/pheby/server.py b/plugin/pheby/server.py index 2e2747d..3fee694 100644 --- a/plugin/pheby/server.py +++ b/plugin/pheby/server.py @@ -531,6 +531,52 @@ class PhebyServer: **({"request_id": request_id} if request_id else {}), }) + async def _handle_yolo_current(self, client, message, request_id): + conversation_id = str(message.get("conversation_id", "")) + if not ConversationRouter.is_valid_conversation_id(conversation_id): + await client.send_json(proto.error_event( + proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id)) + return + result = await self.bridge.yolo_snapshot(conversation_id) + if not result.get("ok"): + await client.send_json(proto.error_event( + result.get("code", proto.ERR_INTERNAL), + result.get("message", "YOLO state unavailable"), request_id)) + return + await client.send_json({ + "type": proto.S_YOLO_SNAPSHOT, + "enabled": bool(result.get("enabled")), + "scope": result.get("scope", "conversation"), + "conversation_id": conversation_id, + **({"request_id": request_id} if request_id else {}), + }) + + async def _handle_yolo_set(self, client, message, request_id): + conversation_id = str(message.get("conversation_id", "")) + enabled = message.get("enabled") + if not ConversationRouter.is_valid_conversation_id(conversation_id) \ + or not isinstance(enabled, bool): + await client.send_json(proto.error_event( + proto.ERR_BAD_REQUEST, + "conversation_id and boolean enabled are required", request_id)) + return + result = await self.bridge.set_yolo(conversation_id, enabled) + if not result.get("ok"): + await client.send_json(proto.error_event( + result.get("code", proto.ERR_INTERNAL), + result.get("message", "YOLO change failed"), request_id)) + return + event = { + "type": proto.S_YOLO_CHANGED, + "enabled": bool(result.get("enabled")), + "scope": result.get("scope", "conversation"), + "conversation_id": conversation_id, + **({"request_id": request_id} if request_id else {}), + } + await client.send_json(event) + await self.broadcast({k: v for k, v in event.items() + if k != "request_id"}) + async def _handle_models_list(self, client, message, request_id): conversation_id = message.get("conversation_id") snapshot = await self.bridge.models_snapshot( @@ -617,6 +663,8 @@ class PhebyServer: proto.C_MODEL_CURRENT: _handle_model_current, proto.C_REASONING_SET: _handle_reasoning_set, proto.C_REASONING_CURRENT: _handle_reasoning_current, + proto.C_YOLO_SET: _handle_yolo_set, + proto.C_YOLO_CURRENT: _handle_yolo_current, } diff --git a/tests/test_pheby.py b/tests/test_pheby.py index 583cd06..afe8c51 100644 --- a/tests/test_pheby.py +++ b/tests/test_pheby.py @@ -585,6 +585,86 @@ class TestBridge: ev = client.ws.events()[-1] assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND + @pytest.mark.asyncio + async def test_yolo_state_is_conversation_scoped_and_persisted(self, monkeypatch): + from pheby import hermes_bridge as hb + import tools.approval + + conversation_id = "a" * 32 + session_key = f"agent:main:pheby:dm:{conversation_id}" + persisted = [] + + class Store: + def peek_session_id(self, key): + assert key == session_key + return "session-1" + + class DB: + def set_session_yolo(self, session_id, enabled): + persisted.append((session_id, enabled)) + + monkeypatch.setattr(hb, "_session_store", lambda: Store()) + monkeypatch.setattr(hb, "_session_db", lambda: DB()) + tools.approval.clear_session(session_key) + try: + assert (await hb.yolo_snapshot(conversation_id))["enabled"] is False + + enabled = await hb.set_yolo(conversation_id, True) + assert enabled == { + "ok": True, + "enabled": True, + "scope": "conversation", + "conversation_id": conversation_id, + } + assert tools.approval.is_session_yolo_enabled(session_key) is True + assert (await hb.yolo_snapshot(conversation_id))["enabled"] is True + + disabled = await hb.set_yolo(conversation_id, False) + assert disabled["enabled"] is False + assert tools.approval.is_session_yolo_enabled(session_key) is False + assert persisted == [("session-1", True), ("session-1", False)] + finally: + tools.approval.clear_session(session_key) + + @pytest.mark.asyncio + async def test_yolo_websocket_handlers_return_snapshot_and_changed(self, tmp_path, + monkeypatch): + server = make_server(tmp_path) + client = FakeClientConnection() + conversation_id = "b" * 32 + calls = [] + + async def snapshot(cid): + calls.append(("current", cid)) + return {"ok": True, "enabled": False, "scope": "conversation", + "conversation_id": cid} + + async def change(cid, enabled): + calls.append(("set", cid, enabled)) + return {"ok": True, "enabled": enabled, "scope": "conversation", + "conversation_id": cid} + + monkeypatch.setattr(server.bridge, "yolo_snapshot", snapshot) + monkeypatch.setattr(server.bridge, "set_yolo", change) + + await server._handle_yolo_current(client, { + "type": proto.C_YOLO_CURRENT, + "conversation_id": conversation_id, + }, "r-yolo-current") + await server._handle_yolo_set(client, { + "type": proto.C_YOLO_SET, + "conversation_id": conversation_id, + "enabled": True, + }, "r-yolo-set") + + events = client.ws.events() + assert events[0]["type"] == proto.S_YOLO_SNAPSHOT + assert events[0]["request_id"] == "r-yolo-current" + assert events[1]["type"] == proto.S_YOLO_CHANGED + assert events[1]["enabled"] is True + assert events[1]["request_id"] == "r-yolo-set" + assert calls == [("current", conversation_id), ("set", conversation_id, True)] + @pytest.mark.asyncio async def test_clarify_push_and_resolve_roundtrip(self, tmp_path): server = make_server(tmp_path)