"""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