feat(extensions): enforce dependencies and permission grants

This commit is contained in:
Xubin Ren 2026-07-26 17:09:15 +08:00
parent 012c7ce034
commit 6e0950833a
8 changed files with 293 additions and 6 deletions

View File

@ -412,6 +412,7 @@ class ExtensionEntryConfig(Base):
enabled: bool = True
trusted: bool = False
permissions: list[str] = Field(default_factory=list)
config: dict[str, Any] = Field(default_factory=dict)

View File

@ -11,6 +11,7 @@ from nanobot.extensions.discovery import (
discover_manifest_root,
)
from nanobot.extensions.native import discover_native_extensions
from nanobot.extensions.preflight import evaluate_dependencies
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
@ -65,6 +66,7 @@ def build_extension_catalog(
for result in discoveries
for candidate in result.candidates
)
candidates, dependency_diagnostics = evaluate_dependencies(candidates)
discovery_diagnostics = tuple(
diagnostic
for result in discoveries
@ -91,6 +93,7 @@ def build_extension_catalog(
snapshot = registry.snapshot()
diagnostics = (
discovery_diagnostics
+ dependency_diagnostics
+ tuple(registry_diagnostics)
+ snapshot.diagnostics
)
@ -129,4 +132,7 @@ def _apply_entry_config(
candidate,
enabled=entry.enabled,
trusted=candidate.trusted or entry.trusted,
granted_permissions=(
candidate.granted_permissions | frozenset(entry.permissions)
),
)

View File

@ -0,0 +1,120 @@
"""Activation preflight for extension dependencies."""
from __future__ import annotations
import importlib.metadata
import json
import os
import shutil
from dataclasses import replace
from pathlib import Path
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
def evaluate_dependencies(
candidates: tuple[ExtensionCandidate, ...],
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
"""Disable candidates with missing hard dependencies and explain why."""
available = {
candidate.manifest.id: candidate.manifest.version
for candidate in candidates
}
checked: list[ExtensionCandidate] = []
diagnostics: list[ExtensionDiagnostic] = []
for candidate in candidates:
failures = [
message
for dependency in candidate.manifest.dependencies
if not dependency.optional
if (
message := _dependency_failure(
dependency,
location=candidate.location,
extensions=available,
)
)
]
if failures:
candidate = replace(candidate, enabled=False)
diagnostics.extend(
ExtensionDiagnostic(
code="dependency_missing",
extension_id=candidate.manifest.id,
message=message,
)
for message in failures
)
checked.append(candidate)
return tuple(checked), tuple(diagnostics)
def _dependency_failure(
dependency: ExtensionDependency,
*,
location: Path | None,
extensions: dict[str, str],
) -> str:
if dependency.kind is DependencyKind.EXECUTABLE:
if shutil.which(dependency.name) is None:
return f"Required executable is not installed: {dependency.name}"
return ""
if dependency.kind is DependencyKind.ENVIRONMENT:
if not os.getenv(dependency.name):
return f"Required environment variable is not set: {dependency.name}"
return ""
if dependency.kind is DependencyKind.PYTHON:
try:
version = importlib.metadata.version(dependency.name)
except importlib.metadata.PackageNotFoundError:
return f"Required Python package is not installed: {dependency.name}"
return _version_failure(dependency, version, "Python package")
if dependency.kind is DependencyKind.NPM:
version = _npm_version(location, dependency.name)
if version is None:
return f"Required npm package is not installed: {dependency.name}"
return _version_failure(dependency, version, "npm package")
if dependency.kind is DependencyKind.EXTENSION:
version = extensions.get(dependency.name)
if version is None:
return f"Required extension is not installed: {dependency.name}"
return _version_failure(dependency, version, "extension")
return f"Unsupported dependency kind: {dependency.kind.value}"
def _npm_version(location: Path | None, name: str) -> str | None:
if location is None:
return None
package = location / "node_modules" / Path(*name.split("/")) / "package.json"
try:
value = json.loads(package.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
version = value.get("version") if isinstance(value, dict) else None
return version if isinstance(version, str) else None
def _version_failure(
dependency: ExtensionDependency,
version: str,
label: str,
) -> str:
if not dependency.specifier:
return ""
try:
matches = Version(version) in SpecifierSet(dependency.specifier)
except (InvalidSpecifier, InvalidVersion):
return (
f"{label} {dependency.name} has an unsupported version constraint: "
f"{dependency.specifier}"
)
if matches:
return ""
return (
f"{label} {dependency.name} {version} does not satisfy "
f"{dependency.specifier}"
)

View File

@ -30,6 +30,7 @@ class ExtensionCandidate:
location: Path | None = None
enabled: bool = True
trusted: bool = False
granted_permissions: frozenset[str] = frozenset()
def __post_init__(self) -> None:
if not isinstance(self.manifest, ExtensionManifest):
@ -38,6 +39,8 @@ class ExtensionCandidate:
raise TypeError("extension candidate scope must be an ExtensionScope")
if self.location is not None and not isinstance(self.location, Path):
raise TypeError("extension candidate location must be a Path or None")
if not isinstance(self.granted_permissions, frozenset):
raise TypeError("extension granted permissions must be a frozenset")
@dataclass(frozen=True, slots=True)
@ -60,9 +63,18 @@ class ExtensionPolicy:
def permits(self, candidate: ExtensionCandidate) -> bool:
extension_id = candidate.manifest.id
requested = {
permission.name for permission in candidate.manifest.permissions
}
return (
candidate.enabled
and (candidate.scope is ExtensionScope.BUILTIN or candidate.trusted)
and (
candidate.scope is ExtensionScope.BUILTIN
or (
candidate.trusted
and requested <= candidate.granted_permissions
)
)
and extension_id not in self.deny
and (not self.allow or extension_id in self.allow)
)
@ -123,8 +135,8 @@ class ExtensionRegistry:
self._candidates[key] = candidate
def snapshot(self) -> ExtensionSnapshot:
active = self._select_active_extensions()
resolved, diagnostics = self._resolve_contributions(active)
active, selection_diagnostics = self._select_active_extensions()
resolved, resolution_diagnostics = self._resolve_contributions(active)
return ExtensionSnapshot(
extensions=tuple(sorted(active.values(), key=lambda item: item.manifest.id)),
contributions=tuple(
@ -136,18 +148,43 @@ class ExtensionRegistry:
),
)
),
diagnostics=tuple(diagnostics),
diagnostics=tuple(selection_diagnostics + resolution_diagnostics),
)
def _select_active_extensions(self) -> dict[str, ExtensionCandidate]:
def _select_active_extensions(
self,
) -> tuple[dict[str, ExtensionCandidate], list[ExtensionDiagnostic]]:
active: dict[str, ExtensionCandidate] = {}
diagnostics: list[ExtensionDiagnostic] = []
for candidate in sorted(
self._candidates.values(),
key=lambda item: (item.scope, item.manifest.id),
):
if self._policy.permits(candidate):
active[candidate.manifest.id] = candidate
return active
continue
if (
candidate.enabled
and candidate.trusted
and candidate.scope is not ExtensionScope.BUILTIN
):
requested = {
permission.name
for permission in candidate.manifest.permissions
}
missing = sorted(requested - candidate.granted_permissions)
if missing:
diagnostics.append(
ExtensionDiagnostic(
code="permission_required",
extension_id=candidate.manifest.id,
message=(
"Grant required extension permissions: "
+ ", ".join(missing)
),
)
)
return active, diagnostics
def _resolve_contributions(
self,

View File

@ -46,6 +46,7 @@ class InstalledExtension:
installed_at: str
enabled: bool = True
trusted: bool = False
granted_permissions: tuple[str, ...] = ()
@classmethod
def from_mapping(cls, value: object) -> InstalledExtension:
@ -60,6 +61,7 @@ class InstalledExtension:
installed_at=str(value["installed_at"]),
enabled=bool(value.get("enabled", True)),
trusted=bool(value.get("trusted", False)),
granted_permissions=tuple(value.get("granted_permissions", ())),
)
@ -101,6 +103,12 @@ class ExtensionStore:
candidate,
enabled=records.get(candidate.manifest.id, _DEFAULT_RECORD).enabled,
trusted=records.get(candidate.manifest.id, _DEFAULT_RECORD).trusted,
granted_permissions=frozenset(
records.get(
candidate.manifest.id,
_DEFAULT_RECORD,
).granted_permissions
),
)
for candidate in result.candidates
)
@ -180,6 +188,16 @@ class ExtensionStore:
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
return self._update_record(extension_id, trusted=trusted)
def set_permissions(
self,
extension_id: str,
permissions: set[str] | frozenset[str],
) -> InstalledExtension:
return self._update_record(
extension_id,
granted_permissions=tuple(sorted(permissions)),
)
def uninstall(self, extension_id: str) -> None:
records = self.records()
if extension_id not in records:
@ -231,6 +249,9 @@ class ExtensionStore:
installed_at=datetime.now(UTC).isoformat(),
enabled=previous.enabled if previous else True,
trusted=trusted or bool(previous and previous.trusted),
granted_permissions=(
previous.granted_permissions if previous else ()
),
)
records[extension_id] = record
self._write_records(records)
@ -283,6 +304,7 @@ _DEFAULT_RECORD = InstalledExtension(
installed_at="",
enabled=True,
trusted=False,
granted_permissions=(),
)

