[eric] settings: SettingsWrite gets a real switch, refused at the route so the toggle is a boundary not a label (ENG-284)

This commit is contained in:
ciregenz
2026-08-14 23:13:12 -07:00
parent 1821d3a612
commit 47ee5d72d5
7 changed files with 167 additions and 0 deletions
@@ -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:
@@ -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
+2
View File
@@ -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
+5
View File
@@ -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)
@@ -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"
@@ -85,6 +85,18 @@ const MemorySettings: React.FC<{
<Switch size="small" checked={form.memory_enabled !== false} onChange={(e) => setForm({ ...form, memory_enabled: e.target.checked })} />
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('agent_settings_write_enabled', 'Let agents change your settings', 'Interface', 'Whether agents may edit Settings themselves.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Let agents change your settings</Typography>
<Typography sx={descSx}>Agents can always read your settings with secrets hidden. Off means they can only ask you to change something, never do it.</Typography>
</Box>
<Switch
size="small"
checked={form.agent_settings_write_enabled !== false}
onChange={(e) => setForm({ ...form, agent_settings_write_enabled: e.target.checked })}
/>
</Box>
<Box sx={{ ...inlineRowSx, alignItems: 'flex-start', flexDirection: 'column', gap: 1.25 }}>
<Box>
<Typography sx={labelSx}>What agents know about you</Typography>
@@ -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: '',