fix(extension): 完成MCP命令目标并收紧Schema边界
This commit is contained in:
@@ -11,6 +11,7 @@ from app.container import build_container
|
||||
from app.contracts import (
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
PluginCommandContext,
|
||||
SkillStatus,
|
||||
ToolCall,
|
||||
)
|
||||
@@ -33,7 +34,7 @@ def mcp_container():
|
||||
container = build_container()
|
||||
installed = container.plugins.install(MCP_FIXTURE)
|
||||
assert installed.status == "permission_required"
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
|
||||
try:
|
||||
yield container
|
||||
finally:
|
||||
@@ -349,7 +350,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
|
||||
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.server_name == "notesagent-mcp-fixture"
|
||||
assert definition.permission == "notes.read"
|
||||
@@ -396,6 +397,46 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
|
||||
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:
|
||||
async def scenario() -> None:
|
||||
mcp_container.plugins.enable("mcp-fixture")
|
||||
@@ -531,7 +572,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
|
||||
container = build_container()
|
||||
installed = container.plugins.install(MCP_FIXTURE)
|
||||
assert installed.status == "permission_required"
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
|
||||
try:
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
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")
|
||||
assert restarted.status == "ready"
|
||||
assert restarted.tools_count == 6
|
||||
assert restarted.tools_count == 7
|
||||
assert mcp_container.tools.contains("mcp-fixture.echo")
|
||||
|
||||
run(scenario())
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.contracts import (
|
||||
PluginSettingType,
|
||||
)
|
||||
from app.extensions import ExtensionError, PluginRuntime
|
||||
from app.extensions.contributions import _secret_reference
|
||||
from app.extensions.runtime import DeclarativePluginHost
|
||||
|
||||
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 plaintext not in settings_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")
|
||||
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:
|
||||
container = build_container()
|
||||
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
|
||||
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")
|
||||
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
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:
|
||||
@@ -363,6 +379,42 @@ backend:
|
||||
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:
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user