View File

@ -0,0 +1,67 @@
from pathlib import Path
from nanobot.extensions import (
DependencyKind,
ExtensionCandidate,
ExtensionDependency,
ExtensionManifest,
ExtensionRuntime,
ExtensionScope,
)
from nanobot.extensions.preflight import evaluate_dependencies
def _candidate(
dependency: ExtensionDependency,
*,
location: Path | None = None,
) -> ExtensionCandidate:
return ExtensionCandidate(
manifest=ExtensionManifest(
id="preflight.test",
name="Preflight test",
version="1.0.0",
runtime=ExtensionRuntime.DECLARATIVE,
dependencies=(dependency,),
),
scope=ExtensionScope.USER,
location=location,
trusted=True,
)
def test_missing_environment_dependency_disables_extension(
monkeypatch,
) -> None:
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
candidate = _candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
)
)
candidates, diagnostics = evaluate_dependencies((candidate,))
assert not candidates[0].enabled
assert diagnostics[0].code == "dependency_missing"
assert "NANOBOT_EXTENSION_TEST_KEY" in diagnostics[0].message
def test_installed_npm_dependency_satisfies_preflight(tmp_path: Path) -> None:
package = tmp_path / "node_modules" / "openclaw"
package.mkdir(parents=True)
(package / "package.json").write_text('{"version":"2026.7.1"}')
candidate = _candidate(
ExtensionDependency(
kind=DependencyKind.NPM,
name="openclaw",
specifier=">=2026.7.0",
),
location=tmp_path,
)
candidates, diagnostics = evaluate_dependencies((candidate,))
assert candidates[0].enabled
assert diagnostics == ()

