Files
OpenMesh/tests/test_mcp_floor.py
zhaolei 6f402ffcee
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
2026-09-13 23:41:04 +08:00

192 lines
8.0 KiB
Python

"""The MCP floor (OPE-136): no config value may drop an `mcp__*` tool below EXTERNAL.
Before this floor, `requires_approval: false` in mcp.json reclassified a whole server's
tools to READ — which skipped not just the approval card but the Discuss-mode denial, the
Auto-approve reviewer, and the audit trail, in one config line. The flag now only ever
waives the CARD (the gate's trusted-MCP branch); the class is welded on.
Pinned the same way OPE-111's catalog floor is pinned: these tests are the invariant.
"""
from __future__ import annotations
import json
import pytest
from coworker.permissions import Mode, PermissionEngine
from coworker.risk import RiskClass, classify
class Meta:
"""The shape mcp/tools.py stamps on every MCP callable."""
def __init__(self, requires_approval: bool, category: str = "mcp") -> None:
self.requires_approval = requires_approval
self.category = category
# -- the pinned invariant ---------------------------------------------------------
# No config value, override rule, or metadata may classify an mcp-category tool below
# EXTERNAL. If a refactor reopens the trapdoor, this is the test that goes red.
@pytest.mark.parametrize(
"metadata",
[Meta(True), Meta(False), None],
ids=["flag_true", "flag_false_the_trapdoor", "no_metadata_fails_closed"],
)
def test_mcp_tools_never_classify_below_external(metadata):
assert classify("mcp__anyserver__anytool", metadata) is RiskClass.EXTERNAL
def test_a_loosening_override_cannot_beat_the_floor():
# Even a rule the loader somehow let through (e.g. a generic glob) is neutralized
# by classify's tighten-only comparison.
assert (
classify("mcp__srv__tool", Meta(False), overrides=lambda name: RiskClass.READ)
is RiskClass.EXTERNAL
)
def test_a_tightening_override_still_works():
assert (
classify("mcp__srv__tool", Meta(True), overrides=lambda name: RiskClass.EXEC)
is RiskClass.EXEC
)
# -- the floor is keyed on the mcp family, nothing wider --------------------------
def test_non_mcp_metadata_tools_keep_the_relaxed_path():
# A plugin/aisuite tool that declares itself approval-free is still READ — the
# floor must not quietly tighten every third-party tool in the app.
assert classify("plugin_notes_search", Meta(False, category="plugin")) is RiskClass.READ
def test_catalog_backed_connector_reads_stay_free():
# The §42 one-click connectors share the mcp__ naming but are relabeled
# category="connector" at wiring (server/manager.py) because the catalog pins
# their kinds first-hand — §36's "connector reads never gate" keeps applying.
assert (
classify("mcp__jira__getJiraIssue", Meta(False, category="connector"))
is RiskClass.READ
)
# Catalog WRITES keep the OPE-111 floor regardless of the flag.
assert (
classify("mcp__jira__createJiraIssue", Meta(False, category="connector"))
is RiskClass.EXTERNAL
)
def test_the_reverse_name_collision_is_closed():
# A CUSTOM server that reuses a catalog read name (server nicknamed "jira") keeps
# category "mcp" and lands on the floor — it does not inherit the catalog's
# first-party read verdict.
assert classify("mcp__jira__getJiraIssue", Meta(False)) is RiskClass.EXTERNAL
def test_the_name_coincidence_no_longer_matters():
# Pre-floor, mcp__atlassian__createJiraIssue (unknown to the catalog) dropped to
# READ on flag false while its mcp__jira__ twin survived via catalog string luck.
assert classify("mcp__atlassian__createJiraIssue", Meta(False)) is RiskClass.EXTERNAL
# -- the override loader refuses what classify would silently ignore ---------------
def test_explicit_mcp_loosening_rules_are_rejected_at_load(tmp_path):
from coworker.overrides import RiskOverrideStore
path = tmp_path / "risk_overrides.json"
path.write_text(
json.dumps(
{
"rules": [
{"pattern": "mcp__notion__*", "risk": "read"}, # refused
{"pattern": "mcp__srv__tool", "risk": "exec"}, # tighten: fine
{"pattern": "plugin_*", "risk": "read"}, # non-MCP: fine
]
}
),
encoding="utf-8",
)
store = RiskOverrideStore(path)
assert store.resolve("mcp__notion__get_page") is None
assert store.resolve("mcp__srv__tool") is RiskClass.EXEC
assert store.resolve("plugin_notes") is RiskClass.READ
assert len(store.rejected) == 1
pattern, reason = store.rejected[0]
assert pattern == "mcp__notion__*"
assert "trust rule" in reason # points at the sanctioned alternative
# -- the mode matrix: what the flag now means at the gate --------------------------
# requires_approval:false = legacy TRUST: waives the card in Ask-for-approval, and
# NOTHING else. One test per cell of the behavior table.
def engine(mode: Mode, tmp_path) -> PermissionEngine:
return PermissionEngine(workspace_root=tmp_path, mode=mode)
def test_matrix_discuss_denies_trusted_mcp(tmp_path):
d = engine(Mode.DISCUSS, tmp_path).evaluate("mcp__jira__createIssue", {}, Meta(False))
assert not d.allowed and not d.needs_user # denied outright, no card offered
def test_matrix_plan_denies_trusted_mcp(tmp_path):
d = engine(Mode.PLAN, tmp_path).evaluate("mcp__jira__createIssue", {}, Meta(False))
assert not d.allowed
def test_matrix_ask_mode_waives_the_card_for_trusted_mcp(tmp_path):
d = engine(Mode.INTERACTIVE, tmp_path).evaluate(
"mcp__jira__createIssue", {}, Meta(False)
)
assert d.allowed
assert d.reason == "trusted MCP tool (server marked don't-ask)"
def test_matrix_ask_mode_still_asks_on_the_default_flag(tmp_path):
d = engine(Mode.INTERACTIVE, tmp_path).evaluate(
"mcp__jira__createIssue", {}, Meta(True)
)
assert not d.allowed and d.needs_user and not d.human_only # reviewer-eligible ask
def test_matrix_auto_approve_routes_trusted_mcp_to_the_reviewer(tmp_path):
# v1 keeps §1.5 conservative: server-config trust does not skip the judge. The
# decision falls through to needs_user, which is exactly what the engine hands to
# the reviewer (and never human_only — the reviewer MAY judge it).
d = engine(Mode.AUTO_APPROVE, tmp_path).evaluate(
"mcp__jira__createIssue", {}, Meta(False)
)
assert not d.allowed and d.needs_user and not d.human_only
def test_matrix_bypass_runs_trusted_and_untrusted_alike(tmp_path):
e = engine(Mode.BYPASS_APPROVALS, tmp_path)
assert e.evaluate("mcp__jira__createIssue", {}, Meta(False)).allowed
assert e.evaluate("mcp__jira__createIssue", {}, Meta(True)).allowed
# -- the gate-order invariant ------------------------------------------------------
# Everything ABOVE the bypass branch in evaluate() survives Bypass mode; read-only
# modes deny before the human-only floors get a say. Line order IS the floor
# hierarchy — this pins it.
def test_gate_order_persistent_authority_survives_bypass(tmp_path):
d = engine(Mode.BYPASS_APPROVALS, tmp_path).evaluate("save_skill", {"name": "x"}, None)
assert not d.allowed and d.needs_user and d.human_only
def test_gate_order_write_fence_survives_bypass(tmp_path):
# tmp_path.parent is absolute and out-of-root on every OS; a literal "C:/..." is
# only absolute on Windows — POSIX reads it as a relative dir named "C:", which
# _candidate() then resolves INTO the workspace root.
outside = str(tmp_path.parent / "outside" / "anywhere.txt")
d = engine(Mode.BYPASS_APPROVALS, tmp_path).evaluate(
"write_file", {"path": outside, "content": "x"}, None
)
assert not d.allowed # refused, not asked — the fence has no mode switch
def test_gate_order_read_only_mode_beats_the_human_only_floor(tmp_path):
# In Discuss there is nothing to grant, so no card — the read-only denial fires
# before the persistent-authority branch ("Read-only modes still hard-deny above
# this", permissions.py).
d = engine(Mode.DISCUSS, tmp_path).evaluate("save_skill", {"name": "x"}, None)
assert not d.allowed and not d.needs_user