← Files Marketing CouncilARCHIVED FILE

scripts/validate_openai_plugin.py

15 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

#!/usr/bin/env python3
"""Public preflight for the Marketing Council ChatGPT/Codex plugin package.

This validator mirrors the release-critical checks used by the Plugin Autopilot
workflow while remaining dependency-free and repository-owned.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import stat
import sys
import unicodedata
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import urlparse

MAX_ENTRIES = 5000
MAX_TOTAL = 512 * 1024 * 1024
MAX_MEMBER = 100 * 1024 * 1024
MAX_IMAGE = 5 * 1024 * 1024
HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
PLUGIN_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
CATEGORIES = {
    "Productivity", "Creativity", "Developer Tools", "Business & Operations",
    "Data & Analytics", "Communication", "Education & Research", "Security",
    "Finance", "Healthcare", "Travel", "Entertainment", "Other",
}
SECRET_BASENAME = re.compile(
    r"^(?:\.env(?:\..*)?|auth\.json|credentials?(?:\..*)?|secrets?(?:\..*)?|\.npmrc|\.pypirc)$",
    re.I,
)
TEXT_SUFFIXES = {".md", ".txt", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".xml", ".py", ".sh", ".svg"}


def add(errors: list[str], condition: bool, message: str) -> None:
    if not condition:
        errors.append(message)


def https_url(value: object) -> bool:
    if not isinstance(value, str) or not value:
        return False
    parsed = urlparse(value)
    return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password


def luminance(color: str) -> float:
    channels = [int(color[i:i + 2], 16) / 255 for i in (1, 3, 5)]
    linear = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels]
    return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]


def contrast(a: str, b: str) -> float:
    high, low = sorted((luminance(a), luminance(b)), reverse=True)
    return (high + 0.05) / (low + 0.05)


def skill_metadata(path: Path) -> tuple[str | None, str | None]:
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---\n"):
        return None, None
    end = text.find("\n---", 4)
    if end < 0:
        return None, None
    block = text[4:end]
    name = re.search(r"(?m)^name:\s*[\"']?([^\n\"']+)", block)
    desc = re.search(r"(?m)^description:\s*(.+)$", block)
    return (name.group(1).strip() if name else None, desc.group(1).strip().strip("\"'") if desc else None)


def skill_frontmatter_keys(path: Path) -> set[str]:
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---\n"):
        return set()
    end = text.find("\n---", 4)
    if end < 0:
        return set()
    keys: set[str] = set()
    for line in text[4:end].splitlines():
        if line and not line.startswith(" ") and ":" in line:
            keys.add(line.split(":", 1)[0].strip())
    return keys


def openai_skill_interface(path: Path) -> dict[str, str]:
    data: dict[str, str] = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped.startswith(("display_name:", "short_description:", "default_prompt:")):
            key, value = stripped.split(":", 1)
            data[key] = value.strip().strip('"')
    return data


def validate_svg(path: Path, field: str, errors: list[str]) -> None:
    try:
        root = ET.fromstring(path.read_text(encoding="utf-8"))
        add(errors, root.tag.split("}")[-1].lower() == "svg", f"{field} root must be svg")
        view_box = root.attrib.get("viewBox") or root.attrib.get("viewbox")
        if view_box:
            values = [float(v) for v in re.split(r"[\s,]+", view_box.strip()) if v]
            add(errors, len(values) == 4 and values[2] == values[3] and values[2] >= 48, f"{field} must be square and at least 48x48")
        else:
            width = float(root.attrib.get("width", "0"))
            height = float(root.attrib.get("height", "0"))
            add(errors, width == height and width >= 48, f"{field} must be square and at least 48x48")
    except Exception as exc:
        errors.append(f"{field} SVG unreadable: {exc}")


def validate_plugin(root: Path) -> dict:
    root = root.resolve()
    errors: list[str] = []
    warnings: list[str] = []
    if not root.is_dir():
        return {"ok": False, "architecture": "unknown", "skills": [], "errors": [f"plugin root is not a directory: {root}"], "warnings": []}

    manifest_path = root / ".codex-plugin" / "plugin.json"
    add(errors, manifest_path.is_file(), "missing .codex-plugin/plugin.json")
    if not manifest_path.is_file():
        return {"ok": False, "architecture": "unknown", "skills": [], "errors": errors, "warnings": warnings}

    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except Exception as exc:
        return {"ok": False, "architecture": "unknown", "skills": [], "errors": [f"manifest unreadable: {exc}"], "warnings": warnings}

    name = manifest.get("name")
    version = manifest.get("version")
    description = manifest.get("description")
    author = manifest.get("author")
    add(errors, isinstance(name, str) and bool(PLUGIN_NAME.fullmatch(name)), "invalid plugin name")
    add(errors, isinstance(version, str) and len(version) <= 64 and bool(SEMVER.fullmatch(version)), "invalid plugin version")
    add(errors, isinstance(description, str) and 0 < len(description) <= 1024, "plugin description must be 1..1024 chars")
    add(errors, isinstance(author, dict) and isinstance(author.get("name"), str) and 0 < len(author["name"]) <= 120, "author.name is required")
    if isinstance(author, dict) and author.get("url") is not None:
        add(errors, https_url(author.get("url")), "author.url must be HTTPS")
    if manifest.get("homepage") is not None:
        add(errors, https_url(manifest.get("homepage")), "homepage must be HTTPS")

    skill_path = manifest.get("skills", "./skills/")
    add(errors, isinstance(skill_path, str) and skill_path.startswith("./") and skill_path[2:].rstrip("/") == "skills", "skills path must resolve to ./skills/")
    skills: list[str] = []
    skill_root = root / "skills"
    if skill_root.is_dir():
        for directory in sorted(p for p in skill_root.iterdir() if p.is_dir()):
            definition = directory / "SKILL.md"
            add(errors, definition.is_file(), f"skill missing SKILL.md: {directory.name}")
            if not definition.is_file():
                continue
            skill_name, skill_description = skill_metadata(definition)
            add(errors, skill_name == directory.name, f"skill name mismatch: {directory.name}")
            add(errors, isinstance(skill_description, str) and 0 < len(skill_description) <= 1024, f"skill description invalid: {directory.name}")
            add(errors, skill_frontmatter_keys(definition) == {"name", "description"}, f"unsupported SKILL.md frontmatter fields: {directory.name}")

            openai_yaml = directory / "agents" / "openai.yaml"
            add(errors, openai_yaml.is_file(), f"skill missing agents/openai.yaml: {directory.name}")
            if openai_yaml.is_file():
                skill_iface = openai_skill_interface(openai_yaml)
                add(errors, bool(skill_iface.get("display_name")), f"skill interface.display_name missing: {directory.name}")
                short = skill_iface.get("short_description", "")
                add(errors, 25 <= len(short) <= 64, f"skill interface.short_description must be 25..64 chars: {directory.name}")
                prompt = skill_iface.get("default_prompt", "")
                add(errors, f"${directory.name}" in prompt, f"skill interface.default_prompt must mention ${directory.name}")

            body = definition.read_text(encoding="utf-8")
            end = body.find("\n---", 4)
            add(errors, end >= 0 and bool(body[end + 4:].strip()), f"skill body empty: {directory.name}")
            if isinstance(name, str) and skill_name:
                add(errors, len(f"{name}:{skill_name}") <= 64, f"combined plugin/skill identity too long: {directory.name}")
            skills.append(directory.name)
    add(errors, bool(skills), "plugin must include at least one skill")

    has_mcp = (root / ".mcp.json").exists() or (root / ".app.json").exists() or "mcpServers" in manifest or "apps" in manifest
    architecture = "hybrid" if has_mcp and skills else "MCP-backed" if has_mcp else "skills-only"

    interface = manifest.get("interface")
    add(errors, isinstance(interface, dict), "manifest interface is required")
    interface = interface if isinstance(interface, dict) else {}
    for field, limit in (("displayName", 30), ("shortDescription", 30), ("longDescription", 4000), ("developerName", 80)):
        value = interface.get(field)
        add(errors, isinstance(value, str) and 0 < len(value) <= limit and "\n" not in value and "\r" not in value, f"interface.{field} invalid")
    add(errors, interface.get("category") in CATEGORIES, "unsupported interface.category")

    capabilities = interface.get("capabilities", [])
    add(errors, isinstance(capabilities, list) and len(capabilities) <= 20, "interface.capabilities invalid")
    if isinstance(capabilities, list):
        for item in capabilities:
            add(errors, isinstance(item, str) and 0 < len(item) <= 120 and "\n" not in item, f"invalid capability: {item!r}")

    prompts = interface.get("defaultPrompt", [])
    add(errors, isinstance(prompts, list) and len(prompts) <= 3, "interface.defaultPrompt invalid")
    normalized: set[str] = set()
    if isinstance(prompts, list):
        for prompt in prompts:
            valid = isinstance(prompt, str) and 0 < len(prompt) <= 128 and "\n" not in prompt and "\r" not in prompt and not re.search(r"(?<![A-Za-z0-9._%+-])@[A-Za-z0-9_-]+", prompt)
            add(errors, bool(valid), f"invalid default prompt: {prompt!r}")
            if isinstance(prompt, str):
                key = " ".join(unicodedata.normalize("NFKC", prompt).split()).casefold()
                add(errors, key not in normalized, "duplicate normalized default prompt")
                normalized.add(key)

    for field, bg in (("brandColor", "#FFFFFF"), ("brandColorDark", "#212121")):
        value = interface.get(field)
        if value is not None:
            add(errors, isinstance(value, str) and bool(HEX.fullmatch(value)), f"interface.{field} must be six-digit hex")
            if isinstance(value, str) and HEX.fullmatch(value):
                add(errors, contrast(value, bg) >= 2.0, f"interface.{field} contrast is below 2:1")

    for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL", "supportURL"):
        value = interface.get(field)
        if value is not None:
            add(errors, https_url(value) and len(value) <= 1024, f"interface.{field} must be an HTTPS URL <=1024 chars")

    for field in ("logo", "composerIcon"):
        value = interface.get(field)
        add(errors, isinstance(value, str) and value.startswith("./"), f"interface.{field} must be a ./-relative path")
        if isinstance(value, str) and value.startswith("./"):
            path = (root / value[2:]).resolve()
            try:
                path.relative_to(root)
                inside = True
            except ValueError:
                inside = False
            add(errors, inside and path.is_file(), f"interface.{field} asset missing or outside root")
            if inside and path.is_file():
                add(errors, path.stat().st_size <= MAX_IMAGE, f"interface.{field} exceeds 5 MiB")
                add(errors, path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".svg"}, f"interface.{field} format unsupported")
                if path.suffix.lower() == ".svg":
                    validate_svg(path, f"interface.{field}", errors)

    files = 0
    dirs = 0
    total = 0
    normalized_paths: dict[str, str] = {}
    absolute_user = re.compile(r"/(?:Users|home)/[A-Za-z0-9._-]+/")
    for current, dirnames, filenames in os.walk(root, topdown=True, followlinks=False):
        current_path = Path(current)
        for d in list(dirnames):
            path = current_path / d
            dirs += 1
            if d == "__pycache__" or path.is_symlink():
                errors.append(f"transient or symlink directory not allowed: {path.relative_to(root)}")
        for filename in filenames:
            path = current_path / filename
            rel = path.relative_to(root).as_posix()
            files += 1
            try:
                mode = path.lstat().st_mode
            except OSError as exc:
                errors.append(f"unreadable member: {rel}: {exc}")
                continue
            add(errors, stat.S_ISREG(mode) and not stat.S_ISLNK(mode), f"unsupported member type: {rel}")
            size = path.stat().st_size
            total += size
            add(errors, size <= MAX_MEMBER, f"member exceeds 100 MiB: {rel}")
            add(errors, filename not in {".DS_Store", "Thumbs.db"} and not filename.startswith("._"), f"OS metadata not allowed: {rel}")
            add(errors, path.suffix.lower() not in {".pyc", ".pyo"}, f"bytecode not allowed: {rel}")
            add(errors, not SECRET_BASENAME.match(filename), f"secret-shaped file not allowed: {rel}")
            key = unicodedata.normalize("NFC", rel).casefold()
            add(errors, key not in normalized_paths or normalized_paths[key] == rel, f"path normalization collision: {rel}")
            normalized_paths[key] = rel
            if size <= 1024 * 1024 and path.suffix.lower() in TEXT_SUFFIXES:
                try:
                    text = path.read_text(encoding="utf-8")
                except UnicodeDecodeError:
                    text = ""
                add(errors, not absolute_user.search(text), f"absolute user path found in public text: {rel}")

    add(errors, files + dirs <= MAX_ENTRIES, f"plugin exceeds {MAX_ENTRIES} entries")
    add(errors, total <= MAX_TOTAL, "plugin extracted size exceeds 512 MiB")
    add(errors, not (root / "hooks" / "hooks.json").exists(), "unexpected executable lifecycle hooks")

    return {
        "ok": not errors,
        "pluginRoot": str(root),
        "name": name if isinstance(name, str) else "",
        "version": version if isinstance(version, str) else "",
        "architecture": architecture,
        "skills": skills,
        "entries": files + dirs,
        "uncompressedBytes": total,
        "errors": errors,
        "warnings": warnings,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Validate a Marketing Council OpenAI plugin package.")
    parser.add_argument("plugin_root", type=Path)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    report = validate_plugin(args.plugin_root)
    if args.json:
        print(json.dumps(report, indent=2, sort_keys=True))
    else:
        print("plugin preflight: " + ("PASS" if report["ok"] else "FAIL"))
        print(f"architecture: {report['architecture']}; skills: {len(report['skills'])}; entries: {report['entries']}")
        for error in report["errors"]:
            print(f"error: {error}", file=sys.stderr)
    raise SystemExit(0 if report["ok"] else 1)


if __name__ == "__main__":
    main()

SHA-256: 3b6aff9016d440f4c8d064b66f6ee55b3cb0b367f8e980985d07af4c27bfb7b7