Expose only public MCP support in marketplace
Constraint: The public marketplace must not contain internal MCP endpoints, shared keys, project keys, or user-token workflows. Rejected: Publishing every configured KeyInfo MCP server | internal knowledge, Firecrawl, and Outline servers are not public distribution targets. Confidence: high Scope-risk: moderate Directive: Keep MCP publication behind an explicit allowlist and verify hidden plugin metadata before pushing. Tested: python scripts/validate_marketplace.py; temporary CODEX_HOME marketplace list/add/remove for mcp-playwright; hidden rg scan for internal MCP URLs and key markers. Not-tested: Live Playwright MCP browser tool invocation inside a fresh Codex session.
This commit is contained in:
@@ -82,6 +82,39 @@ def validate_hooks_field(plugin_dir: Path, manifest_path: Path, hooks: object) -
|
||||
raise AssertionError(f"{manifest_path} hooks must be a path, path list, inline object, or inline object list")
|
||||
|
||||
|
||||
def validate_skills_component(plugin_dir: Path, manifest_path: Path, skills_rel: object) -> None:
|
||||
require(isinstance(skills_rel, str) and skills_rel.startswith("./"), f"{manifest_path} skills path must be relative ./...")
|
||||
skills_dir = validate_manifest_path(plugin_dir, manifest_path, skills_rel, "skills")
|
||||
require(skills_dir.exists(), f"{manifest_path} skills directory does not exist: {skills_dir}")
|
||||
skill_dirs = [path for path in skills_dir.iterdir() if path.is_dir()]
|
||||
require(bool(skill_dirs), f"{manifest_path} skills component must contain at least one skill directory")
|
||||
for skill_dir in skill_dirs:
|
||||
require((skill_dir / "SKILL.md").exists(), f"Missing SKILL.md in {skill_dir}")
|
||||
|
||||
|
||||
def validate_json_component(
|
||||
plugin_dir: Path,
|
||||
manifest_path: Path,
|
||||
value: object,
|
||||
field: str,
|
||||
required_top_level: str,
|
||||
) -> None:
|
||||
require(isinstance(value, str), f"{manifest_path} {field} must be a path string")
|
||||
target = validate_manifest_path(plugin_dir, manifest_path, value, field)
|
||||
payload = load_json(target)
|
||||
require(isinstance(payload, dict), f"{target} must be a JSON object")
|
||||
require(isinstance(payload.get(required_top_level), dict), f"{target} missing object {required_top_level}")
|
||||
|
||||
|
||||
def validate_interface_asset(plugin_dir: Path, manifest_path: Path, value: object, field: str) -> None:
|
||||
if value is None or value == "":
|
||||
return
|
||||
require(isinstance(value, str), f"{manifest_path} interface.{field} must be a string")
|
||||
if value.startswith(("http://", "https://")):
|
||||
return
|
||||
validate_manifest_path(plugin_dir, manifest_path, value, f"interface.{field}")
|
||||
|
||||
|
||||
def validate_manifest(plugin_dir: Path, expected_name: str) -> None:
|
||||
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
|
||||
require(manifest_path.exists(), f"Missing manifest: {manifest_path}")
|
||||
@@ -91,26 +124,24 @@ def validate_manifest(plugin_dir: Path, expected_name: str) -> None:
|
||||
require(PLUGIN_NAME_RE.match(expected_name) is not None, f"{manifest_path} plugin name must be ASCII kebab-case")
|
||||
require(SEMVER_RE.match(str(manifest.get("version", ""))) is not None, f"{manifest_path} version must be semver-like")
|
||||
require(bool(manifest.get("description")), f"{manifest_path} missing description")
|
||||
if "hooks" in manifest:
|
||||
validate_hooks_field(plugin_dir, manifest_path, manifest["hooks"])
|
||||
|
||||
author = manifest.get("author")
|
||||
require(isinstance(author, dict) and bool(author.get("name")), f"{manifest_path} missing author.name")
|
||||
|
||||
skills_rel = manifest.get("skills")
|
||||
require(isinstance(skills_rel, str) and skills_rel.startswith("./"), f"{manifest_path} skills path must be relative ./...")
|
||||
skills_dir = validate_manifest_path(plugin_dir, manifest_path, skills_rel, "skills")
|
||||
require(skills_dir.exists(), f"{manifest_path} skills directory does not exist: {skills_dir}")
|
||||
skill_dirs = [path for path in skills_dir.iterdir() if path.is_dir()]
|
||||
require(bool(skill_dirs), f"{manifest_path} must contain at least one skill directory")
|
||||
for skill_dir in skill_dirs:
|
||||
require((skill_dir / "SKILL.md").exists(), f"Missing SKILL.md in {skill_dir}")
|
||||
declared_components = [
|
||||
key
|
||||
for key in ["skills", "mcpServers", "apps", "hooks"]
|
||||
if key in manifest
|
||||
]
|
||||
require(bool(declared_components), f"{manifest_path} must declare at least one component: skills, mcpServers, apps, or hooks")
|
||||
|
||||
for optional_path_field in ["mcpServers", "apps"]:
|
||||
value = manifest.get(optional_path_field)
|
||||
if value is not None:
|
||||
require(isinstance(value, str), f"{manifest_path} {optional_path_field} must be a path string")
|
||||
validate_manifest_path(plugin_dir, manifest_path, value, optional_path_field)
|
||||
if "skills" in manifest:
|
||||
validate_skills_component(plugin_dir, manifest_path, manifest.get("skills"))
|
||||
if "mcpServers" in manifest:
|
||||
validate_json_component(plugin_dir, manifest_path, manifest.get("mcpServers"), "mcpServers", "mcpServers")
|
||||
if "apps" in manifest:
|
||||
validate_json_component(plugin_dir, manifest_path, manifest.get("apps"), "apps", "apps")
|
||||
if "hooks" in manifest:
|
||||
validate_hooks_field(plugin_dir, manifest_path, manifest["hooks"])
|
||||
|
||||
interface = manifest.get("interface")
|
||||
require(isinstance(interface, dict), f"{manifest_path} missing interface object")
|
||||
@@ -118,6 +149,13 @@ def validate_manifest(plugin_dir: Path, expected_name: str) -> None:
|
||||
require(bool(interface.get(key)), f"{manifest_path} missing interface.{key}")
|
||||
capabilities = interface.get("capabilities")
|
||||
require(isinstance(capabilities, list), f"{manifest_path} interface.capabilities must be a list")
|
||||
validate_interface_asset(plugin_dir, manifest_path, interface.get("logo"), "logo")
|
||||
validate_interface_asset(plugin_dir, manifest_path, interface.get("composerIcon"), "composerIcon")
|
||||
screenshots = interface.get("screenshots")
|
||||
if screenshots is not None:
|
||||
require(isinstance(screenshots, list), f"{manifest_path} interface.screenshots must be a list")
|
||||
for index, screenshot in enumerate(screenshots):
|
||||
validate_interface_asset(plugin_dir, manifest_path, screenshot, f"screenshots[{index}]")
|
||||
|
||||
|
||||
def resolve_marketplace_plugin_path(marketplace_path: Path, source_path: str) -> Path:
|
||||
|
||||
Reference in New Issue
Block a user