View File

@ -3,6 +3,7 @@ from nanobot.extensions import (
ExtensionCandidate,
ExtensionContribution,
ExtensionManifest,
ExtensionPermission,
ExtensionPolicy,
ExtensionRegistry,
ExtensionRuntime,
@ -94,6 +95,35 @@ def test_untrusted_external_extension_is_visible_to_discovery_but_not_active() -
assert snapshot.contributions == ()
def test_external_extension_requires_every_requested_permission() -> None:
candidate = ExtensionCandidate(
manifest=ExtensionManifest(
id="permission.test",
name="Permission test",
version="1.0.0",
runtime=ExtensionRuntime.DECLARATIVE,
permissions=(
ExtensionPermission(name="network", reason="Fetch data."),
ExtensionPermission(
name="filesystem.read",
reason="Read input.",
),
),
),
scope=ExtensionScope.USER,
trusted=True,
granted_permissions=frozenset({"network"}),
)
registry = ExtensionRegistry()
registry.register(candidate)
snapshot = registry.snapshot()
assert snapshot.extensions == ()
assert snapshot.diagnostics[0].code == "permission_required"
assert "filesystem.read" in snapshot.diagnostics[0].message
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
registry = ExtensionRegistry()
registry.register(

View File

@ -40,10 +40,14 @@ def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None:
store.set_trusted(installed.record.id, True)
store.set_enabled(installed.record.id, False)
store.set_permissions(installed.record.id, {"network", "filesystem.read"})
candidate = store.discover().candidates[0]
assert candidate.trusted
assert not candidate.enabled
assert candidate.granted_permissions == frozenset(
{"network", "filesystem.read"}
)
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None: