Skip to content
Merged
30 changes: 30 additions & 0 deletions packages/agent-marketplace/src/agent_marketplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
load_manifest,
save_manifest,
)
from agent_marketplace.marketplace_policy import (
ComplianceResult,
MCPServerPolicy,
MarketplacePolicy,
evaluate_plugin_compliance,
load_marketplace_policy,
)
from agent_marketplace.registry import PluginRegistry
from agent_marketplace.schema_adapters import (
ClaudePluginManifest,
Expand All @@ -26,22 +33,45 @@
extract_mcp_servers,
)
from agent_marketplace.signing import PluginSigner, verify_signature
from agent_marketplace.trust_tiers import (
DEFAULT_TIER_CONFIGS,
TRUST_TIERS,
PluginTrustConfig,
PluginTrustStore,
compute_initial_score,
filter_capabilities,
get_tier_config,
get_trust_tier,
)

__all__ = [
"ClaudePluginManifest",
"ComplianceResult",
"CopilotPluginManifest",
"DEFAULT_TIER_CONFIGS",
"MANIFEST_FILENAME",
"MCPServerPolicy",
"MarketplaceError",
"MarketplacePolicy",
"PluginInstaller",
"PluginManifest",
"PluginRegistry",
"PluginSigner",
"PluginTrustConfig",
"PluginTrustStore",
"PluginType",
"TRUST_TIERS",
"adapt_to_canonical",
"compute_initial_score",
"detect_manifest_format",
"evaluate_plugin_compliance",
"extract_capabilities",
"extract_mcp_servers",
"filter_capabilities",
"get_tier_config",
"get_trust_tier",
"load_manifest",
"load_marketplace_policy",
"save_manifest",
"verify_signature",
]
117 changes: 117 additions & 0 deletions packages/agent-marketplace/src/agent_marketplace/cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
PluginType,
load_manifest,
)
from agent_marketplace.marketplace_policy import (
ComplianceResult,
MarketplacePolicy,
MCPServerPolicy,
evaluate_plugin_compliance,
load_marketplace_policy,
)
from agent_marketplace.schema_adapters import (
adapt_to_canonical,
detect_manifest_format,
Expand Down Expand Up @@ -224,6 +231,116 @@ def publish_plugin(path: str) -> None:
console.print(f"[red]Error:[/red] {exc}")


@plugin.command("evaluate")
@click.argument("manifest_path", metavar="MANIFEST", type=click.Path(exists=True))
@click.option(
"--marketplace-policy",
required=True,
type=click.Path(exists=True),
help="Path to marketplace policy YAML file",
)
def evaluate_plugin(manifest_path: str, marketplace_policy: str) -> None:
"""Evaluate a single plugin manifest against a marketplace policy.

Loads the MANIFEST (agent-plugin.yaml or plugin.json) and checks it
against the marketplace policy. MCP server names are extracted
automatically when the manifest declares them.

Exit code 0 if compliant, 1 if any violations exist.
"""
import json as _json
import sys

policy_path = Path(marketplace_policy)
mpath = Path(manifest_path)

try:
policy = load_marketplace_policy(policy_path)
except MarketplaceError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)

try:
# Load raw data for MCP server extraction
target = mpath
if target.is_dir():
json_path = target / "plugin.json"
yaml_path = target / "agent-plugin.yaml"
if json_path.exists():
target = json_path
elif yaml_path.exists():
target = yaml_path
else:
raise MarketplaceError(f"No manifest found in {target}")

text = target.read_text(encoding="utf-8")
if target.suffix == ".json":
raw_data = _json.loads(text)
else:
import yaml

raw_data = yaml.safe_load(text)

fmt = detect_manifest_format(raw_data)
if fmt == "generic":
manifest = load_manifest(mpath)
else:
manifest = adapt_to_canonical(raw_data, fmt)

mcp_servers = extract_mcp_servers(raw_data)
except MarketplaceError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)

result = evaluate_plugin_compliance(
manifest, policy, mcp_servers or None
)

if result.compliant:
console.print(
f"[green]✓[/green] Plugin '{manifest.name}' is compliant"
)
else:
console.print(
f"[red]✗[/red] Plugin '{manifest.name}' has policy violations:"
)
for violation in result.violations:
console.print(f" - {violation}")
sys.exit(1)


@plugin.command("trust")
@click.argument("plugin_name", metavar="PLUGIN_NAME")
@click.option(
"--store",
"store_path",
default=str(Path(".agentmesh") / "trust.json"),
help="Path to the trust store JSON file",
)
def trust_plugin(plugin_name: str, store_path: str) -> None:
"""Show trust score and tier for a plugin."""
from agent_marketplace.trust_tiers import (
PluginTrustStore,
get_tier_config,
get_trust_tier,
)

store = PluginTrustStore(store_path=Path(store_path))
score = store.get_score(plugin_name)
tier = get_trust_tier(score)
config = get_tier_config(tier)

table = Table(title=f"Trust: {plugin_name}")
table.add_column("Property", style="cyan")
table.add_column("Value")
table.add_row("Score", str(score))
table.add_row("Tier", tier)
table.add_row("Max Token Budget", str(config.max_token_budget))
table.add_row("Max Tool Calls", str(config.max_tool_calls))
table.add_row("Tool Access", config.allowed_tool_access)
console.print(table)


