fix: harden plugin creator sharing validation (#22893)

# Summary

Before this change, the sample plugin creator could emit
placeholder-heavy manifests that fail workspace sharing, and it chose a
repo-local marketplace implicitly whenever it ran from inside a git
checkout.

This PR makes generated plugins share-ready by default. It switches
creation to the personal marketplace unless the caller explicitly opts
into repo-local paths, adds a validator that mirrors the workspace
plugin ingestion contract, and updates the skill prompt and docs to
describe the real flow.

The goal is to stop malformed generated plugins before they reach
sharing and to make the default placement match the personal marketplace
behavior users expect.

## Changes

- Generate share-safe plugin manifests instead of `[TODO: ...]`
placeholder payloads.
- Default plugin and marketplace creation to `~/plugins` and
`~/.agents/plugins/marketplace.json`.
- Keep repo-local marketplace creation available through explicit
`--path` and `--marketplace-path` arguments.
- Add `validate_plugin.py` to check manifests, companion files, skill
frontmatter, skill agent YAML, asset paths, and backend-shaped contracts
before sharing.
- Refresh the plugin creator skill text, reference docs, and default
prompt to describe validation and the personal default.

## Design decisions

- The validator tracks the workspace ingestion schema directly,
including the required `defaultPrompt` alias handling and skill
`agents/openai.yaml` checks.
- The validator keeps one intentional extra preflight rule: leftover
`[TODO: ...]` placeholders are rejected before sharing even when a
single placeholder would not independently violate backend type
validation.
- Repo-local creation stays possible, but it is now explicit instead of
cwd-sensitive.

## Testing

Tests: targeted Python syntax checks, plugin skill validation, staged
diff whitespace validation, 15 generated plugin smoke runs, backend
manifest-schema acceptance for all 15 generated bundles, and a git-repo
cwd regression proving the creator still writes to the personal
marketplace by default.
This commit is contained in:
efrazer-oai
2026-05-18 11:22:42 -07:00
committed by GitHub
Unverified
parent 8c14b08dd1
commit d32cb2c6ac
5 changed files with 673 additions and 65 deletions
@@ -1,6 +1,6 @@
---
name: plugin-creator
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update personal or repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata.
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata.
---
# Plugin Creator
@@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name>
```
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders.
2. Edit `<plugin-path>/.codex-plugin/plugin.json` when the request gives specific metadata.
The scaffold starts with valid defaults and must not contain `[TODO: ...]` placeholders.
3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering:
@@ -26,14 +27,12 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
```
If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
whether the plugin is personal or shared with their team, ask before generating a marketplace entry.
When they choose the repo marketplace, use:
Only use a repo/team marketplace when the user specifically asks for that destination:
```bash
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
--path ./plugins \
--marketplace-path ./.agents/plugins/marketplace.json \
--path <repo-root>/plugins \
--marketplace-path <repo-root>/.agents/plugins/marketplace.json \
--with-marketplace
```
@@ -48,17 +47,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` will be created (for example `~/code/plugins`).
5. Before handing back a generated plugin, run:
```bash
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
```
## What this skill creates
- Default marketplace-backed scaffolds are personal: `~/plugins/<plugin-name>/` plus
`~/.agents/plugins/marketplace.json`.
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
personal vs team, ask which marketplace to update before generating a marketplace entry.
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
- Creates or updates the selected marketplace when `--with-marketplace` is set.
- If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry.
- Fills the manifest with the validated schema shape that the ingestion path accepts.
- Creates or updates `~/.agents/plugins/marketplace.json` when `--with-marketplace` is set.
- If the marketplace file does not exist yet, seed a personal marketplace root before adding the first plugin entry.
- `<plugin-name>` is normalized using skill-creator naming rules:
- `My Plugin``my-plugin`
- `My--Plugin``my-plugin`
@@ -74,8 +77,10 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
## Marketplace workflow
- Personal plugins use `~/.agents/plugins/marketplace.json`.
- Repo/team plugins use `<repo-root>/.agents/plugins/marketplace.json`.
- Personal creation defaults to `~/.agents/plugins/marketplace.json`.
- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only
when the user specifically requests it.
- In either location, the generated source path remains `./plugins/<plugin-name>`.
- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`.
- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list.
- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries.
@@ -113,15 +118,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
```
- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name.
- If the selected marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
- If the target marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
- For a brand-new marketplace file, the root object should look like:
```json
{
"name": "[TODO: marketplace-name]",
"name": "personal",
"interface": {
"displayName": "[TODO: Marketplace Display Name]"
"displayName": "Personal"
},
"plugins": [
{
@@ -144,7 +149,9 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
- Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name.
- Do not remove required structure; keep `.codex-plugin/plugin.json` present.
- Keep manifest values as placeholders until a human or follow-up step explicitly fills them.
- Do not leave `[TODO: ...]` placeholders in plugin manifests.
- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created.
- Omit unsupported plugin manifest fields that validation rejects, including `hooks`.
- If creating files inside an existing plugin path, use `--force` only when overwrite is intentional.
- Preserve any existing marketplace `interface.displayName`.
- When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults.
@@ -176,3 +183,9 @@ After editing `SKILL.md`, run:
```bash
python3 <path-to-skill-creator>/scripts/quick_validate.py .agents/skills/plugin-creator
```
Before handing back a generated plugin, run:
```bash
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
```
@@ -1,6 +1,6 @@
interface:
display_name: "Plugin Creator"
short_description: "Scaffold plugins and marketplace entries"
default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry."
default_prompt: "Use $plugin-creator to scaffold a valid plugin in the personal marketplace, then validate it before handing it back."
icon_small: "./assets/plugin-creator-small.svg"
icon_large: "./assets/plugin-creator.png"
@@ -97,7 +97,8 @@
# Marketplace JSON sample spec
`marketplace.json` depends on where the plugin should live:
`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the
personal marketplace unless the caller explicitly requests a repo-local destination:
- Personal plugin: `~/.agents/plugins/marketplace.json`
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
@@ -166,8 +167,25 @@
- Append new entries unless the user explicitly requests reordering.
- Replace an existing entry for the same plugin only when overwrite is intentional.
- Default new plugin creation to the personal marketplace.
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
personal or team, ask which marketplace to update before creating the entry.
- Use a repo/team marketplace only when the user specifically requests that destination.
- Choose marketplace location to match the selected destination:
- Personal plugin: `~/.agents/plugins/marketplace.json`
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
### Plugin validation notes
- The validator mirrors the workspace plugin ingestion schema so generated plugins follow the same
manifest contract from the start.
- Plugin manifests must include real values for `name`, `version`, `description`,
`author.name`, and the required `interface` fields.
- `version` must use strict semver.
- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when
present.
- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when
present.
- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json`
actually exist.
- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of
generated manifests.
- Run `scripts/validate_plugin.py <plugin-path>` before handing back a generated plugin. It adds one
intentional preflight check that rejects leftover `[TODO: ...]` placeholders.
@@ -16,7 +16,8 @@ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.js
DEFAULT_INSTALL_POLICY = "AVAILABLE"
DEFAULT_AUTH_POLICY = "ON_INSTALL"
DEFAULT_CATEGORY = "Productivity"
DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]"
DEFAULT_MARKETPLACE_NAME = "personal"
DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal"
VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}
VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"}
@@ -40,49 +41,35 @@ def validate_plugin_name(plugin_name: str) -> None:
)
def build_plugin_json(plugin_name: str) -> dict:
return {
def display_name_from_plugin_name(plugin_name: str) -> str:
return " ".join(part.capitalize() for part in plugin_name.split("-"))
def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]:
display_name = display_name_from_plugin_name(plugin_name)
payload: dict[str, Any] = {
"name": plugin_name,
"version": "[TODO: 1.2.0]",
"description": "[TODO: Brief plugin description]",
"version": "0.1.0",
"description": f"{display_name} plugin",
"author": {
"name": "[TODO: Author Name]",
"email": "[TODO: author@example.com]",
"url": "[TODO: https://github.com/author]",
"name": "Local developer",
},
"homepage": "[TODO: https://docs.example.com/plugin]",
"repository": "[TODO: https://github.com/author/plugin]",
"license": "[TODO: MIT]",
"keywords": ["[TODO: keyword1]", "[TODO: keyword2]"],
"skills": "[TODO: ./skills/]",
"hooks": "[TODO: ./hooks.json]",
"mcpServers": "[TODO: ./.mcp.json]",
"apps": "[TODO: ./.app.json]",
"skills": "./skills/",
"interface": {
"displayName": "[TODO: Plugin Display Name]",
"shortDescription": "[TODO: Short description for subtitle]",
"longDescription": "[TODO: Long description for details page]",
"developerName": "[TODO: OpenAI]",
"category": "[TODO: Productivity]",
"capabilities": ["[TODO: Interactive]", "[TODO: Write]"],
"websiteURL": "[TODO: https://openai.com/]",
"privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]",
"termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]",
"defaultPrompt": [
"[TODO: Summarize my inbox and draft replies for me.]",
"[TODO: Find open bugs and turn them into tickets.]",
"[TODO: Review today's meetings and flag gaps.]",
],
"brandColor": "[TODO: #3B82F6]",
"composerIcon": "[TODO: ./assets/icon.png]",
"logo": "[TODO: ./assets/logo.png]",
"screenshots": [
"[TODO: ./assets/screenshot1.png]",
"[TODO: ./assets/screenshot2.png]",
"[TODO: ./assets/screenshot3.png]",
],
"displayName": display_name,
"shortDescription": f"Use {display_name} in Codex.",
"longDescription": f"{display_name} adds a local Codex plugin scaffold.",
"developerName": "Local developer",
"category": DEFAULT_CATEGORY,
"capabilities": [],
"defaultPrompt": f"Help me use {display_name}.",
},
}
if with_mcp:
payload["mcpServers"] = "./.mcp.json"
if with_apps:
payload["apps"] = "./.app.json"
return payload
def build_marketplace_entry(
@@ -112,7 +99,7 @@ def load_json(path: Path) -> dict[str, Any]:
def build_default_marketplace() -> dict[str, Any]:
return {
"name": "[TODO: marketplace-name]",
"name": DEFAULT_MARKETPLACE_NAME,
"interface": {
"displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME,
},
@@ -185,7 +172,7 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a plugin skeleton with placeholder plugin.json."
description="Create a plugin skeleton with a validation-ready plugin.json."
)
parser.add_argument("plugin_name")
parser.add_argument(
@@ -193,7 +180,7 @@ def parse_args() -> argparse.Namespace:
default=str(DEFAULT_PLUGIN_PARENT),
help=(
"Parent directory for plugin creation (defaults to <home>/plugins). "
"Use <repo>/plugins when creating a repo/team plugin."
"Pass an explicit repo path only when a repo/team plugin is intended."
),
)
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
@@ -216,7 +203,7 @@ def parse_args() -> argparse.Namespace:
default=str(DEFAULT_MARKETPLACE_PATH),
help=(
"Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). "
"Use <repo>/.agents/plugins/marketplace.json for a repo/team plugin."
"Pass a repo-rooted marketplace path only when a repo/team plugin is intended."
),
)
parser.add_argument(
@@ -252,7 +239,11 @@ def main() -> None:
plugin_root.mkdir(parents=True, exist_ok=True)
plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json"
write_json(plugin_json_path, build_plugin_json(plugin_name), args.force)
write_json(
plugin_json_path,
build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps),
args.force,
)
optional_directories = {
"skills": args.with_skills,
@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""Validate a generated plugin against the plugin ingestion contract."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlparse
import yaml
TODO_MARKER = "[TODO:"
SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\."
r"(0|[1-9]\d*)\."
r"(0|[1-9]\d*)"
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."
r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate a local Codex plugin.")
parser.add_argument("plugin_path", help="Path to the plugin root directory")
return parser.parse_args()
def main() -> None:
args = parse_args()
plugin_root = Path(args.plugin_path).expanduser().resolve()
errors = validate_plugin(plugin_root)
if errors:
print("Plugin validation failed:")
for error in errors:
print(f"- {error}")
raise SystemExit(1)
print(f"Plugin validation passed: {plugin_root}")
def validate_plugin(plugin_root: Path) -> list[str]:
errors: list[str] = []
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
manifest = load_json_object(manifest_path, errors)
if manifest is None:
return errors
reject_todo_markers(manifest, "$", errors)
validate_manifest_shape(plugin_root, manifest, errors)
return errors
def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
if not path.is_file():
errors.append("missing `.codex-plugin/plugin.json`")
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError:
errors.append("unable to read `.codex-plugin/plugin.json`")
return None
except json.JSONDecodeError:
errors.append("`.codex-plugin/plugin.json` must be valid JSON")
return None
if not isinstance(payload, dict):
errors.append("`.codex-plugin/plugin.json` must contain a JSON object")
return None
return payload
def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None:
if isinstance(value, str):
if TODO_MARKER in value:
errors.append(f"{path} still contains a `[TODO: ...]` placeholder")
return
if isinstance(value, list):
for index, item in enumerate(value):
reject_todo_markers(item, f"{path}[{index}]", errors)
return
if isinstance(value, dict):
for key, item in value.items():
reject_todo_markers(item, f"{path}.{key}", errors)
def validate_manifest_shape(
plugin_root: Path,
manifest: dict[str, Any],
errors: list[str],
) -> None:
allowed_keys = {
"id",
"name",
"version",
"description",
"skills",
"apps",
"mcpServers",
"interface",
"author",
"homepage",
"repository",
"license",
"keywords",
}
for key in sorted(set(manifest) - allowed_keys):
errors.append(f"plugin.json field `{key}` is not accepted by plugin validation")
validate_optional_non_empty_string(manifest, "id", errors)
require_non_empty_string(manifest, "name", errors)
version = require_non_empty_string(manifest, "version", errors)
if version is not None and SEMVER_RE.fullmatch(version) is None:
errors.append("plugin.json field `version` must be strict semver")
require_non_empty_string(manifest, "description", errors)
author = require_object(manifest, "author", errors)
if author is not None:
reject_unknown_fields(author, {"name", "email", "url"}, "author", errors)
require_non_empty_string(author, "name", errors, prefix="author")
validate_optional_non_empty_string(author, "email", errors, prefix="author")
validate_optional_https_url(author, "url", errors, prefix="author")
validate_optional_contract_path(manifest, "skills", "skills", errors)
validate_optional_contract_path(manifest, "apps", ".app.json", errors)
validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors)
if manifest.get("apps") is not None:
validate_app_manifest(
plugin_root / ".app.json",
errors,
)
if manifest.get("mcpServers") is not None:
validate_mcp_manifest(
plugin_root / ".mcp.json",
errors,
)
validate_skill_manifests(plugin_root, errors)
interface = require_object(manifest, "interface", errors)
if interface is None:
return
reject_unknown_fields(
interface,
{
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
"capabilities",
"websiteURL",
"privacyPolicyURL",
"termsOfServiceURL",
"brandColor",
"composerIcon",
"logo",
"screenshots",
"defaultPrompt",
"default_prompt",
},
"interface",
errors,
)
for field in (
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
):
require_non_empty_string(interface, field, errors, prefix="interface")
if "defaultPrompt" not in interface and "default_prompt" not in interface:
errors.append(
"plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required"
)
capabilities = interface.get("capabilities")
if not isinstance(capabilities, list) or not all(
isinstance(value, str) and value.strip() for value in capabilities
):
errors.append("plugin.json field `interface.capabilities` must be an array of strings")
for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):
validate_optional_https_url(interface, field, errors, prefix="interface")
brand_color = interface.get("brandColor")
if brand_color is not None and (
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
):
errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")
for field in ("composerIcon", "logo"):
validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)
screenshots = interface.get("screenshots", [])
if not isinstance(screenshots, list):
errors.append("plugin.json field `interface.screenshots` must be an array")
else:
for index, raw_path in enumerate(screenshots):
validate_asset_path(
plugin_root,
plugin_root,
raw_path,
f"interface.screenshots[{index}]",
errors,
)
def require_object(
payload: dict[str, Any],
key: str,
errors: list[str],
) -> dict[str, Any] | None:
value = payload.get(key)
if not isinstance(value, dict):
errors.append(f"plugin.json field `{key}` must be an object")
return None
return value
def require_non_empty_string(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str | None = None,
) -> str | None:
value = payload.get(key)
field = f"{prefix}.{key}" if prefix is not None else key
if not isinstance(value, str) or not value.strip():
errors.append(f"plugin.json field `{field}` must be a non-empty string")
return None
return value
def validate_optional_non_empty_string(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str | None = None,
) -> None:
value = payload.get(key)
if value is None:
return
field = f"{prefix}.{key}" if prefix is not None else key
if not isinstance(value, str) or not value.strip():
errors.append(f"plugin.json field `{field}` must be a non-empty string")
def reject_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
prefix: str,
errors: list[str],
) -> None:
for key in sorted(set(payload) - allowed_keys):
errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation")
def validate_optional_https_url(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str,
) -> None:
value = payload.get(key)
if value is None:
return
parsed = urlparse(value) if isinstance(value, str) else None
if parsed is None or parsed.scheme != "https" or not parsed.netloc:
errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL")
def validate_optional_contract_path(
payload: dict[str, Any],
key: str,
expected: str,
errors: list[str],
) -> None:
value = payload.get(key)
if value is None:
return
normalized = normalize_contract_path(value) if isinstance(value, str) else None
if normalized != expected:
errors.append(f"plugin.json field `{key}` must resolve to `{expected}`")
def normalize_contract_path(raw_path: str) -> str | None:
path = Path(raw_path)
if path.is_absolute():
return None
normalized = path.as_posix().rstrip("/")
return normalized or None
def validate_app_manifest(path: Path, errors: list[str]) -> None:
payload = load_companion_json_object(path, "`.app.json`", errors)
if payload is None:
return
reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors)
apps = payload.get("apps")
if not isinstance(apps, dict):
errors.append("`.app.json` field `apps` must be an object")
return
for key, value in apps.items():
if not isinstance(value, dict):
errors.append(f"`.app.json` app `{key}` must be an object")
continue
reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors)
app_id = value.get("id")
if not isinstance(app_id, str) or not app_id.strip():
errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string")
def validate_mcp_manifest(path: Path, errors: list[str]) -> None:
payload = load_companion_json_object(path, "`.mcp.json`", errors)
if payload is None:
return
reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors)
servers = payload.get("mcpServers")
if not isinstance(servers, dict):
errors.append("`.mcp.json` field `mcpServers` must be an object")
return
for key, value in servers.items():
if not isinstance(key, str) or not key.strip():
errors.append("`.mcp.json` server names must be non-empty strings")
if not isinstance(value, dict):
errors.append(f"`.mcp.json` server `{key}` must be an object")
def load_companion_json_object(
path: Path,
label: str,
errors: list[str],
) -> dict[str, Any] | None:
if not path.is_file():
errors.append(f"{label} is required when its plugin.json field is present")
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
errors.append(f"{label} must contain valid JSON")
return None
if not isinstance(payload, dict):
errors.append(f"{label} must contain a JSON object")
return None
return payload
def reject_companion_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
prefix: str,
errors: list[str],
) -> None:
for key in sorted(set(payload) - allowed_keys):
errors.append(f"{prefix} field `{key}` is not accepted by plugin validation")
def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None:
skills_root = plugin_root / "skills"
if not skills_root.is_dir():
return
for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name):
if skill_root.name.startswith(".") or not skill_root.is_dir():
continue
validate_skill_manifest(skill_root, errors)
def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None:
skill_md_path = skill_root / "SKILL.md"
if not skill_md_path.is_file():
errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`")
return
try:
contents = skill_md_path.read_text(encoding="utf-8")
except OSError:
errors.append(f"unable to read skill `{skill_root.name}`")
return
if not contents.startswith("---\n"):
errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter")
return
frontmatter_end = contents.find("\n---", 4)
if frontmatter_end == -1:
errors.append(f"skill `{skill_root.name}` frontmatter is not closed")
return
try:
frontmatter = yaml.safe_load(contents[4:frontmatter_end])
except yaml.YAMLError:
errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML")
return
if not isinstance(frontmatter, dict):
errors.append(f"skill `{skill_root.name}` frontmatter must be an object")
return
skill_name = frontmatter.get("name")
if not isinstance(skill_name, str) or not skill_name.strip():
errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty")
description = frontmatter.get("description")
if not isinstance(description, str) or not description.strip():
errors.append(
f"skill `{skill_root.name}` frontmatter field `description` must be non-empty"
)
disable_model_invocation = frontmatter.get("disable-model-invocation")
if disable_model_invocation is None:
disable_model_invocation = frontmatter.get("disable_model_invocation")
if disable_model_invocation not in (None, False):
errors.append(
f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false"
)
agent_yaml_path = skill_root / "agents" / "openai.yaml"
if agent_yaml_path.is_file():
validate_skill_agent_manifest(
plugin_root=skill_root.parent.parent,
skill_root=skill_root,
agent_yaml_path=agent_yaml_path,
errors=errors,
)
def validate_skill_agent_manifest(
*,
plugin_root: Path,
skill_root: Path,
agent_yaml_path: Path,
errors: list[str],
) -> None:
try:
payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8"))
except OSError:
errors.append(f"unable to read skill `{skill_root.name}` agent YAML")
return
except yaml.YAMLError:
errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML")
return
if not isinstance(payload, dict):
errors.append(f"skill `{skill_root.name}` agent YAML must be an object")
return
reject_skill_agent_unknown_fields(
payload,
{"interface", "policy", "dependencies"},
skill_root,
errors,
)
interface = payload.get("interface")
if not isinstance(interface, dict):
errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object")
return
reject_skill_agent_unknown_fields(
interface,
{
"display_name",
"short_description",
"icon_small",
"icon_large",
"brand_color",
"default_prompt",
},
skill_root,
errors,
prefix="interface",
)
for field in ("display_name", "short_description"):
value = interface.get(field)
if not isinstance(value, str) or not value.strip():
errors.append(
f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty"
)
for field in ("icon_small", "icon_large"):
validate_optional_asset_path(
skill_root,
plugin_root,
interface,
field,
errors,
prefix=f"skill `{skill_root.name}` agent field `interface",
)
brand_color = interface.get("brand_color")
if brand_color is not None and (
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
):
errors.append(
f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`"
)
default_prompt = interface.get("default_prompt")
if default_prompt is not None and (
not isinstance(default_prompt, str) or not default_prompt.strip()
):
errors.append(
f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty"
)
policy = payload.get("policy")
if policy is not None:
if not isinstance(policy, dict):
errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object")
else:
reject_skill_agent_unknown_fields(
policy,
{"allow_implicit_invocation"},
skill_root,
errors,
prefix="policy",
)
allow_implicit_invocation = policy.get("allow_implicit_invocation")
if allow_implicit_invocation is not None and not isinstance(
allow_implicit_invocation,
bool,
):
errors.append(
f"skill `{skill_root.name}` agent field "
"`policy.allow_implicit_invocation` must be a boolean"
)
dependencies = payload.get("dependencies")
if dependencies is not None:
if not isinstance(dependencies, dict):
errors.append(
f"skill `{skill_root.name}` agent field `dependencies` must be an object"
)
else:
reject_skill_agent_unknown_fields(
dependencies,
{"tools"},
skill_root,
errors,
prefix="dependencies",
)
def reject_skill_agent_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
skill_root: Path,
errors: list[str],
*,
prefix: str | None = None,
) -> None:
for key in sorted(set(payload) - allowed_keys):
field = f"{prefix}.{key}" if prefix is not None else key
errors.append(
f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation"
)
def validate_optional_asset_path(
base_dir: Path,
allowed_root: Path,
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str = "interface",
) -> None:
raw_path = payload.get(key)
if raw_path is None:
return
validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors)
def validate_asset_path(
base_dir: Path,
allowed_root: Path,
raw_path: Any,
field: str,
errors: list[str],
) -> None:
label = field if field.startswith("skill `") else f"plugin.json field `{field}`"
if not isinstance(raw_path, str) or not raw_path.strip():
errors.append(f"{label} must be a non-empty relative path")
return
candidate = PurePosixPath(raw_path.replace("\\", "/"))
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
errors.append(f"{label} must stay inside the plugin archive")
return
resolved_path = (base_dir / candidate.as_posix()).resolve()
if not resolved_path.is_relative_to(allowed_root.resolve()):
errors.append(f"{label} must stay inside the plugin archive")
return
if not resolved_path.is_file():
errors.append(f"{label} points to a missing file")
if __name__ == "__main__":
main()