scripts/validate_plugin.py
scripts/validate_plugin.pyBrowse 11 files
4,913 tokens
21,739 bytes
Token encoding: o200k_base
Snapshot 18d7ace
← Back to SKILL.md
1#!/usr/bin/env python32"""Validate a generated plugin against the plugin ingestion contract."""3 4from __future__ import annotations5 6import argparse7import json8import re9import sys10from pathlib import Path, PurePosixPath11from typing import Any12from urllib.parse import urlparse13 14import yaml15 16sys.path.insert(0, str(Path(__file__).resolve().parent))17 18from identifier_validation import validate_plugin_identifier19 20 21TODO_MARKER = "[TODO:"22SEMVER_RE = re.compile(23 r"^(0|[1-9]\d*)\."24 r"(0|[1-9]\d*)\."25 r"(0|[1-9]\d*)"26 r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."27 r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"28 r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"29)30HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE)31 32 33def parse_args() -> argparse.Namespace:34 parser = argparse.ArgumentParser(description="Validate a local Codex plugin.")35 parser.add_argument("plugin_path", help="Path to the plugin root directory")36 return parser.parse_args()37 38 39def main() -> None:40 args = parse_args()41 plugin_root = Path(args.plugin_path).expanduser().resolve()42 errors = validate_plugin(plugin_root)43 if errors:44 print("Plugin validation failed:")45 for error in errors:46 print(f"- {error}")47 raise SystemExit(1)48 print(f"Plugin validation passed: {plugin_root}")49 50 51def validate_plugin(plugin_root: Path) -> list[str]:52 errors: list[str] = []53 manifest_path = plugin_root / ".codex-plugin" / "plugin.json"54 manifest = load_json_object(manifest_path, errors)55 if manifest is None:56 return errors57 58 reject_todo_markers(manifest, "$", errors)59 validate_manifest_shape(plugin_root, manifest, errors)60 return errors61 62 63def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:64 if not path.is_file():65 errors.append("missing `.codex-plugin/plugin.json`")66 return None67 try:68 payload = json.loads(path.read_text(encoding="utf-8"))69 except OSError:70 errors.append("unable to read `.codex-plugin/plugin.json`")71 return None72 except json.JSONDecodeError:73 errors.append("`.codex-plugin/plugin.json` must be valid JSON")74 return None75 if not isinstance(payload, dict):76 errors.append("`.codex-plugin/plugin.json` must contain a JSON object")77 return None78 return payload79 80 81def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None:82 if isinstance(value, str):83 if TODO_MARKER in value:84 errors.append(f"{path} still contains a `[TODO: ...]` placeholder")85 return86 if isinstance(value, list):87 for index, item in enumerate(value):88 reject_todo_markers(item, f"{path}[{index}]", errors)89 return90 if isinstance(value, dict):91 for key, item in value.items():92 reject_todo_markers(item, f"{path}.{key}", errors)93 94 95def validate_manifest_shape(96 plugin_root: Path,97 manifest: dict[str, Any],98 errors: list[str],99) -> None:100 allowed_keys = {101 "id",102 "name",103 "version",104 "description",105 "skills",106 "apps",107 "mcpServers",108 "interface",109 "author",110 "homepage",111 "repository",112 "license",113 "keywords",114 }115 for key in sorted(set(manifest) - allowed_keys):116 errors.append(f"plugin.json field `{key}` is not accepted by plugin validation")117 118 validate_optional_non_empty_string(manifest, "id", errors)119 plugin_name = require_non_empty_string(manifest, "name", errors)120 if plugin_name is not None:121 try:122 validate_plugin_identifier(plugin_name)123 except ValueError as error:124 errors.append(f"plugin.json field `name` is invalid: {error}")125 version = require_non_empty_string(manifest, "version", errors)126 if version is not None and SEMVER_RE.fullmatch(version) is None:127 errors.append("plugin.json field `version` must be strict semver")128 require_non_empty_string(manifest, "description", errors)129 130 author = require_object(manifest, "author", errors)131 if author is not None:132 reject_unknown_fields(author, {"name", "email", "url"}, "author", errors)133 require_non_empty_string(author, "name", errors, prefix="author")134 validate_optional_non_empty_string(author, "email", errors, prefix="author")135 validate_optional_https_url(author, "url", errors, prefix="author")136 137 validate_optional_contract_path(manifest, "skills", "skills", errors)138 validate_optional_contract_path(manifest, "apps", ".app.json", errors)139 validate_manifest_mcp_servers(plugin_root, manifest, errors)140 141 if manifest.get("apps") is not None:142 validate_app_manifest(143 plugin_root / ".app.json",144 errors,145 )146 validate_skill_manifests(plugin_root, errors)147 148 interface = require_object(manifest, "interface", errors)149 if interface is None:150 return151 reject_unknown_fields(152 interface,153 {154 "displayName",155 "shortDescription",156 "longDescription",157 "developerName",158 "category",159 "capabilities",160 "websiteURL",161 "privacyPolicyURL",162 "termsOfServiceURL",163 "brandColor",164 "composerIcon",165 "logo",166 "logoDark",167 "screenshots",168 "defaultPrompt",169 "default_prompt",170 },171 "interface",172 errors,173 )174 for field in (175 "displayName",176 "shortDescription",177 "longDescription",178 "developerName",179 "category",180 ):181 require_non_empty_string(interface, field, errors, prefix="interface")182 if "defaultPrompt" not in interface and "default_prompt" not in interface:183 errors.append(184 "plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required"185 )186 capabilities = interface.get("capabilities")187 if not isinstance(capabilities, list) or not all(188 isinstance(value, str) and value.strip() for value in capabilities189 ):190 errors.append(191 "plugin.json field `interface.capabilities` must be an array of strings"192 )193 for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):194 validate_optional_https_url(interface, field, errors, prefix="interface")195 brand_color = interface.get("brandColor")196 if brand_color is not None and (197 not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None198 ):199 errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")200 for field in ("composerIcon", "logo", "logoDark"):201 validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)202 screenshots = interface.get("screenshots", [])203 if not isinstance(screenshots, list):204 errors.append("plugin.json field `interface.screenshots` must be an array")205 else:206 for index, raw_path in enumerate(screenshots):207 validate_asset_path(208 plugin_root,209 plugin_root,210 raw_path,211 f"interface.screenshots[{index}]",212 errors,213 )214 215 216def require_object(217 payload: dict[str, Any],218 key: str,219 errors: list[str],220) -> dict[str, Any] | None:221 value = payload.get(key)222 if not isinstance(value, dict):223 errors.append(f"plugin.json field `{key}` must be an object")224 return None225 return value226 227 228def require_non_empty_string(229 payload: dict[str, Any],230 key: str,231 errors: list[str],232 *,233 prefix: str | None = None,234) -> str | None:235 value = payload.get(key)236 field = f"{prefix}.{key}" if prefix is not None else key237 if not isinstance(value, str) or not value.strip():238 errors.append(f"plugin.json field `{field}` must be a non-empty string")239 return None240 return value241 242 243def validate_optional_non_empty_string(244 payload: dict[str, Any],245 key: str,246 errors: list[str],247 *,248 prefix: str | None = None,249) -> None:250 value = payload.get(key)251 if value is None:252 return253 field = f"{prefix}.{key}" if prefix is not None else key254 if not isinstance(value, str) or not value.strip():255 errors.append(f"plugin.json field `{field}` must be a non-empty string")256 257 258def reject_unknown_fields(259 payload: dict[str, Any],260 allowed_keys: set[str],261 prefix: str,262 errors: list[str],263) -> None:264 for key in sorted(set(payload) - allowed_keys):265 errors.append(266 f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation"267 )268 269 270def validate_optional_https_url(271 payload: dict[str, Any],272 key: str,273 errors: list[str],274 *,275 prefix: str,276) -> None:277 value = payload.get(key)278 if value is None:279 return280 parsed = urlparse(value) if isinstance(value, str) else None281 if parsed is None or parsed.scheme != "https" or not parsed.netloc:282 errors.append(283 f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL"284 )285 286 287def validate_optional_contract_path(288 payload: dict[str, Any],289 key: str,290 expected: str,291 errors: list[str],292) -> None:293 value = payload.get(key)294 if value is None:295 return296 normalized = normalize_contract_path(value) if isinstance(value, str) else None297 if normalized != expected:298 errors.append(f"plugin.json field `{key}` must resolve to `{expected}`")299 300 301def validate_manifest_mcp_servers(302 plugin_root: Path,303 manifest: dict[str, Any],304 errors: list[str],305) -> None:306 value = manifest.get("mcpServers")307 if value is None:308 return309 if isinstance(value, str):310 validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors)311 validate_mcp_manifest(312 plugin_root / ".mcp.json",313 errors,314 )315 return316 if isinstance(value, dict):317 validate_mcp_server_entries(318 value,319 "plugin.json field `mcpServers`",320 "plugin.json field `mcpServers`",321 errors,322 )323 return324 errors.append("plugin.json field `mcpServers` must be a string path or object")325 326 327def normalize_contract_path(raw_path: str) -> str | None:328 path = Path(raw_path)329 if path.is_absolute():330 return None331 normalized = path.as_posix().rstrip("/")332 return normalized or None333 334 335def validate_app_manifest(path: Path, errors: list[str]) -> None:336 payload = load_companion_json_object(path, "`.app.json`", errors)337 if payload is None:338 return339 reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors)340 apps = payload.get("apps")341 if not isinstance(apps, dict):342 errors.append("`.app.json` field `apps` must be an object")343 return344 for key, value in apps.items():345 if not isinstance(value, dict):346 errors.append(f"`.app.json` app `{key}` must be an object")347 continue348 reject_companion_unknown_fields(349 value, {"id", "category"}, f"`.app.json` app `{key}`", errors350 )351 app_id = value.get("id")352 if not isinstance(app_id, str) or not app_id.strip():353 errors.append(354 f"`.app.json` app `{key}` field `id` must be a non-empty string"355 )356 category = value.get("category")357 if category is not None and (358 not isinstance(category, str) or not category.strip()359 ):360 errors.append(361 f"`.app.json` app `{key}` field `category` must be a non-empty string"362 )363 364 365def validate_mcp_manifest(path: Path, errors: list[str]) -> None:366 payload = load_companion_json_object(path, "`.mcp.json`", errors)367 if payload is None:368 return369 reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors)370 servers = payload.get("mcpServers")371 validate_mcp_server_entries(372 servers,373 "`.mcp.json`",374 "`.mcp.json` field `mcpServers`",375 errors,376 )377 378 379def validate_mcp_server_entries(380 servers: Any,381 source_label: str,382 field_label: str,383 errors: list[str],384) -> None:385 if not isinstance(servers, dict):386 errors.append(f"{field_label} must be an object")387 return388 for key, value in servers.items():389 if not isinstance(key, str) or not key.strip():390 errors.append(f"{source_label} server names must be non-empty strings")391 if not isinstance(value, dict):392 errors.append(f"{source_label} server `{key}` must be an object")393 394 395def load_companion_json_object(396 path: Path,397 label: str,398 errors: list[str],399) -> dict[str, Any] | None:400 if not path.is_file():401 errors.append(f"{label} is required when its plugin.json field is present")402 return None403 try:404 payload = json.loads(path.read_text(encoding="utf-8"))405 except (OSError, json.JSONDecodeError):406 errors.append(f"{label} must contain valid JSON")407 return None408 if not isinstance(payload, dict):409 errors.append(f"{label} must contain a JSON object")410 return None411 return payload412 413 414def reject_companion_unknown_fields(415 payload: dict[str, Any],416 allowed_keys: set[str],417 prefix: str,418 errors: list[str],419) -> None:420 for key in sorted(set(payload) - allowed_keys):421 errors.append(f"{prefix} field `{key}` is not accepted by plugin validation")422 423 424def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None:425 skills_root = plugin_root / "skills"426 if not skills_root.is_dir():427 return428 for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name):429 if skill_root.name.startswith(".") or not skill_root.is_dir():430 continue431 validate_skill_manifest(skill_root, errors)432 433 434def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None:435 skill_md_path = skill_root / "SKILL.md"436 if not skill_md_path.is_file():437 errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`")438 return439 try:440 contents = skill_md_path.read_text(encoding="utf-8")441 except OSError:442 errors.append(f"unable to read skill `{skill_root.name}`")443 return444 if not contents.startswith("---\n"):445 errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter")446 return447 frontmatter_end = contents.find("\n---", 4)448 if frontmatter_end == -1:449 errors.append(f"skill `{skill_root.name}` frontmatter is not closed")450 return451 try:452 frontmatter = yaml.safe_load(contents[4:frontmatter_end])453 except yaml.YAMLError:454 errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML")455 return456 if not isinstance(frontmatter, dict):457 errors.append(f"skill `{skill_root.name}` frontmatter must be an object")458 return459 skill_name = frontmatter.get("name")460 if not isinstance(skill_name, str) or not skill_name.strip():461 errors.append(462 f"skill `{skill_root.name}` frontmatter field `name` must be non-empty"463 )464 description = frontmatter.get("description")465 if not isinstance(description, str) or not description.strip():466 errors.append(467 f"skill `{skill_root.name}` frontmatter field `description` must be non-empty"468 )469 disable_model_invocation = frontmatter.get("disable-model-invocation")470 if disable_model_invocation is None:471 disable_model_invocation = frontmatter.get("disable_model_invocation")472 if disable_model_invocation not in (None, False):473 errors.append(474 f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false"475 )476 agent_yaml_path = skill_root / "agents" / "openai.yaml"477 if agent_yaml_path.is_file():478 validate_skill_agent_manifest(479 plugin_root=skill_root.parent.parent,480 skill_root=skill_root,481 agent_yaml_path=agent_yaml_path,482 errors=errors,483 )484 485 486def validate_skill_agent_manifest(487 *,488 plugin_root: Path,489 skill_root: Path,490 agent_yaml_path: Path,491 errors: list[str],492) -> None:493 try:494 payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8"))495 except OSError:496 errors.append(f"unable to read skill `{skill_root.name}` agent YAML")497 return498 except yaml.YAMLError:499 errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML")500 return501 if not isinstance(payload, dict):502 errors.append(f"skill `{skill_root.name}` agent YAML must be an object")503 return504 505 reject_skill_agent_unknown_fields(506 payload,507 {"interface", "policy", "dependencies"},508 skill_root,509 errors,510 )511 interface = payload.get("interface")512 if not isinstance(interface, dict):513 errors.append(514 f"skill `{skill_root.name}` agent field `interface` must be an object"515 )516 return517 reject_skill_agent_unknown_fields(518 interface,519 {520 "display_name",521 "short_description",522 "icon_small",523 "icon_large",524 "brand_color",525 "default_prompt",526 },527 skill_root,528 errors,529 prefix="interface",530 )531 for field in ("display_name", "short_description"):532 value = interface.get(field)533 if not isinstance(value, str) or not value.strip():534 errors.append(535 f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty"536 )537 for field in ("icon_small", "icon_large"):538 validate_optional_asset_path(539 skill_root,540 plugin_root,541 interface,542 field,543 errors,544 prefix=f"skill `{skill_root.name}` agent field `interface",545 )546 brand_color = interface.get("brand_color")547 if brand_color is not None and (548 not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None549 ):550 errors.append(551 f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`"552 )553 default_prompt = interface.get("default_prompt")554 if default_prompt is not None and (555 not isinstance(default_prompt, str) or not default_prompt.strip()556 ):557 errors.append(558 f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty"559 )560 561 policy = payload.get("policy")562 if policy is not None:563 if not isinstance(policy, dict):564 errors.append(565 f"skill `{skill_root.name}` agent field `policy` must be an object"566 )567 else:568 reject_skill_agent_unknown_fields(569 policy,570 {"allow_implicit_invocation"},571 skill_root,572 errors,573 prefix="policy",574 )575 allow_implicit_invocation = policy.get("allow_implicit_invocation")576 if allow_implicit_invocation is not None and not isinstance(577 allow_implicit_invocation,578 bool,579 ):580 errors.append(581 f"skill `{skill_root.name}` agent field "582 "`policy.allow_implicit_invocation` must be a boolean"583 )584 585 dependencies = payload.get("dependencies")586 if dependencies is not None:587 if not isinstance(dependencies, dict):588 errors.append(589 f"skill `{skill_root.name}` agent field `dependencies` must be an object"590 )591 else:592 reject_skill_agent_unknown_fields(593 dependencies,594 {"tools"},595 skill_root,596 errors,597 prefix="dependencies",598 )599 600 601def reject_skill_agent_unknown_fields(602 payload: dict[str, Any],603 allowed_keys: set[str],604 skill_root: Path,605 errors: list[str],606 *,607 prefix: str | None = None,608) -> None:609 for key in sorted(set(payload) - allowed_keys):610 field = f"{prefix}.{key}" if prefix is not None else key611 errors.append(612 f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation"613 )614 615 616def validate_optional_asset_path(617 base_dir: Path,618 allowed_root: Path,619 payload: dict[str, Any],620 key: str,621 errors: list[str],622 *,623 prefix: str = "interface",624) -> None:625 raw_path = payload.get(key)626 if raw_path is None:627 return628 validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors)629 630 631def validate_asset_path(632 base_dir: Path,633 allowed_root: Path,634 raw_path: Any,635 field: str,636 errors: list[str],637) -> None:638 label = field if field.startswith("skill `") else f"plugin.json field `{field}`"639 if not isinstance(raw_path, str) or not raw_path.strip():640 errors.append(f"{label} must be a non-empty relative path")641 return642 candidate = PurePosixPath(raw_path.replace("\\", "/"))643 if candidate.is_absolute() or any(644 part in {"", ".", ".."} for part in candidate.parts645 ):646 errors.append(f"{label} must stay inside the plugin archive")647 return648 resolved_path = (base_dir / candidate.as_posix()).resolve()649 if not resolved_path.is_relative_to(allowed_root.resolve()):650 errors.append(f"{label} must stay inside the plugin archive")651 return652 if not resolved_path.is_file():653 errors.append(f"{label} points to a missing file")654 655 656if __name__ == "__main__":657 main()658 