diff --git a/README.md b/README.md index 65ea3f4..7026ca3 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 121 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 126 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/README.md b/backend/README.md index 2f32253..a087fa7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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_` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 +当前基线为 126 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index 33c6615..fb53ed2 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -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: diff --git a/backend/app/extensions/contributions.py b/backend/app/extensions/contributions.py index 783a7d5..7be209a 100644 --- a/backend/app/extensions/contributions.py +++ b/backend/app/extensions/contributions.py @@ -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( diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py index 510494c..dc3e62f 100644 --- a/backend/app/extensions/mcp.py +++ b/backend/app/extensions/mcp.py @@ -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( diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 3020451..a6f0730 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -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( diff --git a/backend/app/schema_security.py b/backend/app/schema_security.py index 52e87d6..791fa62 100644 --- a/backend/app/schema_security.py +++ b/backend/app/schema_security.py @@ -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), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3688aaa..6c3d4c0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", ] diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py index f3e77c2..def7204 100644 --- a/backend/tests/test_plugin_contributions.py +++ b/backend/tests/test_plugin_contributions.py @@ -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() diff --git a/backend/tests/test_schema_security.py b/backend/tests/test_schema_security.py index 257faa1..cac9412 100644 --- a/backend/tests/test_schema_security.py +++ b/backend/tests/test_schema_security.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock index 03432b5..16eefd2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ]