fix(extension): 按Schema资源作用域校验引用
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
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` 为准。
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ class ToolRegistry:
|
||||
arguments_model: type[BaseModel],
|
||||
executor: ToolExecutor,
|
||||
) -> None:
|
||||
Draft202012Validator.check_schema(definition.parameters)
|
||||
reject_external_schema_references(definition.parameters)
|
||||
with self._lock:
|
||||
if definition.name in self._tools:
|
||||
|
||||
@@ -682,6 +682,13 @@ def validate_settings_definition(
|
||||
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}")
|
||||
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:
|
||||
raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}")
|
||||
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":
|
||||
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
|
||||
try:
|
||||
reject_external_schema_references(spec.parameters)
|
||||
Draft202012Validator.check_schema(spec.parameters)
|
||||
reject_external_schema_references(spec.parameters)
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
|
||||
@@ -680,8 +680,8 @@ class McpBridge:
|
||||
f"MCP tool inputSchema must be an object schema: {remote_name}",
|
||||
)
|
||||
try:
|
||||
reject_external_schema_references(schema)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
reject_external_schema_references(schema)
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise McpBridgeError(
|
||||
|
||||
@@ -1043,8 +1043,8 @@ def _arguments_model_from_schema(
|
||||
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
||||
schema = spec.parameters or {"type": "object", "properties": {}}
|
||||
try:
|
||||
reject_external_schema_references(schema)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
reject_external_schema_references(schema)
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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):
|
||||
@@ -24,47 +30,31 @@ class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
|
||||
|
||||
|
||||
def reject_external_schema_references(schema: Any) -> None:
|
||||
"""只允许可解析的文档内 Fragment,禁止文件和网络检索。"""
|
||||
"""只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析。"""
|
||||
|
||||
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)
|
||||
root = DRAFT202012.create_resource(schema)
|
||||
root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "")
|
||||
registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl()
|
||||
resolver = registry.resolver(root_uri)
|
||||
_validate_resource_references(root, resolver)
|
||||
|
||||
|
||||
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
|
||||
def _validate_resource_references(resource, resolver: Any) -> None:
|
||||
contents = resource.contents
|
||||
if isinstance(contents, dict):
|
||||
for keyword in ("$ref", "$dynamicRef"):
|
||||
if keyword not in contents:
|
||||
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 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
|
||||
for subresource in resource.subresources():
|
||||
_validate_resource_references(
|
||||
subresource,
|
||||
resolver.in_subresource(subresource),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"referencing>=0.36,<1.0",
|
||||
"sqlite-vec>=0.1.9",
|
||||
"uvicorn[standard]>=0.35,<1.0",
|
||||
]
|
||||
|
||||
@@ -430,6 +430,43 @@ fields:
|
||||
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:
|
||||
package = tmp_path / "null-commands"
|
||||
package.mkdir()
|
||||
|
||||
@@ -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:
|
||||
with pytest.raises(UnresolvableLocalSchemaReferenceError):
|
||||
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)
|
||||
|
||||
Generated
+2
@@ -374,6 +374,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "sqlite-vec" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@@ -390,6 +391,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "referencing", specifier = ">=0.36,<1.0" },
|
||||
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user