Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 50 additions & 5 deletions src/agents_shipgate/cli/agent_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand All @@ -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 = [
Expand All @@ -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,
Expand Down
36 changes: 31 additions & 5 deletions src/agents_shipgate/cli/discovery/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 60 additions & 2 deletions src/agents_shipgate/inputs/codex_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"),
Expand Down
Loading