fix(extension): 收紧插件密钥与命令清单边界

This commit is contained in:
2026-09-02 14:08:47 +08:00
parent 0a9cad1c76
commit c1bac00d12
9 changed files with 263 additions and 9 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 103 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 107 项测试、前端 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
``` ```
当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。 当前基线为 107 项测试通过。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` 为准。
+8
View File
@@ -73,6 +73,7 @@ class PluginCommandSpec(BaseModel):
} }
) )
permission: str | None = None permission: str | None = None
secrets: list[str] = Field(default_factory=list)
handler: Literal["echo", "uppercase_selection"] handler: Literal["echo", "uppercase_selection"]
timeout_seconds: int = Field(default=30, ge=1, le=120) timeout_seconds: int = Field(default=30, ge=1, le=120)
@@ -81,6 +82,7 @@ CommandExecutor = Callable[
[dict[str, Any], dict[str, Any]], [dict[str, Any], dict[str, Any]],
PluginCommandEffect | Awaitable[PluginCommandEffect], PluginCommandEffect | Awaitable[PluginCommandEffect],
] ]
PluginSecretResolver = Callable[[str], str | None]
@dataclass(slots=True) @dataclass(slots=True)
@@ -656,6 +658,12 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
"PLUGIN_COMMAND_INVALID", "PLUGIN_COMMAND_INVALID",
"Plugin command when/context entries must be unique.", "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) unknown_when = sorted(set(spec.when) - _WHEN_TOKENS)
if unknown_when: if unknown_when:
raise ExtensionError( raise ExtensionError(
+77 -2
View File
@@ -24,6 +24,7 @@ from app.contracts import (
PluginManifest, PluginManifest,
PluginHostStatus, PluginHostStatus,
PluginSecretStatus, PluginSecretStatus,
PluginSettingType,
PluginSettingsSchema, PluginSettingsSchema,
PluginStatus, PluginStatus,
RetrievalConfig, RetrievalConfig,
@@ -35,6 +36,7 @@ from app.contracts import (
from app.extensions.contributions import ( from app.extensions.contributions import (
CommandRegistry, CommandRegistry,
PluginCommandSpec, PluginCommandSpec,
PluginSecretResolver,
PluginSettingsDefinition, PluginSettingsDefinition,
PluginSettingsStore, PluginSettingsStore,
validate_command_spec, validate_command_spec,
@@ -245,6 +247,7 @@ class DeclarativePluginHost:
arguments: dict[str, Any], arguments: dict[str, Any],
context: dict[str, Any], context: dict[str, Any],
settings: dict[str, Any], settings: dict[str, Any],
resolve_secret: PluginSecretResolver,
) -> PluginCommandEffect: ) -> PluginCommandEffect:
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。""" """执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
@@ -381,6 +384,32 @@ class PluginRuntime:
) )
if settings_definition is not None: if settings_definition is not None:
validate_settings_definition(manifest.plugin_id, settings_definition) 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( record = _PluginRecord(
plugin=Plugin( plugin=Plugin(
@@ -502,6 +531,16 @@ class PluginRuntime:
_spec: PluginCommandSpec = spec, _spec: PluginCommandSpec = spec,
_record: _PluginRecord = record, _record: _PluginRecord = record,
) -> PluginCommandEffect: ) -> 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 = ( settings = (
self.settings.get( self.settings.get(
_record.plugin.manifest.plugin_id, _record.plugin.manifest.plugin_id,
@@ -510,8 +549,38 @@ class PluginRuntime:
if _record.settings_definition is not None if _record.settings_definition is not None
else {} 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( 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) self.commands.register(plugin_id, spec, command_executor)
@@ -782,10 +851,16 @@ class PluginRuntime:
if not path.exists(): if not path.exists():
return [] return []
raw = _read_yaml(path) 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: try:
return [ return [
PluginCommandSpec.model_validate(item) PluginCommandSpec.model_validate(item)
for item in raw.get("commands", []) for item in items
] ]
except ValidationError as exc: except ValidationError as exc:
raise _manifest_error("plugin command", exc) from exc raise _manifest_error("plugin command", exc) from exc
+21
View File
@@ -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}$") _CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
class CredentialStoreError(RuntimeError): class CredentialStoreError(RuntimeError):
@@ -23,6 +24,15 @@ class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ... 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: class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
@@ -170,3 +180,14 @@ class ChainedCredentialResolver:
if value: if value:
return value return value
return None 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)
+4 -2
View File
@@ -1,6 +1,6 @@
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
from app.providers.base import ModelProvider 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.ollama import OllamaProvider
from app.providers.openai_compatible import OpenAICompatibleProvider from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError):
class ProviderFactory: class ProviderFactory:
def __init__(self, credentials: CredentialResolver) -> None: 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: def build(self, config: ProviderConfig) -> ModelProvider:
if config.provider_type in { if config.provider_type in {
+18 -1
View File
@@ -75,7 +75,10 @@ from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError from app.providers.factory import UnsupportedProviderError
from app.providers.base import ProviderError 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.retrieval.engine import engine
from app.services import ( from app.services import (
index_service, index_service,
@@ -92,6 +95,13 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc) 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: 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 "" id_line = f"id: {event_id}\n" if event_id is not None else ""
return f"{id_line}event: {event}\ndata: {payload}\n\n" return f"{id_line}event: {event}\ndata: {payload}\n\n"
@@ -654,6 +664,7 @@ async def delete_plugin_setting_secret(
tags=["Providers"], tags=["Providers"],
) )
async def get_credential_status(credential_id: str) -> CredentialStatus: async def get_credential_status(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
configured = container.credentials.has(credential_id) configured = container.credentials.has(credential_id)
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -669,6 +680,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
async def put_credential( async def put_credential(
credential_id: str, request: CredentialWriteRequest credential_id: str, request: CredentialWriteRequest
) -> CredentialStatus: ) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
container.credentials.put(credential_id, request.api_key.get_secret_value()) container.credentials.put(credential_id, request.api_key.get_secret_value())
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -682,6 +694,7 @@ async def put_credential(
tags=["Providers"], tags=["Providers"],
) )
async def delete_credential(credential_id: str) -> CredentialStatus: async def delete_credential(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
container.credentials.delete(credential_id) container.credentials.delete(credential_id)
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -718,6 +731,7 @@ async def get_provider(provider_id: str) -> ProviderConfig:
tags=["Providers"], tags=["Providers"],
) )
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig: async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
validate_public_credential_id(request.credential_id)
config = ProviderConfig( config = ProviderConfig(
provider_id=f"provider_{uuid4().hex}", provider_id=f"provider_{uuid4().hex}",
provider_type=request.provider_type, provider_type=request.provider_type,
@@ -761,6 +775,8 @@ async def update_provider(
"name and enabled cannot be null when explicitly provided.", "name and enabled cannot be null when explicitly provided.",
) )
updates = {name: getattr(request, name) for name in fields} 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( config = ProviderConfig.model_validate(
{**current.model_dump(mode="python"), **updates} {**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: async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
registered = configurable_provider_or_404(request.provider_id) registered = configurable_provider_or_404(request.provider_id)
if request.credential_context_id: if request.credential_context_id:
validate_public_credential_id(request.credential_context_id)
temporary_config = registered.config.model_copy( temporary_config = registered.config.model_copy(
update={"credential_id": request.credential_context_id, "enabled": True} update={"credential_id": request.credential_context_id, "enabled": True}
) )
+31 -1
View File
@@ -1,16 +1,20 @@
import asyncio import asyncio
import httpx import httpx
import pytest
from app.config import get_settings from app.config import get_settings
from app.contracts import CredentialWriteRequest from app.contracts import CredentialWriteRequest
from app.errors import ApiError
from app.providers.credentials import ( from app.providers.credentials import (
ChainedCredentialResolver, ChainedCredentialResolver,
CredentialStoreError,
EncryptedCredentialStore, EncryptedCredentialStore,
EnvironmentCredentialResolver, EnvironmentCredentialResolver,
) )
from app.providers.factory import ProviderFactory
from app.providers.openai_compatible import OpenAICompatibleProvider 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: 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()) resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
assert resolver.resolve("deepseek") == "saved-key" 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")
+102 -1
View File
@@ -106,7 +106,9 @@ def test_command_only_receives_declared_context() -> None:
def __init__(self) -> None: def __init__(self) -> None:
self.context = 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 self.context = context
return PluginCommandEffect(type="none") return PluginCommandEffect(type="none")
@@ -128,6 +130,81 @@ def test_command_only_receives_declared_context() -> None:
assert host.context == {"selection": "visible"} 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: def test_settings_schema_contains_defaults_and_hides_secret() -> None:
container = build_container() container = build_container()
@@ -262,6 +339,30 @@ fields:
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" 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: def test_settings_missing_and_secret_field_errors_are_stable() -> None:
container = build_container() container = build_container()