From c1bac00d12da4eb2f354810715f80787c4d00540 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 2 Sep 2026 14:08:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(extension):=20=E6=94=B6=E7=B4=A7=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AF=86=E9=92=A5=E4=B8=8E=E5=91=BD=E4=BB=A4=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/README.md | 2 +- backend/app/extensions/contributions.py | 8 ++ backend/app/extensions/runtime.py | 79 +++++++++++++++- backend/app/providers/credentials.py | 21 +++++ backend/app/providers/factory.py | 6 +- backend/app/routes.py | 19 +++- backend/tests/test_credentials.py | 32 ++++++- backend/tests/test_plugin_contributions.py | 103 ++++++++++++++++++++- 9 files changed, 263 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index aa77036..117f3aa 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 103 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 107 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/README.md b/backend/README.md index b1a3bec..915a897 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 ``` -当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 107 项测试通过。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/extensions/contributions.py b/backend/app/extensions/contributions.py index 6a17a38..6d2790d 100644 --- a/backend/app/extensions/contributions.py +++ b/backend/app/extensions/contributions.py @@ -73,6 +73,7 @@ class PluginCommandSpec(BaseModel): } ) permission: str | None = None + secrets: list[str] = Field(default_factory=list) handler: Literal["echo", "uppercase_selection"] timeout_seconds: int = Field(default=30, ge=1, le=120) @@ -81,6 +82,7 @@ CommandExecutor = Callable[ [dict[str, Any], dict[str, Any]], PluginCommandEffect | Awaitable[PluginCommandEffect], ] +PluginSecretResolver = Callable[[str], str | None] @dataclass(slots=True) @@ -656,6 +658,12 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None: "PLUGIN_COMMAND_INVALID", "Plugin command when/context entries must be unique.", ) + if len(spec.secrets) != len(set(spec.secrets)): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command Secret entries must be unique.", + details={"command_id": spec.command_id}, + ) unknown_when = sorted(set(spec.when) - _WHEN_TOKENS) if unknown_when: raise ExtensionError( diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 054424e..08d9321 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -24,6 +24,7 @@ from app.contracts import ( PluginManifest, PluginHostStatus, PluginSecretStatus, + PluginSettingType, PluginSettingsSchema, PluginStatus, RetrievalConfig, @@ -35,6 +36,7 @@ from app.contracts import ( from app.extensions.contributions import ( CommandRegistry, PluginCommandSpec, + PluginSecretResolver, PluginSettingsDefinition, PluginSettingsStore, validate_command_spec, @@ -245,6 +247,7 @@ class DeclarativePluginHost: arguments: dict[str, Any], context: dict[str, Any], settings: dict[str, Any], + resolve_secret: PluginSecretResolver, ) -> PluginCommandEffect: """执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。""" @@ -381,6 +384,32 @@ class PluginRuntime: ) if settings_definition is not None: validate_settings_definition(manifest.plugin_id, settings_definition) + secret_fields = ( + { + field.key + for field in settings_definition.fields + if field.type == PluginSettingType.secret + } + if settings_definition is not None + else set() + ) + for spec in command_specs: + unknown_secrets = sorted(set(spec.secrets) - secret_fields) + if unknown_secrets: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command references undeclared Secret settings.", + details={ + "command_id": spec.command_id, + "secrets": unknown_secrets, + }, + ) + if spec.secrets and "secrets.use" not in manifest.permissions: + raise ExtensionError( + "PLUGIN_PERMISSION_UNDECLARED", + "Commands using Secret settings require the secrets.use permission.", + details={"command_id": spec.command_id}, + ) record = _PluginRecord( plugin=Plugin( @@ -502,6 +531,16 @@ class PluginRuntime: _spec: PluginCommandSpec = spec, _record: _PluginRecord = record, ) -> PluginCommandEffect: + if ( + not _record.plugin.enabled + or _record.plugin.status != PluginStatus.ready + ): + raise ExtensionError( + "PLUGIN_COMMAND_NOT_FOUND", + "Plugin command is not available while its Plugin is inactive.", + status_code=404, + details={"command_id": _spec.command_id}, + ) settings = ( self.settings.get( _record.plugin.manifest.plugin_id, @@ -510,8 +549,38 @@ class PluginRuntime: if _record.settings_definition is not None else {} ) + def resolve_secret(key: str) -> str | None: + if key not in _spec.secrets: + raise ExtensionError( + "PLUGIN_SECRET_ACCESS_DENIED", + "Command cannot access an undeclared Plugin Secret.", + status_code=403, + details={ + "command_id": _spec.command_id, + "key": key, + }, + ) + if "secrets.use" not in _record.plugin.granted_permissions: + raise ExtensionError( + "PLUGIN_SECRET_ACCESS_DENIED", + "Plugin no longer has permission to access Secret settings.", + status_code=403, + details={"command_id": _spec.command_id, "key": key}, + ) + if _record.settings_definition is None: + return None + return self.settings.resolve_secret( + _record.plugin.manifest.plugin_id, + _record.settings_definition, + key, + ) + return await self.host.execute_command( - _spec.handler, arguments, context, settings + _spec.handler, + arguments, + context, + settings, + resolve_secret, ) self.commands.register(plugin_id, spec, command_executor) @@ -782,10 +851,16 @@ class PluginRuntime: if not path.exists(): return [] raw = _read_yaml(path) + items = raw.get("commands", []) + if not isinstance(items, list): + raise ExtensionError( + "EXTENSION_MANIFEST_INVALID", + "Invalid plugin command manifest: commands must be an array.", + ) try: return [ PluginCommandSpec.model_validate(item) - for item in raw.get("commands", []) + for item in items ] except ValidationError as exc: raise _manifest_error("plugin command", exc) from exc diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index 19e83c3..ae1cca6 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -13,6 +13,7 @@ from app.config import get_settings _CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PLUGIN_CREDENTIAL_PREFIX = "plugin." class CredentialStoreError(RuntimeError): @@ -23,6 +24,15 @@ class CredentialResolver(Protocol): def resolve(self, credential_id: str | None) -> str | None: ... +def validate_provider_credential_id(credential_id: str | None) -> None: + """阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。""" + + if credential_id and credential_id.casefold().startswith( + _PLUGIN_CREDENTIAL_PREFIX + ): + raise CredentialStoreError("Credential namespace is reserved for Plugin settings.") + + class EnvironmentCredentialResolver: """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" @@ -170,3 +180,14 @@ class ChainedCredentialResolver: if value: return value return None + + +class ProviderCredentialResolver: + """Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。""" + + def __init__(self, delegate: CredentialResolver) -> None: + self._delegate = delegate + + def resolve(self, credential_id: str | None) -> str | None: + validate_provider_credential_id(credential_id) + return self._delegate.resolve(credential_id) diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 38d1687..84f08a7 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -1,6 +1,6 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType from app.providers.base import ModelProvider -from app.providers.credentials import CredentialResolver +from app.providers.credentials import CredentialResolver, ProviderCredentialResolver from app.providers.ollama import OllamaProvider from app.providers.openai_compatible import OpenAICompatibleProvider @@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError): class ProviderFactory: def __init__(self, credentials: CredentialResolver) -> None: - self.credentials = credentials + # ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止 + # Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。 + self.credentials = ProviderCredentialResolver(credentials) def build(self, config: ProviderConfig) -> ModelProvider: if config.provider_type in { diff --git a/backend/app/routes.py b/backend/app/routes.py index 98a5f94..962bec7 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -75,7 +75,10 @@ from app.extensions import ExtensionError from app.providers.registry import ProviderNotFoundError from app.providers.factory import UnsupportedProviderError from app.providers.base import ProviderError -from app.providers.credentials import CredentialStoreError +from app.providers.credentials import ( + CredentialStoreError, + validate_provider_credential_id, +) from app.retrieval.engine import engine from app.services import ( index_service, @@ -92,6 +95,13 @@ def utc_now() -> datetime: return datetime.now(timezone.utc) +def validate_public_credential_id(credential_id: str | None) -> None: + try: + validate_provider_credential_id(credential_id) + except CredentialStoreError as exc: + raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc + + def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str: id_line = f"id: {event_id}\n" if event_id is not None else "" return f"{id_line}event: {event}\ndata: {payload}\n\n" @@ -654,6 +664,7 @@ async def delete_plugin_setting_secret( tags=["Providers"], ) async def get_credential_status(credential_id: str) -> CredentialStatus: + validate_public_credential_id(credential_id) try: configured = container.credentials.has(credential_id) except CredentialStoreError as exc: @@ -669,6 +680,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus: async def put_credential( credential_id: str, request: CredentialWriteRequest ) -> CredentialStatus: + validate_public_credential_id(credential_id) try: container.credentials.put(credential_id, request.api_key.get_secret_value()) except CredentialStoreError as exc: @@ -682,6 +694,7 @@ async def put_credential( tags=["Providers"], ) async def delete_credential(credential_id: str) -> CredentialStatus: + validate_public_credential_id(credential_id) try: container.credentials.delete(credential_id) except CredentialStoreError as exc: @@ -718,6 +731,7 @@ async def get_provider(provider_id: str) -> ProviderConfig: tags=["Providers"], ) async def create_provider(request: ProviderCreateRequest) -> ProviderConfig: + validate_public_credential_id(request.credential_id) config = ProviderConfig( provider_id=f"provider_{uuid4().hex}", provider_type=request.provider_type, @@ -761,6 +775,8 @@ async def update_provider( "name and enabled cannot be null when explicitly provided.", ) updates = {name: getattr(request, name) for name in fields} + if "credential_id" in fields: + validate_public_credential_id(request.credential_id) config = ProviderConfig.model_validate( {**current.model_dump(mode="python"), **updates} ) @@ -820,6 +836,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse: async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse: registered = configurable_provider_or_404(request.provider_id) if request.credential_context_id: + validate_public_credential_id(request.credential_context_id) temporary_config = registered.config.model_copy( update={"credential_id": request.credential_context_id, "enabled": True} ) diff --git a/backend/tests/test_credentials.py b/backend/tests/test_credentials.py index d985827..37ac388 100644 --- a/backend/tests/test_credentials.py +++ b/backend/tests/test_credentials.py @@ -1,16 +1,20 @@ import asyncio import httpx +import pytest from app.config import get_settings from app.contracts import CredentialWriteRequest +from app.errors import ApiError from app.providers.credentials import ( ChainedCredentialResolver, + CredentialStoreError, EncryptedCredentialStore, EnvironmentCredentialResolver, ) +from app.providers.factory import ProviderFactory from app.providers.openai_compatible import OpenAICompatibleProvider -from app.routes import get_credential_status, put_credential +from app.routes import delete_credential, get_credential_status, put_credential def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None: @@ -72,3 +76,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver()) assert resolver.resolve("deepseek") == "saved-key" + + +def test_public_credential_api_rejects_plugin_namespace() -> None: + operations = [ + get_credential_status("plugin.text-tools.api_key"), + put_credential( + "plugin.text-tools.api_key", + CredentialWriteRequest(api_key="must-not-write"), + ), + delete_credential("plugin.text-tools.api_key"), + ] + for operation in operations: + with pytest.raises(ApiError) as exc: + asyncio.run(operation) + assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED" + + assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None + + +def test_provider_resolver_cannot_read_plugin_secret() -> None: + store = EncryptedCredentialStore() + store.put("plugin.text-tools.api_key", "private-plugin-secret") + resolver = ProviderFactory(store).credentials + + with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"): + resolver.resolve("plugin.text-tools.api_key") diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py index d152beb..d80bf7f 100644 --- a/backend/tests/test_plugin_contributions.py +++ b/backend/tests/test_plugin_contributions.py @@ -106,7 +106,9 @@ def test_command_only_receives_declared_context() -> None: def __init__(self) -> None: self.context = None - async def execute_command(self, handler, arguments, context, settings): + async def execute_command( + self, handler, arguments, context, settings, resolve_secret + ): self.context = context return PluginCommandEffect(type="none") @@ -128,6 +130,81 @@ def test_command_only_receives_declared_context() -> None: assert host.context == {"selection": "visible"} +def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None: + class SecretHost(DeclarativePluginHost): + def __init__(self) -> None: + self.secret = None + self.denied_code = None + + async def execute_command( + self, handler, arguments, context, settings, resolve_secret + ): + self.secret = resolve_secret("api_key") + try: + resolve_secret("undeclared") + except ExtensionError as exc: + self.denied_code = exc.code + return PluginCommandEffect(type="none") + + host = SecretHost() + runtime = PluginRuntime(ToolRegistry(), host=host) + package = tmp_path / "secret-command" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: secret-command +name: Secret Command +version: 1.0.0 +permissions: [secrets.use] +contributes: + commands: [secret-command.run] + settings_sections: [secret-command.general] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text( + """ +commands: + - command_id: secret-command.run + title: Secret Command + locations: [command_palette] + secrets: [api_key] + handler: echo +""".strip(), + encoding="utf-8", + ) + (package / "settings.yaml").write_text( + """ +section_id: secret-command.general +schema_version: 1 +fields: + - key: api_key + label: API Key + type: secret +""".strip(), + encoding="utf-8", + ) + runtime.install(package) + runtime.set_permissions("secret-command", ["secrets.use"]) + runtime.enable("secret-command") + runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret") + + run( + runtime.execute_command( + "secret-command.run", + {}, + PluginCommandContext(selection="visible"), + ) + ) + + assert host.secret == "runtime-only-secret" + assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED" + assert "runtime-only-secret" not in repr(runtime.commands.audit_events()) + + def test_settings_schema_contains_defaults_and_hides_secret() -> None: container = build_container() @@ -262,6 +339,30 @@ fields: assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" +def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None: + package = tmp_path / "null-commands" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: null-commands +name: Null Commands +version: 1.0.0 +contributes: + commands: [] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text("commands:\n", encoding="utf-8") + + with pytest.raises(ExtensionError) as exc: + PluginRuntime(ToolRegistry()).install(package) + + assert exc.value.code == "EXTENSION_MANIFEST_INVALID" + + def test_settings_missing_and_secret_field_errors_are_stable() -> None: container = build_container()