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>
This commit is contained in:
DracieVajko
2026-11-01 12:22:56 -07:00
committed by GitHub
co-authored by Matej
parent 2a413814c5
commit f7f6abf82b
9 changed files with 168 additions and 6 deletions
+60
View File
@@ -3,6 +3,7 @@
import asyncio
import json
from types import SimpleNamespace
from fastapi import HTTPException
import pytest
@@ -117,3 +118,62 @@ def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
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