mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
706 lines
34 KiB
Python
Executable File
706 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate the public ChatGPT and Codex plugin surface with stdlib only."""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
|
|
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
|
|
FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.S)
|
|
# The top-level `metadata` key only: `metadata:` at column 0 followed by
|
|
# whitespace or end of line, so `metadata:extra:` (a different plain key) is kept.
|
|
METADATA_KEY = re.compile(r"metadata:(?:\s|$)")
|
|
TOP_LEVEL_INCLUDE_FILES = ("OPENAI_PLUGIN.md", "NOTICE.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md", "LICENSE")
|
|
CANONICAL_PROJECT_URL = "https://github.com/conorbronsdon/avoid-ai-writing"
|
|
MAX_SVG_BYTES = 256 * 1024
|
|
YAML_MAPPING = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(?:[ ]+(.*)|$)")
|
|
AGENT_METADATA_KEYS = {
|
|
"interface": {"display_name", "short_description", "default_prompt"},
|
|
"policy": {"allow_implicit_invocation", "products"},
|
|
}
|
|
|
|
def parse_frontmatter(path: Path):
|
|
text = path.read_text(encoding="utf-8")
|
|
match = FRONTMATTER.match(text)
|
|
if not match:
|
|
return {}, ""
|
|
meta = {}
|
|
for line in match.group(1).splitlines():
|
|
if ":" not in line or line.startswith((" ", "\t")):
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
meta[key.strip()] = value.strip().strip('"').strip("'")
|
|
return meta, match.group(2).strip()
|
|
|
|
def strip_frontmatter_metadata(text: str) -> str:
|
|
"""Drop the top-level `metadata` block from SKILL.md frontmatter, byte-exact otherwise.
|
|
|
|
Mirrors the OpenAI copy written by scripts/sync-plugin-skill.sh (which calls
|
|
this function): the OpenAI plugin portal rejects `metadata` in SKILL.md
|
|
("Skill interface settings must use agents/openai.yaml"). Line endings are
|
|
preserved; a blank line inside the metadata block does not end it; text
|
|
without a well-formed frontmatter is returned unchanged.
|
|
"""
|
|
match = FRONTMATTER.match(text)
|
|
if not match:
|
|
return text
|
|
inner = match.group(1)
|
|
kept, skip = [], False
|
|
for line in inner.splitlines(keepends=True):
|
|
if METADATA_KEY.match(line):
|
|
skip = True
|
|
continue
|
|
if skip and (line[:1] in (" ", "\t", "#") or line.strip() == ""):
|
|
continue
|
|
skip = False
|
|
kept.append(line)
|
|
joined = "".join(kept)
|
|
if skip and joined:
|
|
# The block ran to the end of the frontmatter, so the last kept line
|
|
# still carries the terminator that used to separate it from the block.
|
|
# Drop exactly that terminator and keep the delimiter's own line ending
|
|
# (the regex leaves a trailing "\r" inside the group for CRLF files).
|
|
joined = joined[:-2] if joined.endswith("\r\n") else joined[:-1]
|
|
if inner.endswith("\r"):
|
|
joined += "\r"
|
|
start, end = match.start(1), match.end(1)
|
|
return text[:start] + joined + text[end:]
|
|
|
|
|
|
def frontmatter_has_metadata(path: Path) -> bool:
|
|
match = FRONTMATTER.match(path.read_text(encoding="utf-8"))
|
|
return bool(match) and any(METADATA_KEY.match(line) for line in match.group(1).split("\n"))
|
|
|
|
|
|
def safe_rel(value: str) -> bool:
|
|
if not value or value != value.strip() or any(ord(ch) < 32 for ch in value):
|
|
return False
|
|
if value.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", value):
|
|
return False
|
|
return ".." not in PurePosixPath(value.replace("\\", "/")).parts
|
|
|
|
def load_json(path: Path, errors):
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
errors.append(f"{path}: invalid JSON: {exc}")
|
|
return {}
|
|
if not isinstance(value, dict):
|
|
errors.append(f"{path}: JSON root must be an object")
|
|
return {}
|
|
return value
|
|
|
|
def typed_member(mapping, key, expected_type, errors, label):
|
|
default = {} if expected_type is dict else []
|
|
value = mapping.get(key, default)
|
|
if not isinstance(value, expected_type):
|
|
kind = "object" if expected_type is dict else "array"
|
|
errors.append(f"{label} must be an {kind}")
|
|
return default
|
|
return value
|
|
|
|
def validate_agent_metadata(path: Path, errors):
|
|
text = path.read_text(encoding="utf-8")
|
|
top_keys, nested_keys = validate_yaml_shape(path, text, errors)
|
|
for key in ("interface", "policy"):
|
|
if key not in top_keys:
|
|
errors.append(f"{path}: missing {key}:")
|
|
for section, keys in (("interface", ("display_name", "short_description")), ("policy", ("allow_implicit_invocation",))):
|
|
for key in keys:
|
|
if key not in nested_keys.get(section, set()):
|
|
errors.append(f"{path}: missing {key}:")
|
|
|
|
lines = text.splitlines()
|
|
policy_entry = next(
|
|
((i, (match.group(2) or "").split("#", 1)[0].strip())
|
|
for i, line in enumerate(lines)
|
|
if (match := YAML_MAPPING.fullmatch(line.rstrip())) and match.group(1) == "policy"),
|
|
None,
|
|
)
|
|
if policy_entry and policy_entry[1]:
|
|
errors.append(f"{path}: policy must be a non-empty mapping")
|
|
return
|
|
if policy_entry is None:
|
|
return
|
|
policy_start = policy_entry[0]
|
|
block = []
|
|
for line in lines[policy_start + 1:]:
|
|
if line and not line[0].isspace() and not line.lstrip().startswith("#"):
|
|
break
|
|
block.append(line)
|
|
candidates = []
|
|
for i, line in enumerate(block):
|
|
leading = re.match(r"^( +)", line)
|
|
match = YAML_MAPPING.fullmatch(line[len(leading.group(1)):].rstrip()) if leading else None
|
|
if leading and match:
|
|
candidates.append((i, len(leading.group(1)), match.group(1), match.group(2) or ""))
|
|
if not candidates:
|
|
errors.append(f"{path}: policy must be a non-empty mapping")
|
|
return
|
|
policy_indent = min(item[1] for item in candidates)
|
|
entries = [(i, key, value) for i, indent, key, value in candidates if indent == policy_indent]
|
|
seen = set()
|
|
for _, key, _ in entries:
|
|
if key in seen:
|
|
errors.append(f"{path}: duplicate policy key: {key}")
|
|
seen.add(key)
|
|
allowed_policy_keys = {"allow_implicit_invocation", "products"}
|
|
for key in sorted(seen - allowed_policy_keys):
|
|
errors.append(f"{path}: unknown policy key: {key}")
|
|
|
|
entry_map = {key: (i, value) for i, key, value in entries}
|
|
implicit = entry_map.get("allow_implicit_invocation")
|
|
if implicit and implicit[1].split("#", 1)[0].strip() not in {"true", "false"}:
|
|
errors.append(f"{path}: policy.allow_implicit_invocation must be true or false")
|
|
|
|
products = entry_map.get("products")
|
|
if products:
|
|
product_index, raw = products
|
|
raw = raw.split("#", 1)[0].strip()
|
|
if raw.startswith("[") and raw.endswith("]"):
|
|
values = [item.strip().strip('"').strip("'") for item in raw[1:-1].split(",") if item.strip()]
|
|
elif not raw:
|
|
next_entry = min((i for i, _, _ in entries if i > product_index), default=len(block))
|
|
product_lines = block[product_index + 1:next_entry]
|
|
values = []
|
|
malformed = False
|
|
for line in product_lines:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
match = re.fullmatch(r"-\s+(.+)", stripped)
|
|
value = parse_supported_yaml_scalar(match.group(1)) if match else None
|
|
if value is None:
|
|
malformed = True
|
|
continue
|
|
values.append(value)
|
|
if malformed:
|
|
errors.append(f"{path}: policy.products must be a YAML list")
|
|
else:
|
|
values = []
|
|
errors.append(f"{path}: policy.products must be a YAML list")
|
|
# agents/openai.yaml policy schema:
|
|
# https://developers.openai.com/plugins/build/skills
|
|
if not values or len(values) != len(set(values)) or not set(values).issubset({"CHAT", "CODEX"}):
|
|
errors.append(f"{path}: policy.products must contain unique CHAT and/or CODEX values")
|
|
|
|
|
|
def parse_supported_yaml_scalar(value: str):
|
|
"""Decode the deliberately small scalar subset supported by this validator."""
|
|
value = value.strip()
|
|
if not value:
|
|
return None
|
|
if value.startswith('"'):
|
|
escaped = False
|
|
for index, char in enumerate(value[1:], 1):
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == '"':
|
|
remainder = value[index + 1:].strip()
|
|
if remainder and not remainder.startswith("#"):
|
|
return None
|
|
try:
|
|
decoded = json.loads(value[:index + 1])
|
|
return decoded if isinstance(decoded, str) else None
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return None
|
|
if value.startswith("'"):
|
|
index = 1
|
|
while index < len(value):
|
|
if value[index] != "'":
|
|
index += 1
|
|
continue
|
|
if index + 1 < len(value) and value[index + 1] == "'":
|
|
index += 2
|
|
continue
|
|
remainder = value[index + 1:].strip()
|
|
if remainder and not remainder.startswith("#"):
|
|
return None
|
|
return value[1:index].replace("''", "'")
|
|
return None
|
|
|
|
comment = re.search(r"(?:^|\s)#", value)
|
|
scalar = value[:comment.start()].rstrip() if comment else value
|
|
if not scalar or any(ord(char) < 32 for char in scalar):
|
|
return None
|
|
if scalar[0] in "-?:,[]{}#&*!|>'\"%@`":
|
|
return None
|
|
return None if re.search(r":(?:\s|$)|[\[\]{}]", scalar) else scalar
|
|
|
|
|
|
def supported_yaml_scalar(value: str) -> bool:
|
|
"""Return whether value uses the scalar subset supported by this validator."""
|
|
return parse_supported_yaml_scalar(value) is not None
|
|
|
|
|
|
def valid_products_value(value: str) -> bool:
|
|
"""Accept an empty block value or the supported inline products sequence."""
|
|
value = value.strip()
|
|
if not value or value.startswith("#"):
|
|
return True
|
|
return bool(
|
|
re.fullmatch(
|
|
r"\[\s*(['\"]?)(?:CHAT|CODEX)\1(?:\s*,\s*(['\"]?)(?:CHAT|CODEX)\2)*\s*\](?:\s+#.*)?",
|
|
value,
|
|
)
|
|
)
|
|
|
|
|
|
def validate_yaml_shape(path: Path, text: str, errors):
|
|
"""Reject lines outside the deliberately small openai.yaml schema."""
|
|
current_section = None
|
|
current_nested = None
|
|
top_keys = set()
|
|
nested_keys = {section: set() for section in AGENT_METADATA_KEYS}
|
|
for number, raw in enumerate(text.splitlines(), 1):
|
|
if not raw.strip():
|
|
continue
|
|
leading = re.match(r"^[ \t]*", raw).group(0)
|
|
if "\t" in leading:
|
|
errors.append(f"{path}: malformed YAML line {number}: tabs are not valid indentation")
|
|
current_nested = None
|
|
continue
|
|
if raw.lstrip().startswith("#"):
|
|
continue
|
|
indent = len(leading)
|
|
stripped = raw.strip()
|
|
if indent == 0:
|
|
match = YAML_MAPPING.fullmatch(raw.rstrip())
|
|
if not match:
|
|
errors.append(f"{path}: malformed YAML line {number}: expected top-level mapping")
|
|
current_section = None
|
|
current_nested = None
|
|
continue
|
|
key, value = match.groups()
|
|
value = value or ""
|
|
if key in top_keys:
|
|
errors.append(f"{path}: duplicate top-level key: {key}")
|
|
top_keys.add(key)
|
|
if key not in AGENT_METADATA_KEYS:
|
|
errors.append(f"{path}: unknown top-level key: {key}")
|
|
current_section = None
|
|
elif value.strip() and not value.strip().startswith("#"):
|
|
errors.append(f"{path}: malformed YAML line {number}: {key} must be a mapping")
|
|
current_section = None
|
|
else:
|
|
current_section = key
|
|
current_nested = None
|
|
continue
|
|
if indent == 2:
|
|
current_nested = None
|
|
if current_section is None:
|
|
errors.append(f"{path}: malformed YAML line {number}: indented value has no parent mapping")
|
|
continue
|
|
match = YAML_MAPPING.fullmatch(stripped)
|
|
if not match:
|
|
errors.append(f"{path}: malformed YAML line {number}: expected key/value mapping")
|
|
continue
|
|
key, value = match.groups()
|
|
value = value or ""
|
|
if key not in AGENT_METADATA_KEYS[current_section]:
|
|
errors.append(f"{path}: unknown {current_section} key: {key}")
|
|
continue
|
|
if key in nested_keys[current_section]:
|
|
errors.append(f"{path}: duplicate {current_section} key: {key}")
|
|
nested_keys[current_section].add(key)
|
|
if current_section == "policy" and key == "products":
|
|
if not valid_products_value(value):
|
|
errors.append(f"{path}: malformed YAML line {number}: unsupported products value")
|
|
elif not value.strip() or value.strip().startswith("#"):
|
|
current_nested = key
|
|
elif current_section == "policy" and key == "allow_implicit_invocation":
|
|
if not re.fullmatch(r"(?:true|false)(?:\s+#.*)?", value.strip()):
|
|
errors.append(f"{path}: malformed YAML line {number}: unsupported boolean value")
|
|
elif not supported_yaml_scalar(value):
|
|
errors.append(f"{path}: malformed YAML line {number}: unsupported scalar value")
|
|
continue
|
|
if indent == 4:
|
|
if current_section != "policy" or current_nested != "products":
|
|
errors.append(f"{path}: malformed YAML line {number}: unexpected nested value")
|
|
continue
|
|
match = re.fullmatch(r"-\s+(.+)", stripped)
|
|
if not match or not supported_yaml_scalar(match.group(1)):
|
|
errors.append(f"{path}: malformed YAML line {number}: invalid products list item")
|
|
continue
|
|
if current_section is None:
|
|
errors.append(f"{path}: malformed YAML line {number}: indented value has no parent mapping")
|
|
else:
|
|
errors.append(f"{path}: malformed YAML line {number}: invalid indentation")
|
|
return top_keys, nested_keys
|
|
|
|
|
|
def graph_sha256(graph: dict) -> str:
|
|
payload = json.dumps(graph, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def validate_routing_matrix(graph: dict, path: Path, errors):
|
|
"""Check the generated route inventory so prose cannot silently drift from the graph."""
|
|
text = path.read_text(encoding="utf-8")
|
|
begin = "<!-- BEGIN GENERATED GRAPH ROUTES -->"
|
|
end = "<!-- END GENERATED GRAPH ROUTES -->"
|
|
if begin not in text or end not in text:
|
|
errors.append(f"{path}: missing generated graph route inventory")
|
|
return
|
|
generated = text.split(begin, 1)[1].split(end, 1)[0].strip()
|
|
expected_lines = [
|
|
f"<!-- skill-graph-sha256: {graph_sha256(graph)} -->",
|
|
"| Type | From | To | When | Max reentries |",
|
|
"| --- | --- | --- | --- | --- |",
|
|
]
|
|
edges = graph.get("edges", [])
|
|
if not isinstance(edges, list):
|
|
errors.append("router skill graph edges must be an array")
|
|
return
|
|
for index, edge in enumerate(edges):
|
|
if not isinstance(edge, dict):
|
|
errors.append(f"router skill graph edge {index} must be an object")
|
|
continue
|
|
malformed = False
|
|
for key in ("type", "from", "to", "when"):
|
|
if not isinstance(edge.get(key), str) or not edge[key].strip():
|
|
errors.append(f"router skill graph edge {index} {key} must be a non-empty string")
|
|
malformed = True
|
|
limit = edge.get("max_reentries", "")
|
|
if limit != "" and (not isinstance(limit, int) or isinstance(limit, bool)):
|
|
errors.append(f"router skill graph edge {index} max_reentries must be an integer")
|
|
malformed = True
|
|
if malformed:
|
|
continue
|
|
expected_lines.append(
|
|
f"| {edge.get('type', '')} | `{edge.get('from', '')}` | `{edge.get('to', '')}` | "
|
|
f"`{edge.get('when', '')}` | {limit} |"
|
|
)
|
|
expected = "\n".join(expected_lines)
|
|
if generated != expected:
|
|
errors.append(f"{path}: generated graph route inventory drifted from skill-graph.json")
|
|
|
|
def check_square_svg(path: Path, errors):
|
|
try:
|
|
if path.stat().st_size > MAX_SVG_BYTES:
|
|
errors.append(f"{path}: SVG exceeds 256 KiB size limit")
|
|
return
|
|
text = path.read_text(encoding="utf-8")
|
|
for declaration in ("<!DOCTYPE", "<!ENTITY"):
|
|
if declaration in text:
|
|
errors.append(f"{path}: SVG contains forbidden XML declaration: {declaration}")
|
|
return
|
|
root = ET.fromstring(text)
|
|
except Exception as exc:
|
|
errors.append(f"{path}: invalid SVG: {exc}")
|
|
return
|
|
if root.tag.split("}")[-1] != "svg":
|
|
errors.append(f"{path}: root element is not svg")
|
|
return
|
|
def number(value):
|
|
if value is None:
|
|
return None
|
|
match = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)", value)
|
|
return float(match.group(1)) if match else None
|
|
width, height = number(root.get("width")), number(root.get("height"))
|
|
if width is None or height is None or width <= 0 or height <= 0 or width != height:
|
|
errors.append(f"{path}: SVG width and height must be equal positive numbers")
|
|
|
|
def validate(root: Path):
|
|
errors, warnings = [], []
|
|
for rel in TOP_LEVEL_INCLUDE_FILES:
|
|
path = root / rel
|
|
if path.is_symlink():
|
|
errors.append(f"symlink not allowed in plugin surface: {rel}")
|
|
elif not path.is_file():
|
|
errors.append(f"missing required top-level file: {rel}")
|
|
manifest_dir = root / ".codex-plugin"
|
|
manifest_path = manifest_dir / "plugin.json"
|
|
if not manifest_path.is_file():
|
|
return errors + ["missing .codex-plugin/plugin.json"], warnings, {}
|
|
extras = [p.name for p in manifest_dir.iterdir() if p.name != "plugin.json"]
|
|
if extras:
|
|
errors.append(f".codex-plugin contains extra entries: {extras}")
|
|
manifest = load_json(manifest_path, errors)
|
|
version = manifest.get("version")
|
|
if not isinstance(version, str) or not SEMVER.match(version):
|
|
errors.append("manifest version must be strict semver")
|
|
if manifest.get("skills") != "./skills/":
|
|
errors.append('manifest skills must be "./skills/"')
|
|
interface = manifest.get("interface")
|
|
if not isinstance(interface, dict):
|
|
errors.append("manifest interface must be an object")
|
|
interface = {}
|
|
for key, limit in (("displayName",30),("shortDescription",30),("longDescription",4000),("developerName",80)):
|
|
value = interface.get(key)
|
|
if not isinstance(value, str) or not value.strip():
|
|
errors.append(f"interface.{key} must be a non-empty string")
|
|
elif "\n" in value and key != "longDescription":
|
|
errors.append(f"interface.{key} must be one line")
|
|
elif len(value) > limit:
|
|
errors.append(f"interface.{key} exceeds {limit} characters")
|
|
capabilities = interface.get("capabilities", [])
|
|
if not isinstance(capabilities, list) or not capabilities or len(capabilities) > 20:
|
|
errors.append("interface.capabilities must contain 1 to 20 items")
|
|
else:
|
|
for item in capabilities:
|
|
if not isinstance(item, str) or not item.strip() or "\n" in item or len(item) > 120:
|
|
errors.append(f"invalid capability: {item!r}")
|
|
prompts = interface.get("defaultPrompt", [])
|
|
if not isinstance(prompts, list) or not prompts or len(prompts) > 3:
|
|
errors.append("interface.defaultPrompt must contain 1 to 3 prompts")
|
|
else:
|
|
normalized = []
|
|
for item in prompts:
|
|
if not isinstance(item, str) or not item.strip() or "\n" in item or len(item) > 128:
|
|
errors.append(f"invalid starter prompt: {item!r}")
|
|
normalized.append(" ".join(item.lower().split()) if isinstance(item, str) else str(item))
|
|
if len(normalized) != len(set(normalized)):
|
|
errors.append("starter prompts must be unique after normalization")
|
|
for key in ("websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"):
|
|
value = interface.get(key)
|
|
if not isinstance(value, str) or not value.startswith("https://") or len(value) > 1024:
|
|
errors.append(f"interface.{key} must be an HTTPS URL")
|
|
# Whoever the published listing points at owns the legal, support and
|
|
# homepage destinations users land on. Those must be the repository-owned
|
|
# canonical project, not a fork. Every public URL in the manifest has to sit
|
|
# under that fixed root.
|
|
author = manifest.get("author")
|
|
author_url = author.get("url") if isinstance(author, dict) else None
|
|
if author_url != CANONICAL_PROJECT_URL:
|
|
errors.append(
|
|
f"author.url must equal the canonical project URL {CANONICAL_PROJECT_URL}: {author_url!r}"
|
|
)
|
|
prefix = CANONICAL_PROJECT_URL.rstrip("/")
|
|
owned = [("homepage", manifest.get("homepage")), ("repository", manifest.get("repository"))]
|
|
owned += [(f"interface.{k}", interface.get(k)) for k in ("websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL")]
|
|
for label, value in owned:
|
|
if isinstance(value, str) and not (value.rstrip("/") == prefix or value.startswith(prefix + "/")):
|
|
errors.append(f"{label} does not point at the canonical project {prefix}: {value}")
|
|
for key in ("composerIcon", "logo"):
|
|
value = interface.get(key)
|
|
if not isinstance(value, str) or not value.startswith("./") or not safe_rel(value):
|
|
errors.append(f"interface.{key} must be a safe ./ relative path")
|
|
continue
|
|
target = root / value[2:]
|
|
if not target.is_file():
|
|
errors.append(f"interface.{key} target missing: {value}")
|
|
elif target.suffix.lower() == ".svg":
|
|
check_square_svg(target, errors)
|
|
skills_root = root / "skills"
|
|
if not skills_root.is_dir():
|
|
return errors + ["missing skills directory"], warnings, manifest
|
|
names = {}
|
|
loose = sorted(p.name for p in skills_root.iterdir() if not p.is_dir())
|
|
if loose:
|
|
errors.append(f"skills contains non-directory entries: {loose}")
|
|
for skill_dir in sorted(p for p in skills_root.iterdir() if p.is_dir()):
|
|
skill_path = skill_dir / "SKILL.md"
|
|
if not skill_path.is_file():
|
|
errors.append(f"{skill_dir}: missing SKILL.md")
|
|
continue
|
|
meta, body = parse_frontmatter(skill_path)
|
|
name, desc = meta.get("name", ""), meta.get("description", "")
|
|
if not name or not desc or not body:
|
|
errors.append(f"{skill_path}: name, description, and body are required")
|
|
if frontmatter_has_metadata(skill_path):
|
|
errors.append(
|
|
f"{skill_path}: `metadata` in SKILL.md frontmatter is rejected by the OpenAI plugin portal; "
|
|
"put interface settings under `interface` in agents/openai.yaml"
|
|
)
|
|
if name:
|
|
if name in names:
|
|
errors.append(f"duplicate skill name {name!r}: {names[name]} and {skill_dir.name}")
|
|
names[name] = skill_dir.name
|
|
agent = skill_dir / "agents" / "openai.yaml"
|
|
if not agent.is_file():
|
|
errors.append(f"{skill_dir}: missing agents/openai.yaml")
|
|
else:
|
|
validate_agent_metadata(agent, errors)
|
|
canonical = root / "SKILL.md"
|
|
openai_copy = skills_root / "avoid-ai-writing" / "SKILL.md"
|
|
if canonical.is_file():
|
|
if not openai_copy.is_file():
|
|
errors.append("skills/avoid-ai-writing/SKILL.md missing; cannot check drift from root SKILL.md")
|
|
elif strip_frontmatter_metadata(canonical.read_bytes().decode("utf-8")).encode("utf-8") != openai_copy.read_bytes():
|
|
errors.append("skills/avoid-ai-writing/SKILL.md drifted from root SKILL.md (expected: root minus the frontmatter `metadata` block)")
|
|
meta, _ = parse_frontmatter(canonical)
|
|
if meta.get("version") != version:
|
|
errors.append(f"canonical SKILL.md version {meta.get('version')!r} does not match manifest {version!r}")
|
|
else:
|
|
warnings.append("root SKILL.md not present in packaged archive; canonical-copy check skipped")
|
|
canonical_bundle = skills_root / "avoid-ai-writing"
|
|
for rel in ("references/patterns.md", "detector/patterns.js", "detector/validate.js", "detector/CATEGORIES.md", "scripts/check-style.js", "scripts/normalize-quotes.js", "scripts/markdown-prose.js", "examples/README.md", "examples/technical.json", "examples/prose.json"):
|
|
bundled = canonical_bundle / rel
|
|
if not bundled.is_file():
|
|
errors.append(f"avoid-ai-writing missing bundled resource: {rel}")
|
|
source = root / rel
|
|
if source.is_file() and bundled.is_file() and source.read_bytes() != bundled.read_bytes():
|
|
errors.append(f"avoid-ai-writing bundled resource drifted from canonical: {rel}")
|
|
graph_path = skills_root / "avoid-ai-writing-router" / "references" / "skill-graph.json"
|
|
graph = load_json(graph_path, errors) if graph_path.is_file() else {}
|
|
if not graph_path.is_file():
|
|
errors.append("missing router skill graph v2 JSON")
|
|
else:
|
|
if graph.get("version") != 2:
|
|
errors.append("router skill graph must be version 2")
|
|
graph_nodes = graph.get("nodes")
|
|
if not isinstance(graph_nodes, dict):
|
|
errors.append("router skill graph nodes must be an object")
|
|
graph_nodes = {}
|
|
missing_from_graph = sorted(set(names) - set(graph_nodes))
|
|
extra_graph_nodes = sorted(set(graph_nodes) - set(names))
|
|
if missing_from_graph:
|
|
errors.append(f"router graph missing public skills: {missing_from_graph}")
|
|
if extra_graph_nodes:
|
|
errors.append(f"router graph references non-public skills: {extra_graph_nodes}")
|
|
if graph.get("canonical_authority") != "avoid-ai-writing":
|
|
errors.append("router graph canonical_authority drifted")
|
|
if graph.get("entrypoint") != "avoid-ai-writing-router":
|
|
errors.append("router graph entrypoint drifted")
|
|
routing_path = skills_root / "avoid-ai-writing-router" / "references" / "routing-matrix.md"
|
|
if not routing_path.is_file():
|
|
errors.append("missing router routing matrix")
|
|
elif isinstance(graph, dict) and graph:
|
|
validate_routing_matrix(graph, routing_path, errors)
|
|
# The preservation validator imports ./patterns.js for residual checks.
|
|
# Both resources must be present in the public archive so that behavior
|
|
# does not silently degrade after packaging.
|
|
verifier_scripts = skills_root / "preservation-verifier" / "scripts"
|
|
for resource in ("validate.js", "patterns.js"):
|
|
if not (verifier_scripts / resource).is_file():
|
|
errors.append(f"preservation-verifier missing bundled resource: scripts/{resource}")
|
|
detector_patterns = skills_root / "ai-writing-detector" / "scripts" / "patterns.js"
|
|
if not detector_patterns.is_file():
|
|
errors.append("ai-writing-detector missing bundled scripts/patterns.js")
|
|
submission = root / "submission"
|
|
if submission.is_dir():
|
|
tests = load_json(submission / "reviewer-tests.json", errors)
|
|
positive_tests = typed_member(tests, "positive", list, errors, "submission reviewer tests positive")
|
|
negative_tests = typed_member(tests, "negative", list, errors, "submission reviewer tests negative")
|
|
if len(positive_tests) < 5:
|
|
errors.append("submission reviewer tests need at least five positive cases")
|
|
if len(negative_tests) < 3:
|
|
errors.append("submission reviewer tests need at least three negative cases")
|
|
listing = load_json(submission / "listing.json", errors)
|
|
source = typed_member(listing, "source", dict, errors, "submission listing source")
|
|
# baseCommit is the origin/main commit the port was last synced against.
|
|
# It may lag origin/main, but it must remain in this branch's history.
|
|
base_commit = source.get("baseCommit")
|
|
if not isinstance(base_commit, str) or not re.fullmatch(r"[0-9a-f]{40}", base_commit):
|
|
errors.append("submission listing source.baseCommit must be a full lowercase commit SHA")
|
|
else:
|
|
ancestry = subprocess.run(
|
|
["git", "-C", str(root), "merge-base", "--is-ancestor", base_commit, "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if ancestry.returncode != 0:
|
|
detail = ancestry.stderr.strip()
|
|
suffix = f": {detail}" if detail else ""
|
|
errors.append(f"submission listing baseCommit is not an ancestor of HEAD: {base_commit}{suffix}")
|
|
fields = typed_member(listing, "fields", dict, errors, "submission listing fields")
|
|
# submission/listing.json restates the manifest. Three fields used to be
|
|
# gated here and the rest were free to drift, which is how a listing can
|
|
# ship a different developer name or support URL than the manifest it
|
|
# claims to describe. The manifest is the source; every duplicated field
|
|
# is asserted against it.
|
|
for listing_key, manifest_key in (
|
|
("name", "displayName"),
|
|
("subtitle", "shortDescription"),
|
|
("description", "longDescription"),
|
|
("category", "category"),
|
|
("developerName", "developerName"),
|
|
("websiteURL", "websiteURL"),
|
|
("supportURL", "supportURL"),
|
|
("privacyPolicyURL", "privacyPolicyURL"),
|
|
("termsOfServiceURL", "termsOfServiceURL"),
|
|
("capabilities", "capabilities"),
|
|
("logo", "logo"),
|
|
("composerIcon", "composerIcon"),
|
|
("brandColor", "brandColor"),
|
|
):
|
|
if fields.get(listing_key) != interface.get(manifest_key):
|
|
errors.append(f"submission listing {listing_key} drifted from manifest interface.{manifest_key}")
|
|
if fields.get("starterPrompts") != interface.get("defaultPrompt"):
|
|
errors.append("submission listing starterPrompts drifted from manifest interface.defaultPrompt")
|
|
if fields.get("version") != version:
|
|
errors.append("submission listing version drifted from manifest")
|
|
if fields.get("packageName") != manifest.get("name"):
|
|
errors.append("submission listing packageName drifted from manifest name")
|
|
# The "checks" block records character counts as evidence for the
|
|
# submission portal's limits. Hand-maintained counts go stale the first
|
|
# time a field is edited, so derive them instead of trusting them.
|
|
checks = typed_member(listing, "checks", dict, errors, "submission listing checks")
|
|
for check_key, source in (
|
|
("nameCharacters", fields.get("name")),
|
|
("subtitleCharacters", fields.get("subtitle")),
|
|
("descriptionCharacters", fields.get("description")),
|
|
("developerNameCharacters", fields.get("developerName")),
|
|
):
|
|
if isinstance(source, str) and check_key in checks and checks[check_key] != len(source):
|
|
errors.append(f"submission listing {check_key} says {checks[check_key]}, actual {len(source)}")
|
|
for check_key, source in (
|
|
("starterPromptCharacters", fields.get("starterPrompts")),
|
|
("capabilityCharacters", fields.get("capabilities")),
|
|
):
|
|
if isinstance(source, list) and check_key in checks:
|
|
actual = [len(item) for item in source if isinstance(item, str)]
|
|
if checks[check_key] != actual:
|
|
errors.append(f"submission listing {check_key} says {checks[check_key]}, actual {actual}")
|
|
identity = typed_member(listing, "publisherIdentity", dict, errors, "submission listing publisherIdentity")
|
|
if identity.get("packagePublisher") != interface.get("developerName"):
|
|
errors.append("submission listing packagePublisher drifted from manifest interface.developerName")
|
|
pack = load_json(submission / "submission-pack.json", errors)
|
|
pack_source = typed_member(pack, "source", dict, errors, "submission pack source")
|
|
if pack_source.get("canonicalSkillVersion") != version:
|
|
errors.append("submission pack canonicalSkillVersion drifted from manifest version")
|
|
if pack_source.get("baseCommit") != base_commit:
|
|
errors.append("submission pack baseCommit drifted from submission listing")
|
|
# Scanning the whole checkout means walking .git (thousands of objects, and
|
|
# loose refs that look nothing like the plugin surface). Only the packaged
|
|
# surface is in scope here; keep this list aligned with
|
|
# scripts/package-openai-plugin.py's INCLUDE_DIRS.
|
|
scan_roots = [root / name for name in (".codex-plugin", "skills", "assets", "submission")]
|
|
for scan_root in scan_roots:
|
|
if not scan_root.is_dir():
|
|
continue
|
|
for path in scan_root.rglob("*"):
|
|
if path.is_symlink():
|
|
errors.append(f"symlink not allowed in plugin surface: {path.relative_to(root)}")
|
|
lowered = path.name.lower()
|
|
if lowered in {".env", "id_rsa", "id_ed25519"}:
|
|
errors.append(f"secret-shaped file not allowed: {path.relative_to(root)}")
|
|
if "__pycache__" in path.parts or lowered.endswith((".pyc", ".pyo")):
|
|
errors.append(f"transient Python artifact not allowed: {path.relative_to(root)}")
|
|
return errors, warnings, {"ok": not errors,"plugin": manifest.get("name"),"version": version,"skills": sorted(names),"errors": errors,"warnings": warnings}
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("root", nargs="?", default=".")
|
|
parser.add_argument("--json", action="store_true")
|
|
parser.add_argument(
|
|
"--strip-frontmatter-metadata",
|
|
metavar="SKILL_MD",
|
|
help="print SKILL_MD with the frontmatter `metadata` block removed (used by sync-plugin-skill.sh) and exit",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.strip_frontmatter_metadata:
|
|
data = Path(args.strip_frontmatter_metadata).read_bytes().decode("utf-8")
|
|
sys.stdout.buffer.write(strip_frontmatter_metadata(data).encode("utf-8"))
|
|
return 0
|
|
errors, warnings, summary = validate(Path(args.root).resolve())
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2))
|
|
else:
|
|
print("OK" if not errors else "FAIL")
|
|
for item in errors: print(f"ERROR: {item}")
|
|
for item in warnings: print(f"WARN: {item}")
|
|
return 1 if errors else 0
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|