From 6e0950833a040f52e62cf09ec214651522b31653 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:09:15 +0800 Subject: [PATCH] feat(extensions): enforce dependencies and permission grants --- nanobot/config/schema.py | 1 + nanobot/extensions/catalog.py | 6 ++ nanobot/extensions/preflight.py | 120 +++++++++++++++++++++++++++++ nanobot/extensions/registry.py | 49 ++++++++++-- nanobot/extensions/store.py | 22 ++++++ tests/extensions/test_preflight.py | 67 ++++++++++++++++ tests/extensions/test_registry.py | 30 ++++++++ tests/extensions/test_store.py | 4 + 8 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 nanobot/extensions/preflight.py create mode 100644 tests/extensions/test_preflight.py diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 299c296a7..9082cee1e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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) diff --git a/nanobot/extensions/catalog.py b/nanobot/extensions/catalog.py index 493b97efa..8e301d55c 100644 --- a/nanobot/extensions/catalog.py +++ b/nanobot/extensions/catalog.py @@ -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) + ), ) diff --git a/nanobot/extensions/preflight.py b/nanobot/extensions/preflight.py new file mode 100644 index 000000000..43422e536 --- /dev/null +++ b/nanobot/extensions/preflight.py @@ -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}" + ) diff --git a/nanobot/extensions/registry.py b/nanobot/extensions/registry.py index d34f7dd81..bd68e03f4 100644 --- a/nanobot/extensions/registry.py +++ b/nanobot/extensions/registry.py @@ -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, diff --git a/nanobot/extensions/store.py b/nanobot/extensions/store.py index 5331f9e13..5303928b8 100644 --- a/nanobot/extensions/store.py +++ b/nanobot/extensions/store.py @@ -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=(), ) diff --git a/tests/extensions/test_preflight.py b/tests/extensions/test_preflight.py new file mode 100644 index 000000000..35ff5bd22 --- /dev/null +++ b/tests/extensions/test_preflight.py @@ -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 == () diff --git a/tests/extensions/test_registry.py b/tests/extensions/test_registry.py index 1e0b98be0..44e808671 100644 --- a/tests/extensions/test_registry.py +++ b/tests/extensions/test_registry.py @@ -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( diff --git a/tests/extensions/test_store.py b/tests/extensions/test_store.py index 7415b1978..189beebda 100644 --- a/tests/extensions/test_store.py +++ b/tests/extensions/test_store.py @@ -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: