Files
openswarm/backend/tests/test_agent_settings_write_gate.py

135 lines
5.6 KiB
Python

"""The SettingsWrite gate is enforced by dispatch, not just drawn in Settings (ENG-284).
SettingsWrite was the one agent tool with no user-facing gate, and it is the tool that can undo the
others. The rule this file exists to enforce is the one from CLAUDE.md: a permission toggle no
dispatch code reads is worse than no toggle, because it sells a boundary that is not there. So the
interesting test is not "the switch stores a bool", it is "a client that ignores the tool list and
posts straight to the route is still refused".
"""
import pytest
from fastapi.testclient import TestClient
from backend.apps.settings.agent_settings_write_allowed import REFUSAL_REASON, agent_settings_write_allowed
from backend.apps.settings.models import AppSettings
def test_defaults_on_so_nobody_loses_a_capability():
assert agent_settings_write_allowed(AppSettings()) is True
assert AppSettings().agent_settings_write_enabled is True
def test_only_an_explicit_false_turns_it_off():
assert agent_settings_write_allowed(AppSettings(agent_settings_write_enabled=False)) is False
assert agent_settings_write_allowed(AppSettings(agent_settings_write_enabled=True)) is True
def test_a_settings_object_from_an_older_install_still_reads_as_allowed():
class Old:
pass
assert agent_settings_write_allowed(Old()) is True
@pytest.fixture()
def p_client():
import secrets
import backend.auth as auth_mod
from backend.main import app
if not auth_mod.TOKEN:
auth_mod.TOKEN = secrets.token_urlsafe(32)
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
@pytest.fixture(autouse=True)
def p_restore_settings():
from backend.apps.settings.settings import load_settings, save_settings
original = load_settings().model_copy(deep=True)
yield
save_settings(original)
def p_write(client, changes):
return client.post("/api/settings-meta/write", json={"changes": changes, "parent_session_id": ""})
def p_set_gate(monkeypatch, allowed: bool):
import backend.main as main_module
monkeypatch.setattr(
"backend.apps.settings.agent_settings_write_allowed.agent_settings_write_allowed",
lambda s: allowed,
)
assert main_module is not None
def test_the_route_refuses_every_field_when_the_gate_is_off(p_client, monkeypatch):
# The forced bypass: a client that never saw the tool list, posting directly. This is the only
# wall that matters, because the tool list is advice and the route is enforcement.
p_set_gate(monkeypatch, False)
res = p_write(p_client, {"theme": "light", "memory_enabled": False})
assert res.status_code == 200
outcomes = res.json()["outcomes"]
assert set(outcomes) == {"theme", "memory_enabled"}
for field, outcome in outcomes.items():
assert outcome["status"] == "refused", f"{field} was not refused"
assert outcome["reason"] == REFUSAL_REASON
def test_nothing_is_written_to_disk_when_the_gate_is_off(p_client, monkeypatch):
from backend.apps.settings.store import load_settings
before = load_settings().theme
p_set_gate(monkeypatch, False)
p_write(p_client, {"theme": "light" if before != "light" else "dark"})
assert load_settings().theme == before, "a refused write must not reach the settings file"
def test_the_gate_open_still_applies_a_write(p_client, monkeypatch):
# Both directions: a gate that refuses everything would pass the test above and be useless.
from backend.apps.settings.store import load_settings
p_set_gate(monkeypatch, True)
res = p_write(p_client, {"memory_enabled": False})
assert res.status_code == 200
assert res.json()["outcomes"]["memory_enabled"]["status"] == "applied"
assert load_settings().memory_enabled is False
def test_read_is_never_gated(p_client, monkeypatch):
# Reading is how an agent answers "what is your theme"; the gate is about changing things.
p_set_gate(monkeypatch, False)
res = p_client.post("/api/settings-meta/read", json={"parent_session_id": ""})
assert res.status_code == 200
assert "settings" in res.json()
def p_tool_lists(monkeypatch, write_enabled: bool):
"""The real builder, driven with a core server carrying the always-on modules."""
import backend.apps.settings.store as store
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.permissions.build_effective_tool_lists import build_effective_tool_lists
# The builder imports load_settings from the store at call time, so patching the module is what
# a real Settings change looks like from its point of view.
monkeypatch.setattr(store, "load_settings", lambda: AppSettings(agent_settings_write_enabled=write_enabled))
session = AgentSession(id="tool-list-test", name="t", model="opus-4-8")
servers = {"openswarm-core": {"env": {"OSW_MCP_MODULES": "meta,settings,apps"}}}
return build_effective_tool_lists(session, servers, {}, False, [], [])
def test_the_tool_list_stops_offering_the_write_when_it_is_off(monkeypatch):
allowed, disallowed = p_tool_lists(monkeypatch, write_enabled=False)
assert "mcp__openswarm-core__SettingsWrite" not in allowed
assert "mcp__openswarm-core__SettingsWrite" in disallowed
# SettingsRead survives: reading redacted settings is how an agent answers without changing anything.
assert "mcp__openswarm-core__SettingsRead" in allowed
def test_the_tool_list_offers_the_write_when_it_is_on(monkeypatch):
allowed, disallowed = p_tool_lists(monkeypatch, write_enabled=True)
assert "mcp__openswarm-core__SettingsWrite" in allowed
assert "mcp__openswarm-core__SettingsWrite" not in disallowed