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

This commit is contained in:
2026-09-02 14:08:47 +08:00
parent 6a08ad898e
commit c3ef9dfa44
18 changed files with 282 additions and 21 deletions
+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
```
当前基线为 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` 为准。
+8
View File
@@ -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(
+77 -2
View File
@@ -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
+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}$")
_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)
+4 -2
View File
@@ -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 {
+18 -1
View File
@@ -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}
)
+31 -1
View File
@@ -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")
+102 -1
View File
@@ -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()