fix(extension): 按Schema资源作用域校验引用

This commit is contained in:
2026-09-02 15:47:54 +08:00
parent ec88795b11
commit b16230ac4c
11 changed files with 114 additions and 46 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 121 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 126 项测试、前端 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
``` ```
当前基线为 121 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 当前基线为 126 项测试通过。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` 为准。
+1
View File
@@ -55,6 +55,7 @@ class ToolRegistry:
arguments_model: type[BaseModel], arguments_model: type[BaseModel],
executor: ToolExecutor, executor: ToolExecutor,
) -> None: ) -> None:
Draft202012Validator.check_schema(definition.parameters)
reject_external_schema_references(definition.parameters) reject_external_schema_references(definition.parameters)
with self._lock: with self._lock:
if definition.name in self._tools: if definition.name in self._tools:
+8 -1
View File
@@ -682,6 +682,13 @@ def validate_settings_definition(
field.minimum is not None or field.maximum is not None field.minimum is not None or field.maximum is not None
): ):
raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}") raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}")
if any(
bound is not None and not math.isfinite(bound)
for bound in (field.minimum, field.maximum)
):
raise _settings_schema_error(
plugin_id, f"Number setting bounds must be finite: {field.key}"
)
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum: if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}") raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}")
if field.type == PluginSettingType.secret and field.default is not None: if field.type == PluginSettingType.secret and field.default is not None:
@@ -746,8 +753,8 @@ 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)
reject_external_schema_references(spec.parameters)
except (SchemaReferenceError, SchemaError) as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc) message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError( raise ExtensionError(
+1 -1
View File
@@ -680,8 +680,8 @@ 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)
reject_external_schema_references(schema)
except (SchemaReferenceError, SchemaError) as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc) message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise McpBridgeError( raise McpBridgeError(
+1 -1
View File
@@ -1043,8 +1043,8 @@ 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)
reject_external_schema_references(schema)
except (SchemaReferenceError, SchemaError) as exc: except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc) message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError( raise ExtensionError(
+31 -41
View File
@@ -3,7 +3,13 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from urllib.parse import unquote from urllib.parse import urljoin
from referencing import Registry
from referencing.exceptions import Unresolvable
from referencing.jsonschema import DRAFT202012
_SCHEMA_BASE_URI = "https://notesagent.invalid/local-schema"
class SchemaReferenceError(ValueError): class SchemaReferenceError(ValueError):
@@ -24,47 +30,31 @@ class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
def reject_external_schema_references(schema: Any) -> None: def reject_external_schema_references(schema: Any) -> None:
"""只允许可解析的文档内 Fragment,禁止文件和网络检索""" """只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析"""
pending = [schema] root = DRAFT202012.create_resource(schema)
local_references: list[str] = [] root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "")
anchors: set[str] = set() registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl()
while pending: resolver = registry.resolver(root_uri)
value = pending.pop() _validate_resource_references(root, resolver)
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: def _validate_resource_references(resource, resolver: Any) -> None:
fragment = unquote(reference[1:]) contents = resource.contents
if not fragment: if isinstance(contents, dict):
return True for keyword in ("$ref", "$dynamicRef"):
if not fragment.startswith("/"): if keyword not in contents:
return fragment in anchors continue
reference = contents[keyword]
if not isinstance(reference, str) or not reference.startswith("#"):
raise ExternalSchemaReferenceError(keyword, reference)
try:
resolver.lookup(reference)
except Unresolvable as exc:
raise UnresolvableLocalSchemaReferenceError(reference) from exc
current = schema for subresource in resource.subresources():
for encoded_segment in fragment[1:].split("/"): _validate_resource_references(
segment = encoded_segment.replace("~1", "/").replace("~0", "~") subresource,
if isinstance(current, dict) and segment in current: resolver.in_subresource(subresource),
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
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28,<1.0", "httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0", "jsonschema>=4.25,<5.0",
"pyyaml>=6.0,<7.0", "pyyaml>=6.0,<7.0",
"referencing>=0.36,<1.0",
"sqlite-vec>=0.1.9", "sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0", "uvicorn[standard]>=0.35,<1.0",
] ]
@@ -430,6 +430,43 @@ fields:
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
@pytest.mark.parametrize("bound", [".nan", ".inf", "-.inf"])
def test_non_finite_setting_bounds_are_rejected(tmp_path: Path, bound: str) -> None:
package = tmp_path / f"invalid-bound-{bound.replace('.', 'dot').replace('-', 'neg')}"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: invalid-bound
name: Invalid Bound
version: 1.0.0
contributes:
settings_sections: [invalid-bound.general]
backend:
type: none
transport: none
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
f"""
section_id: invalid-bound.general
schema_version: 1
fields:
- key: limit
label: Limit
type: number
minimum: {bound}
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
assert "must be finite" in exc.value.message
def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None: def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None:
package = tmp_path / "null-commands" package = tmp_path / "null-commands"
package.mkdir() package.mkdir()
+30
View File
@@ -33,3 +33,33 @@ def test_local_json_schema_fragment_reference_is_allowed() -> None:
def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None: def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None:
with pytest.raises(UnresolvableLocalSchemaReferenceError): with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references({"type": "object", "$ref": reference}) reject_external_schema_references({"type": "object", "$ref": reference})
def test_root_reference_cannot_use_anchor_from_nested_schema_resource() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"type": "string",
}
},
"properties": {"value": {"$ref": "#inside"}},
}
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references(schema)
def test_nested_schema_resource_can_resolve_its_own_anchor() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"allOf": [{"$ref": "#inside"}],
}
}
}
reject_external_schema_references(schema)
+2
View File
@@ -374,6 +374,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "referencing" },
{ name = "sqlite-vec" }, { name = "sqlite-vec" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -390,6 +391,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28,<1.0" }, { name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" }, { name = "jsonschema", specifier = ">=4.25,<5.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
] ]