Files
odysseus/tests/test_retired_settings_interfaces.py
T
DracieVajkoandMatej f7f6abf82b feat(settings): add a tool approval mode with an explicit off (YOLO) option (#6304)
* 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>
2026-11-01 12:22:56 -07:00

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