mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-02 19:22:22 +01:00
* feat: add optional tool approval mode Admin-configurable tool_approval_mode setting (ask/auto) for the post-external-context tool approval gate. - ask (default): existing approval flow, gate stays on - auto: bypasses the approval gate for all runs - same effect as ODYSSEUS_TOOL_APPROVAL_GATE=0. Documented honestly in the settings row copy and in THREAT_MODEL.md, per review on #6304. - value validated server-side ({ask, auto}, anything else -> 400) - no new env var: the setting wires to the existing gate control - tests: setting validation + _tool_approval_gate_bypassed behavior * feat: surface deployment gate state on Tool approval row Follow-up to StressTestor's re-review: when ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value, the Tool approval row still read 'Always ask' even though the gate was off, misleading admins the other way. GET /api/auth/settings now returns tool_approval_gate_env_disabled (derived from TOOL_APPROVAL_GATE_ENABLED) for admins, and the settings row renders a red notice naming the env var and explaining that the gate is off for every run regardless of the stored setting. Non-admin/scrubbed responses do not expose the deployment state. Test covers both env values and the scrub path. * refactor: rename tool approval mode auto to off The gate-off value is now "off" ("Off (YOLO)" in the UI) so the auto-approve name stays available for the future judged mode. --------- Co-authored-by: Matej <matej@local>
180 lines
5.8 KiB
Python
180 lines
5.8 KiB
Python
"""Retired settings stay stored but cannot leak through generic interfaces."""
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
from fastapi import HTTPException
|
|
|
|
import pytest
|
|
|
|
import core.database as database
|
|
import routes.auth_routes as auth_routes
|
|
import src.settings as settings_mod
|
|
from src.agent_tools.admin_tools import do_manage_settings
|
|
|
|
|
|
LEGACY_VALUE = [
|
|
{"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
|
|
]
|
|
|
|
|
|
class _AuthManager:
|
|
def get_username_for_token(self, token):
|
|
return "admin" if token == "admin-session" else None
|
|
|
|
def is_admin(self, username):
|
|
return username == "admin"
|
|
|
|
|
|
class _Request(SimpleNamespace):
|
|
def __init__(self, body=None, *, admin=False):
|
|
super().__init__(
|
|
cookies={
|
|
auth_routes.SESSION_COOKIE: "admin-session"
|
|
} if admin else {},
|
|
_body=body,
|
|
)
|
|
|
|
async def json(self):
|
|
return self._body
|
|
|
|
|
|
def _route(router, path, method):
|
|
return next(
|
|
route.endpoint
|
|
for route in router.routes
|
|
if route.path == path and method in route.methods
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
|
|
store = {
|
|
**settings_mod.DEFAULT_SETTINGS,
|
|
"default_model_fallbacks": list(LEGACY_VALUE),
|
|
"tts_enabled": True,
|
|
}
|
|
|
|
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
|
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
|
|
|
|
def save_settings(updated):
|
|
store.clear()
|
|
store.update(updated)
|
|
|
|
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
|
|
router = auth_routes.setup_auth_routes(_AuthManager())
|
|
get_settings = _route(router, "/api/auth/settings", "GET")
|
|
set_settings = _route(router, "/api/auth/settings", "POST")
|
|
|
|
anonymous = await get_settings(_Request())
|
|
admin = await get_settings(_Request(admin=True))
|
|
|
|
assert "default_model_fallbacks" not in anonymous
|
|
assert "default_model_fallbacks" not in admin
|
|
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
|
|
|
response = await set_settings(_Request({
|
|
"default_model_fallbacks": [],
|
|
"tts_enabled": False,
|
|
}, admin=True))
|
|
|
|
assert "default_model_fallbacks" not in response
|
|
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
|
assert store["tts_enabled"] is False
|
|
|
|
|
|
def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
|
|
store = {
|
|
**settings_mod.DEFAULT_SETTINGS,
|
|
"default_model_fallbacks": list(LEGACY_VALUE),
|
|
}
|
|
save_calls = []
|
|
|
|
class _Db:
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
|
|
monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
|
|
|
|
def save_settings(updated):
|
|
save_calls.append(dict(updated))
|
|
store.clear()
|
|
store.update(updated)
|
|
|
|
monkeypatch.setattr(settings_mod, "save_settings", save_settings)
|
|
|
|
listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
|
|
assert "default_model_fallbacks" not in listed["settings"]
|
|
|
|
for action in ("get", "set", "reset", "delete"):
|
|
payload = {"action": action, "key": "default_model_fallbacks"}
|
|
if action == "set":
|
|
payload["value"] = []
|
|
result = asyncio.run(do_manage_settings(json.dumps(payload)))
|
|
assert result["exit_code"] == 1
|
|
assert "Unknown setting" in result["error"]
|
|
|
|
assert save_calls == []
|
|
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_approval_mode_accepts_only_supported_values(monkeypatch):
|
|
store = dict(settings_mod.DEFAULT_SETTINGS)
|
|
|
|
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
|
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
|
|
|
|
def save_settings(updated):
|
|
store.clear()
|
|
store.update(updated)
|
|
|
|
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
|
|
|
|
router = auth_routes.setup_auth_routes(_AuthManager())
|
|
set_settings = _route(router, "/api/auth/settings", "POST")
|
|
|
|
response = await set_settings(
|
|
_Request({"tool_approval_mode": "off"}, admin=True)
|
|
)
|
|
assert response["tool_approval_mode"] == "off"
|
|
assert store["tool_approval_mode"] == "off"
|
|
|
|
response = await set_settings(
|
|
_Request({"tool_approval_mode": "ask"}, admin=True)
|
|
)
|
|
assert response["tool_approval_mode"] == "ask"
|
|
assert store["tool_approval_mode"] == "ask"
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await set_settings(
|
|
_Request({"tool_approval_mode": "banana"}, admin=True)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_expose_gate_env_kill_switch_to_admins_only(monkeypatch):
|
|
store = dict(settings_mod.DEFAULT_SETTINGS)
|
|
|
|
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
|
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
|
|
|
|
router = auth_routes.setup_auth_routes(_AuthManager())
|
|
get_settings = _route(router, "/api/auth/settings", "GET")
|
|
|
|
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", True)
|
|
response = await get_settings(_Request(admin=True))
|
|
assert response["tool_approval_gate_env_disabled"] is False
|
|
|
|
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", False)
|
|
response = await get_settings(_Request(admin=True))
|
|
assert response["tool_approval_gate_env_disabled"] is True
|
|
# The stored setting itself is untouched by the env kill-switch.
|
|
assert response["tool_approval_mode"] == "ask"
|
|
|
|
# Unauthenticated/scrubbed callers must not learn the deployment gate state.
|
|
response = await get_settings(_Request())
|
|
assert "tool_approval_gate_env_disabled" not in response
|