diff --git a/scripts/generate_plugins_json.py b/scripts/generate_plugins_json.py index 22990fb26..cf1f15f3d 100644 --- a/scripts/generate_plugins_json.py +++ b/scripts/generate_plugins_json.py @@ -24,11 +24,14 @@ from pathlib import Path, PurePosixPath from urllib.parse import quote, unquote +# Both direct script execution and importlib-based tests load this helper. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from plugin_output_utils import prune_unlisted_bundles, write_json + README = Path(__file__).parent.parent / "README.md" OUTPUT = Path(__file__).parent.parent / "plugins.json" MARKETPLACE_OUTPUT = Path(__file__).parent.parent / ".agents" / "plugins" / "marketplace.json" PLUGINS_ROOT = Path(__file__).parent.parent / "plugins" -RETIRED_BUNDLE_IDENTITIES = {("samuelbushi", "uizze"), ("waynehamadi", "datacircle-plugin")} REQUEST_TIMEOUT_SECONDS = 60 MAX_RETRIES = 3 USER_AGENT = "awesome-codex-plugins-generator" @@ -59,6 +62,10 @@ "mturac/everything-openai-codex", } EXTRA_MIRROR_PATHS = { + # Context Guard declares Skills in its manifest; its lifecycle hooks, + # script entry points and state schema live at separate top-level paths. + # Mirror only these published runtime paths, not arbitrary source files. + "GreenLv/codex-context-guard": ("hooks", "scripts", "assets/state.schema.json"), # Workflow MCP bootstrap loads the sibling control-plane runtime; its MCP # App also reads assets/sidebar-icon.svg and Skills reference helper scripts. "TohmaN233/codex-agents-workflow": ("control-plane", "scripts", "assets"), @@ -319,6 +326,19 @@ def collect_selected_paths( for extra_path in EXTRA_MIRROR_PATHS.get(f"{plugin['owner']}/{plugin['repo']}", ()): add_recursive_selection(selected, all_names, plugin_root, extra_path) + # Context Guard's published .codexignore excludes transient build/cache + # artifacts. Never ship them merely because its hook runtime uses scripts/. + if f"{plugin['owner']}/{plugin['repo']}" == "GreenLv/codex-context-guard": + ignored_dirs = { + ".codex", ".git", ".venv", "__pycache__", + ".pytest_cache", ".ruff_cache", "build", "dist", + } + selected = { + path for path in selected + if not ignored_dirs.intersection(PurePosixPath(path).parts) + and not path.endswith(".pyc") + } + return selected @@ -404,6 +424,11 @@ def mirror_plugin_bundle(plugin: dict[str, str]) -> tuple[dict[str, object], str if metadata_only else collect_selected_paths(manifest, names, plugin_root, plugin) ) + # Refuse unsafe ZIP members before clearing an existing published bundle. + for relative_path in selected_paths: + path = PurePosixPath(relative_path) + if path.is_absolute() or ".." in path.parts or "\\" in relative_path: + raise ValueError(f"Unsafe mirrored archive path: {relative_path!r}") mirrored_manifest = sanitize_metadata_only_manifest(manifest, plugin) if metadata_only else manifest plugin_root_relative = plugin_root_relative_path(plugin_root) @@ -469,45 +494,6 @@ def build_marketplace_entry( return entry -def write_json(path: Path, data: dict[str, object]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - -def prune_unlisted_bundles(root: Path, plugins: list[dict[str, str]]) -> list[str]: - """Remove only explicitly reviewed retirements that are absent from README. - - Omission from the parsed catalog never authorizes another retirement. - Keep listed repositories even if this run cannot fetch them, and preflight - the two-level tree for symlinks before deleting any approved bundle. - """ - if not plugins: - raise ValueError("Refusing bundle cleanup from an empty parsed README; explicit maintainer retirement is required") - retained = {(plugin["owner"].casefold(), plugin["repo"].casefold()) for plugin in plugins} - removed: list[str] = [] - if root.is_symlink(): - raise ValueError("Generated plugins root must not be a symlink") - if not root.exists(): - return removed - # Preflight the entire tree before retiring anything. A later invalid - # directory must not leave an earlier bundle partially retired. - retired: list[Path] = [] - for owner in sorted(root.iterdir()): - if owner.is_symlink(): - raise ValueError(f"Generated owner directory must not be a symlink: {owner}") - if not owner.is_dir(): - continue - for repo in sorted(owner.iterdir()): - if repo.is_symlink(): - raise ValueError(f"Generated repo directory must not be a symlink: {repo}") - identity = (owner.name.casefold(), repo.name.casefold()) - if repo.is_dir() and identity in RETIRED_BUNDLE_IDENTITIES and identity not in retained: - retired.append(repo) - for repo in retired: - shutil.rmtree(repo) - removed.append(f"{repo.parent.name}/{repo.name}") - return removed - def main() -> None: plugins = parse_plugins(README) diff --git a/scripts/plugin_output_utils.py b/scripts/plugin_output_utils.py new file mode 100644 index 000000000..a78d04dae --- /dev/null +++ b/scripts/plugin_output_utils.py @@ -0,0 +1,49 @@ +"""JSON artifact writing and explicit retired bundle cleanup helpers. + +Kept separate from the marketplace generator to preserve the 500-line source limit. +""" + +from pathlib import Path +import json +import shutil + +RETIRED_BUNDLE_IDENTITIES = {("samuelbushi", "uizze"), ("waynehamadi", "datacircle-plugin")} + +def write_json(path: Path, data: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def prune_unlisted_bundles(root: Path, plugins: list[dict[str, str]]) -> list[str]: + """Remove only explicitly reviewed retirements that are absent from README. + + Omission from the parsed catalog never authorizes another retirement. + Keep listed repositories even if this run cannot fetch them, and preflight + the two-level tree for symlinks before deleting any approved bundle. + """ + if not plugins: + raise ValueError("Refusing bundle cleanup from an empty parsed README; explicit maintainer retirement is required") + retained = {(plugin["owner"].casefold(), plugin["repo"].casefold()) for plugin in plugins} + removed: list[str] = [] + if root.is_symlink(): + raise ValueError("Generated plugins root must not be a symlink") + if not root.exists(): + return removed + # Preflight the entire tree before retiring anything. A later invalid + # directory must not leave an earlier bundle partially retired. + retired: list[Path] = [] + for owner in sorted(root.iterdir()): + if owner.is_symlink(): + raise ValueError(f"Generated owner directory must not be a symlink: {owner}") + if not owner.is_dir(): + continue + for repo in sorted(owner.iterdir()): + if repo.is_symlink(): + raise ValueError(f"Generated repo directory must not be a symlink: {repo}") + identity = (owner.name.casefold(), repo.name.casefold()) + if repo.is_dir() and identity in RETIRED_BUNDLE_IDENTITIES and identity not in retained: + retired.append(repo) + for repo in retired: + shutil.rmtree(repo) + removed.append(f"{repo.parent.name}/{repo.name}") + return removed diff --git a/tests/test_generate_plugins_json.py b/tests/test_generate_plugins_json.py index 4e95a2284..d398d0f7e 100644 --- a/tests/test_generate_plugins_json.py +++ b/tests/test_generate_plugins_json.py @@ -2,7 +2,7 @@ import tempfile import unittest from unittest.mock import patch -from pathlib import Path +from pathlib import Path, PurePosixPath SPEC = importlib.util.spec_from_file_location( "generate_plugins_json", Path(__file__).resolve().parents[1] / "scripts/generate_plugins_json.py" @@ -12,6 +12,165 @@ class GeneratedBundleCleanupTests(unittest.TestCase): + def test_context_guard_mirror_includes_undeclared_hook_runtime(self): + root = PurePosixPath("codex-context-guard-main") + names = { + "codex-context-guard-main/.codex-plugin/plugin.json", + "codex-context-guard-main/hooks/hooks.json", + "codex-context-guard-main/scripts/context_guard.py", + "codex-context-guard-main/scripts/manage_plugin.py", + "codex-context-guard-main/scripts/smoke_installed.py", + "codex-context-guard-main/scripts/cg_instruction.py", + "codex-context-guard-main/scripts/run_context_guard.sh", + "codex-context-guard-main/scripts/run-context-guard.ps1", + "codex-context-guard-main/scripts/__pycache__/context_guard.cpython-314.pyc", + "codex-context-guard-main/scripts/build/temp.json", + "codex-context-guard-main/hooks/.pytest_cache/state", + "codex-context-guard-main/scripts/dist/bundle.js", + "codex-context-guard-main/scripts/.venv/pyvenv.cfg", + "codex-context-guard-main/hooks/.ruff_cache/index", + "codex-context-guard-main/hooks/.codex/session.json", + "codex-context-guard-main/scripts/.git/config", + "codex-context-guard-main/scripts/compiled.pyc", + "codex-context-guard-main/assets/icon.svg", + "codex-context-guard-main/assets/state.schema.json", + "codex-context-guard-main/internal/not-for-distribution.txt", + } + manifest = { + "name": "context-guard", + "skills": "./skills/", + "interface": {"composerIcon": "./assets/icon.svg"}, + } + plugin = {"owner": "GreenLv", "repo": "codex-context-guard"} + + paths = MODULE.collect_selected_paths(manifest, names, root, plugin) + + self.assertTrue({ + ".codex-plugin/plugin.json", + "hooks/hooks.json", + "scripts/context_guard.py", + "scripts/manage_plugin.py", + "scripts/smoke_installed.py", + "scripts/cg_instruction.py", + "scripts/run_context_guard.sh", + "scripts/run-context-guard.ps1", + "assets/icon.svg", + "assets/state.schema.json", + }.issubset(paths)) + self.assertNotIn("internal/not-for-distribution.txt", paths) + self.assertTrue(all( + name not in paths for name in ( + "scripts/__pycache__/context_guard.cpython-314.pyc", + "scripts/build/temp.json", + "hooks/.pytest_cache/state", + "scripts/dist/bundle.js", + "scripts/.venv/pyvenv.cfg", + "hooks/.ruff_cache/index", + "hooks/.codex/session.json", + "scripts/.git/config", + "scripts/compiled.pyc", + ) + )) + + def test_context_guard_runtime_mirror_does_not_apply_to_unrelated_owner(self): + # A repository with the same basename must not inherit GreenLv's + # source-specific runtime paths or copy unrelated scripts/hooks. + root = PurePosixPath("codex-context-guard-main") + names = { + "codex-context-guard-main/.codex-plugin/plugin.json", + "codex-context-guard-main/hooks/hooks.json", + "codex-context-guard-main/scripts/context_guard.py", + "codex-context-guard-main/assets/state.schema.json", + } + manifest = {"name": "context-guard", "skills": "./skills/"} + + paths = MODULE.collect_selected_paths( + manifest, names, root, + {"owner": "unrelated-owner", "repo": "codex-context-guard"}, + ) + + self.assertEqual(paths, {".codex-plugin/plugin.json"}) + + def test_context_guard_rejects_archive_traversal_before_replacing_bundle(self): + import io + import zipfile + + with tempfile.TemporaryDirectory() as scratch: + root = Path(scratch) / "plugins" + bundle = root / "GreenLv" / "codex-context-guard" + bundle.mkdir(parents=True) + sentinel = bundle / "keep.txt" + sentinel.write_text("preserve-existing-bundle", encoding="utf-8") + + archive_bytes = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as source: + source.writestr( + "codex-context-guard-main/.codex-plugin/plugin.json", + '{"name":"context-guard","skills":"./skills/"}', + ) + source.writestr("codex-context-guard-main/hooks/../../escape.txt", "unsafe") + + archive_bytes.seek(0) + with zipfile.ZipFile(archive_bytes) as source, \ + patch.object(MODULE, "fetch_repo_archive", return_value=source), \ + patch.object(MODULE, "PLUGINS_ROOT", root): + with self.assertRaisesRegex(ValueError, "Unsafe mirrored archive path"): + MODULE.mirror_plugin_bundle( + {"owner": "GreenLv", "repo": "codex-context-guard"} + ) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve-existing-bundle") + self.assertFalse((root / "escape.txt").exists()) + + def test_context_guard_mirror_writes_install_runtime_from_archive(self): + import io + import json + import zipfile + + with tempfile.TemporaryDirectory() as scratch: + root = Path(scratch) / "plugins" + source_bytes = io.BytesIO() + upstream_files = { + ".codex-plugin/plugin.json": json.dumps({ + "name": "context-guard", + "skills": "./skills/", + "interface": {"composerIcon": "./assets/icon.svg"}, + }), + "skills/guard/SKILL.md": "# Guard", + "hooks/hooks.json": '{"hooks":[]}', + "scripts/context_guard.py": "print('runtime')", + "scripts/manage_plugin.py": "print('manager')", + "scripts/smoke_installed.py": "print('smoke')", + "assets/state.schema.json": "{}", + "assets/icon.svg": "", + "scripts/__pycache__/cached.pyc": "not-a-bundle", + } + with zipfile.ZipFile(source_bytes, "w") as archive: + for path, value in upstream_files.items(): + archive.writestr(f"codex-context-guard-main/{path}", value) + + source_bytes.seek(0) + with zipfile.ZipFile(source_bytes) as archive, \ + patch.object(MODULE, "fetch_repo_archive", return_value=archive), \ + patch.object(MODULE, "PLUGINS_ROOT", root): + manifest, marketplace_path, relative_root = MODULE.mirror_plugin_bundle({ + "owner": "GreenLv", "repo": "codex-context-guard" + }) + + mirrored = root / "GreenLv" / "codex-context-guard" + self.assertEqual(manifest["name"], "context-guard") + self.assertEqual(marketplace_path, "./plugins/GreenLv/codex-context-guard") + self.assertEqual(relative_root, "") + for required in ( + ".codex-plugin/plugin.json", "skills/guard/SKILL.md", + "hooks/hooks.json", "scripts/context_guard.py", + "scripts/manage_plugin.py", "scripts/smoke_installed.py", + "assets/state.schema.json", "assets/icon.svg", + ): + with self.subTest(path=required): + self.assertTrue((mirrored / required).is_file()) + self.assertFalse((mirrored / "scripts/__pycache__/cached.pyc").exists()) + def test_branch_qualified_readme_entry_uses_source_branch_metadata(self): with tempfile.TemporaryDirectory() as scratch: readme = Path(scratch) / "README.md"