feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

325
core/companion_audit.py Normal file
View File

@@ -0,0 +1,325 @@
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
action events (post transitions, user events, plugin activations, etc.)
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
2. ``verify_companion_signature`` — constant-time HMAC verification.
3. ``handle_companion_audit_request`` — Starlette handler that validates
the signature, appends the event to the audit log, and returns 200.
The store is intentionally independent of the SQLite sites DB so the
webhook can land before a dashboard UI exists; the UI (future work) can
replace ``CompanionAuditSecretStore`` with DB-backed storage without
changing the wire format.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.companion_audit")
def _parse_timestamp(raw: Any) -> float | None:
"""Coerce the envelope ``timestamp`` field to epoch seconds.
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
companion plugin's current format via PHP ``gmdate``) or a numeric
epoch value (for forward compatibility with other senders).
Returns None on any parse failure so callers can emit a uniform
401 without leaking which step failed.
"""
if raw is None:
return None
if isinstance(raw, int | float):
return float(raw)
if isinstance(raw, str):
s = raw.strip()
if not s:
return None
# Python 3.11+ fromisoformat handles "Z" suffix; on older
# versions we translate it to +00:00 explicitly.
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
try:
return datetime.fromisoformat(iso).timestamp()
except ValueError:
pass
# Numeric-string fallback (e.g. "1712345678").
try:
return float(s)
except ValueError:
return None
return None
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
# (or malicious) companion can't queue up unbounded memory with a
# single request. Real audit events are well under 4 KB; 64 KB is a
# generous ceiling that still fits within Starlette's default limit.
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
# Replay-protection window (seconds). Events whose ``timestamp`` field
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
# Set to 0 or negative to disable (not recommended). Default 5 minutes
# balances clock-skew tolerance against replay risk.
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
def _normalise_url(url: str) -> str:
return url.rstrip("/").strip().lower()
class CompanionAuditSecretStore:
"""File-backed ``site_url -> shared_secret`` map.
The file is JSON; keys are normalised (lowercased, trailing slash
stripped). Access is serialised through a lock to keep reads and
writes atomic on a single-process server. Multi-process deployments
(gunicorn workers) can still race on the write path — that's fine
because the dashboard UI will be the only writer and operates in
the master process for this MVP.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self._cache: dict[str, str] | None = None
def _load(self) -> dict[str, str]:
if self._cache is not None:
return self._cache
if not self.path.exists():
self._cache = {}
return self._cache
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
logger.warning(
"companion audit secret file %s is not a JSON object; ignoring.",
self.path,
)
self._cache = {}
else:
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
self._cache = {}
return self._cache
def _save(self, data: dict[str, str]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(self.path)
try:
os.chmod(self.path, 0o600)
except OSError:
pass
self._cache = dict(data)
def get(self, site_url: str) -> str | None:
with self._lock:
return self._load().get(_normalise_url(site_url))
def set(self, site_url: str, secret: str) -> None:
if not secret or len(secret) < 16:
raise ValueError("companion audit secret must be at least 16 characters")
with self._lock:
data = dict(self._load())
data[_normalise_url(site_url)] = secret
self._save(data)
def delete(self, site_url: str) -> bool:
with self._lock:
data = dict(self._load())
key = _normalise_url(site_url)
if key not in data:
return False
del data[key]
self._save(data)
return True
def list_sites(self) -> list[dict[str, Any]]:
"""List sites with secret metadata only — never returns plaintext."""
with self._lock:
return [
{
"site_url": site_url,
"secret_set": True,
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
}
for site_url, secret in self._load().items()
]
_DEFAULT_STORE_PATH = Path(
os.environ.get(
"COMPANION_AUDIT_SECRETS_PATH",
"/tmp/mcphub-data/companion-audit-secrets.json",
)
)
_default_store: CompanionAuditSecretStore | None = None
def get_companion_audit_store(
path: str | Path | None = None,
) -> CompanionAuditSecretStore:
global _default_store
if path is not None:
return CompanionAuditSecretStore(path)
if _default_store is None:
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
return _default_store
def verify_companion_signature(
body_bytes: bytes, signature_header: str | None, secret: str
) -> bool:
"""Constant-time HMAC-SHA256 verification.
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
Returns False on any shape error so callers can emit 401 uniformly.
"""
if not signature_header or not secret:
return False
sig = signature_header.strip()
if sig.startswith("sha256="):
sig = sig[len("sha256=") :]
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
return False
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.lower(), sig.lower())
# Accepted event names — mirror the PHP side. Events outside this list
# are still accepted but tagged so operators can spot rogue sources.
_KNOWN_EVENTS = frozenset(
{
"transition_post_status",
"deleted_post",
"user_register",
"profile_update",
"deleted_user",
"activated_plugin",
"deactivated_plugin",
"switch_theme",
}
)
async def handle_companion_audit_request(request: Request) -> JSONResponse:
"""Starlette handler for ``POST /api/companion-audit``.
Validates the HMAC signature using a per-site secret, parses the
envelope, applies a replay window on the ``timestamp`` field, and
writes an audit-log entry. Returns 200 on success, 400 on shape
errors, 401 on signature/replay failure, 413 on oversized body.
"""
# Pre-F.20 security sweep: bound the incoming body before reading the
# whole thing. Prefer the framework's Content-Length hint; fall back
# to reading up to one byte past the ceiling so we can cleanly 413.
content_length_header = request.headers.get("content-length")
if content_length_header:
try:
content_length = int(content_length_header)
except ValueError:
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
if content_length > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
# Read raw body — we need the exact bytes for HMAC verification.
body_bytes = await request.body()
if len(body_bytes) > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
if not body_bytes:
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
site_header = request.headers.get("X-Airano-MCP-Site") or ""
signature = request.headers.get("X-Airano-MCP-Signature")
try:
envelope = json.loads(body_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
if not isinstance(envelope, dict):
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
site_url = str(envelope.get("site_url") or site_header or "")
if not site_url:
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
store = get_companion_audit_store()
secret = store.get(site_url)
if secret is None:
# Don't leak whether the site exists; same response as a bad sig.
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
if not verify_companion_signature(body_bytes, signature, secret):
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
# Pre-F.20 security sweep: enforce a replay window on the signed
# timestamp. Captured webhooks replayed outside the window are
# rejected with 401 (same opaque response as a bad signature to
# avoid giving attackers a distinguishing oracle).
if _REPLAY_WINDOW_SECONDS > 0:
ts = _parse_timestamp(envelope.get("timestamp"))
if ts is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
now = time.time()
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
logger.warning(
"companion audit replay rejected site=%s skew=%.1fs",
site_url,
now - ts,
)
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
event_name = str(envelope.get("event") or "unknown")
details = {
"site_url": site_url,
"event": event_name,
"known_event": event_name in _KNOWN_EVENTS,
"timestamp": envelope.get("timestamp"),
"wp_user_id": envelope.get("user_id"),
"plugin_version": envelope.get("plugin_version"),
"data": envelope.get("data"),
}
try:
# Deferred import — avoid pulling audit_log into the store for tests.
from core.audit_log import get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"companion_audit:{event_name}",
details=details,
)
except Exception as exc: # noqa: BLE001
logger.error("failed to append companion audit event: %s", exc)
return JSONResponse(
{"ok": False, "error": "audit_write_failed"},
status_code=500,
)
return JSONResponse({"ok": True, "event": event_name}, status_code=200)