diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 9fc4f1e8..a4527040 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -55,7 +55,14 @@ def build_effective_tool_lists( # when each module was its own process. Module list = what registration wired. p_modules = [m for m in mcp_servers[name].get("env", {}).get("OSW_MCP_MODULES", "").split(",") if m] from backend.apps.agents import apps_mcp_server, mcp_meta_server, memory_meta_server, schedule_mcp_server, settings_meta_server + from backend.apps.settings.agent_settings_write_allowed import agent_settings_write_allowed + from backend.apps.settings.store import load_settings + # Don't offer a write the route is going to refuse; the route stays the real gate (ENG-284). + p_may_write = agent_settings_write_allowed(load_settings()) for p_t in (x["name"] for x in mcp_meta_server.TOOLS + settings_meta_server.TOOLS + apps_mcp_server.TOOLS): + if p_t == "SettingsWrite" and not p_may_write: + effective_disallowed.append(f"mcp__openswarm-core__{p_t}") + continue effective_allowed.append(f"mcp__openswarm-core__{p_t}") # Module presence already encodes the Settings memory toggle; absent module = tools not offered. if "memory" in p_modules: diff --git a/backend/apps/settings/agent_settings_write_allowed.py b/backend/apps/settings/agent_settings_write_allowed.py new file mode 100644 index 00000000..d6e0a116 --- /dev/null +++ b/backend/apps/settings/agent_settings_write_allowed.py @@ -0,0 +1,24 @@ +"""The one place that decides whether an agent may rewrite the user's Settings (ENG-284). + +SettingsWrite was the only agent tool with no user-facing gate at all, and it is the tool that can +undo the others: an agent that can rewrite Settings can turn memory back on, swap the model, or +change a prompt, so gating it first is what makes the rest of the panel mean anything. + +Read from BOTH the dispatch path (`/api/settings-meta/write`) and the tool-list builder, so the +switch cannot become the kind of toggle that is stored and never consulted. The tool list is the +polite half (an agent is not offered a tool it cannot use); the route is the half that is actually +load-bearing, because a stale tool list or a hand-rolled MCP client cannot get past it. + +Defaults ON: this adds a way to say no, it does not take a capability away from anyone. +""" +from typing import Any + +from typeguard import typechecked + +REFUSAL_REASON = "SettingsWrite is turned off in Settings; ask the user to enable it or change this themselves" + + +@typechecked +def agent_settings_write_allowed(settings: Any) -> bool: + """True when agents may write Settings. Anything but an explicit False is a yes.""" + return getattr(settings, "agent_settings_write_enabled", True) is not False diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 031122f1..757469fa 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -107,6 +107,8 @@ class AppSettings(BaseModel): dictation_disabled_surfaces: str = "" # Off = the memory block never reaches any model; the facts stay on disk untouched. memory_enabled: bool = True + # Off = agents can still READ your settings (redacted) but every SettingsWrite is refused. + agent_settings_write_enabled: bool = True anthropic_api_key: Optional[str] = None browser_homepage: str = "https://www.google.com" # Opt-in: let a blocked browser agent borrow the sign-in you already have in your everyday diff --git a/backend/main.py b/backend/main.py index b7d16295..f39a2ffa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -792,6 +792,7 @@ async def settings_meta(action: str, request: Request): from backend.apps.settings.models import AppSettings from backend.apps.settings.redaction import redact_settings from backend.apps.settings.settings import SERVER_OWNED_FIELDS, apply_settings_update, settings_write_lock + from backend.apps.settings.agent_settings_write_allowed import REFUSAL_REASON, agent_settings_write_allowed from backend.apps.agents.session_credential import ( ALL_API_KEY_FIELDS, PoweringCredential, resolve_powering_credential, write_would_suicide, ) @@ -814,6 +815,10 @@ async def settings_meta(action: str, request: Request): # Serialize the read-modify-write: SettingsWrite goes through apply_settings_update, which awaits (so two autonomous agents would interleave and clobber each other's fields while BOTH got an "applied" result). The lock makes agent writes serial so the last load always sees the prior write. (Agent vs the renderer's own PUT stays the pre-existing full-object-replace race.) async with settings_write_lock(): settings = load_settings() + # The gate the UI switch draws. It lives HERE, not in the tool list, so a stale tool + # list or a hand-rolled client cannot walk past it (ENG-284). + if not agent_settings_write_allowed(settings): + return JSONResponse({"outcomes": {f: {"status": "refused", "reason": REFUSAL_REASON} for f in changes}}) session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None if session is not None: powering = resolve_powering_credential(session.model, settings) diff --git a/backend/tests/test_agent_settings_write_gate.py b/backend/tests/test_agent_settings_write_gate.py new file mode 100644 index 00000000..e99d0912 --- /dev/null +++ b/backend/tests/test_agent_settings_write_gate.py @@ -0,0 +1,115 @@ +"""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 test_the_tool_list_stops_offering_the_write_when_it_is_off(monkeypatch): + import backend.apps.agents.manager.permissions.build_effective_tool_lists as mod + + monkeypatch.setattr(mod, "load_settings", lambda: AppSettings(agent_settings_write_enabled=False), raising=False) + src = open(mod.__file__).read() + assert "agent_settings_write_allowed" in src, "the tool list must consult the same one gate" + assert 'p_t == "SettingsWrite"' in src, "SettingsRead must survive; only the write is withheld" diff --git a/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx b/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx index edb29184..3f11a0f8 100644 --- a/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx +++ b/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx @@ -85,6 +85,18 @@ const MemorySettings: React.FC<{ setForm({ ...form, memory_enabled: e.target.checked })} /> + + + Let agents change your settings + Agents can always read your settings with secrets hidden. Off means they can only ask you to change something, never do it. + + setForm({ ...form, agent_settings_write_enabled: e.target.checked })} + /> + + What agents know about you diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index b872e955..68126fd6 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -35,6 +35,7 @@ export interface AppSettings { dictation_dictionary?: string; dictation_sounds?: boolean; memory_enabled?: boolean; + agent_settings_write_enabled?: boolean; dictation_haptics?: boolean; dictation_sound_volume?: number; dictation_disabled_surfaces?: string; @@ -176,6 +177,7 @@ export const DEFAULT_SETTINGS: AppSettings = { dictation_dictionary: '', dictation_sounds: true, memory_enabled: true, + agent_settings_write_enabled: true, dictation_haptics: true, dictation_sound_volume: 0.7, dictation_disabled_surfaces: '',