release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.0a0+1015",
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-official-extensions==0.6.0a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Stable kernel contracts for JWeBot."""
|
||||
|
||||
from .activation import (
|
||||
ActivationError,
|
||||
install_release,
|
||||
rollback_release,
|
||||
verify_installed_release,
|
||||
verify_installed_release_signature,
|
||||
)
|
||||
from .catalog import CatalogError, CommandMatch, ExtensionCatalog
|
||||
from .extension_dispatch import (
|
||||
ExpectedExtension,
|
||||
HostedExtensionError,
|
||||
HostedExtensionRuntime,
|
||||
HostedRelease,
|
||||
SignedExtensionInventory,
|
||||
)
|
||||
from .host_client import (
|
||||
ExtensionHostClient,
|
||||
HostClientError,
|
||||
HostTimeout,
|
||||
UnixExtensionHostClient,
|
||||
)
|
||||
from .manifest import (
|
||||
CapabilityRequest,
|
||||
ManifestError,
|
||||
PluginManifest,
|
||||
PluginType,
|
||||
load_manifest,
|
||||
parse_manifest,
|
||||
)
|
||||
from .routing import (
|
||||
ExecutionPath,
|
||||
RouteDecision,
|
||||
RouteKind,
|
||||
classify_question,
|
||||
parse_local_command,
|
||||
)
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
build_release,
|
||||
load_release_record,
|
||||
release_record_bytes,
|
||||
verify_release,
|
||||
)
|
||||
from .signing import (
|
||||
ReleaseSignature,
|
||||
SignatureError,
|
||||
load_release_signature,
|
||||
release_signature_bytes,
|
||||
sign_release_record,
|
||||
verify_release_signature,
|
||||
)
|
||||
from .source_policy import (
|
||||
PolicyFinding,
|
||||
SourcePolicyError,
|
||||
validate_source_tree,
|
||||
)
|
||||
from .telemetry import RuntimeTelemetry, hash_identifier
|
||||
|
||||
__all__ = [
|
||||
"ActivationError",
|
||||
"CapabilityRequest",
|
||||
"CatalogError",
|
||||
"CommandMatch",
|
||||
"ExtensionCatalog",
|
||||
"ExpectedExtension",
|
||||
"ExecutionPath",
|
||||
"ExtensionHostClient",
|
||||
"HostClientError",
|
||||
"HostTimeout",
|
||||
"HostedExtensionError",
|
||||
"HostedExtensionRuntime",
|
||||
"HostedRelease",
|
||||
"UnixExtensionHostClient",
|
||||
"ManifestError",
|
||||
"PluginManifest",
|
||||
"PluginType",
|
||||
"PolicyFinding",
|
||||
"RouteDecision",
|
||||
"RouteKind",
|
||||
"RuntimeTelemetry",
|
||||
"ReleaseError",
|
||||
"ReleaseRecord",
|
||||
"ReleaseSignature",
|
||||
"SignatureError",
|
||||
"SignedExtensionInventory",
|
||||
"SourcePolicyError",
|
||||
"classify_question",
|
||||
"build_release",
|
||||
"hash_identifier",
|
||||
"load_manifest",
|
||||
"load_release_record",
|
||||
"load_release_signature",
|
||||
"parse_local_command",
|
||||
"parse_manifest",
|
||||
"release_record_bytes",
|
||||
"release_signature_bytes",
|
||||
"install_release",
|
||||
"rollback_release",
|
||||
"sign_release_record",
|
||||
"validate_source_tree",
|
||||
"verify_release",
|
||||
"verify_release_signature",
|
||||
"verify_installed_release",
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Verified release installation with atomic activation and rollback pointers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Iterator
|
||||
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
load_release_record,
|
||||
source_digest_for_files,
|
||||
verify_release,
|
||||
)
|
||||
from .signing import (
|
||||
ReleaseSignature,
|
||||
load_release_signature,
|
||||
release_signature_bytes,
|
||||
verify_release_signature,
|
||||
)
|
||||
|
||||
|
||||
class ActivationError(ValueError):
|
||||
"""Raised when a verified release cannot be installed or activated safely."""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _activation_lock(state_root: Path) -> Iterator[None]:
|
||||
state_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if state_root.is_symlink():
|
||||
raise ActivationError("activation state root cannot be a symlink")
|
||||
lock_path = state_root / ".activation.lock"
|
||||
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _release_target(digest: str) -> Path:
|
||||
if len(digest) != 64 or any(value not in "0123456789abcdef" for value in digest):
|
||||
raise ActivationError("invalid release digest")
|
||||
return Path("releases") / digest
|
||||
|
||||
|
||||
def _read_pointer(state_root: Path, name: str) -> Path | None:
|
||||
pointer = state_root / name
|
||||
if not pointer.exists() and not pointer.is_symlink():
|
||||
return None
|
||||
if not pointer.is_symlink():
|
||||
raise ActivationError(f"{name} pointer is not a symlink")
|
||||
target = Path(os.readlink(pointer))
|
||||
if target.is_absolute() or len(target.parts) != 2 or target.parts[0] != "releases":
|
||||
raise ActivationError(f"{name} pointer has an unsafe target")
|
||||
if not (state_root / target).is_dir():
|
||||
raise ActivationError(f"{name} pointer target is unavailable")
|
||||
return target
|
||||
|
||||
|
||||
def _replace_pointer(state_root: Path, name: str, target: Path) -> None:
|
||||
temporary = state_root / f".{name}.{os.getpid()}.tmp"
|
||||
temporary.unlink(missing_ok=True)
|
||||
temporary.symlink_to(target)
|
||||
try:
|
||||
os.replace(temporary, state_root / name)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _installed_files(
|
||||
release_dir: Path,
|
||||
record: ReleaseRecord,
|
||||
) -> tuple[tuple[str, bytes], ...]:
|
||||
values: list[tuple[str, bytes]] = []
|
||||
for path in sorted(release_dir.rglob("*")):
|
||||
relative = path.relative_to(release_dir).as_posix()
|
||||
if relative in {".release.json", ".release.sig.json"}:
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ActivationError(f"installed release contains a symlink: {relative}")
|
||||
if path.is_file():
|
||||
values.append((relative, path.read_bytes()))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _verify_installed(
|
||||
release_dir: Path,
|
||||
record: ReleaseRecord,
|
||||
signature: ReleaseSignature | None = None,
|
||||
) -> None:
|
||||
installed = _installed_files(release_dir, record)
|
||||
if tuple(path for path, _ in installed) != record.files:
|
||||
raise ActivationError("installed release file list mismatch")
|
||||
if source_digest_for_files(installed) != record.source_digest:
|
||||
raise ActivationError("installed release source digest mismatch")
|
||||
stored_record = release_dir / ".release.json"
|
||||
try:
|
||||
stored = ReleaseRecord.from_mapping(
|
||||
json.loads(stored_record.read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ReleaseError) as error:
|
||||
raise ActivationError("installed release record is invalid") from error
|
||||
if stored != record:
|
||||
raise ActivationError("installed release record mismatch")
|
||||
if signature is not None:
|
||||
stored_signature_path = release_dir / ".release.sig.json"
|
||||
try:
|
||||
stored_signature = load_release_signature(stored_signature_path)
|
||||
except ReleaseError as error:
|
||||
raise ActivationError(
|
||||
"installed release signature is invalid"
|
||||
) from error
|
||||
if stored_signature != signature:
|
||||
raise ActivationError("installed release signature mismatch")
|
||||
|
||||
|
||||
def _extract_verified(
|
||||
artifact: Path,
|
||||
record: ReleaseRecord,
|
||||
destination: Path,
|
||||
signature: ReleaseSignature | None = None,
|
||||
) -> None:
|
||||
with tarfile.open(artifact, mode="r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
relative = PurePosixPath(*path.parts[1:])
|
||||
target = destination.joinpath(*relative.parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ActivationError("verified artifact member cannot be read")
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
shutil.copyfileobj(stream, output)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
(destination / ".release.json").write_text(
|
||||
json.dumps(
|
||||
record.to_mapping(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(destination / ".release.json", 0o600)
|
||||
if signature is not None:
|
||||
(destination / ".release.sig.json").write_bytes(
|
||||
release_signature_bytes(signature)
|
||||
)
|
||||
os.chmod(destination / ".release.sig.json", 0o600)
|
||||
|
||||
|
||||
def install_release(
|
||||
artifact: Path,
|
||||
release_record: Path,
|
||||
state_root: Path,
|
||||
*,
|
||||
signature: Path | None = None,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
health_check: Callable[[Path], None] | None = None,
|
||||
allow_unhealthy_local: bool = False,
|
||||
) -> Path:
|
||||
record = load_release_record(release_record)
|
||||
if allow_unsigned:
|
||||
if (
|
||||
signature is not None
|
||||
or allowed_signers is not None
|
||||
or expected_publisher is not None
|
||||
):
|
||||
raise ActivationError(
|
||||
"unsigned local activation cannot include signature inputs"
|
||||
)
|
||||
signature_record = None
|
||||
else:
|
||||
if signature is None or allowed_signers is None:
|
||||
raise ActivationError("signed release activation is required")
|
||||
signature_record = verify_release_signature(
|
||||
release_record,
|
||||
signature,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
if health_check is None and not allow_unhealthy_local:
|
||||
raise ActivationError("signed release health check is required")
|
||||
verify_release(artifact, record)
|
||||
relative_target = _release_target(record.source_digest)
|
||||
releases = state_root / "releases"
|
||||
release_dir = state_root / relative_target
|
||||
|
||||
with _activation_lock(state_root):
|
||||
created = False
|
||||
releases.mkdir(mode=0o700, exist_ok=True)
|
||||
if releases.is_symlink():
|
||||
raise ActivationError("releases directory cannot be a symlink")
|
||||
if release_dir.is_symlink():
|
||||
raise ActivationError("release destination is unsafe")
|
||||
if release_dir.exists():
|
||||
if not release_dir.is_dir():
|
||||
raise ActivationError("release destination is unsafe")
|
||||
_verify_installed(release_dir, record, signature_record)
|
||||
else:
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{record.source_digest}.",
|
||||
dir=releases,
|
||||
)
|
||||
)
|
||||
os.chmod(staging, 0o700)
|
||||
try:
|
||||
_extract_verified(
|
||||
artifact,
|
||||
record,
|
||||
staging,
|
||||
signature_record,
|
||||
)
|
||||
_verify_installed(staging, record, signature_record)
|
||||
staging.replace(release_dir)
|
||||
created = True
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if health_check is not None:
|
||||
try:
|
||||
health_check(release_dir)
|
||||
except Exception as error:
|
||||
if created:
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
raise ActivationError("release health check failed") from error
|
||||
|
||||
current = _read_pointer(state_root, "current")
|
||||
if current == relative_target:
|
||||
return release_dir
|
||||
if current is not None:
|
||||
_replace_pointer(state_root, "previous", current)
|
||||
_replace_pointer(state_root, "current", relative_target)
|
||||
return release_dir
|
||||
|
||||
|
||||
def verify_installed_release(release_dir: Path) -> ReleaseRecord:
|
||||
if not release_dir.is_dir() or release_dir.is_symlink():
|
||||
raise ActivationError("installed release directory is unsafe")
|
||||
record_path = release_dir / ".release.json"
|
||||
try:
|
||||
record = ReleaseRecord.from_mapping(
|
||||
json.loads(record_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ReleaseError) as error:
|
||||
raise ActivationError("installed release record is invalid") from error
|
||||
if release_dir.name != record.source_digest:
|
||||
raise ActivationError("installed release directory digest mismatch")
|
||||
_verify_installed(release_dir, record)
|
||||
return record
|
||||
|
||||
|
||||
def verify_installed_release_signature(
|
||||
release_dir: Path,
|
||||
allowed_signers: Path,
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
) -> tuple[ReleaseRecord, ReleaseSignature]:
|
||||
record = verify_installed_release(release_dir)
|
||||
signature = verify_release_signature(
|
||||
release_dir / ".release.json",
|
||||
release_dir / ".release.sig.json",
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
_verify_installed(release_dir, record, signature)
|
||||
return record, signature
|
||||
|
||||
|
||||
def rollback_release(
|
||||
state_root: Path,
|
||||
*,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
) -> Path:
|
||||
with _activation_lock(state_root):
|
||||
current = _read_pointer(state_root, "current")
|
||||
previous = _read_pointer(state_root, "previous")
|
||||
if current is None or previous is None:
|
||||
raise ActivationError("no verified previous release is available")
|
||||
previous_dir = state_root / previous
|
||||
if allow_unsigned:
|
||||
if allowed_signers is not None or expected_publisher is not None:
|
||||
raise ActivationError(
|
||||
"unsigned local rollback cannot include trusted publishers"
|
||||
)
|
||||
verify_installed_release(previous_dir)
|
||||
else:
|
||||
if allowed_signers is None:
|
||||
raise ActivationError("signed release rollback is required")
|
||||
verify_installed_release_signature(
|
||||
previous_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
_replace_pointer(state_root, "current", previous)
|
||||
_replace_pointer(state_root, "previous", current)
|
||||
return previous_dir
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Deterministic startup registration and runtime-frozen extension catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
"""Raised when approved manifests cannot form one deterministic catalog."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandMatch:
|
||||
plugin_id: str
|
||||
command: str
|
||||
arguments: str
|
||||
|
||||
|
||||
class ExtensionCatalog:
|
||||
"""Mutable during startup, immutable after the kernel freezes registration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._manifests: dict[str, PluginManifest] = {}
|
||||
self._commands: dict[str, str] = {}
|
||||
self._frozen = False
|
||||
|
||||
@property
|
||||
def frozen(self) -> bool:
|
||||
return self._frozen
|
||||
|
||||
@property
|
||||
def manifests(self) -> Mapping[str, PluginManifest]:
|
||||
return MappingProxyType(self._manifests)
|
||||
|
||||
@property
|
||||
def commands(self) -> Mapping[str, str]:
|
||||
return MappingProxyType(self._commands)
|
||||
|
||||
def register(self, manifest: PluginManifest) -> None:
|
||||
if self._frozen:
|
||||
raise CatalogError("extension catalog is frozen")
|
||||
if manifest.plugin_id in self._manifests:
|
||||
raise CatalogError(f"duplicate plugin id: {manifest.plugin_id}")
|
||||
for command in manifest.capabilities.commands:
|
||||
owner = self._commands.get(command.casefold())
|
||||
if owner is not None:
|
||||
raise CatalogError(
|
||||
f"command {command} already belongs to {owner}"
|
||||
)
|
||||
self._manifests[manifest.plugin_id] = manifest
|
||||
for command in manifest.capabilities.commands:
|
||||
self._commands[command.casefold()] = manifest.plugin_id
|
||||
|
||||
def freeze(self) -> None:
|
||||
self._frozen = True
|
||||
|
||||
def resolve_command(self, question: str) -> CommandMatch | None:
|
||||
if not self._frozen:
|
||||
raise CatalogError("extension catalog must be frozen before routing")
|
||||
normalized = question.lstrip()
|
||||
folded = normalized.casefold()
|
||||
for command_key, plugin_id in sorted(
|
||||
self._commands.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
if folded == command_key:
|
||||
return CommandMatch(
|
||||
plugin_id=plugin_id,
|
||||
command=normalized[: len(command_key)],
|
||||
arguments="",
|
||||
)
|
||||
prefix = command_key + " "
|
||||
if folded.startswith(prefix):
|
||||
return CommandMatch(
|
||||
plugin_id=plugin_id,
|
||||
command=normalized[: len(command_key)],
|
||||
arguments=normalized[len(prefix) :].strip(),
|
||||
)
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
"""Signed startup catalog and isolated extension-host dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol
|
||||
|
||||
from jwebot_sdk import API_VERSION, ContractViolation, ExtensionResult
|
||||
|
||||
from .activation import verify_installed_release_signature
|
||||
from .catalog import ExtensionCatalog
|
||||
from .host_client import ServiceHandler
|
||||
from .manifest import PluginManifest, load_manifest
|
||||
|
||||
|
||||
class HostedExtensionError(ValueError):
|
||||
"""Controlled signed-catalog, health, or invocation failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpectedExtension:
|
||||
plugin_id: str
|
||||
author: str
|
||||
commands: tuple[str, ...]
|
||||
service_profiles: tuple[str, ...]
|
||||
description: str = ""
|
||||
help_entries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostedRelease:
|
||||
manifest: PluginManifest
|
||||
release_dir: Path
|
||||
|
||||
|
||||
class HostInvoker(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
service_handler: ServiceHandler | None,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
def _active_release_dir(plugin_root: Path) -> Path | None:
|
||||
current = plugin_root / "current"
|
||||
if not current.exists() and not current.is_symlink():
|
||||
return None
|
||||
if not current.is_symlink():
|
||||
raise HostedExtensionError("extension current pointer is not a symlink")
|
||||
target = Path(os.readlink(current))
|
||||
if (
|
||||
target.is_absolute()
|
||||
or len(target.parts) != 2
|
||||
or target.parts[0] != "releases"
|
||||
):
|
||||
raise HostedExtensionError("extension current pointer is unsafe")
|
||||
release_dir = plugin_root / target
|
||||
if not release_dir.is_dir() or release_dir.is_symlink():
|
||||
raise HostedExtensionError("extension current release is unavailable")
|
||||
return release_dir
|
||||
|
||||
|
||||
class SignedExtensionInventory:
|
||||
"""Load reviewed signed manifests during startup, then freeze routing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
catalog: ExtensionCatalog,
|
||||
releases: Mapping[str, HostedRelease],
|
||||
) -> None:
|
||||
if not catalog.frozen:
|
||||
raise HostedExtensionError("signed extension catalog must be frozen")
|
||||
self.catalog = catalog
|
||||
self._releases = MappingProxyType(dict(releases))
|
||||
|
||||
@property
|
||||
def releases(self) -> Mapping[str, HostedRelease]:
|
||||
return self._releases
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
state_root: Path,
|
||||
allowed_signers: Path,
|
||||
expected: Sequence[ExpectedExtension],
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
require_all: bool = False,
|
||||
) -> "SignedExtensionInventory":
|
||||
catalog = ExtensionCatalog()
|
||||
releases: dict[str, HostedRelease] = {}
|
||||
missing: list[str] = []
|
||||
for descriptor in sorted(expected, key=lambda item: item.plugin_id):
|
||||
plugin_root = state_root / descriptor.plugin_id
|
||||
release_dir = _active_release_dir(plugin_root)
|
||||
if release_dir is None:
|
||||
missing.append(descriptor.plugin_id)
|
||||
continue
|
||||
try:
|
||||
record, _ = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
manifest = load_manifest(release_dir / "plugin.toml")
|
||||
except Exception as error:
|
||||
raise HostedExtensionError(
|
||||
f"signed extension verification failed: {descriptor.plugin_id}"
|
||||
) from error
|
||||
if (
|
||||
record.plugin_id != descriptor.plugin_id
|
||||
or record.author != descriptor.author
|
||||
or manifest.plugin_id != descriptor.plugin_id
|
||||
or manifest.author != descriptor.author
|
||||
or (
|
||||
descriptor.description
|
||||
and manifest.description != descriptor.description
|
||||
)
|
||||
or (
|
||||
descriptor.help_entries
|
||||
and manifest.help_entries != descriptor.help_entries
|
||||
)
|
||||
or manifest.capabilities.commands != descriptor.commands
|
||||
or manifest.capabilities.service_profiles
|
||||
!= descriptor.service_profiles
|
||||
):
|
||||
raise HostedExtensionError(
|
||||
f"signed extension inventory mismatch: {descriptor.plugin_id}"
|
||||
)
|
||||
catalog.register(manifest)
|
||||
releases[descriptor.plugin_id] = HostedRelease(
|
||||
manifest=manifest,
|
||||
release_dir=release_dir,
|
||||
)
|
||||
if require_all and missing:
|
||||
raise HostedExtensionError(
|
||||
"required signed extensions are missing: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
catalog.freeze()
|
||||
return cls(catalog, releases)
|
||||
|
||||
|
||||
class HostedExtensionRuntime:
|
||||
"""Health-gated dispatch over signed, frozen Extension Host releases."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory: SignedExtensionInventory,
|
||||
invokers: Mapping[str, HostInvoker],
|
||||
service_handler: ServiceHandler,
|
||||
*,
|
||||
require_all_healthy: bool = False,
|
||||
) -> None:
|
||||
self.inventory = inventory
|
||||
self.service_handler = service_handler
|
||||
active: dict[str, HostInvoker] = {}
|
||||
failures: dict[str, str] = {}
|
||||
for plugin_id, release in inventory.releases.items():
|
||||
invoker = invokers.get(plugin_id)
|
||||
if invoker is None:
|
||||
failures[plugin_id] = "HostInvokerMissing"
|
||||
continue
|
||||
try:
|
||||
response = invoker({"op": "health"}, None)
|
||||
self._validate_health(plugin_id, release.manifest, response)
|
||||
except Exception as error:
|
||||
failures[plugin_id] = type(error).__name__
|
||||
continue
|
||||
active[plugin_id] = invoker
|
||||
if require_all_healthy and len(active) != len(inventory.releases):
|
||||
unavailable = sorted(set(inventory.releases) - set(active))
|
||||
raise HostedExtensionError(
|
||||
"required extension hosts are unhealthy: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
self._active = MappingProxyType(active)
|
||||
self._failures = MappingProxyType(failures)
|
||||
|
||||
@staticmethod
|
||||
def _validate_health(
|
||||
plugin_id: str,
|
||||
manifest: PluginManifest,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or response.get("author") != manifest.author
|
||||
or response.get("description") != manifest.description
|
||||
or tuple(response.get("help", ())) != manifest.help_entries
|
||||
or tuple(response.get("commands", ()))
|
||||
!= manifest.capabilities.commands
|
||||
or tuple(response.get("service_profiles", ()))
|
||||
!= manifest.capabilities.service_profiles
|
||||
):
|
||||
raise HostedExtensionError("extension host health response mismatch")
|
||||
|
||||
@property
|
||||
def active_plugin_ids(self) -> frozenset[str]:
|
||||
return frozenset(self._active)
|
||||
|
||||
@property
|
||||
def failures(self) -> Mapping[str, str]:
|
||||
return self._failures
|
||||
|
||||
def available(self, plugin_id: str) -> bool:
|
||||
return plugin_id in self._active
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
command: str,
|
||||
arguments: str,
|
||||
event: Mapping[str, Any],
|
||||
) -> ExtensionResult:
|
||||
invoker = self._active.get(plugin_id)
|
||||
if invoker is None:
|
||||
raise HostedExtensionError("extension host is not active")
|
||||
release = self.inventory.releases.get(plugin_id)
|
||||
if release is None:
|
||||
raise HostedExtensionError("extension release is not registered")
|
||||
if command.casefold() not in {
|
||||
value.casefold()
|
||||
for value in release.manifest.capabilities.commands
|
||||
}:
|
||||
raise HostedExtensionError("extension command is not declared")
|
||||
try:
|
||||
response = invoker(
|
||||
{
|
||||
"op": "invoke_command",
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": dict(event),
|
||||
},
|
||||
self.service_handler,
|
||||
)
|
||||
except Exception as error:
|
||||
raise HostedExtensionError("extension host invocation failed") from error
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or not isinstance(response.get("result"), Mapping)
|
||||
):
|
||||
raise HostedExtensionError("extension host response mismatch")
|
||||
try:
|
||||
return ExtensionResult.from_mapping(response["result"])
|
||||
except ContractViolation as error:
|
||||
raise HostedExtensionError(
|
||||
"extension host result violates the SDK contract"
|
||||
) from error
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Parent-side clients for one isolated extension-host invocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .telemetry import RuntimeTelemetry
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 256 * 1024
|
||||
MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_STDERR_BYTES = 64 * 1024
|
||||
MAX_SERVICE_CALLS = 8
|
||||
ServiceHandler = Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
|
||||
|
||||
class HostClientError(ValueError):
|
||||
"""Controlled parent-side host transport failure."""
|
||||
|
||||
|
||||
class HostTimeout(HostClientError):
|
||||
"""Extension host exceeded its parent-enforced wall-time deadline."""
|
||||
|
||||
|
||||
def _message_payload(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
payload = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HostClientError("extension message is not JSON serializable") from error
|
||||
if not payload or len(payload) > MAX_REQUEST_BYTES:
|
||||
raise HostClientError("extension request exceeds the size limit")
|
||||
return payload + b"\n"
|
||||
|
||||
|
||||
def _decode_message(payload: bytes) -> dict[str, Any]:
|
||||
if not payload or len(payload) > MAX_RESPONSE_BYTES:
|
||||
raise HostClientError("extension host output exceeds the size limit")
|
||||
try:
|
||||
response = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise HostClientError("extension host returned invalid JSON") from error
|
||||
if not isinstance(response, dict):
|
||||
raise HostClientError("extension host returned an invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def _final_response(response: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if response.get("status") == "error":
|
||||
raise HostClientError("extension host returned an error")
|
||||
if response.get("status") != "ok":
|
||||
raise HostClientError("extension host returned an invalid response")
|
||||
return dict(response)
|
||||
|
||||
|
||||
def _service_request(
|
||||
response: Mapping[str, Any],
|
||||
) -> tuple[str, str, str, Mapping[str, object]]:
|
||||
call_id = response.get("call_id")
|
||||
profile = response.get("profile")
|
||||
operation = response.get("operation")
|
||||
payload = response.get("payload")
|
||||
if (
|
||||
not isinstance(call_id, str)
|
||||
or not call_id
|
||||
or len(call_id) > 40
|
||||
or not isinstance(profile, str)
|
||||
or not profile
|
||||
or len(profile) > 80
|
||||
or not isinstance(operation, str)
|
||||
or not operation
|
||||
or len(operation) > 80
|
||||
or not isinstance(payload, Mapping)
|
||||
):
|
||||
raise HostClientError("extension host service request is invalid")
|
||||
return call_id, profile, operation, payload
|
||||
|
||||
|
||||
class _MessageReader:
|
||||
def __init__(
|
||||
self,
|
||||
read_chunk: Callable[[float], bytes],
|
||||
*,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
self.read_chunk = read_chunk
|
||||
self.deadline = deadline
|
||||
self.buffer = bytearray()
|
||||
self.total = 0
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
while True:
|
||||
newline = self.buffer.find(b"\n")
|
||||
if newline >= 0:
|
||||
payload = bytes(self.buffer[:newline])
|
||||
del self.buffer[: newline + 1]
|
||||
return _decode_message(payload)
|
||||
remaining = self.deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HostTimeout("extension host timed out")
|
||||
chunk = self.read_chunk(remaining)
|
||||
if not chunk:
|
||||
if self.buffer:
|
||||
payload = bytes(self.buffer)
|
||||
self.buffer.clear()
|
||||
return _decode_message(payload)
|
||||
raise HostClientError("extension host closed without a response")
|
||||
self.total += len(chunk)
|
||||
if self.total > MAX_RESPONSE_BYTES:
|
||||
raise HostClientError(
|
||||
"extension host output exceeds the size limit"
|
||||
)
|
||||
self.buffer.extend(chunk)
|
||||
|
||||
|
||||
def _protocol_exchange(
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
send: Callable[[bytes], None],
|
||||
read_chunk: Callable[[float], bytes],
|
||||
timeout_seconds: float,
|
||||
service_handler: ServiceHandler | None,
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
plugin_id: str = "",
|
||||
transport: str = "",
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
reader = _MessageReader(read_chunk, deadline=deadline)
|
||||
send(_message_payload(request))
|
||||
service_calls = 0
|
||||
while True:
|
||||
response = reader.read()
|
||||
if response.get("status") != "service_request":
|
||||
return _final_response(response)
|
||||
service_calls += 1
|
||||
if service_calls > MAX_SERVICE_CALLS:
|
||||
raise HostClientError("extension host exceeded the service call limit")
|
||||
call_id, profile, operation, payload = _service_request(response)
|
||||
service_started = time.monotonic()
|
||||
if telemetry is not None:
|
||||
telemetry.emit(
|
||||
"host.service.request",
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
)
|
||||
if service_handler is None:
|
||||
send(
|
||||
_message_payload(
|
||||
{
|
||||
"op": "service_error",
|
||||
"call_id": call_id,
|
||||
"error": "ServiceUnavailable",
|
||||
}
|
||||
)
|
||||
)
|
||||
if telemetry is not None:
|
||||
telemetry.elapsed(
|
||||
"host.service.complete",
|
||||
service_started,
|
||||
status="error",
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
error_type="ServiceUnavailable",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
result = service_handler(profile, operation, payload)
|
||||
if not isinstance(result, Mapping):
|
||||
raise HostClientError(
|
||||
"kernel service handler returned an invalid result"
|
||||
)
|
||||
reply = {
|
||||
"op": "service_result",
|
||||
"call_id": call_id,
|
||||
"result": dict(result),
|
||||
}
|
||||
except Exception as error:
|
||||
reply = {
|
||||
"op": "service_error",
|
||||
"call_id": call_id,
|
||||
"error": type(error).__name__[:80],
|
||||
}
|
||||
if time.monotonic() > deadline:
|
||||
raise HostTimeout("kernel service exceeded the host deadline")
|
||||
if telemetry is not None:
|
||||
telemetry.elapsed(
|
||||
"host.service.complete",
|
||||
service_started,
|
||||
status=(
|
||||
"ok"
|
||||
if reply["op"] == "service_result"
|
||||
else "error"
|
||||
),
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
error_type=(
|
||||
""
|
||||
if reply["op"] == "service_result"
|
||||
else str(reply["error"])
|
||||
),
|
||||
)
|
||||
send(_message_payload(reply))
|
||||
|
||||
|
||||
class _StderrCollector(threading.Thread):
|
||||
def __init__(self, stream: Any) -> None:
|
||||
super().__init__(name="jwebot-extension-stderr", daemon=True)
|
||||
self.stream = stream
|
||||
self.content = bytearray()
|
||||
self.overflow = False
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
chunk = self.stream.read(16 * 1024)
|
||||
if not chunk:
|
||||
return
|
||||
remaining = MAX_STDERR_BYTES + 1 - len(self.content)
|
||||
if remaining > 0:
|
||||
self.content.extend(chunk[:remaining])
|
||||
if len(self.content) > MAX_STDERR_BYTES:
|
||||
self.overflow = True
|
||||
|
||||
|
||||
def _kill_process_group(process: subprocess.Popen[bytes]) -> None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
process.kill()
|
||||
|
||||
|
||||
class ExtensionHostClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
command: Sequence[str] | None = None,
|
||||
timeout_seconds: float = 8.0,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned_local: bool = False,
|
||||
plugin_id: str = "",
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
) -> None:
|
||||
self.command = tuple(
|
||||
command
|
||||
or (
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-m",
|
||||
"jwebot_extension_host",
|
||||
)
|
||||
)
|
||||
if (
|
||||
not self.command
|
||||
or timeout_seconds <= 0
|
||||
or (
|
||||
allow_unsigned_local
|
||||
and (
|
||||
allowed_signers is not None
|
||||
or expected_publisher is not None
|
||||
)
|
||||
)
|
||||
):
|
||||
raise HostClientError("invalid extension host client configuration")
|
||||
if command is None and allowed_signers is None and not allow_unsigned_local:
|
||||
raise HostClientError("default extension host requires publisher trust")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.allowed_signers = allowed_signers
|
||||
self.expected_publisher = expected_publisher
|
||||
self.allow_unsigned_local = allow_unsigned_local
|
||||
self.plugin_id = plugin_id[:64]
|
||||
self.telemetry = telemetry
|
||||
|
||||
def _command(self, release_dir: Path) -> list[str]:
|
||||
command = [
|
||||
*self.command,
|
||||
"--release-dir",
|
||||
str(release_dir),
|
||||
]
|
||||
if self.allow_unsigned_local:
|
||||
command.append("--allow-unsigned-local")
|
||||
elif self.allowed_signers is not None:
|
||||
command.extend(
|
||||
[
|
||||
"--allowed-signers",
|
||||
str(self.allowed_signers),
|
||||
]
|
||||
)
|
||||
if self.expected_publisher is not None:
|
||||
command.extend(["--publisher", self.expected_publisher])
|
||||
return command
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
release_dir: Path,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
service_handler: ServiceHandler | None = None,
|
||||
) -> dict[str, Any]:
|
||||
invocation_started = time.monotonic()
|
||||
try:
|
||||
resolved_release = release_dir.resolve(strict=True)
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError(
|
||||
"extension release directory is unavailable"
|
||||
) from error
|
||||
if not resolved_release.is_dir():
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type="ReleaseUnavailable",
|
||||
)
|
||||
raise HostClientError("extension release path is not a directory")
|
||||
|
||||
environment = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key in {"LANG", "LC_ALL", "TZ"}
|
||||
}
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
self._command(resolved_release),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd="/",
|
||||
env=environment,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
bufsize=0,
|
||||
)
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError("cannot start extension host") from error
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.process.spawn",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
process="extension-host",
|
||||
)
|
||||
assert process.stdin is not None
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stderr = _StderrCollector(process.stderr)
|
||||
stderr.start()
|
||||
deadline = time.monotonic() + self.timeout_seconds
|
||||
|
||||
def send(payload: bytes) -> None:
|
||||
try:
|
||||
process.stdin.write(payload)
|
||||
except (BrokenPipeError, OSError) as error:
|
||||
raise HostClientError(
|
||||
"extension host closed its input"
|
||||
) from error
|
||||
|
||||
def read_chunk(timeout: float) -> bytes:
|
||||
ready, _, _ = select.select(
|
||||
[process.stdout.fileno()],
|
||||
[],
|
||||
[],
|
||||
timeout,
|
||||
)
|
||||
if not ready:
|
||||
raise HostTimeout("extension host timed out")
|
||||
return os.read(process.stdout.fileno(), 64 * 1024)
|
||||
|
||||
try:
|
||||
response = _protocol_exchange(
|
||||
request,
|
||||
send=send,
|
||||
read_chunk=read_chunk,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
service_handler=service_handler,
|
||||
telemetry=self.telemetry,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
)
|
||||
process.stdin.close()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HostTimeout("extension host timed out")
|
||||
try:
|
||||
return_code = process.wait(timeout=remaining)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise HostTimeout("extension host timed out") from error
|
||||
stderr.join(timeout=1)
|
||||
if stderr.overflow:
|
||||
raise HostClientError(
|
||||
"extension host output exceeds the size limit"
|
||||
)
|
||||
if return_code != 0:
|
||||
raise HostClientError("extension host returned an error")
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
)
|
||||
return response
|
||||
except Exception as error:
|
||||
if process.poll() is None:
|
||||
_kill_process_group(process)
|
||||
process.wait()
|
||||
stderr.join(timeout=1)
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
if not stream.closed:
|
||||
stream.close()
|
||||
|
||||
|
||||
class UnixExtensionHostClient:
|
||||
"""Client for a systemd-activated AF_UNIX extension-host socket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 8.0,
|
||||
plugin_id: str = "",
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
) -> None:
|
||||
if not socket_path.is_absolute() or timeout_seconds <= 0:
|
||||
raise HostClientError("invalid Unix host client configuration")
|
||||
self.socket_path = socket_path
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.plugin_id = plugin_id[:64]
|
||||
self.telemetry = telemetry
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
service_handler: ServiceHandler | None = None,
|
||||
) -> dict[str, Any]:
|
||||
invocation_started = time.monotonic()
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(self.timeout_seconds)
|
||||
connection.connect(str(self.socket_path))
|
||||
|
||||
def send(payload: bytes) -> None:
|
||||
connection.sendall(payload)
|
||||
|
||||
def read_chunk(timeout: float) -> bytes:
|
||||
connection.settimeout(timeout)
|
||||
return connection.recv(64 * 1024)
|
||||
|
||||
response = _protocol_exchange(
|
||||
request,
|
||||
send=send,
|
||||
read_chunk=read_chunk,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
service_handler=service_handler,
|
||||
telemetry=self.telemetry,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
)
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
)
|
||||
return response
|
||||
except HostTimeout as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
except HostClientError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
except socket.timeout as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type="HostTimeout",
|
||||
)
|
||||
raise HostTimeout("extension host socket timed out") from error
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError(
|
||||
"extension host socket is unavailable"
|
||||
) from error
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Strict parser for reviewed extension manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from jwebot_sdk import API_VERSION
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ModuleNotFoundError:
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
PLUGIN_ID = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
SEMVER = re.compile(r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$")
|
||||
AUTHOR = re.compile(r"^[A-Za-z][A-Za-z0-9._ -]{0,63}$")
|
||||
ENTRYPOINT = re.compile(
|
||||
r"^[A-Za-z_][A-Za-z0-9_.]*:[A-Za-z_][A-Za-z0-9_]*$"
|
||||
)
|
||||
COMMAND = re.compile(r"^/[\w.-]{1,40}$")
|
||||
NETWORK_PROFILE = re.compile(r"^[a-z][a-z0-9-]{1,39}$")
|
||||
ALLOWED_TOP_LEVEL = {
|
||||
"id",
|
||||
"author",
|
||||
"description",
|
||||
"help",
|
||||
"version",
|
||||
"api_version",
|
||||
"entrypoint",
|
||||
"type",
|
||||
"capabilities",
|
||||
}
|
||||
ALLOWED_CAPABILITIES = {
|
||||
"commands",
|
||||
"storage",
|
||||
"network_profiles",
|
||||
"service_profiles",
|
||||
}
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""Raised when an extension manifest violates the kernel policy."""
|
||||
|
||||
|
||||
class PluginType(str, Enum):
|
||||
COMMAND = "command"
|
||||
ENRICHMENT = "enrichment"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
def _required_string(
|
||||
value: Mapping[str, Any],
|
||||
key: str,
|
||||
*,
|
||||
pattern: re.Pattern[str] | None = None,
|
||||
) -> str:
|
||||
raw = value.get(key)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise ManifestError(f"{key} must be a non-empty string")
|
||||
if pattern is not None and not pattern.fullmatch(raw):
|
||||
raise ManifestError(f"{key} has an invalid format")
|
||||
return raw
|
||||
|
||||
|
||||
def _string_list(
|
||||
value: object,
|
||||
*,
|
||||
key: str,
|
||||
pattern: re.Pattern[str],
|
||||
limit: int,
|
||||
) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise ManifestError(f"{key} must be a list of strings")
|
||||
if len(value) > limit:
|
||||
raise ManifestError(f"{key} exceeds {limit} entries")
|
||||
if any(not pattern.fullmatch(item) for item in value):
|
||||
raise ManifestError(f"{key} contains an invalid value")
|
||||
if len(set(value)) != len(value):
|
||||
raise ManifestError(f"{key} contains duplicates")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _optional_description(value: Mapping[str, Any]) -> str:
|
||||
raw = value.get("description", "")
|
||||
if not isinstance(raw, str):
|
||||
raise ManifestError("description must be a string")
|
||||
description = raw.strip()
|
||||
if len(description) > 120 or "\n" in description or "\r" in description:
|
||||
raise ManifestError("description exceeds the display boundary")
|
||||
return description
|
||||
|
||||
|
||||
def _help_entries(value: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
raw = value.get("help", [])
|
||||
if not isinstance(raw, list) or not all(
|
||||
isinstance(item, str)
|
||||
for item in raw
|
||||
):
|
||||
raise ManifestError("help must be a list of strings")
|
||||
if len(raw) > 8:
|
||||
raise ManifestError("help exceeds 8 entries")
|
||||
entries = tuple(item.strip() for item in raw)
|
||||
if any(
|
||||
not item
|
||||
or not item.startswith("/")
|
||||
or len(item) > 160
|
||||
or "\n" in item
|
||||
or "\r" in item
|
||||
for item in entries
|
||||
):
|
||||
raise ManifestError("help contains an invalid display entry")
|
||||
if len(set(entries)) != len(entries):
|
||||
raise ManifestError("help contains duplicates")
|
||||
return entries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRequest:
|
||||
commands: tuple[str, ...] = ()
|
||||
storage: bool = False
|
||||
network_profiles: tuple[str, ...] = ()
|
||||
service_profiles: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
plugin_id: str
|
||||
author: str
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
version: str
|
||||
api_version: str
|
||||
entrypoint: str
|
||||
plugin_type: PluginType
|
||||
capabilities: CapabilityRequest
|
||||
|
||||
|
||||
def parse_manifest(value: Mapping[str, Any]) -> PluginManifest:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ManifestError("manifest must be an object")
|
||||
unknown = set(value) - ALLOWED_TOP_LEVEL
|
||||
if unknown:
|
||||
raise ManifestError(f"unknown manifest keys: {', '.join(sorted(unknown))}")
|
||||
|
||||
plugin_id = _required_string(value, "id", pattern=PLUGIN_ID)
|
||||
author = _required_string(value, "author", pattern=AUTHOR)
|
||||
description = _optional_description(value)
|
||||
help_entries = _help_entries(value)
|
||||
if bool(description) != bool(help_entries):
|
||||
raise ManifestError(
|
||||
"description and help must be declared together"
|
||||
)
|
||||
version = _required_string(value, "version", pattern=SEMVER)
|
||||
api_version = _required_string(value, "api_version")
|
||||
if api_version != API_VERSION:
|
||||
raise ManifestError(f"unsupported api_version: {api_version}")
|
||||
entrypoint = _required_string(value, "entrypoint", pattern=ENTRYPOINT)
|
||||
entry_module = entrypoint.partition(":")[0].partition(".")[0]
|
||||
if entry_module != plugin_id.replace("-", "_"):
|
||||
raise ManifestError("entrypoint must use the plugin module namespace")
|
||||
try:
|
||||
plugin_type = PluginType(_required_string(value, "type"))
|
||||
except ValueError as error:
|
||||
raise ManifestError("type must be command, enrichment, or scheduled") from error
|
||||
|
||||
raw_capabilities = value.get("capabilities", {})
|
||||
if not isinstance(raw_capabilities, Mapping):
|
||||
raise ManifestError("capabilities must be an object")
|
||||
unknown_capabilities = set(raw_capabilities) - ALLOWED_CAPABILITIES
|
||||
if unknown_capabilities:
|
||||
raise ManifestError(
|
||||
"unknown capabilities: " + ", ".join(sorted(unknown_capabilities))
|
||||
)
|
||||
storage = raw_capabilities.get("storage", False)
|
||||
if not isinstance(storage, bool):
|
||||
raise ManifestError("capabilities.storage must be boolean")
|
||||
commands = _string_list(
|
||||
raw_capabilities.get("commands"),
|
||||
key="capabilities.commands",
|
||||
pattern=COMMAND,
|
||||
limit=16,
|
||||
)
|
||||
if len({command.casefold() for command in commands}) != len(commands):
|
||||
raise ManifestError("capabilities.commands contains case-insensitive duplicates")
|
||||
capabilities = CapabilityRequest(
|
||||
commands=commands,
|
||||
storage=storage,
|
||||
network_profiles=_string_list(
|
||||
raw_capabilities.get("network_profiles"),
|
||||
key="capabilities.network_profiles",
|
||||
pattern=NETWORK_PROFILE,
|
||||
limit=8,
|
||||
),
|
||||
service_profiles=_string_list(
|
||||
raw_capabilities.get("service_profiles"),
|
||||
key="capabilities.service_profiles",
|
||||
pattern=NETWORK_PROFILE,
|
||||
limit=8,
|
||||
),
|
||||
)
|
||||
|
||||
if plugin_type is PluginType.COMMAND and not capabilities.commands:
|
||||
raise ManifestError("command extension must declare at least one command")
|
||||
if plugin_type is PluginType.COMMAND and not description:
|
||||
raise ManifestError(
|
||||
"command extension must declare description and help"
|
||||
)
|
||||
if plugin_type is not PluginType.COMMAND and capabilities.commands:
|
||||
raise ManifestError("only command extensions may declare commands")
|
||||
if plugin_type is not PluginType.COMMAND and help_entries:
|
||||
raise ManifestError("only command extensions may declare help")
|
||||
|
||||
return PluginManifest(
|
||||
plugin_id=plugin_id,
|
||||
author=author,
|
||||
description=description,
|
||||
help_entries=help_entries,
|
||||
version=version,
|
||||
api_version=api_version,
|
||||
entrypoint=entrypoint,
|
||||
plugin_type=plugin_type,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> PluginManifest:
|
||||
if tomllib is None:
|
||||
raise ManifestError("TOML parser unavailable; install tomli on Python < 3.11")
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
value = tomllib.load(stream)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
raise ManifestError("cannot read plugin manifest") from error
|
||||
return parse_manifest(value)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Deterministic extension release artifacts and digest verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .manifest import AUTHOR, PluginManifest, load_manifest
|
||||
from .source_policy import validate_source_tree
|
||||
|
||||
|
||||
MAX_RELEASE_FILES = 66
|
||||
MAX_RELEASE_BYTES = 1024 * 1024
|
||||
MAX_ARTIFACT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class ReleaseError(ValueError):
|
||||
"""Raised when a release tree or artifact is not immutable and bounded."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def release_record_bytes(record: "ReleaseRecord") -> bytes:
|
||||
return _canonical_json(record.to_mapping()) + b"\n"
|
||||
|
||||
|
||||
def source_digest_for_files(files: tuple[tuple[str, bytes], ...]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for relative, content in files:
|
||||
path_bytes = relative.encode("utf-8")
|
||||
digest.update(len(path_bytes).to_bytes(4, "big"))
|
||||
digest.update(path_bytes)
|
||||
digest.update(len(content).to_bytes(8, "big"))
|
||||
digest.update(content)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(128 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def collect_release_files(
|
||||
root: Path,
|
||||
manifest: PluginManifest,
|
||||
) -> tuple[tuple[str, bytes], ...]:
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
raise ReleaseError("extension root must be a real directory")
|
||||
validate_source_tree(root, manifest)
|
||||
|
||||
allowed: list[Path] = [root / "plugin.toml"]
|
||||
readme = root / "README.md"
|
||||
if readme.exists():
|
||||
allowed.append(readme)
|
||||
|
||||
source_root = root / "src"
|
||||
for path in sorted(source_root.rglob("*")):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink():
|
||||
raise ReleaseError(f"release symlink is forbidden: {relative}")
|
||||
if path.is_dir():
|
||||
continue
|
||||
if (
|
||||
"__pycache__" in path.relative_to(source_root).parts
|
||||
and path.suffix == ".pyc"
|
||||
):
|
||||
continue
|
||||
if not path.is_file() or path.suffix != ".py":
|
||||
raise ReleaseError(f"unsupported release file: {relative}")
|
||||
allowed.append(path)
|
||||
|
||||
values: list[tuple[str, bytes]] = []
|
||||
total_bytes = 0
|
||||
for path in sorted(set(allowed), key=lambda item: item.relative_to(root).as_posix()):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseError(f"release file is unavailable: {relative}")
|
||||
if PurePosixPath(relative).is_absolute() or ".." in PurePosixPath(relative).parts:
|
||||
raise ReleaseError(f"unsafe release path: {relative}")
|
||||
content = path.read_bytes()
|
||||
total_bytes += len(content)
|
||||
values.append((relative, content))
|
||||
|
||||
if len(values) > MAX_RELEASE_FILES:
|
||||
raise ReleaseError(f"release exceeds {MAX_RELEASE_FILES} files")
|
||||
if total_bytes > MAX_RELEASE_BYTES:
|
||||
raise ReleaseError(f"release exceeds {MAX_RELEASE_BYTES} bytes")
|
||||
return tuple(values)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseRecord:
|
||||
plugin_id: str
|
||||
author: str
|
||||
plugin_version: str
|
||||
api_version: str
|
||||
source_digest: str
|
||||
artifact_name: str
|
||||
artifact_sha256: str
|
||||
artifact_size: int
|
||||
files: tuple[str, ...]
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"plugin_id": self.plugin_id,
|
||||
"author": self.author,
|
||||
"plugin_version": self.plugin_version,
|
||||
"api_version": self.api_version,
|
||||
"source_digest": self.source_digest,
|
||||
"artifact_name": self.artifact_name,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"artifact_size": self.artifact_size,
|
||||
"files": list(self.files),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ReleaseRecord":
|
||||
try:
|
||||
if value.get("schema_version") != 2:
|
||||
raise ReleaseError("unsupported release schema")
|
||||
files = value["files"]
|
||||
if not isinstance(files, list) or not all(
|
||||
isinstance(item, str) for item in files
|
||||
):
|
||||
raise ReleaseError("release files must be a string list")
|
||||
author = value["author"]
|
||||
if not isinstance(author, str) or not AUTHOR.fullmatch(author):
|
||||
raise ReleaseError("release author has an invalid format")
|
||||
record = cls(
|
||||
plugin_id=str(value["plugin_id"]),
|
||||
author=author,
|
||||
plugin_version=str(value["plugin_version"]),
|
||||
api_version=str(value["api_version"]),
|
||||
source_digest=str(value["source_digest"]),
|
||||
artifact_name=str(value["artifact_name"]),
|
||||
artifact_sha256=str(value["artifact_sha256"]),
|
||||
artifact_size=int(value["artifact_size"]),
|
||||
files=tuple(files),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ReleaseError("invalid release record") from error
|
||||
for digest in (record.source_digest, record.artifact_sha256):
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
raise ReleaseError("invalid release digest")
|
||||
if record.artifact_size < 1 or record.artifact_size > MAX_ARTIFACT_BYTES:
|
||||
raise ReleaseError("invalid artifact size")
|
||||
if len(record.files) > MAX_RELEASE_FILES or len(set(record.files)) != len(record.files):
|
||||
raise ReleaseError("invalid release file list")
|
||||
return record
|
||||
|
||||
|
||||
def _write_artifact(
|
||||
path: Path,
|
||||
files: tuple[tuple[str, bytes], ...],
|
||||
) -> None:
|
||||
with path.open("wb") as raw:
|
||||
with gzip.GzipFile(
|
||||
filename="",
|
||||
mode="wb",
|
||||
compresslevel=9,
|
||||
fileobj=raw,
|
||||
mtime=0,
|
||||
) as compressed:
|
||||
with tarfile.open(
|
||||
fileobj=compressed,
|
||||
mode="w",
|
||||
format=tarfile.USTAR_FORMAT,
|
||||
) as archive:
|
||||
for relative, content in files:
|
||||
info = tarfile.TarInfo(name=f"plugin/{relative}")
|
||||
info.size = len(content)
|
||||
info.mode = 0o644
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = ""
|
||||
info.gname = ""
|
||||
info.mtime = 0
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
|
||||
def _publish_immutable(temporary: Path, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
if destination.read_bytes() != temporary.read_bytes():
|
||||
raise ReleaseError(f"immutable release already exists: {destination.name}")
|
||||
temporary.unlink()
|
||||
return
|
||||
temporary.replace(destination)
|
||||
|
||||
|
||||
def build_release(
|
||||
root: Path,
|
||||
output: Path,
|
||||
*,
|
||||
manifest: PluginManifest | None = None,
|
||||
) -> tuple[Path, Path, ReleaseRecord]:
|
||||
manifest = manifest or load_manifest(root / "plugin.toml")
|
||||
files = collect_release_files(root, manifest)
|
||||
source_digest = source_digest_for_files(files)
|
||||
stem = f"{manifest.plugin_id}-{manifest.version}-{source_digest[:16]}"
|
||||
artifact = output / f"{stem}.tar.gz"
|
||||
record_path = output / f"{stem}.release.json"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{stem}.",
|
||||
suffix=".tmp",
|
||||
dir=output,
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
_write_artifact(temporary, files)
|
||||
artifact_size = temporary.stat().st_size
|
||||
if artifact_size > MAX_ARTIFACT_BYTES:
|
||||
raise ReleaseError(f"artifact exceeds {MAX_ARTIFACT_BYTES} bytes")
|
||||
record = ReleaseRecord(
|
||||
plugin_id=manifest.plugin_id,
|
||||
author=manifest.author,
|
||||
plugin_version=manifest.version,
|
||||
api_version=manifest.api_version,
|
||||
source_digest=source_digest,
|
||||
artifact_name=artifact.name,
|
||||
artifact_sha256=_sha256_file(temporary),
|
||||
artifact_size=artifact_size,
|
||||
files=tuple(relative for relative, _ in files),
|
||||
)
|
||||
_publish_immutable(temporary, artifact)
|
||||
record_bytes = release_record_bytes(record)
|
||||
record_temporary = record_path.with_name(f".{record_path.name}.{os.getpid()}.tmp")
|
||||
record_temporary.write_bytes(record_bytes)
|
||||
try:
|
||||
_publish_immutable(record_temporary, record_path)
|
||||
finally:
|
||||
record_temporary.unlink(missing_ok=True)
|
||||
return artifact, record_path, record
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_release_record(path: Path) -> ReleaseRecord:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ReleaseError("cannot read release record") from error
|
||||
if not isinstance(value, Mapping):
|
||||
raise ReleaseError("release record must be an object")
|
||||
return ReleaseRecord.from_mapping(value)
|
||||
|
||||
|
||||
def verify_release(artifact: Path, record: ReleaseRecord) -> None:
|
||||
if artifact.name != record.artifact_name:
|
||||
raise ReleaseError("artifact name does not match release record")
|
||||
try:
|
||||
size = artifact.stat().st_size
|
||||
except OSError as error:
|
||||
raise ReleaseError("artifact is unavailable") from error
|
||||
if size != record.artifact_size:
|
||||
raise ReleaseError("artifact size mismatch")
|
||||
if _sha256_file(artifact) != record.artifact_sha256:
|
||||
raise ReleaseError("artifact digest mismatch")
|
||||
|
||||
files: list[tuple[str, bytes]] = []
|
||||
try:
|
||||
with tarfile.open(artifact, mode="r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) > MAX_RELEASE_FILES:
|
||||
raise ReleaseError("artifact contains too many files")
|
||||
total_bytes = 0
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if (
|
||||
not member.isfile()
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or not path.parts
|
||||
or path.parts[0] != "plugin"
|
||||
):
|
||||
raise ReleaseError("artifact contains an unsafe member")
|
||||
relative = PurePosixPath(*path.parts[1:]).as_posix()
|
||||
if not relative or relative.startswith("/"):
|
||||
raise ReleaseError("artifact contains an unsafe path")
|
||||
total_bytes += member.size
|
||||
if total_bytes > MAX_RELEASE_BYTES:
|
||||
raise ReleaseError("artifact expands beyond the size limit")
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ReleaseError("artifact member cannot be read")
|
||||
files.append((relative, stream.read()))
|
||||
except (OSError, tarfile.TarError, EOFError) as error:
|
||||
raise ReleaseError("cannot read release artifact") from error
|
||||
|
||||
normalized = tuple(sorted(files))
|
||||
if tuple(path for path, _ in normalized) != record.files:
|
||||
raise ReleaseError("artifact file list mismatch")
|
||||
if source_digest_for_files(normalized) != record.source_digest:
|
||||
raise ReleaseError("artifact source digest mismatch")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pure routing primitives with no model, storage, or GUI dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class RouteKind(str, Enum):
|
||||
LOCAL = "local"
|
||||
CHAT = "chat"
|
||||
CLOUD = "cloud"
|
||||
|
||||
|
||||
class ExecutionPath(str, Enum):
|
||||
LOCAL_COMMAND = "local-command"
|
||||
DEEPSEEK = "deepseek"
|
||||
OPENCLAW = "openclaw"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDecision:
|
||||
route: RouteKind
|
||||
reason: str
|
||||
command_kind: str = ""
|
||||
arguments: str = ""
|
||||
|
||||
@property
|
||||
def execution_path(self) -> ExecutionPath:
|
||||
return {
|
||||
RouteKind.LOCAL: ExecutionPath.LOCAL_COMMAND,
|
||||
RouteKind.CHAT: ExecutionPath.DEEPSEEK,
|
||||
RouteKind.CLOUD: ExecutionPath.OPENCLAW,
|
||||
}[self.route]
|
||||
|
||||
|
||||
def parse_local_command(
|
||||
question: str,
|
||||
aliases: Mapping[str, str],
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the longest registered slash-command prefix."""
|
||||
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
return "", ""
|
||||
raw = normalized[1:].lstrip()
|
||||
folded = raw.casefold()
|
||||
for command, kind in sorted(
|
||||
aliases.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
command_key = command.casefold()
|
||||
if folded == command_key:
|
||||
return kind, ""
|
||||
if folded.startswith(command_key):
|
||||
return kind, raw[len(command):].strip()
|
||||
command, _, arguments = raw.partition(" ")
|
||||
return aliases.get(command.casefold(), ""), arguments.strip()
|
||||
|
||||
|
||||
def classify_question(
|
||||
question: str,
|
||||
aliases: Mapping[str, str],
|
||||
) -> RouteDecision:
|
||||
normalized = question.lstrip()
|
||||
command_kind, arguments = parse_local_command(normalized, aliases)
|
||||
if command_kind:
|
||||
return RouteDecision(
|
||||
route=RouteKind.LOCAL,
|
||||
reason=f"local_{command_kind}",
|
||||
command_kind=command_kind,
|
||||
arguments=arguments,
|
||||
)
|
||||
if normalized.startswith("/"):
|
||||
return RouteDecision(route=RouteKind.LOCAL, reason="local_unknown")
|
||||
if normalized.startswith(("#", "#")):
|
||||
return RouteDecision(route=RouteKind.CLOUD, reason="hash_command")
|
||||
return RouteDecision(route=RouteKind.CHAT, reason="direct_chat")
|
||||
@@ -0,0 +1,396 @@
|
||||
"""OpenSSH publisher signatures for canonical extension release records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
load_release_record,
|
||||
release_record_bytes,
|
||||
verify_release,
|
||||
)
|
||||
|
||||
|
||||
SIGNATURE_SCHEMA_VERSION = 1
|
||||
SIGNATURE_FORMAT = "sshsig"
|
||||
SIGNATURE_NAMESPACE = "jwebot-release-v1"
|
||||
MAX_RELEASE_RECORD_BYTES = 128 * 1024
|
||||
MAX_SIGNATURE_BYTES = 16 * 1024
|
||||
MAX_ALLOWED_SIGNERS_BYTES = 64 * 1024
|
||||
PUBLISHER_ID = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
|
||||
RELEASE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,159}\.release\.json$")
|
||||
SSH_SIGNATURE_HEADER = b"-----BEGIN SSH SIGNATURE-----"
|
||||
SSH_SIGNATURE_FOOTER = b"-----END SSH SIGNATURE-----"
|
||||
SIGNATURE_PAYLOAD_PREFIX = b"JWEBOT-RELEASE-SIGNATURE-V1\0"
|
||||
|
||||
|
||||
class SignatureError(ReleaseError):
|
||||
"""Raised when a release signature or publisher trust check fails."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _signature_payload(
|
||||
publisher: str,
|
||||
release_name: str,
|
||||
release_bytes: bytes,
|
||||
) -> bytes:
|
||||
publisher_bytes = publisher.encode("ascii")
|
||||
name_bytes = release_name.encode("ascii")
|
||||
return b"".join(
|
||||
(
|
||||
SIGNATURE_PAYLOAD_PREFIX,
|
||||
len(publisher_bytes).to_bytes(2, "big"),
|
||||
publisher_bytes,
|
||||
len(name_bytes).to_bytes(2, "big"),
|
||||
name_bytes,
|
||||
len(release_bytes).to_bytes(8, "big"),
|
||||
release_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _release_name(record: ReleaseRecord) -> str:
|
||||
suffix = ".tar.gz"
|
||||
if not record.artifact_name.endswith(suffix):
|
||||
raise SignatureError("release artifact name is invalid")
|
||||
name = f"{record.artifact_name[: -len(suffix)]}.release.json"
|
||||
if not RELEASE_NAME.fullmatch(name):
|
||||
raise SignatureError("release record name is invalid")
|
||||
return name
|
||||
|
||||
|
||||
def _read_canonical_release(path: Path) -> tuple[ReleaseRecord, bytes]:
|
||||
record = load_release_record(path)
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("release record is unavailable") from error
|
||||
if len(content) > MAX_RELEASE_RECORD_BYTES:
|
||||
raise SignatureError("release record exceeds the size limit")
|
||||
if content != release_record_bytes(record):
|
||||
raise SignatureError("release record is not canonical")
|
||||
return record, content
|
||||
|
||||
|
||||
def _publish_immutable(content: bytes, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
try:
|
||||
current = destination.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("signature destination is unavailable") from error
|
||||
if current != content:
|
||||
raise SignatureError(
|
||||
f"immutable signature already exists: {destination.name}"
|
||||
)
|
||||
return
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=destination.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _signature_path(release_path: Path) -> Path:
|
||||
suffix = ".release.json"
|
||||
if not release_path.name.endswith(suffix):
|
||||
raise SignatureError("release record name is invalid")
|
||||
stem = release_path.name[: -len(suffix)]
|
||||
return release_path.with_name(f"{stem}.release.sig.json")
|
||||
|
||||
|
||||
def _run_ssh_keygen(
|
||||
arguments: list[str],
|
||||
*,
|
||||
input_bytes: bytes,
|
||||
ssh_keygen: Path,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(ssh_keygen), *arguments],
|
||||
input=input_bytes,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
env={
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise SignatureError("ssh-keygen execution failed") from error
|
||||
if len(result.stdout) > MAX_SIGNATURE_BYTES or len(result.stderr) > 16 * 1024:
|
||||
raise SignatureError("ssh-keygen output exceeds the size limit")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_allowed_signers(path: Path) -> None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("trusted publisher file is unavailable") from error
|
||||
if not 1 <= len(content) <= MAX_ALLOWED_SIGNERS_BYTES:
|
||||
raise SignatureError("trusted publisher file is invalid")
|
||||
try:
|
||||
text = content.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SignatureError("trusted publisher file must be ASCII") from error
|
||||
key_count = 0
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
fields = stripped.split()
|
||||
if len(fields) < 3 or fields[1] != "ssh-ed25519":
|
||||
raise SignatureError(
|
||||
"trusted publishers must use simple Ed25519 entries"
|
||||
)
|
||||
principals = fields[0].split(",")
|
||||
if not principals or any(
|
||||
not PUBLISHER_ID.fullmatch(principal)
|
||||
for principal in principals
|
||||
):
|
||||
raise SignatureError("trusted publisher principal is invalid")
|
||||
try:
|
||||
key_blob = base64.b64decode(fields[2], validate=True)
|
||||
except (ValueError, binascii.Error) as error:
|
||||
raise SignatureError("trusted publisher key is invalid") from error
|
||||
if not 32 <= len(key_blob) <= 256:
|
||||
raise SignatureError("trusted publisher key is invalid")
|
||||
key_count += 1
|
||||
if key_count > 64:
|
||||
raise SignatureError("trusted publisher file has too many keys")
|
||||
if key_count == 0:
|
||||
raise SignatureError("trusted publisher file contains no keys")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSignature:
|
||||
publisher: str
|
||||
release_name: str
|
||||
release_sha256: str
|
||||
signature: bytes
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SIGNATURE_SCHEMA_VERSION,
|
||||
"format": SIGNATURE_FORMAT,
|
||||
"namespace": SIGNATURE_NAMESPACE,
|
||||
"publisher": self.publisher,
|
||||
"release_name": self.release_name,
|
||||
"release_sha256": self.release_sha256,
|
||||
"signature_base64": base64.b64encode(self.signature).decode("ascii"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ReleaseSignature":
|
||||
expected = {
|
||||
"schema_version",
|
||||
"format",
|
||||
"namespace",
|
||||
"publisher",
|
||||
"release_name",
|
||||
"release_sha256",
|
||||
"signature_base64",
|
||||
}
|
||||
if set(value) != expected:
|
||||
raise SignatureError("invalid release signature fields")
|
||||
if value.get("schema_version") != SIGNATURE_SCHEMA_VERSION:
|
||||
raise SignatureError("unsupported release signature schema")
|
||||
if value.get("format") != SIGNATURE_FORMAT:
|
||||
raise SignatureError("unsupported release signature format")
|
||||
if value.get("namespace") != SIGNATURE_NAMESPACE:
|
||||
raise SignatureError("invalid release signature namespace")
|
||||
publisher = value.get("publisher")
|
||||
release_name = value.get("release_name")
|
||||
release_sha256 = value.get("release_sha256")
|
||||
encoded = value.get("signature_base64")
|
||||
if not isinstance(publisher, str) or not PUBLISHER_ID.fullmatch(publisher):
|
||||
raise SignatureError("publisher has an invalid format")
|
||||
if not isinstance(release_name, str) or not RELEASE_NAME.fullmatch(
|
||||
release_name
|
||||
):
|
||||
raise SignatureError("signed release name has an invalid format")
|
||||
if (
|
||||
not isinstance(release_sha256, str)
|
||||
or len(release_sha256) != 64
|
||||
or any(value not in "0123456789abcdef" for value in release_sha256)
|
||||
):
|
||||
raise SignatureError("signed release digest is invalid")
|
||||
if not isinstance(encoded, str) or len(encoded) > MAX_SIGNATURE_BYTES * 2:
|
||||
raise SignatureError("encoded release signature is invalid")
|
||||
try:
|
||||
signature = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, binascii.Error) as error:
|
||||
raise SignatureError("encoded release signature is invalid") from error
|
||||
if (
|
||||
not signature.startswith(SSH_SIGNATURE_HEADER)
|
||||
or SSH_SIGNATURE_FOOTER not in signature
|
||||
or len(signature) > MAX_SIGNATURE_BYTES
|
||||
):
|
||||
raise SignatureError("OpenSSH release signature is invalid")
|
||||
return cls(
|
||||
publisher=publisher,
|
||||
release_name=release_name,
|
||||
release_sha256=release_sha256,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
|
||||
def release_signature_bytes(signature: ReleaseSignature) -> bytes:
|
||||
return _canonical_json(signature.to_mapping()) + b"\n"
|
||||
|
||||
|
||||
def load_release_signature(path: Path) -> ReleaseSignature:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
value = json.loads(content.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SignatureError("cannot read release signature") from error
|
||||
if not isinstance(value, Mapping):
|
||||
raise SignatureError("release signature must be an object")
|
||||
signature = ReleaseSignature.from_mapping(value)
|
||||
if content != release_signature_bytes(signature):
|
||||
raise SignatureError("release signature record is not canonical")
|
||||
return signature
|
||||
|
||||
|
||||
def sign_release_record(
|
||||
release_path: Path,
|
||||
private_key: Path,
|
||||
publisher: str,
|
||||
*,
|
||||
artifact: Path | None = None,
|
||||
output: Path | None = None,
|
||||
ssh_keygen: Path = Path("/usr/bin/ssh-keygen"),
|
||||
) -> tuple[Path, ReleaseSignature]:
|
||||
if not PUBLISHER_ID.fullmatch(publisher):
|
||||
raise SignatureError("publisher has an invalid format")
|
||||
if not private_key.is_file():
|
||||
raise SignatureError("publisher private key is unavailable")
|
||||
record, release_bytes = _read_canonical_release(release_path)
|
||||
expected_release_name = _release_name(record)
|
||||
if release_path.name != expected_release_name:
|
||||
raise SignatureError("release record name does not match its artifact")
|
||||
verify_release(
|
||||
artifact or release_path.with_name(record.artifact_name),
|
||||
record,
|
||||
)
|
||||
result = _run_ssh_keygen(
|
||||
[
|
||||
"-Y",
|
||||
"sign",
|
||||
"-q",
|
||||
"-f",
|
||||
str(private_key),
|
||||
"-n",
|
||||
SIGNATURE_NAMESPACE,
|
||||
"-",
|
||||
],
|
||||
input_bytes=_signature_payload(
|
||||
publisher,
|
||||
expected_release_name,
|
||||
release_bytes,
|
||||
),
|
||||
ssh_keygen=ssh_keygen,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignatureError("release signing failed")
|
||||
signature = ReleaseSignature(
|
||||
publisher=publisher,
|
||||
release_name=expected_release_name,
|
||||
release_sha256=_sha256(release_bytes),
|
||||
signature=result.stdout,
|
||||
)
|
||||
signature = ReleaseSignature.from_mapping(signature.to_mapping())
|
||||
output = output or _signature_path(release_path)
|
||||
_publish_immutable(release_signature_bytes(signature), output)
|
||||
return output, signature
|
||||
|
||||
|
||||
def verify_release_signature(
|
||||
release_path: Path,
|
||||
signature_path: Path,
|
||||
allowed_signers: Path,
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
ssh_keygen: Path = Path("/usr/bin/ssh-keygen"),
|
||||
) -> ReleaseSignature:
|
||||
record, release_bytes = _read_canonical_release(release_path)
|
||||
signature = load_release_signature(signature_path)
|
||||
if expected_publisher is not None and signature.publisher != expected_publisher:
|
||||
raise SignatureError("release publisher does not match policy")
|
||||
if signature.release_name != _release_name(record):
|
||||
raise SignatureError("signature references a different release record")
|
||||
if signature.release_sha256 != _sha256(release_bytes):
|
||||
raise SignatureError("signed release digest mismatch")
|
||||
if not allowed_signers.is_file():
|
||||
raise SignatureError("trusted publisher file is invalid")
|
||||
_validate_allowed_signers(allowed_signers)
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".jwebot-release-signature.",
|
||||
suffix=".sshsig",
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(signature.signature)
|
||||
result = _run_ssh_keygen(
|
||||
[
|
||||
"-Y",
|
||||
"verify",
|
||||
"-q",
|
||||
"-f",
|
||||
str(allowed_signers),
|
||||
"-I",
|
||||
signature.publisher,
|
||||
"-n",
|
||||
SIGNATURE_NAMESPACE,
|
||||
"-s",
|
||||
str(temporary),
|
||||
],
|
||||
input_bytes=_signature_payload(
|
||||
signature.publisher,
|
||||
signature.release_name,
|
||||
release_bytes,
|
||||
),
|
||||
ssh_keygen=ssh_keygen,
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
raise SignatureError("release signature verification failed")
|
||||
return signature
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Static preflight policy for reviewed extension source trees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
MAX_SOURCE_FILES = 64
|
||||
MAX_SOURCE_BYTES = 512 * 1024
|
||||
ALLOWED_STDLIB_IMPORTS = frozenset(
|
||||
{
|
||||
"collections",
|
||||
"dataclasses",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"enum",
|
||||
"functools",
|
||||
"itertools",
|
||||
"math",
|
||||
"operator",
|
||||
"re",
|
||||
"statistics",
|
||||
"string",
|
||||
"typing",
|
||||
}
|
||||
)
|
||||
FORBIDDEN_CALLS = frozenset(
|
||||
{
|
||||
"__import__",
|
||||
"breakpoint",
|
||||
"compile",
|
||||
"delattr",
|
||||
"eval",
|
||||
"exec",
|
||||
"getattr",
|
||||
"globals",
|
||||
"input",
|
||||
"locals",
|
||||
"open",
|
||||
"setattr",
|
||||
"vars",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyFinding:
|
||||
path: str
|
||||
line: int
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class SourcePolicyError(ValueError):
|
||||
def __init__(self, findings: tuple[PolicyFinding, ...]) -> None:
|
||||
self.findings = findings
|
||||
summary = "; ".join(
|
||||
f"{finding.path}:{finding.line} {finding.code} {finding.message}"
|
||||
for finding in findings[:5]
|
||||
)
|
||||
super().__init__(summary or "extension source policy failed")
|
||||
|
||||
|
||||
class _PolicyVisitor(ast.NodeVisitor):
|
||||
def __init__(self, path: str, local_module: str) -> None:
|
||||
self.path = path
|
||||
self.local_module = local_module
|
||||
self.findings: list[PolicyFinding] = []
|
||||
|
||||
def add(self, node: ast.AST, code: str, message: str) -> None:
|
||||
self.findings.append(
|
||||
PolicyFinding(
|
||||
path=self.path,
|
||||
line=getattr(node, "lineno", 1),
|
||||
code=code,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
def import_allowed(self, module: str) -> bool:
|
||||
top_level = module.partition(".")[0]
|
||||
return (
|
||||
top_level == "jwebot_sdk"
|
||||
or top_level == self.local_module
|
||||
or top_level in ALLOWED_STDLIB_IMPORTS
|
||||
)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
if not self.import_allowed(alias.name):
|
||||
self.add(
|
||||
node,
|
||||
"JWP001",
|
||||
f"import is not allowed: {alias.name}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
if node.level == 0 and not self.import_allowed(node.module or ""):
|
||||
self.add(
|
||||
node,
|
||||
"JWP001",
|
||||
f"import is not allowed: {node.module or '<unknown>'}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_CALLS:
|
||||
self.add(
|
||||
node,
|
||||
"JWP002",
|
||||
f"dynamic or privileged builtin is not allowed: {node.func.id}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
||||
if node.attr.startswith("__") and node.attr.endswith("__"):
|
||||
self.add(
|
||||
node,
|
||||
"JWP003",
|
||||
f"dunder attribute access is not allowed: {node.attr}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def validate_source_tree(
|
||||
root: Path,
|
||||
manifest: PluginManifest,
|
||||
) -> tuple[Path, ...]:
|
||||
source_root = root / "src"
|
||||
if not source_root.is_dir() or source_root.is_symlink():
|
||||
raise SourcePolicyError(
|
||||
(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP004",
|
||||
message="src must be a real directory",
|
||||
),
|
||||
)
|
||||
)
|
||||
paths = sorted(source_root.rglob("*.py"))
|
||||
findings: list[PolicyFinding] = []
|
||||
if not paths:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP004",
|
||||
message="extension contains no Python source",
|
||||
)
|
||||
)
|
||||
if len(paths) > MAX_SOURCE_FILES:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP005",
|
||||
message=f"extension exceeds {MAX_SOURCE_FILES} source files",
|
||||
)
|
||||
)
|
||||
|
||||
local_module = manifest.entrypoint.partition(":")[0].partition(".")[0]
|
||||
total_bytes = 0
|
||||
for path in paths:
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=1,
|
||||
code="JWP006",
|
||||
message="source symlinks and non-regular files are forbidden",
|
||||
)
|
||||
)
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
total_bytes += len(raw)
|
||||
try:
|
||||
source = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=1,
|
||||
code="JWP007",
|
||||
message="source must be UTF-8",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source, filename=relative)
|
||||
except SyntaxError as error:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=error.lineno or 1,
|
||||
code="JWP008",
|
||||
message="source contains invalid Python syntax",
|
||||
)
|
||||
)
|
||||
continue
|
||||
visitor = _PolicyVisitor(relative, local_module)
|
||||
visitor.visit(tree)
|
||||
findings.extend(visitor.findings)
|
||||
|
||||
if total_bytes > MAX_SOURCE_BYTES:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP009",
|
||||
message=f"source exceeds {MAX_SOURCE_BYTES} bytes",
|
||||
)
|
||||
)
|
||||
if findings:
|
||||
raise SourcePolicyError(tuple(findings))
|
||||
return tuple(paths)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Privacy-safe structured runtime telemetry for latency and failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Union
|
||||
|
||||
|
||||
TELEMETRY_SCHEMA_VERSION = 1
|
||||
EVENT_NAME = re.compile(r"^[a-z][a-z0-9_.-]{2,79}$")
|
||||
FIELD_NAME = re.compile(r"^[a-z][a-z0-9_]{1,39}$")
|
||||
ALLOWED_FIELDS = frozenset(
|
||||
{
|
||||
"ack_kind",
|
||||
"attempts",
|
||||
"bytes",
|
||||
"chars",
|
||||
"count",
|
||||
"confirmed_by",
|
||||
"delivered",
|
||||
"depth",
|
||||
"duplicate",
|
||||
"error_type",
|
||||
"execution_path",
|
||||
"fallback",
|
||||
"flag",
|
||||
"flag_wait_ms",
|
||||
"height",
|
||||
"mode",
|
||||
"operation",
|
||||
"outcome",
|
||||
"phase",
|
||||
"plugin_id",
|
||||
"pixels",
|
||||
"polls",
|
||||
"process",
|
||||
"queue",
|
||||
"queue_state",
|
||||
"reason",
|
||||
"roi",
|
||||
"route",
|
||||
"source",
|
||||
"threshold",
|
||||
"timeout_ms",
|
||||
"transport",
|
||||
"worker",
|
||||
"width",
|
||||
"x",
|
||||
"y",
|
||||
}
|
||||
)
|
||||
Scalar = Union[str, int, float, bool, None]
|
||||
|
||||
|
||||
def hash_identifier(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
return ""
|
||||
return hashlib.sha256(
|
||||
text.encode("utf-8", "replace")
|
||||
).hexdigest()[:12]
|
||||
|
||||
|
||||
class RuntimeTelemetry:
|
||||
"""Emit bounded JSON logs and retain low-cardinality process aggregates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
component: str,
|
||||
enabled: bool = True,
|
||||
observer: Callable[[Mapping[str, Scalar]], None] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.component = component[:40]
|
||||
self.enabled = enabled
|
||||
self.observer = observer
|
||||
self.logger = logger or logging.getLogger("jwebot.telemetry")
|
||||
self.monotonic = monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._counts: dict[str, int] = {}
|
||||
self._latencies: dict[str, dict[str, float]] = {}
|
||||
|
||||
def emit(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
status: str = "ok",
|
||||
elapsed_ms: float | None = None,
|
||||
event_id: object = "",
|
||||
group_id: object = "",
|
||||
**fields: Scalar,
|
||||
) -> Mapping[str, Scalar]:
|
||||
if not self.enabled:
|
||||
return MappingProxyType({})
|
||||
safe_name = name if EVENT_NAME.fullmatch(name) else "telemetry.invalid"
|
||||
safe_status = (
|
||||
status[:24]
|
||||
if isinstance(status, str) and status
|
||||
else "unknown"
|
||||
)
|
||||
value: dict[str, Scalar] = {
|
||||
"schema_version": TELEMETRY_SCHEMA_VERSION,
|
||||
"at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"name": safe_name,
|
||||
"status": safe_status,
|
||||
"component": self.component,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
event_hash = hash_identifier(event_id)
|
||||
group_hash = hash_identifier(group_id)
|
||||
if event_hash:
|
||||
value["event_hash"] = event_hash
|
||||
if group_hash:
|
||||
value["group_hash"] = group_hash
|
||||
if elapsed_ms is not None:
|
||||
value["elapsed_ms"] = round(max(0.0, float(elapsed_ms)), 3)
|
||||
for key, item in fields.items():
|
||||
if (
|
||||
key in ALLOWED_FIELDS
|
||||
and FIELD_NAME.fullmatch(key)
|
||||
and isinstance(item, (str, int, float, bool, type(None)))
|
||||
):
|
||||
value[key] = item[:120] if isinstance(item, str) else item
|
||||
metric_key = f"{safe_name}:{safe_status}"
|
||||
with self._lock:
|
||||
self._counts[metric_key] = self._counts.get(metric_key, 0) + 1
|
||||
if elapsed_ms is not None:
|
||||
latency = self._latencies.setdefault(
|
||||
safe_name,
|
||||
{"count": 0.0, "total_ms": 0.0, "max_ms": 0.0},
|
||||
)
|
||||
latency["count"] += 1
|
||||
latency["total_ms"] += max(0.0, float(elapsed_ms))
|
||||
latency["max_ms"] = max(
|
||||
latency["max_ms"],
|
||||
max(0.0, float(elapsed_ms)),
|
||||
)
|
||||
try:
|
||||
self.logger.info(
|
||||
"telemetry %s",
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if self.observer is not None:
|
||||
try:
|
||||
self.observer(MappingProxyType(dict(value)))
|
||||
except Exception:
|
||||
pass
|
||||
return MappingProxyType(value)
|
||||
|
||||
def elapsed(
|
||||
self,
|
||||
name: str,
|
||||
started: float,
|
||||
**kwargs: Any,
|
||||
) -> Mapping[str, Scalar]:
|
||||
return self.emit(
|
||||
name,
|
||||
elapsed_ms=(self.monotonic() - started) * 1000,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def span(
|
||||
self,
|
||||
name: str,
|
||||
**fields: Scalar,
|
||||
) -> Iterator[None]:
|
||||
started = self.monotonic()
|
||||
try:
|
||||
yield
|
||||
except BaseException as error:
|
||||
self.elapsed(
|
||||
name,
|
||||
started,
|
||||
status="error",
|
||||
error_type=type(error).__name__,
|
||||
**fields,
|
||||
)
|
||||
raise
|
||||
else:
|
||||
self.elapsed(name, started, **fields)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
counts = dict(self._counts)
|
||||
latencies = {
|
||||
name: {
|
||||
"count": int(value["count"]),
|
||||
"average_ms": round(
|
||||
value["total_ms"] / value["count"],
|
||||
3,
|
||||
),
|
||||
"max_ms": round(value["max_ms"], 3),
|
||||
}
|
||||
for name, value in self._latencies.items()
|
||||
if value["count"]
|
||||
}
|
||||
return {
|
||||
"schema_version": TELEMETRY_SCHEMA_VERSION,
|
||||
"counts": counts,
|
||||
"latencies": latencies,
|
||||
}
|
||||
Reference in New Issue
Block a user