mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 05:17:09 +00:00
Backend core of the MCP trust redesign:
- Risk floor: every mcp__ tool classifies EXTERNAL, always — category-keyed,
fails closed on missing metadata; user overrides may only tighten (loader
and writer both refuse loosening rules, pointing at trust rules instead).
Closes the name-collision path where a server named like a catalog
connector inherited softer classification (and the reverse, via the
backed-connector category relabel at wiring).
- Durable per-tool trust replaces the server-wide requires_approval:false
trapdoor: "Always allow this tool" writes a named, visible, revocable rule
to the user-local override store; the legacy flag stays honored but gets a
one-click migration to per-tool rules. Server-side validated in
_grant_offered — cards cannot mint what the server won't honor (the
always_trust outcome also joins the approval_outcome validation tuple).
- Remove server revokes its trust rules (exact names and server-scoped
globs): they lived in a separate store keyed by name, so a future server
added under the same name inherited don't-ask rules sight unseen.
Sign-out deliberately keeps trust — tokens only.
- Run grants ("Allow for this request"): ApprovalOutcome.THIS_RUN covers the
exact tool for the remainder of the current run — in-memory, cleared at
the run boundary (finish, interrupt, teardown) plus a fresh-run belt;
never skips the auto-approve reviewer; offered for EXTERNAL risk only
(EXEC keeps command-scoped grants, EGRESS its domain grant). Covered
calls carry a run_grant transcript origin and auto_allowed audit rows.
- Honest evidence plumbing: PermissionRequest carries the MCP destination
(stamped from the user's own server config at registration, never from
server claims); parked approvals store category/destination/reason so a
redelivered card shows the live card's evidence; approval bodies stop
baking in the "requires approval" boilerplate.
- Connect-time tool review backend: include_tools is an include-list
(unchecked tools are never registered — fail-closed growth), and
exclude_tools records explicit declines so "new" can only ever mean the
server's menu grew.
- Trusted-origin split: the engine distinguishes a user's trust rule from
the legacy server flag so the transcript chip can name the right source.
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""OPE-136 §3 — the existence lever: `include_tools` decides which of a server's tools
|
|
are REGISTERED at all. An unchecked tool is not blocked — it is absent: the model never
|
|
sees its name or schema, so there is nothing to invoke, trick, or approve. And because
|
|
include_tools is an include-list, tools a server ships later are excluded until the
|
|
user opts them in (fail-closed growth).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from coworker.mcp.config import MCPServerDef
|
|
from coworker.mcp.tools import build_callables
|
|
from coworker.tools.registry import ToolRegistry
|
|
|
|
|
|
def _tool(name: str) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
name=name,
|
|
description=f"vendor description of {name}",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
)
|
|
|
|
|
|
def _build(server: MCPServerDef, tool_names: list[str]):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return build_callables(
|
|
server, [_tool(n) for n in tool_names], lambda t, a: None, loop
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
OFFERED = ["getIssue", "createIssue", "deleteIssue"]
|
|
|
|
|
|
def test_unchecked_tools_are_absent_from_the_registry():
|
|
server = MCPServerDef(
|
|
name="jirax",
|
|
transport="http",
|
|
url="https://mcp.example.com/v1/mcp",
|
|
include_tools=["getIssue", "createIssue"], # deleteIssue unchecked
|
|
)
|
|
registry = ToolRegistry()
|
|
registry.register_all(_build(server, OFFERED))
|
|
assert registry.names() == ["mcp__jirax__getIssue", "mcp__jirax__createIssue"]
|
|
# Absent means absent: no schema for the model, nothing to execute.
|
|
assert registry.get("mcp__jirax__deleteIssue") is None
|
|
|
|
|
|
def test_no_include_list_means_everything_registers():
|
|
server = MCPServerDef(
|
|
name="jirax", transport="http", url="https://mcp.example.com/v1/mcp"
|
|
)
|
|
assert len(_build(server, OFFERED)) == 3
|
|
|
|
|
|
def test_fail_closed_growth_a_new_server_tool_stays_out():
|
|
# The user reviewed and saved [getIssue, createIssue]; a later handshake ships a
|
|
# brand-new adminPurge. It must not register until the user opts in.
|
|
server = MCPServerDef(
|
|
name="jirax",
|
|
transport="http",
|
|
url="https://mcp.example.com/v1/mcp",
|
|
include_tools=["getIssue", "createIssue"],
|
|
)
|
|
grown = OFFERED + ["adminPurge"]
|
|
names = [fn.__name__ for fn in _build(server, grown)]
|
|
assert "mcp__jirax__adminPurge" not in names
|
|
assert names == ["mcp__jirax__getIssue", "mcp__jirax__createIssue"]
|
|
|
|
|
|
def test_destination_stamp_travels_with_every_callable():
|
|
# OPE-136 finding 4 plumbing: the card's scope chip reads this — from the user's
|
|
# own server config, never the server's claims.
|
|
http_server = MCPServerDef(
|
|
name="jirax", transport="http", url="https://MCP.Example.com/v1/mcp"
|
|
)
|
|
(fn, *_) = _build(http_server, ["getIssue"])
|
|
assert fn.__coworker_mcp_destination__ == {
|
|
"transport": "http",
|
|
"host": "mcp.example.com", # lowercased
|
|
}
|
|
stdio_server = MCPServerDef(name="localfs", transport="stdio", command="npx")
|
|
(fn2,) = _build(stdio_server, ["read"])
|
|
assert fn2.__coworker_mcp_destination__ == {"transport": "stdio", "host": ""}
|