fix(extension): 按Schema资源作用域校验引用
This commit is contained in:
@@ -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
@@ -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` 为准。
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Generated
+2
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user