# Register batch evaluation command
try:
from agent_marketplace.batch import evaluate_batch_command
Expand Down
184 changes: 184 additions & 0 deletions packages/agent-marketplace/src/agent_marketplace/marketplace_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""
Marketplace Policy Enforcement

Defines marketplace-level policies for MCP server allowlist/blocklist
enforcement, plugin type restrictions, and signature requirements.
Operators can declare which MCP servers are permitted for plugins.
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Optional

import yaml
from pydantic import BaseModel, Field, field_validator

from agent_marketplace.exceptions import MarketplaceError
from agent_marketplace.manifest import PluginManifest

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Policy models
# ---------------------------------------------------------------------------


class MCPServerPolicy(BaseModel):
"""Controls which MCP servers plugins are allowed to use."""

mode: str = Field(
"allowlist",
description="Enforcement mode: 'allowlist' or 'blocklist'",
)
allowed: list[str] = Field(
default_factory=list,
description="Allowed MCP server names (when mode=allowlist)",
)
blocked: list[str] = Field(
default_factory=list,
description="Blocked MCP server names (when mode=blocklist)",
)
require_declaration: bool = Field(
False,
description="Plugins must declare all MCP servers they use",
)

@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("allowlist", "blocklist"):
raise MarketplaceError(
f"Invalid MCP server policy mode: {v} (expected 'allowlist' or 'blocklist')"
)
return v


class MarketplacePolicy(BaseModel):
"""Top-level marketplace policy controlling plugin admission."""

mcp_servers: MCPServerPolicy = Field(
default_factory=MCPServerPolicy,
description="MCP server allowlist/blocklist policy",
)
allowed_plugin_types: Optional[list[str]] = Field(
None,
description="Restrict which plugin types may be registered",
)
require_signature: bool = Field(
False,
description="Require Ed25519 signatures on all plugins",
)


class ComplianceResult(BaseModel):
"""Result of evaluating a plugin against a marketplace policy."""

compliant: bool = Field(..., description="Whether the plugin is compliant")
violations: list[str] = Field(
default_factory=list,
description="Human-readable violation descriptions",
)


# ---------------------------------------------------------------------------
# Policy loading
# ---------------------------------------------------------------------------


def load_marketplace_policy(path: Path) -> MarketplacePolicy:
"""Load a marketplace policy from a YAML file.

Args:
path: Path to the policy YAML file.

Returns:
Parsed MarketplacePolicy.

Raises:
MarketplaceError: If the file is missing or invalid.
"""
if not path.exists():
raise MarketplaceError(f"Marketplace policy file not found: {path}")
try:
with open(path) as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise MarketplaceError("Marketplace policy must be a YAML mapping")
return MarketplacePolicy(**data)
except MarketplaceError:
raise
except Exception as exc:
raise MarketplaceError(f"Failed to load marketplace policy: {exc}") from exc


# ---------------------------------------------------------------------------
# Compliance evaluation
# ---------------------------------------------------------------------------


def evaluate_plugin_compliance(
manifest: PluginManifest,
policy: MarketplacePolicy,
mcp_servers: list[str] | None = None,
) -> ComplianceResult:
"""Check whether a plugin manifest complies with a marketplace policy.

Args:
manifest: The plugin manifest to evaluate.
policy: The marketplace policy to enforce.
mcp_servers: Optional list of MCP server names declared by the plugin.
When ``None``, MCP declaration checks that require a server list
will flag a violation if ``require_declaration`` is enabled.

Returns:
A :class:`ComplianceResult` indicating compliance status and any
violations.
"""
violations: list[str] = []

# -- Signature requirement ------------------------------------------------
if policy.require_signature and not manifest.signature:
violations.append(
f"Plugin '{manifest.name}' must be signed (Ed25519 signature required)"
)

# -- Plugin type restriction ----------------------------------------------
if policy.allowed_plugin_types is not None:
if manifest.plugin_type.value not in policy.allowed_plugin_types:
violations.append(
f"Plugin type '{manifest.plugin_type.value}' is not allowed "
f"(allowed: {', '.join(policy.allowed_plugin_types)})"
)

# -- MCP server policy ----------------------------------------------------
mcp_policy = policy.mcp_servers

if mcp_policy.require_declaration and mcp_servers is None:
violations.append(
f"Plugin '{manifest.name}' must declare its MCP servers"
)

if mcp_servers is not None:
if mcp_policy.mode == "allowlist" and mcp_policy.allowed:
disallowed = [s for s in mcp_servers if s not in mcp_policy.allowed]
if disallowed:
violations.append(
f"MCP servers not in allowlist: {', '.join(disallowed)}"
)

if mcp_policy.mode == "blocklist" and mcp_policy.blocked:
blocked_found = [s for s in mcp_servers if s in mcp_policy.blocked]
if blocked_found:
violations.append(
f"MCP servers are blocked: {', '.join(blocked_found)}"
)

return ComplianceResult(
compliant=len(violations) == 0,
violations=violations,
)
Loading
Loading