diff --git a/CHANGELOG.md b/CHANGELOG.md index 797ade7a..48b1efac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,16 @@ still requires reviewer approval. Conventional test/golden fixtures are no longer inferred as undeclared deployed surfaces unless the manifest explicitly declares them. No schema or runtime-contract version changes. +- **Codex marketplace coverage and plugin-path containment.** Local plugin + packages reached through a declared Codex marketplace now count as declared + tool surfaces for local-control routing, and detect/init plus the zero-install + detector no longer propose redundant direct-package rows for those roots. + Direct-package loading now hard-rejects a source whose + `.codex-plugin/plugin.json` symlink target escapes the manifest directory; + marketplace entries with the same escape are skipped and cannot grant + coverage or supply verification bytes. Malformed, non-UTF-8, oversized, + remote, or escaping marketplace inputs stay fail-closed. No schema or + runtime-contract version changes. - **Evidence-basis policy gate (P0, `0.16.0b5`).** Semantic claims and risk hints now carry a typed evidence basis, stable claim IDs, and derived policy diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index ea50d423..3a0707a1 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -14,9 +14,12 @@ from agents_shipgate.core.boundary_diff import BoundaryChangeSet, BoundaryInputIssue from agents_shipgate.core.boundary_registry import is_agent_boundary_path from agents_shipgate.core.codex_boundary import parse_unified_diff +from agents_shipgate.core.errors import InputParseError +from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 from agents_shipgate.schemas.agent_result import AgentResultV2 from agents_shipgate.schemas.codex_boundary_result import CodexBoundaryResultV2 +from agents_shipgate.schemas.manifest import AgentsShipgateManifest from agents_shipgate.triggers import ( SURFACE_CLASS_CAPABILITY, _git_diff_context, @@ -216,11 +219,13 @@ def _declared_tool_surfaces_changed( ``openapi``) or a directory the loader scans recursively (``openai_agents_sdk``, ``google_adk``, ``langchain``, ``crewai``, ``codex_plugin``, ``codex_config``), so a changed file matches when it - equals the declared path or sits under it. Two exclusions keep this from - becoming noise: a declared path that resolves to the workspace root (e.g. - ``codex_config`` with ``path: .``) is dropped — it would otherwise match - every file, including docs — and changed files the boundary evaluator - already inspects are dropped, since ``check`` did evaluate those. + equals the declared path or sits under it. Valid local roots referenced by + a declared Codex marketplace are included because ``verify`` scans those + packages transitively. Two exclusions keep this from becoming noise: a + declared path that resolves to the workspace root (e.g. ``codex_config`` + with ``path: .``) is dropped — it would otherwise match every file, + including docs — and changed files the boundary evaluator already + inspects are dropped, since ``check`` did evaluate those. """ if not changed_files or not config_path.is_file(): @@ -242,6 +247,13 @@ def _declared_tool_surfaces_changed( if rel in {"", "."}: # whole-workspace root: matching all files is noise. continue declared.add(rel) + declared.update( + _declared_codex_marketplace_roots( + manifest=manifest, + manifest_dir=manifest_dir, + workspace=workspace, + ) + ) if not declared: return [] matched = [ @@ -253,6 +265,39 @@ def _declared_tool_surfaces_changed( return sorted(matched) +def _declared_codex_marketplace_roots( + *, + manifest: AgentsShipgateManifest, + manifest_dir: Path, + workspace: Path, +) -> set[str]: + """Return contained local plugin roots reached through marketplaces.""" + + roots: set[str] = set() + for source in manifest.tool_sources: + if ( + source.type != "codex_plugin" + or source.mode != "marketplace" + or not source.path + ): + continue + try: + marketplace_roots = resolve_local_codex_marketplace_roots( + marketplace_path=manifest_dir / str(source.path), + base_dir=manifest_dir, + ) + except (InputParseError, OSError, RuntimeError, UnicodeError): + continue + for root in marketplace_roots: + try: + rel = root.relative_to(workspace).as_posix() + except ValueError: + continue + if rel not in {"", "."}: + roots.add(rel) + return roots + + def git_boundary_change_set( *, workspace: Path, diff --git a/src/agents_shipgate/cli/discovery/signals.py b/src/agents_shipgate/cli/discovery/signals.py index bf9c2a22..bd69adb4 100644 --- a/src/agents_shipgate/cli/discovery/signals.py +++ b/src/agents_shipgate/cli/discovery/signals.py @@ -9,10 +9,11 @@ on a populated manifest and would no-op here. Instead it borrows their constants where they map cleanly onto detection signals (e.g. :data:`agents_shipgate.inputs.langchain.TOOL_DECORATOR_MODULES`). -The one deliberate adapter touchpoint is the suggested-source parse probe -(:func:`agents_shipgate.cli.discovery.artifacts.probe_suggested_source`), -which keeps ``suggested_sources`` exactly as strict as ``scan``'s input -parsing so ``init`` never writes a tool source that ``scan`` rejects. +The deliberate input-layer touchpoints are the suggested-source parse probe +(:func:`agents_shipgate.cli.discovery.artifacts.probe_suggested_source`) and +the local Codex marketplace root resolver. They keep ``init`` from writing a +source that ``scan`` rejects or duplicating a package already reached through +a marketplace; neither executes user code. Scoring (per plan §1, post-review v4): @@ -59,6 +60,8 @@ _relative, probe_suggested_source, ) +from agents_shipgate.core.errors import InputParseError +from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots from agents_shipgate.inputs.conductor import conductor_agent_task_types from agents_shipgate.schemas.detect import ( CodexPluginCandidate, @@ -651,11 +654,34 @@ def _suggested_sources( def _codex_plugin_candidates(workspace: Path) -> list[CodexPluginCandidate]: + files = _candidate_files(workspace) + covered_roots: set[Path] = set() + for path in files: + if not ( + path.name == "marketplace.json" + and path.parent.as_posix().endswith(".agents/plugins") + ): + continue + try: + covered_roots.update( + resolve_local_codex_marketplace_roots( + marketplace_path=path, + base_dir=workspace, + ) + ) + except (InputParseError, OSError, RuntimeError, UnicodeError): + continue + candidates: list[CodexPluginCandidate] = [] seen: set[tuple[str, str]] = set() - for path in _candidate_files(workspace): + for path in files: if path.name == "plugin.json" and path.parent.name == ".codex-plugin": root = path.parent.parent + try: + if root.resolve() in covered_roots: + continue + except (OSError, RuntimeError): + pass rel = _relative(root, workspace) key = ("package", rel) if key in seen: diff --git a/src/agents_shipgate/inputs/codex_plugin.py b/src/agents_shipgate/inputs/codex_plugin.py index c4d8eea9..f0b4dda3 100644 --- a/src/agents_shipgate/inputs/codex_plugin.py +++ b/src/agents_shipgate/inputs/codex_plugin.py @@ -13,6 +13,7 @@ from agents_shipgate.inputs.common import ( PositionIndex, json_pointer_escape, + load_structured_file, load_structured_file_with_positions, load_text_file, manifest_relative_path, @@ -225,6 +226,54 @@ def _load_marketplace_source( return loaded +def resolve_local_codex_marketplace_roots( + *, + marketplace_path: Path, + base_dir: Path, +) -> tuple[Path, ...]: + """Resolve the contained local package roots declared by a marketplace. + + Stop at the package boundary: plugin contents remain the normal loader's + responsibility so a malformed declared plugin still routes to ``verify``. + """ + + resolved_marketplace = resolve_input_path(base_dir, str(marketplace_path)) + data = load_structured_file(resolved_marketplace) + if not isinstance(data, dict): + raise InputParseError( + f"Codex marketplace file must contain an object: {resolved_marketplace}" + ) + plugins = data.get("plugins") + if not isinstance(plugins, list): + raise InputParseError( + "Codex marketplace file must contain a plugins array: " + f"{resolved_marketplace}" + ) + + summary = CodexPluginMarketplaceSummary( + source_id="coverage", + name=data.get("name") if isinstance(data.get("name"), str) else None, + path=manifest_relative_path(str(resolved_marketplace), base_dir), + plugin_count=0, + ) + roots: list[Path] = [] + for index, entry in enumerate(plugins): + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + continue + root = _resolve_marketplace_plugin_root( + entry=entry, + base_dir=base_dir, + marketplace=summary, + pointer=f"/plugins/{index}", + ) + if root is not None: + roots.append(root.resolve()) + return tuple(dict.fromkeys(roots)) + + def _load_plugin_package( *, source: ToolSourceConfig, @@ -397,7 +446,8 @@ def _resolve_package_root( raise InputParseError( f"Codex plugin source must be a plugin root directory or {PLUGIN_MANIFEST}: {path}" ) - if not manifest_path.exists(): + resolved_manifest = resolve_input_path(base_dir, str(manifest_path)) + if not resolved_manifest.is_file(): raise InputParseError(f"Codex plugin manifest not found: {manifest_path}") return root, manifest_path @@ -437,7 +487,15 @@ def _resolve_marketplace_plugin_root( {"plugin": entry.get("name"), "reason": str(exc)} ) return None - if not (root / PLUGIN_MANIFEST).exists(): + manifest_path = root / PLUGIN_MANIFEST + try: + resolved_manifest = resolve_input_path(base_dir, str(manifest_path)) + except InputParseError as exc: + marketplace.skipped_entries.append( + {"plugin": entry.get("name"), "reason": str(exc)} + ) + return None + if not resolved_manifest.is_file(): marketplace.skipped_entries.append( { "plugin": entry.get("name"), diff --git a/tests/test_codex_boundary_check.py b/tests/test_codex_boundary_check.py index debd3511..b6188d57 100644 --- a/tests/test_codex_boundary_check.py +++ b/tests/test_codex_boundary_check.py @@ -11,6 +11,7 @@ from agents_shipgate.cli.agent_result import build_codex_agent_result from agents_shipgate.cli.main import app from agents_shipgate.core.codex_boundary import evaluate_codex_boundary_result +from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots ROOT = Path(__file__).resolve().parent.parent CORPUS = ROOT / "tests" / "corpus" / "codex_boundary" @@ -397,6 +398,240 @@ def test_explicitly_declared_test_fixture_still_routes_to_verify(tmp_path: Path) assert not any(item.code == "undeclared_capability_surface" for item in result.diagnostics) +_PLUGIN_PATH = "plugins/reviewer/.codex-plugin/plugin.json" +_PLUGIN_DIFF = ( + f"diff --git a/{_PLUGIN_PATH} b/{_PLUGIN_PATH}\n" + f"--- a/{_PLUGIN_PATH}\n" + f"+++ b/{_PLUGIN_PATH}\n" + "@@ -1 +1 @@\n" + '-{"name":"reviewer","version":"1.0.0"}\n' + '+{"name":"reviewer","version":"2.0.0"}\n' +) + + +def _write_marketplace_workspace( + root: Path, + *, + marketplace_text: str | bytes, + plugin_text: str = '{"name":"reviewer","version":"2.0.0"}', +) -> None: + _write_manifest( + root, + " - id: local-market\n" + " type: codex_plugin\n" + " mode: marketplace\n" + " path: .agents/plugins/marketplace.json\n", + ) + plugin = root / _PLUGIN_PATH + plugin.parent.mkdir(parents=True) + plugin.write_text(plugin_text, encoding="utf-8") + marketplace = root / ".agents/plugins/marketplace.json" + marketplace.parent.mkdir(parents=True) + if isinstance(marketplace_text, bytes): + marketplace.write_bytes(marketplace_text) + else: + marketplace.write_text(marketplace_text, encoding="utf-8") + + +@pytest.mark.parametrize("plugin_text", ['{"name":"reviewer"}', "{not-json"]) +def test_marketplace_local_plugin_change_routes_to_verify( + tmp_path: Path, + plugin_text: str, +) -> None: + _write_marketplace_workspace( + tmp_path, + plugin_text=plugin_text, + marketplace_text=json.dumps( + { + "plugins": [ + { + "name": "reviewer", + "source": {"source": "local", "path": "plugins/reviewer"}, + } + ] + } + ), + ) + + result = build_codex_agent_result( + agent="codex", + workspace=tmp_path, + diff_text=_PLUGIN_DIFF, + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.control.state == "agent_action_required" + assert result.control.next_action.kind == "verify" + assert any(item.code == "capability_change_requires_verify" for item in result.diagnostics) + assert not any(item.code == "undeclared_capability_surface" for item in result.diagnostics) + + +def test_marketplace_absolute_local_plugin_change_routes_to_verify( + tmp_path: Path, +) -> None: + plugin_root = (tmp_path / "plugins/reviewer").resolve() + _write_marketplace_workspace( + tmp_path, + marketplace_text=json.dumps( + { + "plugins": [ + { + "name": "reviewer", + "source": {"source": "local", "path": str(plugin_root)}, + } + ] + } + ), + ) + + result = build_codex_agent_result( + agent="codex", + workspace=tmp_path, + diff_text=_PLUGIN_DIFF, + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.control.state == "agent_action_required" + assert result.control.next_action.kind == "verify" + assert any(item.code == "capability_change_requires_verify" for item in result.diagnostics) + assert not any(item.code == "undeclared_capability_surface" for item in result.diagnostics) + + +def test_marketplace_workspace_root_does_not_cover_every_changed_file( + tmp_path: Path, +) -> None: + _write_marketplace_workspace( + tmp_path, + marketplace_text=json.dumps( + { + "plugins": [ + { + "name": "root-plugin", + "source": {"source": "local", "path": "."}, + } + ] + } + ), + ) + root_manifest = tmp_path / ".codex-plugin/plugin.json" + root_manifest.parent.mkdir(parents=True) + root_manifest.write_text('{"name":"root-plugin"}', encoding="utf-8") + marketplace = tmp_path / ".agents/plugins/marketplace.json" + assert resolve_local_codex_marketplace_roots( + marketplace_path=marketplace, + base_dir=tmp_path, + ) == (tmp_path.resolve(),) + docs_diff = ( + "diff --git a/README.md b/README.md\n" + "--- a/README.md\n" + "+++ b/README.md\n" + "@@ -1 +1,2 @@\n" + " hello\n" + "+world\n" + ) + + result = build_codex_agent_result( + agent="codex", + workspace=tmp_path, + diff_text=docs_diff, + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.decision == "allow" + assert not any(item.code == "capability_change_requires_verify" for item in result.diagnostics) + + +@pytest.mark.parametrize( + "marketplace_text", + [ + pytest.param("{not-json", id="malformed"), + pytest.param(b"\xff", id="non-utf8"), + pytest.param( + json.dumps( + { + "plugins": [ + { + "name": "reviewer", + "source": {"source": "github", "path": "plugins/reviewer"}, + } + ] + } + ), + id="remote", + ), + pytest.param( + json.dumps( + { + "plugins": [ + { + "name": "reviewer", + "source": {"source": "local", "path": "../reviewer"}, + } + ] + } + ), + id="outside", + ), + ], +) +def test_unresolved_marketplace_root_remains_undeclared( + tmp_path: Path, + marketplace_text: str | bytes, +) -> None: + _write_marketplace_workspace(tmp_path, marketplace_text=marketplace_text) + + result = build_codex_agent_result( + agent="codex", + workspace=tmp_path, + diff_text=_PLUGIN_DIFF, + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.control.next_action.kind == "discover" + assert any(item.code == "undeclared_capability_surface" for item in result.diagnostics) + + +def test_marketplace_plugin_manifest_symlink_escape_remains_undeclared( + tmp_path: Path, +) -> None: + _write_marketplace_workspace( + tmp_path, + marketplace_text=json.dumps( + { + "plugins": [ + { + "name": "reviewer", + "source": {"source": "local", "path": "plugins/reviewer"}, + } + ] + } + ), + ) + outside_manifest = tmp_path.parent / f"{tmp_path.name}-outside-plugin.json" + outside_manifest.write_text('{"name":"reviewer"}', encoding="utf-8") + plugin_manifest = tmp_path / _PLUGIN_PATH + plugin_manifest.unlink() + try: + plugin_manifest.symlink_to(outside_manifest) + except OSError as exc: # pragma: no cover - platform permission fallback + pytest.skip(f"symlinks unavailable: {exc}") + + result = build_codex_agent_result( + agent="codex", + workspace=tmp_path, + diff_text=_PLUGIN_DIFF, + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.control.next_action.kind == "discover" + assert any(item.code == "undeclared_capability_surface" for item in result.diagnostics) + + def test_check_warns_on_change_under_declared_directory_source(tmp_path: Path) -> None: # A directory tool source (loaders scan files inside it) must match a # changed file *under* the directory, not only an exact path equal to it. diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 179afd44..4c183e4d 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -11,7 +11,7 @@ from agents_shipgate.cli.discovery.template import render_auto_manifest from agents_shipgate.cli.scan import run_scan from agents_shipgate.config.loader import load_manifest -from agents_shipgate.core.errors import ConfigError +from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.schemas.diagnostics import DIAG_CODEX_PLUGIN_PACKAGE_DETECTED @@ -65,6 +65,42 @@ def test_codex_plugin_package_scan_keeps_non_tools_out_of_inventory( assert "SHIP-CODEX-PLUGIN-APP-SURFACE-NOT-ENUMERABLE" in check_ids +def test_codex_plugin_package_rejects_external_manifest_symlink(tmp_path: Path) -> None: + plugin_manifest = tmp_path / "plugins/browserish/.codex-plugin/plugin.json" + plugin_manifest.parent.mkdir(parents=True) + outside_manifest = tmp_path.parent / f"{tmp_path.name}-outside-plugin.json" + outside_manifest.write_text('{"name":"browserish"}', encoding="utf-8") + try: + plugin_manifest.symlink_to(outside_manifest) + except OSError as exc: # pragma: no cover - platform permission fallback + pytest.skip(f"symlinks unavailable: {exc}") + manifest = _write_manifest( + tmp_path, + textwrap.dedent( + """ + version: "0.1" + project: + name: codex-plugin-test + agent: + name: codex-plugin-review + environment: + target: local + tool_sources: + - id: browserish + type: codex_plugin + mode: package + path: plugins/browserish + output: + packet: + enabled: false + """ + ), + ) + + with pytest.raises(InputParseError, match="resolves outside manifest directory"): + run_scan(config_path=manifest) + + def test_unknown_codex_plugin_component_does_not_prove_empty_surface( tmp_path: Path, ) -> None: diff --git a/tests/test_detect.py b/tests/test_detect.py index 36a7c41e..a1b075d2 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import shutil import subprocess import textwrap @@ -10,6 +11,7 @@ import pytest from agents_shipgate.cli.discovery.signals import detect_workspace +from agents_shipgate.cli.discovery.template import render_auto_manifest from agents_shipgate.schemas.detect import DetectResult SAMPLES = Path(__file__).resolve().parent.parent / "samples" @@ -50,6 +52,82 @@ def _write_skipped_fixture_signals(root: Path) -> None: (plugin / "plugin.json").write_text("{}", encoding="utf-8") +def _write_plugin_marketplace( + root: Path, + *, + marketplace: object | str | bytes, + plugin_text: str = '{"name":"reviewer"}', +) -> Path: + workspace = root / "workspace" + plugin = workspace / "plugins/reviewer/.codex-plugin/plugin.json" + plugin.parent.mkdir(parents=True) + plugin.write_text(plugin_text, encoding="utf-8") + path = workspace / ".agents/plugins/marketplace.json" + path.parent.mkdir(parents=True) + if isinstance(marketplace, bytes): + path.write_bytes(marketplace) + else: + path.write_text( + marketplace if isinstance(marketplace, str) else json.dumps(marketplace), + encoding="utf-8", + ) + return workspace + + +def _local_marketplace(source: str = "local") -> dict[str, object]: + return { + "plugins": [ + { + "name": "reviewer", + "source": {"source": source, "path": "plugins/reviewer"}, + } + ] + } + + +@pytest.mark.parametrize("plugin_text", ['{"name":"reviewer"}', "{not-json"]) +def test_detect_deduplicates_marketplace_covered_package( + tmp_path: Path, + plugin_text: str, +) -> None: + workspace = _write_plugin_marketplace( + tmp_path, + marketplace=_local_marketplace(), + plugin_text=plugin_text, + ) + + result = detect_workspace(workspace) + + assert [(item.mode, item.path) for item in result.codex_plugin_candidates] == [ + ("marketplace", ".agents/plugins/marketplace.json") + ] + rendered = render_auto_manifest(workspace, result) + assert rendered.count("type: codex_plugin") == 1 + assert "path: plugins/reviewer" not in rendered + + +@pytest.mark.parametrize( + "marketplace", + [ + pytest.param("{not-json", id="malformed"), + pytest.param(b"\xff", id="non-utf8"), + pytest.param(_local_marketplace("github"), id="remote"), + ], +) +def test_detect_keeps_package_for_unresolved_marketplace( + tmp_path: Path, + marketplace: object | str | bytes, +) -> None: + workspace = _write_plugin_marketplace(tmp_path, marketplace=marketplace) + + result = detect_workspace(workspace) + + assert {(item.mode, item.path) for item in result.codex_plugin_candidates} == { + ("marketplace", ".agents/plugins/marketplace.json"), + ("package", "plugins/reviewer"), + } + + def test_detects_langchain_sample() -> None: result = detect_workspace(SAMPLES / "simple_langchain_agent") assert result.is_agent_project is True diff --git a/tests/test_zero_install_detector.py b/tests/test_zero_install_detector.py index 6dac36fe..d8454412 100644 --- a/tests/test_zero_install_detector.py +++ b/tests/test_zero_install_detector.py @@ -23,6 +23,7 @@ import pytest from agents_shipgate.cli.discovery import detect_workspace +from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT_PATH = REPO_ROOT / "tools" / "shipgate-detect.py" @@ -259,6 +260,105 @@ def test_script_and_cli_skip_common_fixture_dirs(script_module, tmp_path): assert result["workspace_signals"]["python_file_count"] == 0 +def test_script_and_cli_dedupe_marketplace_covered_package(script_module, tmp_path): + plugin = tmp_path / "plugins/reviewer/.codex-plugin/plugin.json" + plugin.parent.mkdir(parents=True) + plugin.write_text("{not-json", encoding="utf-8") + marketplace = tmp_path / ".agents/plugins/marketplace.json" + marketplace.parent.mkdir(parents=True) + marketplace.write_text( + '{"plugins":[{"name":"reviewer","source":' + '{"source":"local","path":"plugins/reviewer"}}]}', + encoding="utf-8", + ) + + script_result = script_module.detect(tmp_path) + cli_result = detect_workspace(tmp_path.resolve()).model_dump(mode="json") + + expected = [("marketplace", ".agents/plugins/marketplace.json")] + assert [ + (item["mode"], item["path"]) + for item in script_result["codex_plugin_candidates"] + ] == expected + assert [ + (item["mode"], item["path"]) + for item in cli_result["codex_plugin_candidates"] + ] == expected + + +def test_script_and_cli_reject_oversized_marketplace_for_dedupe( + script_module, + tmp_path, +): + plugin = tmp_path / "plugins/reviewer/.codex-plugin/plugin.json" + plugin.parent.mkdir(parents=True) + plugin.write_text('{"name":"reviewer"}', encoding="utf-8") + marketplace = tmp_path / ".agents/plugins/marketplace.json" + marketplace.parent.mkdir(parents=True) + marketplace.write_text( + '{"plugins":[{"name":"reviewer","source":' + '{"source":"local","path":"plugins/reviewer"}}],"padding":"' + + ("x" * (10 * 1024 * 1024)) + + '"}', + encoding="utf-8", + ) + + script_result = script_module.detect(tmp_path) + cli_result = detect_workspace(tmp_path.resolve()).model_dump(mode="json") + + expected = { + ("marketplace", ".agents/plugins/marketplace.json"), + ("package", "plugins/reviewer"), + } + assert { + (item["mode"], item["path"]) + for item in script_result["codex_plugin_candidates"] + } == expected + assert { + (item["mode"], item["path"]) + for item in cli_result["codex_plugin_candidates"] + } == expected + + +def test_script_and_cli_reject_plugin_manifest_symlink_escape_from_coverage( + script_module, + tmp_path, +): + plugin = tmp_path / "plugins/reviewer/.codex-plugin/plugin.json" + plugin.parent.mkdir(parents=True) + outside_manifest = tmp_path.parent / f"{tmp_path.name}-outside-plugin.json" + outside_manifest.write_text('{"name":"reviewer"}', encoding="utf-8") + try: + plugin.symlink_to(outside_manifest) + except OSError as exc: # pragma: no cover - platform permission fallback + pytest.skip(f"symlinks unavailable: {exc}") + marketplace = tmp_path / ".agents/plugins/marketplace.json" + marketplace.parent.mkdir(parents=True) + marketplace.write_text( + '{"plugins":[{"name":"reviewer","source":' + '{"source":"local","path":"plugins/reviewer"}}]}', + encoding="utf-8", + ) + + script_result = script_module.detect(tmp_path) + cli_result = detect_workspace(tmp_path.resolve()).model_dump(mode="json") + + assert script_module._local_marketplace_roots(tmp_path, [marketplace]) == set() + assert resolve_local_codex_marketplace_roots( + marketplace_path=marketplace, + base_dir=tmp_path, + ) == () + expected = {("marketplace", ".agents/plugins/marketplace.json")} + assert { + (item["mode"], item["path"]) + for item in script_result["codex_plugin_candidates"] + } == expected + assert { + (item["mode"], item["path"]) + for item in cli_result["codex_plugin_candidates"] + } == expected + + def test_script_detects_workspace_named_fixture_dir(script_module, tmp_path): workspace = tmp_path / "fixtures" workspace.mkdir() diff --git a/tools/shipgate-detect.py b/tools/shipgate-detect.py index ba36a99d..188f40c1 100644 --- a/tools/shipgate-detect.py +++ b/tools/shipgate-detect.py @@ -64,7 +64,8 @@ from pathlib import Path from typing import Any -SCRIPT_VERSION = "0.2.2" +SCRIPT_VERSION = "0.2.3" +MAX_STRUCTURED_FILE_BYTES = 10 * 1024 * 1024 # Framework signal vocabulary (mirror of cli/discovery/signals.py). LANGCHAIN_IMPORTS = { @@ -391,6 +392,48 @@ def _confidence(score: float) -> str: return "high" if score >= 4.0 else "medium" if score >= 2.5 else "low" +def _local_marketplace_roots(workspace: Path, paths: list[Path]) -> set[Path]: + """Resolve contained local plugin roots without loading plugin contents.""" + roots: set[Path] = set() + for path in paths: + try: + path.resolve().relative_to(workspace) + if path.stat().st_size > MAX_STRUCTURED_FILE_BYTES: + continue + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, RuntimeError, UnicodeDecodeError, json.JSONDecodeError, ValueError): + continue + entries = payload.get("plugins") if isinstance(payload, dict) else None + if not isinstance(entries, list): + continue + for entry in entries: + source = entry.get("source") if isinstance(entry, dict) else None + name = entry.get("name") if isinstance(entry, dict) else None + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(source, dict) or source.get("source") != "local": + continue + raw_path = source.get("path") + if not isinstance(raw_path, str) or not raw_path.strip(): + continue + candidate = Path(raw_path) + try: + root = ( + candidate if candidate.is_absolute() else workspace / candidate + ).resolve() + root.relative_to(workspace) + except (OSError, RuntimeError, ValueError): + continue + try: + plugin_manifest = (root / ".codex-plugin" / "plugin.json").resolve() + plugin_manifest.relative_to(workspace) + except (OSError, RuntimeError, ValueError): + continue + if plugin_manifest.is_file(): + roots.add(root) + return roots + + def detect(workspace: Path) -> dict[str, Any]: workspace = workspace.resolve() files = _walk_files(workspace) @@ -532,12 +575,29 @@ def detect(workspace: Path) -> dict[str, Any]: failures.append({"type": kind, "path": p, "reason": reason}) excluded = [e for e in failures if e["path"] not in suggested_paths] + marketplace_paths = [ + path + for path in files + if path.name == "marketplace.json" + and path.parent.as_posix().endswith(".agents/plugins") + ] + marketplace_roots = _local_marketplace_roots(workspace, marketplace_paths) codex_plugin_candidates: list[dict[str, str]] = [] seen_codex: set[tuple[str, str]] = set() for path in files: rel = _rel(path, workspace) if path.name == "plugin.json" and path.parent.name == ".codex-plugin": - root_rel = _rel(path.parent.parent, workspace) + try: + path.resolve().relative_to(workspace) + except (OSError, RuntimeError, ValueError): + continue + root = path.parent.parent + try: + if root.resolve() in marketplace_roots: + continue + except (OSError, RuntimeError): + pass + root_rel = _rel(root, workspace) key = ("package", root_rel) if key not in seen_codex: seen_codex.add(key)