fix(extension): 完成MCP命令目标并收紧Schema边界

This commit is contained in:
2026-09-02 14:52:03 +08:00
parent c1bac00d12
commit cff38158f6
14 changed files with 403 additions and 28 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 107 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 116 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
+1 -1
View File
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run pytest uv run pytest
``` ```
当前基线为 107 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 当前基线为 116 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+2
View File
@@ -11,6 +11,7 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
from app.contracts import ToolCall, ToolDefinition, ToolResult from app.contracts import ToolCall, ToolDefinition, ToolResult
from app.schema_security import reject_external_schema_references
ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]] ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]]
@@ -54,6 +55,7 @@ class ToolRegistry:
arguments_model: type[BaseModel], arguments_model: type[BaseModel],
executor: ToolExecutor, executor: ToolExecutor,
) -> None: ) -> None:
reject_external_schema_references(definition.parameters)
with self._lock: with self._lock:
if definition.name in self._tools: if definition.name in self._tools:
raise ValueError(f"Tool already registered: {definition.name}") raise ValueError(f"Tool already registered: {definition.name}")
+20 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import inspect import inspect
import json import json
import math import math
@@ -17,7 +18,7 @@ from typing import Any, Awaitable, Callable, Literal
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings from app.config import get_settings
from app.contracts import ( from app.contracts import (
@@ -34,6 +35,10 @@ from app.contracts import (
) )
from app.extensions.errors import ExtensionError from app.extensions.errors import ExtensionError
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") _CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$") _SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
@@ -74,9 +79,16 @@ class PluginCommandSpec(BaseModel):
) )
permission: str | None = None permission: str | None = None
secrets: list[str] = Field(default_factory=list) secrets: list[str] = Field(default_factory=list)
handler: Literal["echo", "uppercase_selection"] handler: Literal["echo", "uppercase_selection"] | None = None
mcp_tool: str | None = None
timeout_seconds: int = Field(default=30, ge=1, le=120) timeout_seconds: int = Field(default=30, ge=1, le=120)
@model_validator(mode="after")
def validate_execution_target(self) -> "PluginCommandSpec":
if (self.handler is None) == (self.mcp_tool is None):
raise ValueError("Command must declare exactly one handler or mcp_tool target.")
return self
CommandExecutor = Callable[ CommandExecutor = Callable[
[dict[str, Any], dict[str, Any]], [dict[str, Any], dict[str, Any]],
@@ -689,11 +701,13 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
if spec.parameters.get("type", "object") != "object": if spec.parameters.get("type", "object") != "object":
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.") raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
try: try:
reject_external_schema_references(spec.parameters)
Draft202012Validator.check_schema(spec.parameters) Draft202012Validator.check_schema(spec.parameters)
except SchemaError as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError( raise ExtensionError(
"PLUGIN_COMMAND_INVALID", "PLUGIN_COMMAND_INVALID",
f"Plugin command parameters contain invalid JSON Schema: {exc.message}", f"Plugin command parameters contain invalid JSON Schema: {message}",
) from exc ) from exc
@@ -746,7 +760,8 @@ def _secret_field(
def _secret_reference(plugin_id: str, key: str) -> str: def _secret_reference(plugin_id: str, key: str) -> str:
return f"plugin.{plugin_id}.{key}" digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest()
return f"plugin.{digest}"
def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError: def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError:
+8 -2
View File
@@ -29,6 +29,10 @@ from app.contracts import (
PluginHostStatus, PluginHostStatus,
ToolDefinition, ToolDefinition,
) )
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
MCP_PROTOCOL_VERSION = "2025-11-25" MCP_PROTOCOL_VERSION = "2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS = { SUPPORTED_PROTOCOL_VERSIONS = {
@@ -676,11 +680,13 @@ class McpBridge:
f"MCP tool inputSchema must be an object schema: {remote_name}", f"MCP tool inputSchema must be an object schema: {remote_name}",
) )
try: try:
reject_external_schema_references(schema)
Draft202012Validator.check_schema(schema) Draft202012Validator.check_schema(schema)
except SchemaError as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise McpBridgeError( raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP_TOOL_SCHEMA_INVALID",
f"Invalid MCP tool schema for {remote_name}: {exc.message}", f"Invalid MCP tool schema for {remote_name}: {message}",
) from exc ) from exc
metadata = raw.get("_meta") metadata = raw.get("_meta")
permission = ( permission = (
+89 -7
View File
@@ -5,13 +5,14 @@ import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from uuid import uuid4
import yaml import yaml
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError from jsonschema.exceptions import SchemaError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
from app.agent.tools import ToolExecutionContext, ToolRegistry from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS from app.agent.permissions import KNOWN_PERMISSIONS
from app.contracts import ( from app.contracts import (
ModelCapability, ModelCapability,
@@ -45,6 +46,10 @@ from app.extensions.contributions import (
from app.extensions.errors import ExtensionError from app.extensions.errors import ExtensionError
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
from app.providers.credentials import EncryptedCredentialStore from app.providers.credentials import EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") _EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
@@ -410,6 +415,22 @@ class PluginRuntime:
"Commands using Secret settings require the secrets.use permission.", "Commands using Secret settings require the secrets.use permission.",
details={"command_id": spec.command_id}, details={"command_id": spec.command_id},
) )
if spec.mcp_tool is not None:
_validate_id("MCP command target", spec.mcp_tool)
if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith(
f"{manifest.plugin_id}."
):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target must use the current Plugin namespace.",
details={"command_id": spec.command_id},
)
if spec.mcp_tool in manifest.contributes.tools:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target cannot also be exposed as an Agent Tool.",
details={"command_id": spec.command_id},
)
record = _PluginRecord( record = _PluginRecord(
plugin=Plugin( plugin=Plugin(
@@ -492,14 +513,21 @@ class PluginRuntime:
discovered = self._start_mcp(record) discovered = self._start_mcp(record)
actual = {item.definition.name for item in discovered} actual = {item.definition.name for item in discovered}
declared = set(declared_tools) declared = set(declared_tools)
if actual != declared: command_targets = {
spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None
}
expected = declared | command_targets
if actual != expected:
raise ExtensionError( raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID", "PLUGIN_CONTRIBUTION_INVALID",
"Discovered MCP tools must exactly match Plugin contributions.", "Discovered MCP tools must exactly match Tool and Command targets.",
details={"declared": sorted(declared), "actual": sorted(actual)}, details={"declared": sorted(expected), "actual": sorted(actual)},
) )
for item in discovered: for item in discovered:
if item.definition.name in declared:
self._register_mcp_tool(record, item) self._register_mcp_tool(record, item)
else:
record.mcp_remote_names[item.definition.name] = item.remote_name
else: else:
for spec in record.tools: for spec in record.tools:
arguments_model = _arguments_model(spec) arguments_model = _arguments_model(spec)
@@ -549,6 +577,7 @@ class PluginRuntime:
if _record.settings_definition is not None if _record.settings_definition is not None
else {} else {}
) )
def resolve_secret(key: str) -> str | None: def resolve_secret(key: str) -> str | None:
if key not in _spec.secrets: if key not in _spec.secrets:
raise ExtensionError( raise ExtensionError(
@@ -569,11 +598,62 @@ class PluginRuntime:
) )
if _record.settings_definition is None: if _record.settings_definition is None:
return None return None
return self.settings.resolve_secret( value = self.settings.resolve_secret(
_record.plugin.manifest.plugin_id, _record.plugin.manifest.plugin_id,
_record.settings_definition, _record.settings_definition,
key, key,
) )
field = next(
item
for item in _record.settings_definition.fields
if item.key == key
)
if field.required and value is None:
raise ExtensionError(
"PLUGIN_SECRET_REQUIRED",
"A required Plugin Secret has not been configured.",
status_code=409,
details={"command_id": _spec.command_id, "key": key},
)
return value
if _spec.mcp_tool is not None:
remote_name = _record.mcp_remote_names[_spec.mcp_tool]
secret_values = {
key: value
for key in _spec.secrets
if (value := resolve_secret(key)) is not None
}
try:
effect = await self.mcp.call_tool(
_record.plugin.manifest.plugin_id,
remote_name,
{
"_notesagent": {
"command_id": _spec.command_id,
"arguments": arguments,
"context": context,
"secrets": secret_values,
}
},
request_id=f"command:{uuid4().hex}",
)
except ToolExecutionError as exc:
raise ExtensionError(
exc.code,
"MCP Command target execution failed.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
try:
return PluginCommandEffect.model_validate(effect)
except ValidationError as exc:
raise ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"MCP Command target returned an invalid effect.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
return await self.host.execute_command( return await self.host.execute_command(
_spec.handler, _spec.handler,
@@ -963,11 +1043,13 @@ def _arguments_model_from_schema(
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
schema = spec.parameters or {"type": "object", "properties": {}} schema = spec.parameters or {"type": "object", "properties": {}}
try: try:
reject_external_schema_references(schema)
Draft202012Validator.check_schema(schema) Draft202012Validator.check_schema(schema)
except SchemaError as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError( raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID", "PLUGIN_TOOL_SCHEMA_INVALID",
f"Invalid JSON Schema for tool {spec.name}: {exc.message}", f"Invalid JSON Schema for tool {spec.name}: {message}",
details={"tool": spec.name}, details={"tool": spec.name},
) from exc ) from exc
if schema.get("type", "object") != "object" or not isinstance( if schema.get("type", "object") != "object" or not isinstance(
+70
View File
@@ -0,0 +1,70 @@
"""共享 JSON Schema 安全约束。"""
from __future__ import annotations
from typing import Any
from urllib.parse import unquote
class SchemaReferenceError(ValueError):
"""Schema 引用不符合宿主的离线、文档内解析约束。"""
class ExternalSchemaReferenceError(SchemaReferenceError):
def __init__(self, keyword: str, reference: Any) -> None:
super().__init__(f"External JSON Schema reference is not allowed: {reference!r}")
self.keyword = keyword
self.reference = reference
class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
def __init__(self, reference: str) -> None:
super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}")
self.reference = reference
def reject_external_schema_references(schema: Any) -> None:
"""只允许可解析的文档内 Fragment,禁止文件和网络检索。"""
pending = [schema]
local_references: list[str] = []
anchors: set[str] = set()
while pending:
value = pending.pop()
if isinstance(value, dict):
for key, child in value.items():
if key in {"$ref", "$dynamicRef"}:
if not isinstance(child, str) or not child.startswith("#"):
raise ExternalSchemaReferenceError(key, child)
local_references.append(child)
elif key in {"$anchor", "$dynamicAnchor"} and isinstance(child, str):
anchors.add(child)
pending.append(child)
elif isinstance(value, list):
pending.extend(value)
for reference in local_references:
if not _local_reference_exists(schema, reference, anchors):
raise UnresolvableLocalSchemaReferenceError(reference)
def _local_reference_exists(schema: Any, reference: str, anchors: set[str]) -> bool:
fragment = unquote(reference[1:])
if not fragment:
return True
if not fragment.startswith("/"):
return fragment in anchors
current = schema
for encoded_segment in fragment[1:].split("/"):
segment = encoded_segment.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict) and segment in current:
current = current[segment]
elif isinstance(current, list) and segment.isdecimal():
index = int(segment)
if index >= len(current):
return False
current = current[index]
else:
return False
return True
@@ -0,0 +1,20 @@
commands:
- command_id: mcp-fixture.notify
title: MCP 通知
description: 通过隔离 MCP Host 返回宿主白名单通知 effect。
icon: bolt
locations:
- command_palette
when:
- editor.has_selection
context:
- selection
secrets:
- api_key
mcp_tool: mcp-fixture.command
parameters:
type: object
properties:
message:
type: string
additionalProperties: false
@@ -1,9 +1,10 @@
id: mcp-fixture id: mcp-fixture
name: MCP Fixture name: MCP Fixture
version: 1.0.0 version: 1.0.0
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。 description: 阶段 C/D 离线联调 Fixture,覆盖 MCP Tool、Command 与错误边界。
permissions: permissions:
- notes.read - notes.read
- secrets.use
contributes: contributes:
tools: tools:
- mcp-fixture.echo - mcp-fixture.echo
@@ -12,6 +13,10 @@ contributes:
- mcp-fixture.large - mcp-fixture.large
- mcp-fixture.environment - mcp-fixture.environment
- mcp-fixture.exit - mcp-fixture.exit
commands:
- mcp-fixture.notify
settings_sections:
- mcp-fixture.general
backend: backend:
type: mcp type: mcp
transport: stdio transport: stdio
+35 -1
View File
@@ -54,6 +54,11 @@ TOOLS = {
"large": tool("large", "Return a result larger than the host limit."), "large": tool("large", "Return a result larger than the host limit."),
"environment": tool("environment", "Report whether host secrets leaked into the process."), "environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture process."), "exit": tool("exit", "Terminate the fixture process."),
"command": tool(
"command",
"Execute a NotesAgent Plugin Command envelope.",
{"_notesagent": {"type": "object"}},
),
} }
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。 # suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"] TOOLS["echo"]["inputSchema"]["required"] = ["text"]
@@ -62,6 +67,28 @@ TOOLS["echo"]["inputSchema"]["required"] = ["text"]
def call_tool(request_id: int, params: dict[str, Any]) -> None: def call_tool(request_id: int, params: dict[str, Any]) -> None:
name = params.get("name") name = params.get("name")
arguments = params.get("arguments") or {} arguments = params.get("arguments") or {}
if name == "command":
envelope = arguments.get("_notesagent") or {}
command_arguments = envelope.get("arguments") or {}
context = envelope.get("context") or {}
secrets = envelope.get("secrets") or {}
message = command_arguments.get("message") or context.get("selection") or ""
respond(
request_id,
{
"content": [{"type": "text", "text": "command completed"}],
"structuredContent": {
"type": "notification",
"payload": {
"level": "success",
"message": str(message),
"secret_configured": isinstance(secrets.get("api_key"), str),
},
},
"isError": False,
},
)
return
if name == "echo": if name == "echo":
text = str(arguments.get("text", "")) text = str(arguments.get("text", ""))
structured_content = {"echo": text} structured_content = {"echo": text}
@@ -183,7 +210,14 @@ def main() -> None:
elif params.get("cursor") == "page-2": elif params.get("cursor") == "page-2":
respond( respond(
request_id, request_id,
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]}, {
"tools": [
TOOLS["large"],
TOOLS["environment"],
TOOLS["exit"],
TOOLS["command"],
]
},
) )
else: else:
respond( respond(
@@ -0,0 +1,7 @@
section_id: mcp-fixture.general
schema_version: 1
fields:
- key: api_key
label: Fixture API Key
type: secret
required: true
+45 -4
View File
@@ -11,6 +11,7 @@ from app.container import build_container
from app.contracts import ( from app.contracts import (
AgentRunCreateRequest, AgentRunCreateRequest,
AgentRunStatus, AgentRunStatus,
PluginCommandContext,
SkillStatus, SkillStatus,
ToolCall, ToolCall,
) )
@@ -33,7 +34,7 @@ def mcp_container():
container = build_container() container = build_container()
installed = container.plugins.install(MCP_FIXTURE) installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required" assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"]) container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
try: try:
yield container yield container
finally: finally:
@@ -349,7 +350,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
ToolExecutionContext(run_id="run_mcp_fixture"), ToolExecutionContext(run_id="run_mcp_fixture"),
) )
assert status.tools_count == 6 assert status.tools_count == 7
assert status.protocol_version == "2025-11-25" assert status.protocol_version == "2025-11-25"
assert status.server_name == "notesagent-mcp-fixture" assert status.server_name == "notesagent-mcp-fixture"
assert definition.permission == "notes.read" assert definition.permission == "notes.read"
@@ -396,6 +397,46 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
run(scenario()) run(scenario())
def test_mcp_command_target_receives_scoped_context_and_declared_secret(
mcp_container,
) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
assert not mcp_container.tools.contains("mcp-fixture.command")
with pytest.raises(ExtensionError) as missing:
await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(selection="来自选区"),
)
assert missing.value.code == "PLUGIN_SECRET_REQUIRED"
mcp_container.plugins.put_setting_secret(
"mcp-fixture", "api_key", "mcp-command-secret"
)
result = await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(
note_id="must-not-enter-envelope",
selection="来自选区",
),
)
assert result.effect.type == "notification"
assert result.effect.payload == {
"level": "success",
"message": "来自选区",
"secret_configured": True,
}
assert "mcp-command-secret" not in repr(
mcp_container.plugins.commands.audit_events()
)
run(scenario())
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None: def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None: async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture") mcp_container.plugins.enable("mcp-fixture")
@@ -531,7 +572,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
container = build_container() container = build_container()
installed = container.plugins.install(MCP_FIXTURE) installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required" assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"]) container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
try: try:
with pytest.raises(ExtensionError) as exc: with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-fixture") container.plugins.enable("mcp-fixture")
@@ -565,7 +606,7 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container)
restarted = mcp_container.plugins.restart_host("mcp-fixture") restarted = mcp_container.plugins.restart_host("mcp-fixture")
assert restarted.status == "ready" assert restarted.status == "ready"
assert restarted.tools_count == 6 assert restarted.tools_count == 7
assert mcp_container.tools.contains("mcp-fixture.echo") assert mcp_container.tools.contains("mcp-fixture.echo")
run(scenario()) run(scenario())
+63 -5
View File
@@ -13,6 +13,7 @@ from app.contracts import (
PluginSettingType, PluginSettingType,
) )
from app.extensions import ExtensionError, PluginRuntime from app.extensions import ExtensionError, PluginRuntime
from app.extensions.contributions import _secret_reference
from app.extensions.runtime import DeclarativePluginHost from app.extensions.runtime import DeclarativePluginHost
TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools" TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools"
@@ -260,24 +261,39 @@ def test_secret_roundtrip_never_enters_plain_settings_storage() -> None:
assert "api_key" not in schema.values assert "api_key" not in schema.values
assert plaintext not in settings_path.read_text(encoding="utf-8") assert plaintext not in settings_path.read_text(encoding="utf-8")
assert plaintext not in credentials_path.read_text(encoding="utf-8") assert plaintext not in credentials_path.read_text(encoding="utf-8")
assert container.credentials.resolve("plugin.text-tools.api_key") == plaintext stored_settings = json.loads(settings_path.read_text(encoding="utf-8"))
reference = stored_settings["text-tools"]["secret_refs"]["api_key"]
assert reference.startswith("plugin.")
assert len(reference) == 71
assert "text-tools" not in reference and "api_key" not in reference
assert container.credentials.resolve(reference) == plaintext
deleted = container.plugins.delete_setting_secret("text-tools", "api_key") deleted = container.plugins.delete_setting_secret("text-tools", "api_key")
assert deleted.configured is False assert deleted.configured is False
assert container.credentials.resolve("plugin.text-tools.api_key") is None assert container.credentials.resolve(reference) is None
def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None: def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None:
container = build_container() container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 12}) container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
container.plugins.put_setting_secret("text-tools", "api_key", "temporary") container.plugins.put_setting_secret("text-tools", "api_key", "temporary")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
reference = json.loads(settings_path.read_text(encoding="utf-8"))[
"text-tools"
]["secret_refs"]["api_key"]
container.plugins.uninstall("text-tools") container.plugins.uninstall("text-tools")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
stored = json.loads(settings_path.read_text(encoding="utf-8")) stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "text-tools" not in stored assert "text-tools" not in stored
assert container.credentials.resolve("plugin.text-tools.api_key") is None assert container.credentials.resolve(reference) is None
def test_plugin_secret_reference_has_fixed_credential_safe_length() -> None:
reference = _secret_reference("p" * 512, "k" * 128)
assert reference.startswith("plugin.")
assert len(reference) <= 128
def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None: def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None:
@@ -363,6 +379,42 @@ backend:
assert exc.value.code == "EXTENSION_MANIFEST_INVALID" assert exc.value.code == "EXTENSION_MANIFEST_INVALID"
def test_external_command_schema_reference_is_rejected(tmp_path: Path) -> None:
package = tmp_path / "external-ref"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: external-ref
name: External Ref
version: 1.0.0
contributes:
commands: [external-ref.run]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: external-ref.run
title: External Ref
locations: [command_palette]
handler: echo
parameters:
$ref: file:///host/private-schema.json
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_COMMAND_INVALID"
assert "External JSON Schema reference" in exc.value.message
def test_settings_missing_and_secret_field_errors_are_stable() -> None: def test_settings_missing_and_secret_field_errors_are_stable() -> None:
container = build_container() container = build_container()
@@ -394,4 +446,10 @@ def test_corrupted_plugin_settings_namespace_returns_stable_error() -> None:
container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan") container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan")
assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR" assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR"
assert container.credentials.resolve("plugin.text-tools.api_key") is None credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
credential_ids = (
json.loads(credentials_path.read_text(encoding="utf-8")).keys()
if credentials_path.exists()
else []
)
assert not any(item.startswith("plugin.") for item in credential_ids)
+35
View File
@@ -0,0 +1,35 @@
import pytest
from app.schema_security import (
ExternalSchemaReferenceError,
UnresolvableLocalSchemaReferenceError,
reject_external_schema_references,
)
@pytest.mark.parametrize(
"schema",
[
{"$ref": "file:///host/private-schema.json"},
{"properties": {"value": {"$ref": "https://schema.invalid/value.json"}}},
{"allOf": [{"$dynamicRef": "https://schema.invalid/dynamic"}]},
],
)
def test_external_json_schema_references_are_rejected(schema) -> None:
with pytest.raises(ExternalSchemaReferenceError):
reject_external_schema_references(schema)
def test_local_json_schema_fragment_reference_is_allowed() -> None:
reject_external_schema_references(
{
"$defs": {"value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/value"}},
}
)
@pytest.mark.parametrize("reference", ["#/$defs/missing", "#missing-anchor"])
def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None:
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references({"type": "object", "$ref": reference})