mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
151 lines
6.5 KiB
Python
151 lines
6.5 KiB
Python
"""'Always approve' persistence invariant.
|
|
|
|
The bug: the dispatch gate READ a tool's policy from one slot while the
|
|
'Always approve' button WROTE it to another (the raw mcp__server__action name
|
|
in builtin_permissions vs the parsed inner action on the owning tool), so the
|
|
next call never saw the policy and the button behaved like a one-time accept.
|
|
|
|
The seal: both sides now resolve the slot through resolve_policy_slot(), so a
|
|
WRITE always lands where the READ looks. These tests pin that for every
|
|
tool-name shape, including the round-trip that the old code failed.
|
|
"""
|
|
|
|
from backend.apps.tools_lib.tools_lib import resolve_policy_slot, PolicySlot
|
|
from backend.apps.tools_lib.mcp_config import sanitize_server_name
|
|
from backend.apps.tools_lib.models import ToolDefinition
|
|
|
|
|
|
def p_mcp_tool(name: str) -> ToolDefinition:
|
|
return ToolDefinition(name=name, mcp_config={"command": "x"}, enabled=True, tool_permissions={})
|
|
|
|
|
|
def test_slot_for_builtin_tool():
|
|
assert resolve_policy_slot("Bash", []) == PolicySlot("builtin", "Bash", None)
|
|
assert resolve_policy_slot("Read", []) == PolicySlot("builtin", "Read", None)
|
|
|
|
|
|
def test_slot_for_our_browser_and_invoke_agents_uses_inner_name():
|
|
# These live in builtin_permissions under the INNER name, not the namespaced one.
|
|
assert resolve_policy_slot("mcp__openswarm-core__BrowserAgent", []) == \
|
|
PolicySlot("builtin", "BrowserAgent", None)
|
|
assert resolve_policy_slot("mcp__openswarm-core__InvokeAgent", []) == \
|
|
PolicySlot("builtin", "InvokeAgent", None)
|
|
|
|
|
|
def test_slot_for_community_mcp_points_at_the_owning_tool():
|
|
tool = p_mcp_tool("My Notion Server")
|
|
slug = sanitize_server_name(tool.name)
|
|
assert resolve_policy_slot(f"mcp__{slug}__notion-fetch", [tool]) == \
|
|
PolicySlot("mcp", tool.id, "notion-fetch")
|
|
|
|
|
|
def test_slot_for_unknown_mcp_has_no_write_target():
|
|
assert resolve_policy_slot("mcp__ghostserver__do-thing", []) == \
|
|
PolicySlot("mcp", None, "do-thing")
|
|
|
|
|
|
# read/write mirror the dispatch-gate branches in agent_manager (effective_policy / set_tool_policy): both key through resolve_policy_slot.
|
|
def p_read(tool_name, builtin_perms, tools):
|
|
slot = resolve_policy_slot(tool_name, tools)
|
|
if slot.store == "builtin":
|
|
return builtin_perms.get(slot.key, "ask")
|
|
if slot.key is not None:
|
|
for t in tools:
|
|
if t.id == slot.key:
|
|
return t.tool_permissions.get(slot.action, "ask")
|
|
return "ask"
|
|
|
|
|
|
def p_write(tool_name, policy, builtin_perms, tools):
|
|
slot = resolve_policy_slot(tool_name, tools)
|
|
if slot.store == "builtin":
|
|
builtin_perms[slot.key] = policy
|
|
return
|
|
if slot.key is not None:
|
|
for t in tools:
|
|
if t.id == slot.key:
|
|
t.tool_permissions[slot.action] = policy
|
|
return
|
|
|
|
|
|
def test_always_approve_round_trips_for_every_tool_shape():
|
|
"""The invariant the old code violated: after WRITE(always_allow), the very
|
|
next READ returns always_allow, for builtin, our agents, and community MCP."""
|
|
notion = p_mcp_tool("Notion")
|
|
slug = sanitize_server_name("Notion")
|
|
tools = [notion]
|
|
builtin_perms: dict[str, str] = {}
|
|
|
|
shapes = [
|
|
"Bash",
|
|
"Read",
|
|
"mcp__openswarm-core__BrowserAgent",
|
|
"mcp__openswarm-core__InvokeAgent",
|
|
f"mcp__{slug}__notion-fetch",
|
|
]
|
|
for tool_name in shapes:
|
|
assert p_read(tool_name, builtin_perms, tools) != "always_allow"
|
|
p_write(tool_name, "always_allow", builtin_perms, tools)
|
|
assert p_read(tool_name, builtin_perms, tools) == "always_allow", \
|
|
f"{tool_name}: write did not land in the slot the gate reads"
|
|
|
|
|
|
def test_two_actions_on_the_same_mcp_server_are_independent():
|
|
"""Approving one action must not silently approve a sibling action."""
|
|
tool = p_mcp_tool("Notion")
|
|
slug = sanitize_server_name("Notion")
|
|
tools = [tool]
|
|
bp: dict[str, str] = {}
|
|
p_write(f"mcp__{slug}__notion-fetch", "always_allow", bp, tools)
|
|
assert p_read(f"mcp__{slug}__notion-fetch", bp, tools) == "always_allow"
|
|
assert p_read(f"mcp__{slug}__notion-create-pages", bp, tools) == "ask"
|
|
|
|
|
|
# ---- Integration: the same round-trip through the REAL file persistence the gate uses (load_builtin_permissions / _save / _load_all), so 'write then re-read' survives a save+reload, not just an in-memory dict. ----
|
|
import backend.apps.tools_lib.tools_lib as tl
|
|
|
|
|
|
def test_builtin_policy_survives_a_real_file_reload(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tl, "BUILTIN_PERMS_PATH", str(tmp_path / "builtin_permissions.json"))
|
|
slot = tl.resolve_policy_slot("Read", [])
|
|
perms = tl.load_builtin_permissions()
|
|
perms[slot.key] = "always_allow"
|
|
tl.save_builtin_permissions(perms)
|
|
# Fresh read (what the next session / a reload does) finds it at the read key.
|
|
reloaded = tl.load_builtin_permissions()
|
|
assert reloaded.get(tl.resolve_policy_slot("Read", []).key) == "always_allow"
|
|
|
|
|
|
def test_mcp_policy_survives_a_real_tool_file_reload(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tl, "DATA_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tl, "p_tools_cache", None)
|
|
monkeypatch.setattr(tl, "p_tools_cache_sig", None)
|
|
tl.save(p_mcp_tool("Notion"))
|
|
slug = sanitize_server_name("Notion")
|
|
name = f"mcp__{slug}__notion-fetch"
|
|
|
|
# WRITE via the resolver against the freshly loaded tool, then persist.
|
|
tools = tl.load_all_tools()
|
|
slot = tl.resolve_policy_slot(name, tools)
|
|
target = next(t for t in tools if t.id == slot.key)
|
|
target.tool_permissions[slot.action] = "always_allow"
|
|
tl.save(target)
|
|
|
|
# RELOAD from disk and read via the resolver: the policy is there.
|
|
tools2 = tl.load_all_tools()
|
|
rslot = tl.resolve_policy_slot(name, tools2)
|
|
got = next(t for t in tools2 if t.id == rslot.key)
|
|
assert got.tool_permissions.get(rslot.action) == "always_allow"
|
|
|
|
|
|
def test_legacy_precollapse_ids_canonicalize_to_core():
|
|
# A 1.7.5 session/log can still name the old per-server ids; they must land in the SAME builtin slot as their openswarm-core successors, or a remembered approval silently stops matching after the upgrade.
|
|
for legacy, inner in (
|
|
("mcp__openswarm-browser-agent__BrowserAgent", "BrowserAgent"),
|
|
("mcp__openswarm-web__WebSearch", "WebSearch"),
|
|
("mcp__openswarm-settings-meta__SettingsWrite", "SettingsWrite"),
|
|
("mcp__openswarm-schedule__ScheduleWorkflow", "ScheduleWorkflow"),
|
|
):
|
|
assert resolve_policy_slot(legacy, []) == resolve_policy_slot(f"mcp__openswarm-core__{inner}", [])
|
|
assert resolve_policy_slot(legacy, []).key == inner
|