Source code for wolfhece._plugin_trust

"""Plugin trust store — persists per-plugin approval decisions.

Trust levels
------------
``BUILTIN``
    The plugin ships inside ``wolfhece/_builtin_plugins/``.  Always trusted,
    no user action required.

``APPROVED``
    The user has explicitly approved the plugin *in its current state*: the
    SHA-256 digest of ``companion.py`` + ``plugin.toml`` matches the stored
    value.

``CHANGED``
    The user previously approved the plugin, but at least one of the two
    tracked files has changed since then.  Re-approval is required.

``UNKNOWN``
    The plugin has never been seen; the user has not yet been asked.

Usage example
-------------
::

    from wolfhece._plugin_trust import get_default_store, TrustStatus

    store = get_default_store()
    status = store.get_status(plugin_info)
    if status in (TrustStatus.UNKNOWN, TrustStatus.CHANGED):
        # --- show approval dialog to the user ---
        store.approve(plugin_info)

The trust file is a human-readable JSON document stored in the OS user-config
directory (no extra dependencies required):

* **Windows** — ``%APPDATA%\\wolfhece\\trusted_plugins.json``
* **Linux / macOS** — ``$XDG_CONFIG_HOME/wolfhece/trusted_plugins.json``
  (defaults to ``~/.config/wolfhece/``)
"""
from __future__ import annotations

import hashlib
import json
import logging
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from wolfhece._plugin_loader import PluginInfo

[docs] _logger = logging.getLogger(__name__)
#: Directory that ships with the wolfhece package. #: All plugins located under this path are considered built-in and implicitly #: trusted — no user approval is needed.
[docs] BUILTIN_PLUGINS_DIR: Path = Path(__file__).parent / '_builtin_plugins'
# --------------------------------------------------------------------------- # TrustStatus # ---------------------------------------------------------------------------
[docs] class TrustStatus(Enum): """Approval status of a discovered plugin."""
[docs] BUILTIN = auto() #: Ships with wolfhece — always trusted.
[docs] APPROVED = auto() #: User-approved; files unchanged since approval.
[docs] CHANGED = auto() #: Approved before, but files changed — re-approve.
[docs] UNKNOWN = auto() #: Never approved; user has not been asked yet.
# --------------------------------------------------------------------------- # Hash helpers # ---------------------------------------------------------------------------
[docs] def compute_plugin_hash(plugin_dir: Path) -> str: """Return a SHA-256 hex digest that covers ``companion.py`` + ``plugin.toml``. Both file names are included in the digest so that renaming a file without changing its content still invalidates the hash. The files are processed in a deterministic order so the result is stable across platforms. :param plugin_dir: Root directory of the plugin (must contain both files). :return: 64-character lowercase hex string. """ h = hashlib.sha256() for filename in ('plugin.toml', 'companion.py'): fp = plugin_dir / filename if fp.exists(): h.update(filename.encode()) h.update(fp.read_bytes()) return h.hexdigest()
# --------------------------------------------------------------------------- # User-config directory (no external deps) # ---------------------------------------------------------------------------
[docs] def _user_config_dir() -> Path: """Return the wolfhece user-config directory without importing extras.""" import os if os.name == 'nt': # Windows base = Path(os.environ.get('APPDATA', str(Path.home()))) else: # Linux / macOS base = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) return base / 'wolfhece'
# --------------------------------------------------------------------------- # TrustStore # ---------------------------------------------------------------------------
[docs] class TrustStore: """Persist per-plugin approval decisions in a local JSON file. Each entry maps a plugin *slug* (``PluginInfo.name``) to a record with: * ``hash`` — SHA-256 digest at the time of approval * ``approved_at`` — ISO-8601 UTC timestamp * ``display_name`` — for human readability only * ``version`` — version string at the time of approval Parameters ---------- trust_file : Path to the JSON trust file. Defaults to ``<user-config-dir>/wolfhece/trusted_plugins.json``. """ def __init__(self, trust_file: Path | None = None) -> None: if trust_file is None: trust_file = _user_config_dir() / 'trusted_plugins.json'
[docs] self._trust_file: Path = trust_file
[docs] self._data: dict[str, dict] = self._load()
# -- persistence ---------------------------------------------------------
[docs] def _load(self) -> dict[str, dict]: if not self._trust_file.exists(): return {} try: return json.loads(self._trust_file.read_text(encoding='utf-8')) except Exception as exc: _logger.warning( "Could not read trust store '%s': %s", self._trust_file, exc ) return {}
[docs] def _save(self) -> None: try: self._trust_file.parent.mkdir(parents=True, exist_ok=True) self._trust_file.write_text( json.dumps(self._data, indent=2, ensure_ascii=False), encoding='utf-8', ) except Exception as exc: _logger.warning( "Could not save trust store '%s': %s", self._trust_file, exc )
# -- public API ----------------------------------------------------------
[docs] def get_status(self, info: 'PluginInfo') -> TrustStatus: """Return the :class:`TrustStatus` for *info*. The check is performed in this order: 1. If the plugin path is inside :data:`BUILTIN_PLUGINS_DIR` → :attr:`~TrustStatus.BUILTIN`. 2. If no record exists for ``info.name`` → :attr:`~TrustStatus.UNKNOWN`. 3. If the stored hash matches the current digest → :attr:`~TrustStatus.APPROVED`. 4. Otherwise → :attr:`~TrustStatus.CHANGED`. """ try: info.path.resolve().relative_to(BUILTIN_PLUGINS_DIR.resolve()) return TrustStatus.BUILTIN except ValueError: pass record = self._data.get(info.name) if record is None: return TrustStatus.UNKNOWN current_hash = compute_plugin_hash(info.path) if record.get('hash') == current_hash: return TrustStatus.APPROVED return TrustStatus.CHANGED
[docs] def approve(self, info: 'PluginInfo') -> None: """Record user approval for the *current* state of *info*. Computes the hash of ``companion.py`` + ``plugin.toml`` and stores it alongside a timestamp and human-readable metadata. """ import datetime self._data[info.name] = { 'hash': compute_plugin_hash(info.path), 'approved_at': datetime.datetime.now( datetime.timezone.utc ).isoformat(), 'display_name': info.display_name, 'version': info.manifest.version, } self._save() _logger.info( "Plugin '%s' v%s approved.", info.name, info.manifest.version )
[docs] def revoke(self, name: str) -> None: """Remove any stored approval for the plugin *name*. After revocation :meth:`get_status` returns :attr:`~TrustStatus.UNKNOWN` for that slug. """ if name in self._data: self._data.pop(name) self._save() _logger.info("Plugin '%s' trust revoked.", name)
[docs] def get_approved_at(self, name: str) -> str | None: """Return the ISO-8601 timestamp of the last approval, or ``None``.""" record = self._data.get(name) return record.get('approved_at') if record else None
@property
[docs] def trust_file(self) -> Path: """Path to the backing JSON file.""" return self._trust_file
# --------------------------------------------------------------------------- # Module-level default store (lazy singleton) # ---------------------------------------------------------------------------
[docs] _default_store: TrustStore | None = None
[docs] def get_default_store() -> TrustStore: """Return the process-wide :class:`TrustStore` (created on first call).""" global _default_store if _default_store is None: _default_store = TrustStore() return _default_store
[docs] def reset_default_store(trust_file: Path | None = None) -> TrustStore: """Replace the default store — useful for testing. :param trust_file: Custom path; ``None`` resets to the system default. :return: The new :class:`TrustStore` instance. """ global _default_store _default_store = TrustStore(trust_file) return _default_store