diff --git a/apps/api/src/api/v1/mcp_control_plane.py b/apps/api/src/api/v1/mcp_control_plane.py new file mode 100644 index 000000000..5d3a6ac71 --- /dev/null +++ b/apps/api/src/api/v1/mcp_control_plane.py @@ -0,0 +1,70 @@ +"""Read-only API for the 12-product MCP control plane cockpit.""" + +from __future__ import annotations + +import asyncio +import json + +import structlog +from fastapi import APIRouter, HTTPException, Query, status + +from src.models.mcp_control_plane import McpControlPlaneOverview +from src.services.mcp_control_plane_service import ( + build_mcp_control_plane_overview, + collect_mcp_control_plane_runtime, + load_mcp_control_plane_catalog, +) +from src.services.product_governance_matrix_readback import ( + load_latest_product_governance_matrix_readback, +) +from src.services.public_redaction import redact_public_lan_topology + +logger = structlog.get_logger(__name__) +router = APIRouter(prefix="/mcp-control-plane", tags=["MCP Control Plane"]) + + +@router.get( + "/overview", + response_model=McpControlPlaneOverview, + summary="取得 12 產品 MCP 控制面總覽", + description=( + "整合 Gitea product governance matrix、AWOOOI MCP Provider Registry、" + "Gateway aggregate audit 與既有 RAG stats。回應只包含 catalog、狀態與聚合數字;" + "不安裝外部 MCP、不呼叫工具、不讀取 secret、不保存 request/response body、" + "不寫入 RAG,且 GitHub 全面排除。" + ), +) +async def get_mcp_control_plane_overview( + include_runtime: bool = Query( + True, + description="是否讀取 provider registry、Gateway 24h aggregate 與 RAG stats", + ), +) -> McpControlPlaneOverview: + try: + catalog, product_matrix = await asyncio.gather( + asyncio.to_thread(load_mcp_control_plane_catalog), + asyncio.to_thread(load_latest_product_governance_matrix_readback), + ) + runtime = await collect_mcp_control_plane_runtime() if include_runtime else None + payload = build_mcp_control_plane_overview( + catalog=catalog, + product_matrix=product_matrix, + runtime=runtime, + ) + return McpControlPlaneOverview.model_validate( + redact_public_lan_topology(payload) + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="MCP control plane source inventory is not available", + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error( + "mcp_control_plane_inventory_invalid", + error_type=type(exc).__name__, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="MCP control plane source inventory is invalid", + ) from exc diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 640a2cc85..875e29313 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -65,6 +65,7 @@ from src.api.v1 import iwooos as iwooos_v1 # IwoooS security governance API from src.api.v1 import knowledge as knowledge_v1 # KB Phase 1: Knowledge Base from src.api.v1 import learning as learning_v1 # Phase D-G P0: Learning API from src.api.v1 import metrics as metrics_v1 # Phase 7: Gold Metrics (真實血脈) +from src.api.v1 import mcp_control_plane as mcp_control_plane_v1 from src.api.v1 import monitoring as monitoring_v1 # 2026-04-03: 監控工具狀態 from src.api.v1 import notifications as notifications_v1 # 2026-04-10: 通知頻道狀態 from src.api.v1 import ( @@ -1269,6 +1270,11 @@ app.include_router( app.include_router( rag_v1.router, prefix="/api/v1", tags=["RAG Knowledge Base"] ) # Phase 33 ADR-067: RAG 知識庫 +app.include_router( + mcp_control_plane_v1.router, + prefix="/api/v1", + tags=["MCP Control Plane"], +) app.include_router( errors_v1.router, prefix="/api/v1", tags=["Errors"] ) # #40: Sentry 錯誤 BFF API diff --git a/apps/api/src/models/mcp_control_plane.py b/apps/api/src/models/mcp_control_plane.py new file mode 100644 index 000000000..e1d3aaedd --- /dev/null +++ b/apps/api/src/models/mcp_control_plane.py @@ -0,0 +1,67 @@ +"""Response contracts for the cross-product MCP control plane.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class McpCapabilityReadback(BaseModel): + """One governed internal capability or external discovery candidate.""" + + model_config = ConfigDict(extra="allow") + + capability_id: str + title: str + source_type: str + catalog_source: str + catalog_url: str | None = None + decision: str + risk_tier: str + scope: str + data_boundary: str + deployment_allowed: bool + version_state: str + digest_state: str + sbom_state: str + signature_state: str + rag_policy: str + blockers: list[str] = Field(default_factory=list) + evidence_refs: list[str] = Field(default_factory=list) + + +class McpProductReadback(BaseModel): + """One product joined with Gitea truth and MCP desired/observed state.""" + + model_config = ConfigDict(extra="allow") + + product_id: str + canonical_repo: str + source_control_status: str + inventory_state: str + existing_capability_ids: list[str] + recommended_capability_ids: list[str] + rejected_capability_ids: list[str] + blockers: list[str] + + +class McpControlPlaneOverview(BaseModel): + """Redacted read-only cockpit payload.""" + + schema_version: str + generated_at: str + status: str + summary: dict[str, Any] + architecture: dict[str, Any] + security_policy: dict[str, Any] + supply_chain_policy: dict[str, Any] + version_lifecycle: dict[str, Any] + runtime: dict[str, Any] + catalog_sources: list[dict[str, Any]] + capabilities: list[McpCapabilityReadback] + products: list[McpProductReadback] + active_blockers: list[str] + next_actions: list[str] + operation_boundaries: dict[str, bool] + source_refs: dict[str, str] diff --git a/apps/api/src/plugins/mcp/gateway.py b/apps/api/src/plugins/mcp/gateway.py index e82ad5784..525690120 100644 --- a/apps/api/src/plugins/mcp/gateway.py +++ b/apps/api/src/plugins/mcp/gateway.py @@ -35,8 +35,6 @@ AwoooP Phase 5.2: ADR-116 五閘門強制執行 from __future__ import annotations -import hashlib -import json import time from dataclasses import dataclass, field from datetime import UTC, datetime @@ -56,6 +54,11 @@ from src.db.awooop_models import ( AwoooPProject, ) from src.plugins.mcp.interfaces import MCPToolResult +from src.plugins.mcp.redaction_middleware import ( + compute_safe_hash, + redact_mcp_input, + redact_mcp_output, +) from src.plugins.mcp.registry import get_provider_registry logger = structlog.get_logger(__name__) @@ -99,6 +102,13 @@ class GateApprovalError(McpGatewayError): super().__init__("E-MCP-GATE-005", msg, gate=5) +class GateRateLimitError(McpGatewayError): + def __init__(self, msg: str = "distributed rate limit exceeded") -> None: + # Rate limiting is part of the governed tool/grant boundary (Gate 3), + # preserving the existing durable audit schema's 1..5 gate range. + super().__init__("E-MCP-RATE-429", msg, gate=3) + + class CredentialResolutionError(McpGatewayError): def __init__(self, msg: str = "credential 解析失敗") -> None: super().__init__("E-MCP-GATE-009", msg, gate=0) @@ -125,6 +135,7 @@ class GateCheckResult: gate1_project: bool = False gate2_agent: bool = False gate3_tool: bool = False + distributed_rate_limit: bool = False gate4_env: bool = False gate5_approval: bool = False @@ -133,6 +144,7 @@ class GateCheckResult: "gate1_project": self.gate1_project, "gate2_agent": self.gate2_agent, "gate3_tool": self.gate3_tool, + "distributed_rate_limit": self.distributed_rate_limit, "gate4_env": self.gate4_env, "gate5_approval": self.gate5_approval, } @@ -143,6 +155,7 @@ class GateCheckResult: self.gate1_project, self.gate2_agent, self.gate3_tool, + self.distributed_rate_limit, self.gate4_env, self.gate5_approval, ]) @@ -186,6 +199,10 @@ class McpGateway: # Gate 3 — Tool + Grant tool_row, grant_row = await self._gate3_tool(ctx, gate_result) + # Gate 3b — Redis-backed distributed rate limit. A process-local + # counter would diverge across replicas and is therefore forbidden. + await self._enforce_distributed_rate_limit(ctx, gate_result) + # Gate 4 — Environment(shadow mode 直接放行) await self._gate4_environment(ctx, tool_row, gate_result) @@ -320,6 +337,46 @@ class McpGateway: gate_result.gate3_tool = True return tool_row, grant_row + async def _enforce_distributed_rate_limit( + self, + ctx: GatewayContext, + gate_result: GateCheckResult, + ) -> None: + """Apply an atomic per-project/agent/tool Redis fixed-window limit.""" + limits = {"read": 120, "write": 20, "admin": 5} + limit = limits.get(ctx.required_scope, 5) + window_seconds = 60 + key = ( + "mcp_rate_limit:" + f"{ctx.project_id}:{ctx.agent_id}:{ctx.tool_name}:{ctx.required_scope}" + ) + script = """ + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + """ + try: + redis = get_redis() + count = int(await redis.eval(script, 1, key, window_seconds)) + except Exception as exc: + logger.warning( + "mcp_distributed_rate_limit_unavailable", + project_id=ctx.project_id, + tool_name=ctx.tool_name, + required_scope=ctx.required_scope, + error_type=type(exc).__name__, + ) + raise GateRateLimitError( + "distributed rate-limit backend unavailable; fail closed" + ) from exc + if count > limit: + raise GateRateLimitError( + f"tool '{ctx.tool_name}' exceeded {limit} calls per {window_seconds}s" + ) + gate_result.distributed_rate_limit = True + async def _gate4_environment( self, ctx: GatewayContext, @@ -390,7 +447,8 @@ class McpGateway: """呼叫底層 MCP provider 執行工具""" provider = await self._resolve_provider(ctx, tool_row) - # 找不到 provider → 回傳 shadow no-op + # A missing provider is evidence of an unclosed runtime path. Shadow mode + # must not turn that absence into a successful execution receipt. if provider is None: logger.warning( "mcp_gateway_no_provider", @@ -398,12 +456,21 @@ class McpGateway: is_shadow=ctx.is_shadow, ) return MCPToolResult( - success=True, - execution_id=f"shadow-noop-{ctx.tool_name}", - output={"shadow": True, "message": "no provider registered, shadow no-op"}, + success=False, + execution_id=f"shadow-provider-missing-{ctx.tool_name}", + output={ + "shadow": True, + "status": "degraded", + "provider_registered": False, + }, + error="mcp_provider_not_registered", ) - audit_params = dict(parameters) + # Tool parameters are an execution boundary, not an audit payload. Apply + # credential and pattern redaction before they can reach any provider; + # the canonical audit context is re-added separately and is stripped by + # AuditedMCPToolProvider before the inner provider call. + audit_params = redact_mcp_input(parameters) existing_audit = ( parameters.get("_mcp_audit") if isinstance(parameters, dict) and isinstance(parameters.get("_mcp_audit"), dict) @@ -420,7 +487,11 @@ class McpGateway: "agent_role": existing_audit.get("agent_role") or ctx.agent_id, "gateway_path": "awooop_mcp_gateway", } - return await provider.execute(ctx.tool_name, audit_params) + result = await provider.execute(ctx.tool_name, audit_params) + result.output = redact_mcp_output(result.output) + if result.data is not None: + result.data = result.output + return result async def _resolve_provider( self, @@ -472,15 +543,14 @@ class McpGateway: ) -> None: """寫 awooop_mcp_gateway_audit — 只寫 hash,不寫明文 input/output""" try: - input_hash = hashlib.sha256( - json.dumps(parameters, sort_keys=True, default=str).encode() - ).hexdigest() + # Hash only the redacted canonical form. This avoids persisting a + # reversible low-entropy hash of credential-shaped input while still + # retaining deterministic request correlation. + input_hash = compute_safe_hash(redact_mcp_input(parameters)) output_hash: str | None = None if result is not None: - output_hash = hashlib.sha256( - json.dumps(result.output, sort_keys=True, default=str).encode() - ).hexdigest() + output_hash = compute_safe_hash(redact_mcp_output(result.output)) gate_payload = { **gate_result.as_dict(), @@ -489,6 +559,9 @@ class McpGateway: "policy_enforced": True, "is_shadow": ctx.is_shadow, "required_scope": ctx.required_scope, + "body_storage": "hash_only", + "input_redaction_enforced": True, + "output_redaction_enforced": True, } audit = AwoooPMcpGatewayAudit( diff --git a/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py b/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py index 040dd5ff2..501958458 100644 --- a/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py +++ b/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py @@ -11,8 +11,11 @@ prove this route has rolled out. from __future__ import annotations import asyncio +import copy import json +import time from collections.abc import Mapping +from datetime import UTC, datetime from typing import Any from sqlalchemy import text @@ -51,6 +54,13 @@ _CONSUMER_DIRECT_CONNECT_TIMEOUT_SECONDS = 1.5 _CONSUMER_DIRECT_QUERY_TIMEOUT_SECONDS = 2.0 _CONSUMER_TELEGRAM_CONTEXT_TIMEOUT_SECONDS = 1.5 _CONSUMER_STATEMENT_TIMEOUT_MS = 1500 +_CONSUMER_READBACK_CACHE_TTL_SECONDS = 20.0 +_consumer_readback_cache: dict[ + tuple[int, int, str], tuple[float, dict[str, Any]] +] = {} +_consumer_readback_inflight: dict[ + tuple[int, int, str], asyncio.Task[dict[str, Any]] +] = {} logger = get_logger(__name__) @@ -58,7 +68,48 @@ async def load_latest_ai_agent_log_controlled_writeback_consumer_readback( *, project_id: str = DEFAULT_PROJECT_ID, ) -> dict[str, Any]: - """Return live consumer bindings for LOG controlled writeback receipts.""" + """Return live consumer bindings without duplicating the same DB read burst.""" + + loop = asyncio.get_running_loop() + cache_key = (id(loop), id(get_db_context), project_id) + cached = _consumer_readback_cache_get(cache_key=cache_key) + if cached is not None: + return cached + + task = _consumer_readback_inflight.get(cache_key) + coalesced = task is not None + if task is None: + task = loop.create_task( + _load_latest_ai_agent_log_controlled_writeback_consumer_readback_uncached( + project_id=project_id + ) + ) + _consumer_readback_inflight[cache_key] = task + + try: + payload = await asyncio.shield(task) + payload = _with_consumer_readback_freshness( + payload, + cache_hit=False, + cache_age_seconds=0.0, + inflight_coalesced=coalesced, + ) + if _consumer_readback_cacheable(payload): + _consumer_readback_cache[cache_key] = ( + time.monotonic(), + copy.deepcopy(payload), + ) + return payload + finally: + if task.done() and _consumer_readback_inflight.get(cache_key) is task: + _consumer_readback_inflight.pop(cache_key, None) + + +async def _load_latest_ai_agent_log_controlled_writeback_consumer_readback_uncached( + *, + project_id: str, +) -> dict[str, Any]: + """Read current receipts once; the public loader owns cache/coalescing.""" try: rows, consumer_rows = await _load_consumer_rows_with_retry(project_id=project_id) @@ -94,6 +145,68 @@ async def load_latest_ai_agent_log_controlled_writeback_consumer_readback( ) +def _consumer_readback_cache_get( + *, + cache_key: tuple[int, int, str], +) -> dict[str, Any] | None: + now = time.monotonic() + for key, (stored_at, _payload) in list(_consumer_readback_cache.items()): + if now - stored_at > _CONSUMER_READBACK_CACHE_TTL_SECONDS: + _consumer_readback_cache.pop(key, None) + + cached = _consumer_readback_cache.get(cache_key) + if cached is None: + return None + stored_at, payload = cached + return _with_consumer_readback_freshness( + payload, + cache_hit=True, + cache_age_seconds=max(0.0, now - stored_at), + inflight_coalesced=False, + ) + + +def _consumer_readback_cacheable(payload: Mapping[str, Any]) -> bool: + boundaries = payload.get("operation_boundaries") + return bool( + isinstance(boundaries, Mapping) + and boundaries.get("metadata_ledger_read_performed") is True + and boundaries.get("consumer_apply_receipt_read_performed") is True + ) + + +def _with_consumer_readback_freshness( + payload: Mapping[str, Any], + *, + cache_hit: bool, + cache_age_seconds: float, + inflight_coalesced: bool, +) -> dict[str, Any]: + result = copy.deepcopy(dict(payload)) + prior = result.get("readback_freshness") + observed_at = ( + str(prior.get("observed_at") or "") + if isinstance(prior, Mapping) + else "" + ) + result["readback_freshness"] = { + "observed_at": observed_at or datetime.now(UTC).isoformat(), + "cache_ttl_seconds": _CONSUMER_READBACK_CACHE_TTL_SECONDS, + "cache_hit": cache_hit, + "cache_age_seconds": round(cache_age_seconds, 3), + "stale": False, + "inflight_coalesced": inflight_coalesced, + } + return result + + +def _reset_consumer_readback_cache_for_tests() -> None: + """Reset process-local readback state between focused unit tests.""" + + _consumer_readback_cache.clear() + _consumer_readback_inflight.clear() + + async def _load_consumer_rows_with_retry( *, project_id: str, diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index 903899a83..9edb2b681 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -7733,9 +7733,18 @@ async def claim_catalog_drift_failed_check_modes( { "project_id": project_id, "candidate_max_age_hours": max(1, max_age_hours), - "scan_limit": min(100, max(10, limit * 10)), + "scan_limit": 100, }, ) + ranked_rows: list[ + tuple[ + int, + dict[str, Any], + AnsibleCheckModeClaim, + str, + str, + ] + ] = [] for row_value in result.mappings().all(): row = dict(row_value) previous_input = _json_loads(row.get("input")) @@ -7744,6 +7753,9 @@ async def claim_catalog_drift_failed_check_modes( or previous_input.get("playbook_path") or "" ) + previous_catalog_id = str( + previous_input.get("catalog_id") or "" + ) previous_catalog_revision = str( previous_input.get("catalog_revision") or "" ) @@ -7768,6 +7780,33 @@ async def claim_catalog_drift_failed_check_modes( or not (path_changed or revision_changed) ): continue + catalog_route_changed = bool( + previous_catalog_id + and canonical_claim.catalog_id != previous_catalog_id + ) + replay_priority = ( + 0 if catalog_route_changed else 1 if path_changed else 2 + ) + ranked_rows.append( + ( + replay_priority, + row, + canonical_claim, + previous_check_path, + previous_catalog_revision, + ) + ) + + for ( + _, + row, + canonical_claim, + previous_check_path, + previous_catalog_revision, + ) in sorted(ranked_rows, key=lambda item: item[0]): + current_catalog_revision = str( + canonical_claim.input_payload.get("catalog_revision") or "" + ) catalog_replay_revision = ( current_catalog_revision or f"path:{canonical_claim.playbook_path}" diff --git a/apps/api/src/services/mcp_control_plane_service.py b/apps/api/src/services/mcp_control_plane_service.py new file mode 100644 index 000000000..f29d79f74 --- /dev/null +++ b/apps/api/src/services/mcp_control_plane_service.py @@ -0,0 +1,451 @@ +"""Cross-product MCP control plane catalog and runtime readback. + +The service deliberately separates committed desired/source inventory from live +runtime evidence. Marketplace directories are discovery inputs only; this code +never installs packages, resolves credentials, calls a tool, writes RAG data, or +promotes an external candidate. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import structlog +from sqlalchemy import text + +from src.db.base import get_db_context +from src.plugins.mcp.registry import get_provider_registry +from src.services.mcp_tool_registry import get_mcp_tool_registry +from src.services.snapshot_paths import default_operations_dir + +logger = structlog.get_logger(__name__) + +SCHEMA_VERSION = "mcp_control_plane_overview_v1" +CATALOG_SCHEMA_VERSION = "mcp_control_plane_catalog_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") +_DEFAULT_OPERATIONS_DIR = default_operations_dir(Path(__file__)) +_CATALOG_FILE = "mcp-control-plane-catalog.snapshot.json" +_EXPECTED_PRODUCT_IDS = { + "awoooi", + "ewoooc", + "2026fifa", + "agent-bounty-protocol", + "awooogo", + "stockplatform-v2", + "vibework", + "momo-pro-system", + "tsenyang-website", + "vtuber", + "bitan-pharmacy", + "clawbot-openclaw", +} + + +def load_mcp_control_plane_catalog( + operations_dir: Path | None = None, +) -> dict[str, Any]: + """Load and validate the committed 12-product MCP catalog.""" + directory = operations_dir or _DEFAULT_OPERATIONS_DIR + path = directory / _CATALOG_FILE + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + _validate_catalog(payload, str(path)) + return payload + + +def build_mcp_control_plane_overview( + *, + catalog: dict[str, Any], + product_matrix: dict[str, Any], + runtime: dict[str, Any] | None = None, + generated_at: str | None = None, +) -> dict[str, Any]: + """Join catalog desired state with source-control and runtime truth.""" + _validate_catalog(catalog, "catalog") + matrix_rows = { + str(row.get("product_id")): row + for row in _dict_rows(product_matrix.get("products")) + if row.get("product_id") + } + if set(matrix_rows) != _EXPECTED_PRODUCT_IDS: + raise ValueError( + "product governance matrix must contain the exact 12 governed products: " + f"missing={sorted(_EXPECTED_PRODUCT_IDS - set(matrix_rows))}, " + f"unexpected={sorted(set(matrix_rows) - _EXPECTED_PRODUCT_IDS)}" + ) + + capability_rows = _dict_rows(catalog.get("capabilities")) + capability_index = { + str(row["capability_id"]): row for row in capability_rows + } + products: list[dict[str, Any]] = [] + for desired in _dict_rows(catalog.get("products")): + product_id = str(desired["product_id"]) + source = matrix_rows[product_id] + existing_ids = _strings(desired.get("existing_capability_ids")) + recommended_ids = _strings(desired.get("recommended_capability_ids")) + rejected_ids = _strings(desired.get("rejected_capability_ids")) + products.append( + { + "product_id": product_id, + "canonical_repo": str(source.get("canonical_repo") or ""), + "source_control_status": str( + source.get("source_control_status") or "unknown" + ), + "prod_branch": str(source.get("prod_branch") or "main"), + "prod_sha_short": str(source.get("prod_sha_short") or ""), + "visibility_readback": str( + source.get("visibility_readback") or "unknown" + ), + "inventory_state": str(desired.get("inventory_state") or "unknown"), + "existing_capability_ids": existing_ids, + "recommended_capability_ids": recommended_ids, + "rejected_capability_ids": rejected_ids, + "existing_capabilities": [ + _capability_summary(capability_index[item]) for item in existing_ids + ], + "recommended_capabilities": [ + _capability_summary(capability_index[item]) + for item in recommended_ids + ], + "rejected_capabilities": [ + _capability_summary(capability_index[item]) for item in rejected_ids + ], + "blockers": _strings(desired.get("blockers")), + } + ) + + runtime_payload = runtime or { + "requested": False, + "status": "not_requested", + "provider_registry": { + "status": "not_requested", + "provider_count": 0, + "provider_names": [], + "tool_count": 0, + }, + "gateway_audit_24h": { + "status": "not_requested", + "call_count": 0, + "success_count": 0, + "blocked_count": 0, + "failed_count": 0, + }, + "rag": { + "status": "not_requested", + "total_chunks": 0, + "sources": 0, + }, + } + external_rows = [ + row for row in capability_rows if row.get("source_type") == "external" + ] + promotion_ready = sum( + 1 for row in external_rows if row.get("deployment_allowed") is True + ) + detected_products = sum( + 1 + for row in products + if row["inventory_state"] + not in {"no_committed_mcp_runtime_detected", "no_federated_mcp_inventory_readback"} + ) + quarantined_products = sum( + 1 + for row in products + if "quarantine" in row["inventory_state"] + or "remediation_required" in row["inventory_state"] + ) + + active_blockers = set(_strings(catalog["version_lifecycle"].get("active_blockers"))) + active_blockers.update( + { + "cross_product_runtime_federation_receipts_missing", + "external_candidate_immutable_supply_chain_receipts_missing", + "external_rag_quarantine_promotion_verifier_missing", + "distributed_gateway_rate_limit_receipt_missing", + } + ) + rag = runtime_payload.get("rag") if isinstance(runtime_payload.get("rag"), dict) else {} + if rag.get("total_chunks") == 0: + active_blockers.add("internal_rag_index_empty") + if runtime_payload.get("status") not in {"ready", "not_requested"}: + active_blockers.add("mcp_runtime_readback_degraded") + + source_summary = product_matrix.get("summary") + source_summary = source_summary if isinstance(source_summary, dict) else {} + return { + "schema_version": SCHEMA_VERSION, + "generated_at": generated_at + or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds"), + "status": "partial_degraded_with_safe_next_action", + "summary": { + "product_count": len(products), + "expected_product_count": 12, + "source_control_ready_product_count": int( + source_summary.get("source_control_ready_product_count") or 0 + ), + "mcp_source_detected_product_count": detected_products, + "mcp_source_gap_product_count": len(products) - detected_products, + "quarantined_or_remediation_product_count": quarantined_products, + "capability_count": len(capability_rows), + "external_candidate_count": len(external_rows), + "external_promotion_ready_count": promotion_ready, + "github_in_scope_count": 0, + "active_blocker_count": len(active_blockers), + "runtime_provider_count": int( + _nested(runtime_payload, "provider_registry", "provider_count") or 0 + ), + "runtime_tool_count": int( + _nested(runtime_payload, "provider_registry", "tool_count") or 0 + ), + }, + "architecture": catalog["architecture"], + "security_policy": catalog["security_policy"], + "supply_chain_policy": catalog["supply_chain_policy"], + "version_lifecycle": catalog["version_lifecycle"], + "runtime": runtime_payload, + "catalog_sources": catalog["source_policy"]["catalog_sources"], + "capabilities": capability_rows, + "products": products, + "active_blockers": sorted(active_blockers), + "next_actions": [ + "federate_ewoooc_and_momo_read_only_inventory_health_and_version_receipts", + "disable_agent_bounty_github_and_prompt_injection_tool_path", + "remove_bitan_github_external_provider_from_effective_policy", + "implement_durable_version_discovery_compatibility_canary_and_rollback_controller", + "mirror_and_pin_each_approved_external_candidate_before_shadow", + "wire_quarantine_scan_citation_verifier_and_governed_rag_promotion", + ], + "operation_boundaries": { + "read_only_api_allowed": True, + "public_response_redacted_aggregate_only": True, + "mutation_endpoint_exposed": False, + "operator_auth_required_for_future_mutation": True, + "external_install_allowed": False, + "external_tool_call_allowed": False, + "external_rag_write_allowed": False, + "github_allowed": False, + "secret_value_read_allowed": False, + "request_or_response_body_storage_allowed": False, + "production_upgrade_allowed": False, + }, + "source_refs": { + "catalog": f"docs/operations/{_CATALOG_FILE}", + "product_matrix": "GET /api/v1/agents/product-governance-matrix-readback", + "runtime": "live provider registry, MCP tool registry, gateway audit aggregate, and RAG stats", + }, + } + + +async def collect_mcp_control_plane_runtime() -> dict[str, Any]: + """Collect aggregate-only runtime evidence without request or response bodies.""" + blockers: list[str] = [] + try: + provider_registry = get_provider_registry() + tool_registry = get_mcp_tool_registry() + provider_payload = { + "status": "registered", + "provider_count": len(provider_registry.names()), + "provider_names": sorted(provider_registry.names()), + "tool_count": tool_registry.tool_count, + } + except Exception as exc: + logger.warning( + "mcp_control_plane_provider_registry_readback_failed", + error_type=type(exc).__name__, + ) + blockers.append("provider_registry_readback_failed") + provider_payload = { + "status": "degraded", + "provider_count": 0, + "provider_names": [], + "tool_count": 0, + } + + try: + # Control-plane readback is deliberately scoped to the canonical AWOOOI + # tenant. Query the governed pgvector table directly so repository + # fallback-to-zero cannot disguise a DB/RLS failure as a healthy empty + # index. Only aggregate counts leave this service. + async with get_db_context("awoooi") as db: + result = await db.execute( + text( + """ + SELECT + COUNT(*)::int AS total_chunks, + COUNT(DISTINCT source)::int AS sources + FROM rag_chunks + """ + ) + ) + rag_stats = result.mappings().one() + rag_payload = { + "status": "ready", + "total_chunks": int(rag_stats.get("total_chunks") or 0), + "sources": int(rag_stats.get("sources") or 0), + "scope": "project:awoooi", + } + except Exception as exc: + logger.warning( + "mcp_control_plane_rag_readback_failed", + error_type=type(exc).__name__, + ) + blockers.append("rag_stats_readback_failed") + rag_payload = { + "status": "degraded", + "total_chunks": 0, + "sources": 0, + "scope": "project:awoooi", + } + + try: + async with get_db_context("awoooi") as db: + result = await db.execute( + text( + """ + SELECT + COUNT(*)::int AS call_count, + COUNT(*) FILTER (WHERE result_status = 'success')::int AS success_count, + COUNT(*) FILTER (WHERE result_status = 'blocked')::int AS blocked_count, + COUNT(*) FILTER (WHERE result_status IN ('failed', 'timeout'))::int AS failed_count + FROM awooop_mcp_gateway_audit + WHERE created_at >= NOW() - INTERVAL '24 hours' + """ + ) + ) + row = result.mappings().one() + gateway_payload = { + "status": "ready", + "call_count": int(row["call_count"] or 0), + "success_count": int(row["success_count"] or 0), + "blocked_count": int(row["blocked_count"] or 0), + "failed_count": int(row["failed_count"] or 0), + "body_storage": "hash_only", + "scope": "project:awoooi", + } + except Exception as exc: + logger.warning( + "mcp_control_plane_gateway_audit_readback_failed", + error_type=type(exc).__name__, + ) + blockers.append("gateway_audit_readback_failed") + gateway_payload = { + "status": "degraded", + "call_count": 0, + "success_count": 0, + "blocked_count": 0, + "failed_count": 0, + "body_storage": "hash_only", + "scope": "project:awoooi", + } + + return { + "requested": True, + "status": "ready" if not blockers else "degraded", + "provider_registry": provider_payload, + "gateway_audit_24h": gateway_payload, + "rag": rag_payload, + "federated_product_runtime_receipt_count": 0, + "federated_product_runtime_receipt_expected": 12, + "blockers": sorted(blockers), + } + + +def _validate_catalog(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != CATALOG_SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + architecture = payload.get("architecture") or {} + if architecture.get("decision") != "single_control_plane_reuse_existing_gateway_registry_rag": + raise ValueError(f"{label}: duplicate gateway/RAG architecture is forbidden") + if architecture.get("separate_gateway_allowed") is not False: + raise ValueError(f"{label}: separate gateway must remain false") + if architecture.get("separate_rag_database_allowed") is not False: + raise ValueError(f"{label}: separate RAG database must remain false") + + source_policy = payload.get("source_policy") or {} + for key in ( + "github_allowed", + "github_actions_allowed", + "github_artifact_sources_allowed", + ): + if source_policy.get(key) is not False: + raise ValueError(f"{label}: {key} must remain false") + security = payload.get("security_policy") or {} + if security.get("external_rag_auto_write_allowed") is not False: + raise ValueError(f"{label}: external RAG auto-write must remain false") + if security.get("gateway_request_body_storage") != "forbidden_hash_only": + raise ValueError(f"{label}: gateway request body policy must be hash-only") + + capabilities = _dict_rows(payload.get("capabilities")) + capability_ids = [str(row.get("capability_id") or "") for row in capabilities] + if not all(capability_ids) or len(capability_ids) != len(set(capability_ids)): + raise ValueError(f"{label}: capability IDs must be non-empty and unique") + capability_id_set = set(capability_ids) + github_rows = [ + row for row in capabilities if row.get("capability_id") == "rejected.github" + ] + if len(github_rows) != 1 or github_rows[0].get("decision") != "rejected": + raise ValueError(f"{label}: GitHub capability must be explicitly rejected") + for row in capabilities: + if row.get("source_type") != "external": + continue + if row.get("deployment_allowed") is True and any( + row.get(field) in {None, "", "missing", "unresolved"} + for field in ("version_state", "digest_state", "sbom_state", "signature_state") + ): + raise ValueError( + f"{label}: external capability {row.get('capability_id')} cannot deploy without immutable receipts" + ) + + products = _dict_rows(payload.get("products")) + product_ids = {str(row.get("product_id") or "") for row in products} + if product_ids != _EXPECTED_PRODUCT_IDS or len(products) != 12: + raise ValueError( + f"{label}: catalog must contain exact 12 products; " + f"missing={sorted(_EXPECTED_PRODUCT_IDS - product_ids)}, " + f"unexpected={sorted(product_ids - _EXPECTED_PRODUCT_IDS)}" + ) + for row in products: + refs = ( + _strings(row.get("existing_capability_ids")) + + _strings(row.get("recommended_capability_ids")) + + _strings(row.get("rejected_capability_ids")) + ) + unknown = sorted(set(refs) - capability_id_set) + if unknown: + raise ValueError( + f"{label}: product {row.get('product_id')} references unknown capabilities {unknown}" + ) + + +def _capability_summary(row: dict[str, Any]) -> dict[str, Any]: + return { + "capability_id": row["capability_id"], + "title": row["title"], + "decision": row["decision"], + "risk_tier": row["risk_tier"], + "deployment_allowed": row["deployment_allowed"], + } + + +def _dict_rows(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [row for row in value if isinstance(row, dict)] + + +def _strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item)] + + +def _nested(payload: dict[str, Any], first: str, second: str) -> Any: + nested = payload.get(first) + return nested.get(second) if isinstance(nested, dict) else None diff --git a/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py b/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py index 5b40b1d75..044c19d59 100644 --- a/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py +++ b/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import pytest @@ -63,6 +64,8 @@ class _FailingContext: @pytest.fixture(autouse=True) def _stub_telegram_alert_card_readback(monkeypatch): + consumer_module._reset_consumer_readback_cache_for_tests() + async def fake_alert_card_readback(**_kwargs): return _telegram_alert_card_readback() @@ -71,6 +74,8 @@ def _stub_telegram_alert_card_readback(monkeypatch): "list_ai_alert_card_delivery_readback", fake_alert_card_readback, ) + yield + consumer_module._reset_consumer_readback_cache_for_tests() def _ledger_rows() -> list[dict]: @@ -215,6 +220,69 @@ async def test_log_controlled_writeback_consumer_loader_reads_live_ledger(monkey ] +@pytest.mark.asyncio +async def test_log_controlled_writeback_consumer_loader_reuses_fresh_success( + monkeypatch, +): + fake_db = _FakeDb(_ledger_rows(), _consumer_receipt_rows()) + monkeypatch.setattr( + consumer_module, + "get_db_context", + lambda project_id: _FakeContext(fake_db), + ) + + first = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + second = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + + assert first["readback_freshness"]["cache_hit"] is False + assert second["readback_freshness"]["cache_hit"] is True + assert second["readback_freshness"]["stale"] is False + assert second["readback_freshness"]["cache_age_seconds"] >= 0 + assert fake_db.params == [ + {"operation_type": "log_controlled_writeback_dispatched"}, + {"operation_type": "log_controlled_writeback_consumed"}, + ] + + +@pytest.mark.asyncio +async def test_log_controlled_writeback_consumer_loader_coalesces_concurrent_reads( + monkeypatch, +): + started = asyncio.Event() + release = asyncio.Event() + call_count = 0 + + async def delayed_rows(*, project_id: str): + nonlocal call_count + assert project_id == "awoooi" + call_count += 1 + started.set() + await release.wait() + return _ledger_rows(), _consumer_receipt_rows() + + monkeypatch.setattr( + consumer_module, + "_load_consumer_rows_with_retry", + delayed_rows, + ) + + first_task = asyncio.create_task( + load_latest_ai_agent_log_controlled_writeback_consumer_readback() + ) + await started.wait() + second_task = asyncio.create_task( + load_latest_ai_agent_log_controlled_writeback_consumer_readback() + ) + await asyncio.sleep(0) + release.set() + first, second = await asyncio.gather(first_task, second_task) + + assert call_count == 1 + assert first["readback_freshness"]["inflight_coalesced"] is False + assert second["readback_freshness"]["inflight_coalesced"] is True + assert first["consumer_bindings"] == second["consumer_bindings"] + + @pytest.mark.asyncio async def test_log_controlled_writeback_consumer_loader_reads_apply_receipts(monkeypatch): fake_db = _FakeDb(_ledger_rows(), _consumer_receipt_rows()) @@ -270,6 +338,39 @@ async def test_log_controlled_writeback_consumer_loader_degrades_on_db_pressure( assert payload["operation_boundaries"]["runtime_target_write_performed"] is False +@pytest.mark.asyncio +async def test_log_controlled_writeback_consumer_loader_does_not_cache_db_fallback( + monkeypatch, +): + monkeypatch.setattr( + consumer_module, + "get_db_context", + lambda project_id: _FailingContext(), + ) + direct_attempt_count = 0 + + async def fake_direct_unavailable(*, project_id: str): + nonlocal direct_attempt_count + assert project_id == "awoooi" + direct_attempt_count += 1 + return None + + monkeypatch.setattr( + consumer_module, + "_load_consumer_rows_direct", + fake_direct_unavailable, + ) + + first = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + second = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + + assert direct_attempt_count == 2 + assert first["readback_freshness"]["cache_hit"] is False + assert second["readback_freshness"]["cache_hit"] is False + assert first["readback"]["db_read_status"] == "unavailable" + assert second["readback"]["db_read_status"] == "unavailable" + + @pytest.mark.asyncio async def test_log_controlled_writeback_consumer_loader_uses_direct_fallback( monkeypatch, diff --git a/apps/api/tests/test_host_disk_pressure_ansible_catalog.py b/apps/api/tests/test_host_disk_pressure_ansible_catalog.py index d373e032c..1314b40c0 100644 --- a/apps/api/tests/test_host_disk_pressure_ansible_catalog.py +++ b/apps/api/tests/test_host_disk_pressure_ansible_catalog.py @@ -150,6 +150,109 @@ def test_historical_catalog_replay_is_per_failed_row_and_provider_silent() -> No assert "telegram_provider_delivery" in send_source +@pytest.mark.asyncio +async def test_catalog_replay_prioritizes_semantic_route_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.services import awooop_ansible_check_mode_service as service + + class Result: + def __init__(self, *, rows=None, scalar=None): + self._rows = rows or [] + self._scalar = scalar + + def mappings(self): + return self + + def all(self): + return self._rows + + def scalar_one_or_none(self): + return self._scalar + + class DB: + def __init__(self): + self.params: list[dict] = [] + + async def execute(self, _statement, params): + self.params.append(params) + if len(self.params) == 1: + return Result( + rows=[ + { + "op_id": "00000000-0000-0000-0000-000000000301", + "parent_op_id": ( + "00000000-0000-0000-0000-000000000300" + ), + "incident_id": "INC-NODE-REVISION-ONLY", + "incident_alertname": "NodeExporterDown", + "incident_alert_category": "availability", + "incident_affected_services": [ + "node-exporter-110" + ], + "input": { + "source_candidate_op_id": ( + "00000000-0000-0000-0000-000000000300" + ), + "incident_id": "INC-NODE-REVISION-ONLY", + "catalog_id": "ansible:110-devops", + "catalog_revision": "legacy-revision", + "playbook_path": ( + "infra/ansible/playbooks/" + "110-node-exporter-repair.yml" + ), + "inventory_hosts": ["host_110"], + "risk_level": "low", + }, + }, + { + "op_id": "00000000-0000-0000-0000-000000000311", + "parent_op_id": ( + "00000000-0000-0000-0000-000000000310" + ), + "incident_id": "INC-DISK-SEMANTIC-ROUTE", + "incident_alertname": "HostOutOfDiskSpace", + "incident_alert_category": "host_resource", + "incident_affected_services": [ + "node-exporter-188" + ], + "input": { + "source_candidate_op_id": ( + "00000000-0000-0000-0000-000000000310" + ), + "incident_id": "INC-DISK-SEMANTIC-ROUTE", + "catalog_id": "ansible:110-devops", + "catalog_revision": "legacy-revision", + "playbook_path": ( + "infra/ansible/playbooks/110-devops.yml" + ), + "inventory_hosts": ["host_110"], + "risk_level": "medium", + }, + }, + ] + ) + return Result( + scalar="00000000-0000-0000-0000-000000000312" + ) + + db = DB() + + @asynccontextmanager + async def fake_get_db_context(_project_id): + yield db + + monkeypatch.setattr(service, "get_db_context", fake_get_db_context) + + claims = await claim_catalog_drift_failed_check_modes(limit=1) + + assert db.params[0]["scan_limit"] == 100 + assert len(claims) == 1 + assert claims[0].incident_id == "INC-DISK-SEMANTIC-ROUTE" + assert claims[0].catalog_id == "ansible:188-disk-pressure" + assert claims[0].input_payload["catalog_route_changed"] is True + + def test_verified_wrong_catalog_is_reconciled_before_failure_replays() -> None: claim_source = inspect.getsource( claim_semantically_misrouted_verified_applies diff --git a/apps/api/tests/test_mcp_control_plane_api.py b/apps/api/tests/test_mcp_control_plane_api.py new file mode 100644 index 000000000..45a9b61f4 --- /dev/null +++ b/apps/api/tests/test_mcp_control_plane_api.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +# ruff: noqa: E402 +import json +import os +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +from src.api.v1.mcp_control_plane import router +from src.services.mcp_control_plane_service import ( + build_mcp_control_plane_overview, + load_mcp_control_plane_catalog, +) +from src.services.product_governance_matrix_readback import ( + load_latest_product_governance_matrix_readback, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_OPERATIONS_DIR = _REPO_ROOT / "docs" / "operations" + + +def test_catalog_contains_exact_12_products_and_explicit_missing_rows() -> None: + catalog = load_mcp_control_plane_catalog(_OPERATIONS_DIR) + + product_ids = {row["product_id"] for row in catalog["products"]} + assert len(product_ids) == 12 + assert {"ewoooc", "momo-pro-system"}.issubset(product_ids) + assert catalog["architecture"]["separate_gateway_allowed"] is False + assert catalog["architecture"]["separate_rag_database_allowed"] is False + assert catalog["source_policy"]["github_allowed"] is False + assert catalog["security_policy"]["external_rag_auto_write_allowed"] is False + + capabilities = { + row["capability_id"]: row for row in catalog["capabilities"] + } + assert capabilities["rejected.github"]["decision"] == "rejected" + assert capabilities["quarantine.agent-bounty-mcp"]["decision"] == "quarantine" + assert capabilities["external.smithery-site-audit-shadow"]["decision"] == "evaluation_shadow_candidate" + assert capabilities["external.smithery-site-audit-shadow"]["deployment_allowed"] is False + assert capabilities["rejected.smithery-technical-analysis"]["decision"] == "rejected" + assert "tool_output_contains_cross_tool_prompt_injection" in capabilities[ + "quarantine.agent-bounty-mcp" + ]["blockers"] + assert not any( + row["deployment_allowed"] + for row in catalog["capabilities"] + if row["source_type"] == "external" + ) + + +def test_overview_joins_committed_product_matrix_without_runtime_claims() -> None: + catalog = load_mcp_control_plane_catalog(_OPERATIONS_DIR) + matrix = load_latest_product_governance_matrix_readback(_OPERATIONS_DIR) + + payload = build_mcp_control_plane_overview( + catalog=catalog, + product_matrix=matrix, + generated_at="2026-07-15T19:00:00+08:00", + ) + + assert payload["schema_version"] == "mcp_control_plane_overview_v1" + assert payload["status"] == "partial_degraded_with_safe_next_action" + assert payload["summary"]["product_count"] == 12 + assert payload["summary"]["mcp_source_detected_product_count"] == 7 + assert payload["summary"]["external_promotion_ready_count"] == 0 + assert payload["summary"]["github_in_scope_count"] == 0 + assert payload["runtime"]["status"] == "not_requested" + + products = {row["product_id"]: row for row in payload["products"]} + assert products["ewoooc"]["canonical_repo"] == "wooo/ewoooc" + assert products["momo-pro-system"]["canonical_repo"] == "wooo/momo-pro-system" + assert products["agent-bounty-protocol"]["inventory_state"] == "source_detected_quarantined" + + +def test_catalog_rejects_external_deployment_without_immutable_receipts( + tmp_path: Path, +) -> None: + catalog = load_mcp_control_plane_catalog(_OPERATIONS_DIR) + external = next( + row for row in catalog["capabilities"] if row["source_type"] == "external" + ) + external["deployment_allowed"] = True + (tmp_path / "mcp-control-plane-catalog.snapshot.json").write_text( + json.dumps(catalog), + encoding="utf-8", + ) + + try: + load_mcp_control_plane_catalog(tmp_path) + except ValueError as exc: + assert "cannot deploy without immutable receipts" in str(exc) + else: + raise AssertionError("unsafe external deployment must be rejected") + + +def test_overview_endpoint_returns_real_committed_catalog_without_runtime() -> None: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get( + "/api/v1/mcp-control-plane/overview?include_runtime=false" + ) + + assert response.status_code == 200 + data = response.json() + assert data["summary"]["product_count"] == 12 + assert data["runtime"]["status"] == "not_requested" + assert data["operation_boundaries"]["github_allowed"] is False + assert data["operation_boundaries"]["public_response_redacted_aggregate_only"] is True + assert data["operation_boundaries"]["mutation_endpoint_exposed"] is False + assert data["operation_boundaries"]["operator_auth_required_for_future_mutation"] is True diff --git a/apps/api/tests/test_mcp_gateway_audit.py b/apps/api/tests/test_mcp_gateway_audit.py index bc4c5b60c..d4ad22202 100644 --- a/apps/api/tests/test_mcp_gateway_audit.py +++ b/apps/api/tests/test_mcp_gateway_audit.py @@ -123,3 +123,41 @@ async def test_execute_tool_resolves_provider_by_tool_manifest( assert parameters["_mcp_audit"]["gateway_path"] == "awooop_mcp_gateway" assert parameters["_mcp_audit"]["incident_id"] == "INC-GW" assert parameters["_mcp_audit"]["flywheel_node"] == "sense" + + +@pytest.mark.asyncio +async def test_execute_tool_redacts_input_and_output_at_gateway_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class SensitiveProvider(FakeProvider): + async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult: + self.calls.append((tool_name, parameters)) + return MCPToolResult( + success=True, + execution_id="provider-redacted", + output={ + "connection": "postgresql://reader:plain-password@10.0.1.5/prod" + }, + ) + + provider = SensitiveProvider() + monkeypatch.setattr( + gateway_module, + "get_provider_registry", + lambda: FakeProviderRegistry(provider), + ) + + result = await McpGateway(FakeDb())._execute_tool( + GatewayContext( + project_id="awoooi", + agent_id="pre_decision_investigator", + tool_name="kubectl_get", + ), + SimpleNamespace(tool_name="kubectl_get"), + {"namespace": "awoooi-prod", "api_key_value": "do-not-store"}, + ) + + sent = provider.calls[0][1] + assert sent["api_key_value"] == "[REDACTED:CREDENTIAL_ISOLATION]" + assert "plain-password" not in str(result.output) + assert "10.0.1.5" not in str(result.output) diff --git a/apps/api/tests/test_mcp_gateway_gate5.py b/apps/api/tests/test_mcp_gateway_gate5.py index 74616b99f..8b9543d96 100644 --- a/apps/api/tests/test_mcp_gateway_gate5.py +++ b/apps/api/tests/test_mcp_gateway_gate5.py @@ -8,6 +8,7 @@ from src.plugins.mcp import gateway as gateway_module from src.plugins.mcp.gateway import ( GateApprovalError, GateCheckResult, + GateRateLimitError, GatewayContext, McpGateway, ) @@ -24,6 +25,11 @@ class FakeRedis: async def aclose(self) -> None: self.closed = True + async def eval(self, _script: str, _keys: int, key: str, _window: int) -> int: + value = int(self.values.get(key, "0")) + 1 + self.values[key] = str(value) + return value + @pytest.mark.asyncio async def test_gate5_uses_shared_redis_pool_without_closing( @@ -102,3 +108,48 @@ async def test_gate5_read_scope_skips_redis( assert called is False assert gate_result.gate5_approval is True + + +@pytest.mark.asyncio +async def test_gateway_distributed_rate_limit_uses_redis_and_sets_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis = FakeRedis() + monkeypatch.setattr(gateway_module, "get_redis", lambda: redis) + gate_result = GateCheckResult() + + await McpGateway(db=None)._enforce_distributed_rate_limit( + GatewayContext( + project_id="awoooi", + agent_id="openclaw-sre", + tool_name="k8s_get", + required_scope="read", + ), + gate_result, + ) + + assert gate_result.distributed_rate_limit is True + assert redis.values[ + "mcp_rate_limit:awoooi:openclaw-sre:k8s_get:read" + ] == "1" + + +@pytest.mark.asyncio +async def test_gateway_distributed_rate_limit_fails_closed_when_redis_is_down( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unavailable() -> FakeRedis: + raise RuntimeError("redis unavailable") + + monkeypatch.setattr(gateway_module, "get_redis", unavailable) + + with pytest.raises(GateRateLimitError): + await McpGateway(db=None)._enforce_distributed_rate_limit( + GatewayContext( + project_id="awoooi", + agent_id="openclaw-sre", + tool_name="k8s_apply", + required_scope="write", + ), + GateCheckResult(), + ) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1f68c7b8d..8f7677087 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -93,7 +93,115 @@ "tenants": "租戶", "operationsOverview": "營運總覽", "iwooos": "IwoooS", - "iwooosSecurityCompliance": "IwoooS 安全合規" + "iwooosSecurityCompliance": "IwoooS Security & Compliance", + "mcpControlPlane": "MCP Control Plane" + }, + "automation": { + "eyebrow": "AI Automation", + "pageDescription": "Manage automated repair, AI command, drift detection, and cross-product MCP governance with verifiable runtime evidence.", + "mcpControlPlaneDesc": "Govern MCP inventory, external candidates, version supply chain, Gateway, and RAG across 12 products.", + "autoRepairDesc": "Detect and remediate infrastructure incidents under risk policy.", + "neuralCommandDesc": "AI command center with governed decisions and controlled execution.", + "driftDesc": "Continuously detect configuration drift and remediation gaps.", + "open": "Open", + "allSystemsOperational": "Automation modules remain under observation; use each runtime readback for degraded states." + }, + "mcpControlPlane": { + "eyebrow": "12-product governance", + "title": "MCP Control Plane", + "subtitle": "Govern every product's integrated, recommended, quarantined, and rejected MCP capabilities in one place. The control surface reuses AWOOOI Gateway, Provider Registry, pgvector, and Redis instead of duplicating infrastructure.", + "refresh": "Refresh", + "states": { + "loading": "Reading the MCP catalog, Gateway, and RAG runtime evidence…", + "error": "MCP control-plane readback failed", + "apiMissing": "The production API endpoint is not configured.", + "unknownError": "Unknown error" + }, + "summary": { + "title": "First-viewport overview", + "generatedAt": "Generated at {value}" + }, + "metrics": { + "products": "Governed products", + "productsDetail": "The production governance matrix must contain 12.", + "detected": "MCP source detected", + "detectedDetail": "Source evidence is not runtime closure.", + "gaps": "Missing MCP readback", + "gapsDetail": "Product federation receipts are still required.", + "providers": "Runtime providers", + "providersDetail": "{tools} tools are registered.", + "external": "External candidates", + "externalDetail": "Promotion ready: {ready}; floating versions cannot deploy.", + "blockers": "Active blockers", + "blockersDetail": "Gaps remain executable work items, never false completion." + }, + "architecture": { + "badge": "Architecture decision", + "title": "New control page, existing execution planes", + "detail": "The page is new; Gateway, Registry, audit, authorization, and RAG remain single planes. This prevents grants, data classification, vector indexes, rollback, and receipts from splitting.", + "gateway": "Gateway", + "registry": "Registry", + "rag": "RAG" + }, + "runtime": { + "title": "Runtime readback", + "calls24h": "Gateway calls / 24h", + "blocked24h": "Blocked / 24h", + "ragChunks": "RAG chunks", + "federated": "Product federation receipts", + "hashOnly": "Gateway audit stores only redacted input/output hashes, never request or response bodies." + }, + "products": { + "title": "12-product MCP matrix", + "subtitle": "Gitea source truth, detected capabilities, recommendations, and blockers remain distinct.", + "filterLabel": "Filter product MCP status", + "filters": { + "all": "All", + "existing": "Source detected", + "candidate": "Recommended", + "gap": "Has gaps" + }, + "hasExisting": "MCP source evidence detected", + "noExisting": "No MCP source evidence detected", + "source": "source: {value}", + "existing": "Existing", + "recommended": "Recommended", + "blockers": "Blockers" + }, + "external": { + "title": "Smithery / mcp.so candidate assessment", + "subtitle": "Directories are discovery sources only. Exact version, digest, SBOM, signature, authorization, and rollback are mandatory before promotion.", + "caption": "External MCP candidates and supply-chain state", + "columns": { + "capability": "Capability", + "source": "Directory", + "decision": "Decision", + "scope": "Scope", + "version": "Version", + "supply": "Digest / SBOM / signature", + "promote": "Deployable" + }, + "allowed": "Deployment allowed", + "blocked": "Deployment not allowed" + }, + "lifecycle": { + "title": "Version discovery to rollback loop", + "subtitle": "Target discovery cadence: {cadence}; every version diff must pass replay and canary.", + "runtimeState": "Current runtime state: {value}. Static policy is never presented as a running scheduler." + }, + "security": { + "title": "Enforced security boundaries", + "hashOnly": "Inputs and outputs are redacted before use; audit stores hashes only.", + "distributedRateLimit": "Redis atomic distributed rate limit, failing closed when unavailable.", + "quarantine": "External content stays quarantined until prompt-injection, provenance, and independent verification pass.", + "github": "GitHub MCP, Actions, and GitHub-hosted artifacts are excluded.", + "supplyChain": "npx -y, @latest, :latest, and unpinned artifacts cannot enter canary." + }, + "blockers": { + "title": "Unclosed production gaps", + "active": "Active blockers", + "next": "Next safe actions" + } }, "delivery": { "eyebrow": "AWOOOI Delivery", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 33d854875..aed53b34c 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -93,7 +93,115 @@ "tenants": "租戶", "operationsOverview": "營運總覽", "iwooos": "IwoooS", - "iwooosSecurityCompliance": "IwoooS 安全合規" + "iwooosSecurityCompliance": "IwoooS 安全合規", + "mcpControlPlane": "MCP 控制面" + }, + "automation": { + "eyebrow": "AI 自動化", + "pageDescription": "集中管理自動修復、AI 指揮、漂移偵測與跨產品 MCP 治理,並保留可驗證的 runtime 證據。", + "mcpControlPlaneDesc": "掌控 12 個產品的 MCP 現況、外部候選、版本供應鏈、Gateway 與 RAG 治理。", + "autoRepairDesc": "依風險政策自動偵測並修復基礎設施異常。", + "neuralCommandDesc": "具備認知決策與受控執行的 AI 指揮中心。", + "driftDesc": "持續偵測跨環境的組態漂移與修復缺口。", + "open": "開啟", + "allSystemsOperational": "自動化模組持續監看;個別 degraded 狀態請以各頁 runtime readback 為準。" + }, + "mcpControlPlane": { + "eyebrow": "12 產品治理", + "title": "MCP 控制面", + "subtitle": "在單一頁面掌控所有產品目前整合、建議、隔離與拒絕的 MCP;控制面沿用 AWOOOI 既有 Gateway、Provider Registry、pgvector 與 Redis,不再建立重複基礎設施。", + "refresh": "重新讀取", + "states": { + "loading": "正在讀取 MCP catalog、Gateway 與 RAG runtime 證據…", + "error": "MCP 控制面讀取失敗", + "apiMissing": "production API 端點尚未設定。", + "unknownError": "未知錯誤" + }, + "summary": { + "title": "首屏總覽", + "generatedAt": "資料時間:{value}" + }, + "metrics": { + "products": "治理產品", + "productsDetail": "production governance matrix 應為 12。", + "detected": "已有 MCP source", + "detectedDetail": "只代表 source scan,不等於 runtime closure。", + "gaps": "尚無 MCP readback", + "gapsDetail": "需建立產品端 federation receipt。", + "providers": "Runtime providers", + "providersDetail": "已登記 {tools} 個工具。", + "external": "外部候選", + "externalDetail": "promotion ready:{ready};未固定版本不得部署。", + "blockers": "Active blockers", + "blockersDetail": "缺口保留為可執行工作,不誤報完成。" + }, + "architecture": { + "badge": "架構決策", + "title": "新增控制頁,整併既有執行平面", + "detail": "頁面是新的;Gateway、Registry、稽核、權限與 RAG 仍只有一套。這能避免 grant、資料分類、向量索引、rollback 與 audit receipt 分裂。", + "gateway": "Gateway", + "registry": "Registry", + "rag": "RAG" + }, + "runtime": { + "title": "Runtime readback", + "calls24h": "Gateway calls / 24h", + "blocked24h": "Blocked / 24h", + "ragChunks": "RAG chunks", + "federated": "產品 federation receipts", + "hashOnly": "Gateway 稽核只保存脫敏後 input/output hash;不保存 request 或 response body。" + }, + "products": { + "title": "12 產品 MCP 矩陣", + "subtitle": "同時顯示 Gitea source truth、已偵測能力、建議導入與未完成 blocker。", + "filterLabel": "產品 MCP 篩選", + "filters": { + "all": "全部", + "existing": "已有 source", + "candidate": "有建議", + "gap": "有缺口" + }, + "hasExisting": "已有 MCP source evidence", + "noExisting": "尚無 MCP source evidence", + "source": "source:{value}", + "existing": "現有", + "recommended": "建議", + "blockers": "阻擋" + }, + "external": { + "title": "Smithery / mcp.so 候選評估", + "subtitle": "目錄只用來發現候選;未完成 exact version、digest、SBOM、簽章、授權與 rollback 前一律不可 promotion。", + "caption": "外部 MCP 候選及供應鏈狀態", + "columns": { + "capability": "能力", + "source": "目錄", + "decision": "決策", + "scope": "Scope", + "version": "版本", + "supply": "Digest / SBOM / 簽章", + "promote": "可部署" + }, + "allowed": "已允許部署", + "blocked": "尚未允許部署" + }, + "lifecycle": { + "title": "版本發現到回滾循環", + "subtitle": "目標 discovery cadence:{cadence};每次 version diff 都必須走完整 replay 與 canary。", + "runtimeState": "目前 runtime 狀態:{value}。此欄不會用靜態 policy 冒充已運行排程。" + }, + "security": { + "title": "強制安全邊界", + "hashOnly": "Gateway input/output 送出前脫敏,audit 僅存 hash。", + "distributedRateLimit": "Redis 原子計數的 distributed rate limit;backend 故障時 fail-closed。", + "quarantine": "外部內容先進隔離區,通過提示注入、來源與獨立 verifier 才能 promotion。", + "github": "GitHub MCP、Actions 與 GitHub-hosted artifact 全面排除。", + "supplyChain": "npx -y、@latest、:latest 與未固定 artifact 不可進 canary。" + }, + "blockers": { + "title": "尚未關閉的 production 缺口", + "active": "Active blockers", + "next": "Next safe actions" + } }, "delivery": { "eyebrow": "AWOOOI Delivery", diff --git a/apps/web/src/app/[locale]/automation/mcp/page.tsx b/apps/web/src/app/[locale]/automation/mcp/page.tsx new file mode 100644 index 000000000..5265893c1 --- /dev/null +++ b/apps/web/src/app/[locale]/automation/mcp/page.tsx @@ -0,0 +1,516 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { + AlertTriangle, + Boxes, + CheckCircle2, + Database, + GitBranch, + Network, + RefreshCw, + Search, + ShieldCheck, + Workflow, + XCircle, +} from 'lucide-react' +import { AppLayout } from '@/components/layout' +import { getRuntimeApiV1BaseUrl } from '@/lib/runtime-api-base' + +type Filter = 'all' | 'existing' | 'candidate' | 'gap' + +interface CapabilitySummary { + capability_id: string + title: string + decision: string + risk_tier: string + deployment_allowed: boolean +} + +interface Capability extends CapabilitySummary { + source_type: string + catalog_source: string + catalog_url?: string | null + scope: string + data_boundary: string + version_state: string + digest_state: string + sbom_state: string + signature_state: string + rag_policy: string + blockers: string[] +} + +interface ProductRow { + product_id: string + canonical_repo: string + source_control_status: string + prod_sha_short?: string + inventory_state: string + existing_capability_ids: string[] + recommended_capability_ids: string[] + rejected_capability_ids: string[] + existing_capabilities?: CapabilitySummary[] + recommended_capabilities?: CapabilitySummary[] + blockers: string[] +} + +interface RuntimeReadback { + status: string + provider_registry?: { + status: string + provider_count: number + provider_names: string[] + tool_count: number + } + gateway_audit_24h?: { + status: string + call_count: number + success_count: number + blocked_count: number + failed_count: number + body_storage?: string + } + rag?: { + status: string + total_chunks: number + sources: number + } + federated_product_runtime_receipt_count?: number + federated_product_runtime_receipt_expected?: number +} + +interface Overview { + generated_at: string + status: string + summary: { + product_count: number + mcp_source_detected_product_count: number + mcp_source_gap_product_count: number + quarantined_or_remediation_product_count: number + capability_count: number + external_candidate_count: number + external_promotion_ready_count: number + active_blocker_count: number + runtime_provider_count: number + runtime_tool_count: number + } + architecture: { + decision: string + gateway: string + provider_registry: string + rag: string + separate_gateway_allowed: boolean + separate_rag_database_allowed: boolean + } + security_policy: { + external_content_default: string + external_rag_auto_write_allowed: boolean + gateway_request_body_storage: string + distributed_rate_limit_required: boolean + } + supply_chain_policy: { + floating_versions_allowed: boolean + npx_yes_allowed: boolean + latest_tag_allowed: boolean + required_before_canary: string[] + } + version_lifecycle: { + desired_loop: string[] + discovery_cadence: string + runtime_state: string + active_blockers: string[] + } + runtime: RuntimeReadback + capabilities: Capability[] + products: ProductRow[] + active_blockers: string[] + next_actions: string[] +} + +const toneForDecision = (decision: string) => { + if (decision === 'integrated' || decision.startsWith('federate')) { + return 'border-[#8fc29a] bg-[#f0faf2] text-[#17602a]' + } + if (decision === 'rejected' || decision === 'quarantine') { + return 'border-[#efb4a7] bg-[#fff2ef] text-[#a53f2b]' + } + return 'border-[#e8c77a] bg-[#fff9e9] text-[#7b5815]' +} + +const isGap = (product: ProductRow) => + product.inventory_state.startsWith('no_') || product.blockers.length > 0 + +function StatusBadge({ value }: { value: string }) { + return ( + + {value.replaceAll('_', ' ')} + + ) +} + +function MetricCard({ + label, + value, + detail, + tone = 'neutral', +}: { + label: string + value: number | string + detail: string + tone?: 'neutral' | 'ok' | 'warn' | 'danger' +}) { + const toneClass = { + neutral: 'text-[#44423d]', + ok: 'text-[#17602a]', + warn: 'text-[#7b5815]', + danger: 'text-[#a53f2b]', + }[tone] + return ( +
+

{label}

+

{value}

+

{detail}

+
+ ) +} + +export default function McpControlPlanePage({ params }: { params: { locale: string } }) { + const t = useTranslations('mcpControlPlane') + const [overview, setOverview] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('all') + + const loadOverview = useCallback(async () => { + setLoading(true) + setError(null) + const base = getRuntimeApiV1BaseUrl() + if (!base) { + setError(t('states.apiMissing')) + setLoading(false) + return + } + try { + const response = await fetch(`${base}/mcp-control-plane/overview`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + setOverview(await response.json() as Overview) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('states.unknownError')) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + void loadOverview() + }, [loadOverview]) + + const filteredProducts = useMemo(() => { + if (!overview) return [] + if (filter === 'existing') return overview.products.filter(row => row.existing_capability_ids.length > 0) + if (filter === 'candidate') return overview.products.filter(row => row.recommended_capability_ids.length > 0) + if (filter === 'gap') return overview.products.filter(isGap) + return overview.products + }, [filter, overview]) + + const externalCapabilities = useMemo( + () => overview?.capabilities.filter(row => row.source_type === 'external') ?? [], + [overview], + ) + + const runtime = overview?.runtime + const gateway = runtime?.gateway_audit_24h + const rag = runtime?.rag + + return ( + +
+
+
+
+
+
+
+
+

{t('eyebrow')}

+

{t('title')}

+
+
+

{t('subtitle')}

+
+ +
+ +
+ {loading && !overview ? ( +
+ {t('states.loading')} +
+ ) : error && !overview ? ( +
+
+
+

{error}

+
+ ) : null} +
+ + {overview ? ( +
+
+
+
+

{t('summary.title')}

+

+ {t('summary.generatedAt', { value: overview.generated_at })} +

+
+ +
+
+ + + + + + +
+
+ +
+
+
+
+
+ {[ + [t('architecture.gateway'), overview.architecture.gateway], + [t('architecture.registry'), overview.architecture.provider_registry], + [t('architecture.rag'), overview.architecture.rag], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+
+
+
+
+
{t('runtime.calls24h')}
+
{gateway?.call_count ?? 0}
+
+
+
{t('runtime.blocked24h')}
+
{gateway?.blocked_count ?? 0}
+
+
+
{t('runtime.ragChunks')}
+
{rag?.total_chunks ?? 0}
+
+
+
{t('runtime.federated')}
+
+ {runtime?.federated_product_runtime_receipt_count ?? 0}/{runtime?.federated_product_runtime_receipt_expected ?? 12} +
+
+
+

{t('runtime.hashOnly')}

+
+
+ +
+
+
+

{t('products.title')}

+

{t('products.subtitle')}

+
+
+ {(['all', 'existing', 'candidate', 'gap'] as Filter[]).map(value => ( + + ))} +
+
+ +
+ {filteredProducts.map(product => ( +
+
+
+

{product.product_id}

+

{product.canonical_repo}

+
+ {product.existing_capability_ids.length > 0 ? ( + + ) : ( + + )} +
+
+ + + {t('products.source', { value: product.source_control_status })} + +
+
+
{t('products.existing')}
{product.existing_capability_ids.length}
+
{t('products.recommended')}
{product.recommended_capability_ids.length}
+
{t('products.blockers')}
{product.blockers.length}
+
+
+ {(product.existing_capabilities ?? []).slice(0, 2).map(item => ( +
+ {item.title} + +
+ ))} + {(product.recommended_capabilities ?? []).slice(0, 2).map(item => ( +
+ {item.title} + +
+ ))} +
+
+ ))} +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + {externalCapabilities.map(item => ( + + + + + + + + + + ))} + +
{t('external.caption')}
{t('external.columns.capability')}{t('external.columns.source')}{t('external.columns.decision')}{t('external.columns.scope')}{t('external.columns.version')}{t('external.columns.supply')}{t('external.columns.promote')}
{item.title}
{item.capability_id}
{item.catalog_source}{item.scope}{item.version_state}{item.digest_state} / {item.sbom_state} / {item.signature_state} + {item.deployment_allowed ? : } +
+
+
+ +
+
+
+
+

{t('lifecycle.subtitle', { cadence: overview.version_lifecycle.discovery_cadence })}

+
    + {overview.version_lifecycle.desired_loop.map((step, index) => ( +
  1. + {index + 1} + {step.replaceAll('_', ' ')} +
  2. + ))} +
+
+ {t('lifecycle.runtimeState', { value: overview.version_lifecycle.runtime_state })} +
+
+ +
+
+
+
    +
  • +
  • +
  • +
  • +
  • +
+
+
+ +
+
+
+
+
+

{t('blockers.active')}

+
    + {overview.active_blockers.map(item =>
  • • {item.replaceAll('_', ' ')}
  • )} +
+
+
+

{t('blockers.next')}

+
    + {overview.next_actions.map((item, index) =>
  1. {index + 1}. {item.replaceAll('_', ' ')}
  2. )} +
+
+
+
+
+ ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/app/[locale]/automation/page.tsx b/apps/web/src/app/[locale]/automation/page.tsx index e69eeb9d3..a0b890b75 100644 --- a/apps/web/src/app/[locale]/automation/page.tsx +++ b/apps/web/src/app/[locale]/automation/page.tsx @@ -14,6 +14,7 @@ import { Activity, ArrowRight, GitBranch, + Network, Wrench, Zap, } from 'lucide-react' @@ -36,6 +37,17 @@ export default function AutomationPage({ params }: { params: { locale: string } const ta = useTranslations('automation') const modules: AutomationModule[] = [ + { + id: 'mcp-control-plane', + label: t('mcpControlPlane'), + description: ta('mcpControlPlaneDesc'), + href: `/${params.locale}/automation/mcp`, + icon: Network, + accentColor: 'text-[#356aa0]', + bgColor: 'bg-[#f4f8ff]', + borderColor: 'border-[#b9cfea] hover:border-[#356aa0]', + badgeColor: 'bg-[#f4f8ff] border-[#b9cfea] text-[#356aa0]', + }, { id: 'auto-repair', label: t('autoRepair'), @@ -84,7 +96,7 @@ export default function AutomationPage({ params }: { params: { locale: string }

- AI Automation + {ta('eyebrow')}

{t('knowledgeAutomation')} @@ -97,7 +109,7 @@ export default function AutomationPage({ params }: { params: { locale: string }

{/* Module cards */} -
+
{modules.map(({ id, label, description, href, icon: Icon, accentColor, bgColor, borderColor, badgeColor }) => ( None: @@ -244,6 +244,66 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): image.runtime_ref.endswith("@" + image.target_registry_digest) ) + def test_argocd_control_plane_batch_reuses_verified_staging_artifact(self) -> None: + policy = controller.load_policy(POLICY_PATH) + staging = controller.load_staging_policy(STAGING_POLICY_PATH).images[0] + batch = { + image.asset_id: image + for image in policy.images + if image.asset_id + in { + "runtime-image:argocd-server", + "runtime-image:argocd-application-controller", + } + } + + self.assertEqual( + set(batch), + { + "runtime-image:argocd-server", + "runtime-image:argocd-application-controller", + }, + ) + self.assertEqual( + { + ( + image.workload.kind, + image.workload.namespace, + image.workload.name, + image.workload.container, + image.workload.container_kind, + ) + for image in batch.values() + }, + { + ( + "deployment", + "argocd", + "argocd-server", + "argocd-server", + "container", + ), + ( + "statefulset", + "argocd", + "argocd-application-controller", + "argocd-application-controller", + "container", + ), + }, + ) + for image in batch.values(): + self.assertEqual(image.source_index_digest, staging.source_index_digest) + self.assertEqual(image.platform_digest, staging.platform_digest) + self.assertEqual(image.push_ref, staging.push_ref) + self.assertEqual( + image.target_registry_digest, + "sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + ) + self.assertTrue( + image.runtime_ref.endswith("@" + image.target_registry_digest) + ) + def test_policy_rejects_external_pull_and_mutable_runtime_ref(self) -> None: payload = json.loads(POLICY_PATH.read_text(encoding="utf-8")) with tempfile.TemporaryDirectory() as temp: