mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
feat(extensions): enforce dependencies and permission grants
This commit is contained in:
parent
012c7ce034
commit
6e0950833a
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
120
nanobot/extensions/preflight.py
Normal file
120
nanobot/extensions/preflight.py
Normal 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}"
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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=(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
67
tests/extensions/test_preflight.py
Normal file
67
tests/extensions/test_preflight.py
Normal 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 == ()
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user