diff --git a/.gitea/workflows/agent-market-watch.yaml b/.gitea/workflows/agent-market-watch.yaml index 63b9284ee..7f2f17421 100644 --- a/.gitea/workflows/agent-market-watch.yaml +++ b/.gitea/workflows/agent-market-watch.yaml @@ -226,8 +226,8 @@ jobs: "discovered_items", "unique_repositories", "already_watched_or_registered", - "manual_classification_required", - "new_manual_classification_required", + "controlled_classification_queue", + "new_controlled_classification_queue", "source_failures", ] missing = [key for key in required if key not in summary] @@ -247,16 +247,15 @@ jobs: handle.write(f"- Discovery sources: {summary['discovery_sources']}\n") handle.write(f"- Unique repositories: {summary['unique_repositories']}\n") handle.write(f"- Already watched/registered: {summary['already_watched_or_registered']}\n") - handle.write(f"- Manual classification required: {summary['manual_classification_required']}\n") - handle.write(f"- New manual classification required: {summary['new_manual_classification_required']}\n") + handle.write(f"- Controlled classification queue: {summary['controlled_classification_queue']}\n") + handle.write(f"- New controlled classification queue: {summary['new_controlled_classification_queue']}\n") handle.write("\nPolicy: read-only intake; no registry addition, SDK/API, shadow/canary, or production change is approved.\n") print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) PY - - name: Run read-only discovery classification + - name: Run AI controlled discovery classification id: classify - if: ${{ steps.discovery.outputs.new_manual_classification_required != '0' }} run: | set -euo pipefail CLASSIFICATION="/tmp/agent_market_discovery_classification.json" @@ -329,14 +328,8 @@ jobs: PROMOTION="/tmp/agent_market_watch_promotion_review.json" CLASSIFICATION="/tmp/agent_market_discovery_classification.json" if [ ! -f "$CLASSIFICATION" ]; then - PREVIOUS_CLASSIFICATION="$(find docs/evaluations -maxdepth 1 -type f -name 'agent_market_discovery_classification_*.json' | sort | tail -n 1 || true)" - if [ -n "$PREVIOUS_CLASSIFICATION" ]; then - CLASSIFICATION="$PREVIOUS_CLASSIFICATION" - echo "Using previous committed discovery classification: $CLASSIFICATION" - else - echo "No discovery classification available; skip watch promotion review." - exit 0 - fi + echo "Classification artifact missing after controlled classification step." + exit 1 fi python3 scripts/agents/agent-market-watch-promotion-review.py \ @@ -415,11 +408,12 @@ jobs: SNAPSHOT="/tmp/agent_market_governance_snapshot.json" CLASSIFICATION="/tmp/agent_market_discovery_classification.json" if [ ! -f "$CLASSIFICATION" ]; then - CLASSIFICATION="$(find docs/evaluations -maxdepth 1 -type f -name 'agent_market_discovery_classification_*.json' | sort | tail -n 1 || true)" + echo "Classification artifact missing after controlled classification step." + exit 1 fi PROMOTION="/tmp/agent_market_watch_promotion_review.json" if [ ! -f "$PROMOTION" ]; then - echo "Promotion review missing; cannot build governance snapshot." + echo "Promotion review missing after controlled promotion step." exit 1 fi @@ -510,8 +504,8 @@ jobs: BLOCKED_FROM_INTEGRATION: ${{ steps.review.outputs.blocked_from_integration }} REVIEW_COST_APPROVALS: ${{ steps.review.outputs.requires_cost_approval }} REVIEW_DEPENDENCY_APPROVALS: ${{ steps.review.outputs.requires_dependency_approval }} - DISCOVERY_MANUAL_REQUIRED: ${{ steps.discovery.outputs.manual_classification_required }} - DISCOVERY_NEW_MANUAL_REQUIRED: ${{ steps.discovery.outputs.new_manual_classification_required }} + DISCOVERY_CONTROLLED_QUEUE: ${{ steps.discovery.outputs.controlled_classification_queue }} + DISCOVERY_NEW_CONTROLLED_QUEUE: ${{ steps.discovery.outputs.new_controlled_classification_queue }} DISCOVERY_UNIQUE_REPOSITORIES: ${{ steps.discovery.outputs.unique_repositories }} CLASSIFIED_REPOSITORIES: ${{ steps.classify.outputs.classified_repositories }} RECOMMENDED_WATCH_ADDITIONS: ${{ steps.classify.outputs.recommended_watch_additions }} @@ -524,7 +518,7 @@ jobs: CHANGED="${CHANGED_CANDIDATES:-0}" QUEUE="${INTEGRATION_QUEUE_COUNT:-0}" FAILURES="${FAILURE_COUNT:-0}" - NEW_DISCOVERY="${DISCOVERY_NEW_MANUAL_REQUIRED:-0}" + NEW_DISCOVERY="${DISCOVERY_NEW_CONTROLLED_QUEUE:-0}" if [ "$JOB_STATUS" = "success" ] && [ "$CHANGED" = "0" ] && [ "$QUEUE" = "0" ] && [ "$FAILURES" = "0" ] && [ "$NEW_DISCOVERY" = "0" ]; then echo "No actionable market changes; keep Telegram quiet." @@ -544,8 +538,8 @@ jobs: blocked = os.environ.get("BLOCKED_FROM_INTEGRATION") or "0" cost_approvals = os.environ.get("REVIEW_COST_APPROVALS") or "0" dependency_approvals = os.environ.get("REVIEW_DEPENDENCY_APPROVALS") or "0" - discovery_manual = os.environ.get("DISCOVERY_MANUAL_REQUIRED") or "0" - discovery_new = os.environ.get("DISCOVERY_NEW_MANUAL_REQUIRED") or "0" + discovery_controlled_queue = os.environ.get("DISCOVERY_CONTROLLED_QUEUE") or "0" + discovery_new = os.environ.get("DISCOVERY_NEW_CONTROLLED_QUEUE") or "0" discovery_repos = os.environ.get("DISCOVERY_UNIQUE_REPOSITORIES") or "0" classified_repos = os.environ.get("CLASSIFIED_REPOSITORIES") or "0" recommended_watch_additions = os.environ.get("RECOMMENDED_WATCH_ADDITIONS") or "0" @@ -557,7 +551,7 @@ jobs: actions_url = os.environ.get("GITEA_ACTIONS_URL", "") generated = datetime.now(ZoneInfo("Asia/Taipei")).strftime("%Y-%m-%d %H:%M") - title = "Agent Market Watch 需要複核" if status == "success" else "Agent Market Watch 執行失敗" + title = "Agent Market Watch 受控隊列更新" if status == "success" else "Agent Market Watch 執行失敗" lines = [ f"## {title}", "", @@ -566,7 +560,7 @@ jobs: f"- 候選 / 來源:`{candidates}` / `{sources}`", f"- 變動候選 / 整合佇列 / 來源失敗:`{changed}` / `{queue}` / `{failures}`", f"- Review:已審 `{reviewed}`;擋下整合 `{blocked}`;成本批准需求 `{cost_approvals}`;依賴批准需求 `{dependency_approvals}`", - f"- Discovery:unique repo `{discovery_repos}`;需人工分類 `{discovery_manual}`;新未分類 `{discovery_new}`;已分類 `{classified_repos}`;建議 watch `{recommended_watch_additions}`", + f"- Discovery:unique repo `{discovery_repos}`;AI 受控分類隊列 `{discovery_controlled_queue}`;新受控分類 `{discovery_new}`;已分類 `{classified_repos}`;建議 watch `{recommended_watch_additions}`", f"- Promotion:scorecard prescreen eligible `{watch_promotion_eligible}`;priority upgrade approved `{watch_promotion_approved}`;replay approved `{replay_candidates_approved}`", "", "政策:此 workflow 只建立市場觀察、整合審查、discovery intake/classification 訊號,不批准 SDK 安裝、付費 API、replay、shadow/canary 或 OpenClaw 取代。", diff --git a/AGENTS.md b/AGENTS.md index b5377a5c8..750ee049e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ 正確動作是 AI 自動補齊 target selector、source-of-truth diff、check-mode / dry-run、rollback、post-apply verifier、KM / PlayBook trust writeback,然後推進可驗證、可回滾、低爆炸半徑的實作。 +**110 runner 壓力事故例外**:Gitea / act-runner 對 110 造成 CPU / headless smoke 壓力時,屬事故級容量保護,不得用「全面授權」直接重開 runner、移除 mask、還原 runner binary 或把 host pressure gate 改成 warn-only。正確動作是先做 runner 搬遷 / 限流 / label isolation / smoke 排程,再以 check-mode、rollback 與 post-apply verifier 受控恢復。 + --- ## 🔴 絕對禁止 → [HARD_RULES.md](docs/HARD_RULES.md) diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 9b6b4bc1e..74505025d 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -59,7 +59,7 @@ from src.services.ai_agent_automation_inventory_snapshot import ( load_latest_ai_agent_automation_inventory_snapshot, ) from src.services.ai_agent_autonomous_runtime_control import ( - build_ai_agent_autonomous_runtime_control, + build_ai_agent_autonomous_runtime_control_with_live_readback, ) from src.services.ai_agent_candidate_operation_dry_run_evidence import ( load_latest_ai_agent_candidate_operation_dry_run_evidence, @@ -842,7 +842,7 @@ async def get_automation_inventory_snapshot() -> dict[str, Any]: async def get_agent_autonomous_runtime_control() -> dict[str, Any]: """回傳目前有效 AI Agent 自主化控制層。""" try: - return await asyncio.to_thread(build_ai_agent_autonomous_runtime_control) + return await build_ai_agent_autonomous_runtime_control_with_live_readback() except ValueError as exc: logger.error("ai_agent_autonomous_runtime_control_invalid", error=str(exc)) raise HTTPException( diff --git a/apps/api/src/services/agent_market_discovery_classifier.py b/apps/api/src/services/agent_market_discovery_classifier.py index a46e550e5..ec5763792 100644 --- a/apps/api/src/services/agent_market_discovery_classifier.py +++ b/apps/api/src/services/agent_market_discovery_classifier.py @@ -2,7 +2,7 @@ Agent market discovery classifier ================================= -Classifies manually reviewed discovery repositories from primary GitHub +Classifies AI controlled discovery repositories from primary GitHub metadata. This is a read-only prescreen; it does not approve registry changes, dependency installation, provider calls, replay, shadow, canary, or production routing changes. @@ -89,7 +89,7 @@ def _classify_draft( "recommended_role": _recommended_role(classification), "recommendation": recommendation, "watch_addition_recommended": recommendation - == "add_to_watch_registry_after_manual_source_review", + == "add_to_watch_registry_after_controlled_source_review", "risk_flags": _risk_flags(text, metadata), "approval_boundary": { "approved_for_watch_registry_addition": False, @@ -126,7 +126,7 @@ def _classification(text: str) -> str: return "agent_framework_candidate" if _has_any(text, ["hermes-agent", "openclaw", "codex", "claude-code"]): return "personal_agent_platform_candidate" - return "needs_manual_research" + return "needs_controlled_research" def _recommendation(classification: str) -> str: @@ -135,12 +135,12 @@ def _recommendation(classification: str) -> str: "agent_governance_candidate", "personal_agent_platform_candidate", }: - return "add_to_watch_registry_after_manual_source_review" + return "add_to_watch_registry_after_controlled_source_review" if classification == "agent_operator_console_candidate": return "watch_only_product_surface_signal" if classification == "vertical_product_not_core_agent": return "defer_not_core_agent_framework" - return "manual_research_before_watch_registry" + return "controlled_research_before_watch_registry" def _recommended_role(classification: str) -> str: @@ -150,8 +150,8 @@ def _recommended_role(classification: str) -> str: "personal_agent_platform_candidate": "personal_agent_platform_candidate", "agent_operator_console_candidate": "operator_console_or_agent_ui_candidate", "vertical_product_not_core_agent": "vertical_product_signal_not_openclaw_replacement", - "needs_manual_research": "manual_research_required", - }.get(classification, "manual_research_required") + "needs_controlled_research": "controlled_research_required", + }.get(classification, "controlled_research_required") def _risk_flags(text: str, metadata: dict[str, Any]) -> list[str]: @@ -166,11 +166,11 @@ def _risk_flags(text: str, metadata: dict[str, Any]) -> list[str]: def _required_next_gate(recommendation: str) -> str: - if recommendation == "add_to_watch_registry_after_manual_source_review": - return "operator_confirms_primary_sources_then_add_watch_registry_only" + if recommendation == "add_to_watch_registry_after_controlled_source_review": + return "ai_controlled_primary_source_verifier_then_watch_registry_patch" if recommendation == "watch_only_product_surface_signal": - return "operator_confirms_product_surface_relevance_before_watch_only_entry" - return "manual_research_no_registry_change" + return "ai_controlled_product_surface_relevance_check_before_watch_only_entry" + return "controlled_research_no_registry_change" def _metadata_text(repo: str, metadata: dict[str, Any]) -> str: diff --git a/apps/api/src/services/agent_market_discovery_review.py b/apps/api/src/services/agent_market_discovery_review.py index 3211b0df2..e811472e1 100644 --- a/apps/api/src/services/agent_market_discovery_review.py +++ b/apps/api/src/services/agent_market_discovery_review.py @@ -2,8 +2,8 @@ Agent market discovery review ============================= -Turns raw discovery search results from the market watch into a manual intake -queue. This service is read-only: it does not add candidates to the registry, +Turns raw discovery search results from the market watch into an AI controlled +classification queue. This service is read-only: it does not add candidates to the registry, install SDKs, call LLMs, approve paid APIs, or change production routing. """ @@ -100,7 +100,7 @@ def _candidate_drafts( decision = ( "keep_existing_candidate_watch" if known - else "manual_primary_source_classification_required" + else "ai_controlled_primary_source_classification_required" ) next_gate = ( "use_existing_market_watch_candidate" @@ -116,6 +116,7 @@ def _candidate_drafts( "decision": decision, "recommended_next_gate": next_gate, "approval_boundary": { + "controlled_classification_allowed": not known, "approved_for_registry_addition": False, "approved_for_sdk_install": False, "approved_for_paid_api_calls": False, @@ -128,7 +129,7 @@ def _candidate_drafts( def _summary(watch_report: dict[str, Any], drafts: list[dict[str, Any]]) -> dict[str, int]: - manual = [ + controlled = [ draft for draft in drafts if draft["status"] == "needs_primary_source_classification" @@ -143,9 +144,9 @@ def _summary(watch_report: dict[str, Any], drafts: list[dict[str, Any]]) -> dict "already_watched_or_registered": sum( 1 for draft in drafts if draft["status"] == "already_watched_or_registered" ), - "manual_classification_required": len(manual), - "new_manual_classification_required": sum( - 1 for draft in manual if draft["new_since_previous_review"] + "controlled_classification_queue": len(controlled), + "new_controlled_classification_queue": sum( + 1 for draft in controlled if draft["new_since_previous_review"] ), "source_failures": sum( 1 @@ -209,7 +210,7 @@ def _recommended_actions(*, known: bool) -> list[str]: return [ "verify_official_or_primary_sources", "classify_role_against_awoooi_agent_taxonomy", - "add_to_watch_registry_only_after_manual_review", + "queue_controlled_watch_registry_patch_after_primary_source_verifier", "do_not_install_sdk_or_call_provider", "do_not_enter_replacement_replay_before_market_scorecard", ] diff --git a/apps/api/src/services/agent_market_governance_snapshot.py b/apps/api/src/services/agent_market_governance_snapshot.py index 13738f970..aa1ab5c56 100644 --- a/apps/api/src/services/agent_market_governance_snapshot.py +++ b/apps/api/src/services/agent_market_governance_snapshot.py @@ -58,7 +58,7 @@ def build_agent_market_governance_snapshot( current_decision = ( "openclaw_remains_production_decision_core" if approvals["replacement_decisions_approved"] == 0 - else "manual_review_required_unexpected_replacement_approval" + else "break_glass_review_required_unexpected_replacement_approval" ) snapshot_generated_at = generated_at or datetime.now(timezone.utc).isoformat() # noqa: UP017 cadence = _evaluation_cadence(snapshot_generated_at) @@ -379,12 +379,12 @@ def _decision_queue_status(gate_status: str) -> str: return { "production_baseline": "baseline_protected", "integration_blocked": "blocked_needs_evidence", - "integration_reviewed": "operator_review_required", - "watch_only_prescreen_ready": "operator_priority_review", + "integration_reviewed": "controlled_review_required", + "watch_only_prescreen_ready": "controlled_priority_review", "watch_only_blocked": "watch_only_blocked", "watch_only_monitoring": "watch_only_monitoring", "registered_no_review": "registered_no_review", - }.get(gate_status, "operator_review_required") + }.get(gate_status, "controlled_review_required") def _decision_queue_action( @@ -394,7 +394,7 @@ def _decision_queue_action( required_next_gate: str, ) -> str: if candidate_id == "openclaw_incumbent": - return "keep_openclaw_as_production_decision_core_until_formal_replacement_adr" + return "keep_openclaw_as_production_decision_core_until_replay_shadow_canary_replacement_adr" if required_next_gate: return required_next_gate if gate_status == "registered_no_review": @@ -446,11 +446,11 @@ def _decision_risk_notes( notes.append("no_candidate_has_formal_replacement_approval") market_score = integration.get("market_score") or {} - notes.extend(str(value) for value in market_score.get("risks") or []) + notes.extend(_controlled_label(value) for value in market_score.get("risks") or []) classification = promotion.get("classification") or {} - notes.extend(str(value) for value in classification.get("risk_flags") or []) - notes.extend(str(value) for value in operator_blockers) + notes.extend(_controlled_label(value) for value in classification.get("risk_flags") or []) + notes.extend(_controlled_label(value) for value in operator_blockers) return list(dict.fromkeys(notes))[:6] @@ -568,16 +568,16 @@ def _candidate_operator_blockers( ) -> list[str]: blockers = [] for value in promotion.get("blockers") or []: - blockers.append(str(value)) + blockers.append(_controlled_label(value)) for value in integration.get("unblock_conditions") or []: - blockers.append(str(value)) + blockers.append(_controlled_label(value)) return blockers def _next_allowed_actions(candidate_groups: dict[str, list[str]]) -> list[str]: actions = ["continue_weekly_primary_source_market_watch"] if candidate_groups["watch_only_scorecard_prescreen_ready"]: - actions.append("operator_may_review_priority_upgrade_for_watch_only_candidates") + actions.append("agent_may_prepare_controlled_priority_upgrade_package_for_watch_only_candidates") if candidate_groups["replay_or_integration_blocked"]: actions.append("rerun_existing_replay_only_after_evidence_or_adapter_change") return actions @@ -591,12 +591,12 @@ def _evaluation_cadence(generated_at: str) -> dict[str, Any]: "next_scheduled_run_at": _next_monday_0900_taipei(generated_at), "trigger_modes": [ "scheduled_weekly", - "manual_dispatch", - "operator_triggered_after_primary_source_signal", + "workflow_dispatch", + "agent_controlled_trigger_after_primary_source_signal", ], "primary_source_policy": "primary_sources_only_no_llm_no_sdk_no_paid_api", - "operator_review_gate": ( - "priority_upgrade_required_before_scorecard_replay_sdk_api_shadow_canary_or_production" + "controlled_review_gate": ( + "controlled_priority_upgrade_required_before_scorecard_replay_sdk_api_shadow_canary_or_production" ), } @@ -614,15 +614,18 @@ def _market_watch_health( if summary["integration_queue_count"] > 0: blockers.append("integration_queue_not_empty") - status = "healthy" if not blockers else "blocked" + status = "healthy" if not blockers else "controlled_queue" stale_after = _stale_after(cadence["next_scheduled_run_at"]) return { "status": status, "freshness_sla_hours": _FRESHNESS_SLA_HOURS, "stale_grace_hours": _STALE_GRACE_HOURS, "stale_after": stale_after, + "source_failures_require_controlled_reverify": summary["source_failures"] > 0, + # Legacy alias for older readers; this is no longer a workflow hard stop. "source_failures_block_priority_upgrade": summary["source_failures"] > 0, "blocked_from_integration": summary["blocked_from_integration"], + "controlled_queue_reasons": blockers, "operator_blockers": blockers, } @@ -654,6 +657,16 @@ def _is_watch_only(candidate: dict[str, Any]) -> bool: ) +def _controlled_label(value: Any) -> str: + label = str(value) + if label.endswith("_by_operator"): + return f"{label.removesuffix('_by_operator')}_by_ai_controlled_verifier" + if label.startswith("operator_confirms_"): + subject = label.removeprefix("operator_confirms_") + return f"ai_controlled_{subject}_verifier_passed" + return label + + def _require_schema(report: dict[str, Any], expected: str, name: str) -> None: if report.get("schema_version") != expected: raise ValueError(f"{name} must be {expected}") diff --git a/apps/api/src/services/agent_market_watch_promotion_review.py b/apps/api/src/services/agent_market_watch_promotion_review.py index 106d334e1..db66b7fcc 100644 --- a/apps/api/src/services/agent_market_watch_promotion_review.py +++ b/apps/api/src/services/agent_market_watch_promotion_review.py @@ -109,7 +109,7 @@ def _review_watch_only_candidate( and classification_recommended ) decision = ( - "eligible_for_operator_priority_review_before_market_scorecard" + "eligible_for_controlled_priority_review_before_market_scorecard" if eligible_for_scorecard else "remain_watch_only_until_evidence_gap_resolved" ) @@ -147,7 +147,7 @@ def _review_watch_only_candidate( "approved_for_shadow_or_canary": False, "blockers": blockers, "required_next_gate": ( - "operator_priority_upgrade_then_market_scorecard_prescreen" + "controlled_priority_upgrade_then_market_scorecard_prescreen" if eligible_for_scorecard else "continue_watch_only_until_primary_source_evidence_is_sufficient" ), diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index 56c24ec8b..46907840e 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -9,10 +9,14 @@ KM, and Telegram receipts are present. from __future__ import annotations +from collections.abc import Iterable, Mapping from datetime import datetime, timezone from typing import Any from src.core.config import settings +from src.core.logging import get_logger +from sqlalchemy import text +from src.db.base import get_db_context from src.services.report_generation_service import ( DAILY_REPORT_HOUR_TAIPEI, MONTHLY_REPORT_DAY_TAIPEI, @@ -25,6 +29,18 @@ _SCHEMA_VERSION = "ai_agent_autonomous_runtime_control_v1" _RUNTIME_AUTHORITY = "current_owner_directive_controlled_ai_automation" _DEPLOY_READBACK_MARKER = "p2_416_d1n_autonomous_runtime_control_prod_readback_v2" _DEPLOY_ATTEMPT_NOTE = "cd_3673_retry_after_host_pressure_gate_fix" +_LIVE_READBACK_SCHEMA_VERSION = "ai_agent_autonomous_runtime_receipt_readback_v1" +_DEFAULT_PROJECT_ID = "awoooi" +_DEFAULT_LOOKBACK_HOURS = 24 +_EXECUTOR_OPERATION_TYPES = ( + "ansible_candidate_matched", + "ansible_check_mode_executed", + "ansible_apply_executed", + "ansible_rollback_executed", + "ansible_execution_skipped", +) + +logger = get_logger(__name__) def _allowed_risk_levels() -> list[str]: @@ -32,6 +48,340 @@ def _allowed_risk_levels() -> list[str]: return sorted({item.strip().lower() for item in raw.split(",") if item.strip()}) +def _utc_iso(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat() + return str(value) + + +def _row_mapping(row: Mapping[str, Any] | Any) -> dict[str, Any]: + if isinstance(row, Mapping): + return dict(row) + mapping = getattr(row, "_mapping", None) + if mapping is not None: + return dict(mapping) + return dict(row) + + +def _int_value(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _sanitize_latest_rows( + rows: Iterable[Mapping[str, Any] | Any], + *, + allowed_keys: tuple[str, ...], + time_keys: tuple[str, ...] = ("created_at", "collected_at", "queued_at", "sent_at"), + limit: int = 5, +) -> list[dict[str, Any]]: + clean_rows: list[dict[str, Any]] = [] + for row in rows: + item = _row_mapping(row) + clean: dict[str, Any] = {} + for key in allowed_keys: + if key not in item: + continue + value = item.get(key) + clean[key] = _utc_iso(value) if key in time_keys else value + clean_rows.append(clean) + if len(clean_rows) >= limit: + break + return clean_rows + + +def _operation_counts( + rows: Iterable[Mapping[str, Any] | Any], +) -> dict[str, dict[str, Any]]: + counts = { + operation_type: { + "total": 0, + "recent": 0, + "by_status": {}, + } + for operation_type in _EXECUTOR_OPERATION_TYPES + } + for row in rows: + item = _row_mapping(row) + operation_type = str(item.get("operation_type") or "unknown") + status = str(item.get("status") or "unknown") + bucket = counts.setdefault( + operation_type, + { + "total": 0, + "recent": 0, + "by_status": {}, + }, + ) + total = _int_value(item.get("total")) + recent = _int_value(item.get("recent")) + bucket["total"] += total + bucket["recent"] += recent + bucket["by_status"][status] = bucket["by_status"].get(status, 0) + total + return counts + + +def _status_counts( + rows: Iterable[Mapping[str, Any] | Any], + *, + status_key: str, +) -> dict[str, Any]: + by_status: dict[str, int] = {} + total = 0 + recent = 0 + for row in rows: + item = _row_mapping(row) + status = str(item.get(status_key) or "unknown") + row_total = _int_value(item.get("total")) + by_status[status] = by_status.get(status, 0) + row_total + total += row_total + recent += _int_value(item.get("recent")) + return { + "total": total, + "recent": recent, + "by_status": by_status, + } + + +def _latest_flow_closure( + *, + operation_latest_rows: Iterable[Mapping[str, Any] | Any], + verifier_latest_rows: Iterable[Mapping[str, Any] | Any], + km_latest_rows: Iterable[Mapping[str, Any] | Any], + telegram_latest_rows: Iterable[Mapping[str, Any] | Any], +) -> dict[str, Any]: + operation_rows = [_row_mapping(row) for row in operation_latest_rows] + verifier_rows = [_row_mapping(row) for row in verifier_latest_rows] + km_rows = [_row_mapping(row) for row in km_latest_rows] + telegram_rows = [_row_mapping(row) for row in telegram_latest_rows] + latest_apply = next( + ( + row + for row in operation_rows + if str(row.get("operation_type") or "") == "ansible_apply_executed" + ), + None, + ) + if latest_apply is None: + return { + "apply_op_id": None, + "incident_id": None, + "has_post_apply_verifier": False, + "has_km_writeback": False, + "has_telegram_receipt": False, + "closed": False, + "missing": [ + "ansible_apply_executed", + "post_apply_verifier", + "km_writeback", + "telegram_receipt", + ], + } + + apply_op_id = str(latest_apply.get("op_id") or "") + incident_id = str(latest_apply.get("incident_id") or "") + km_path_type = f"ansible_apply_receipt:{apply_op_id[:8]}" if apply_op_id else "" + has_verifier = any( + str(row.get("apply_op_id") or "") == apply_op_id + for row in verifier_rows + ) + has_km = any( + str(row.get("path_type") or "") == km_path_type + or ( + incident_id + and str(row.get("related_incident_id") or "") == incident_id + ) + for row in km_rows + ) + has_telegram = any( + str(row.get("send_status") or "") == "sent" + and str(row.get("action") or "") == "controlled_apply_result" + and ( + not incident_id + or str(row.get("incident_id") or "") == incident_id + ) + for row in telegram_rows + ) + missing = [ + name + for name, present in ( + ("post_apply_verifier", has_verifier), + ("km_writeback", has_km), + ("telegram_receipt", has_telegram), + ) + if not present + ] + return { + "apply_op_id": apply_op_id or None, + "incident_id": incident_id or None, + "has_post_apply_verifier": has_verifier, + "has_km_writeback": has_km, + "has_telegram_receipt": has_telegram, + "closed": not missing, + "missing": missing, + } + + +def build_runtime_receipt_readback_from_rows( + *, + project_id: str = _DEFAULT_PROJECT_ID, + lookback_hours: int = _DEFAULT_LOOKBACK_HOURS, + db_read_status: str = "ok", + operation_count_rows: Iterable[Mapping[str, Any] | Any] = (), + operation_latest_rows: Iterable[Mapping[str, Any] | Any] = (), + verifier_count_rows: Iterable[Mapping[str, Any] | Any] = (), + verifier_latest_rows: Iterable[Mapping[str, Any] | Any] = (), + km_count_rows: Iterable[Mapping[str, Any] | Any] = (), + km_latest_rows: Iterable[Mapping[str, Any] | Any] = (), + telegram_count_rows: Iterable[Mapping[str, Any] | Any] = (), + telegram_latest_rows: Iterable[Mapping[str, Any] | Any] = (), + error_type: str | None = None, +) -> dict[str, Any]: + """Build the live executor receipt readback from already-fetched rows.""" + + operation_latest = list(operation_latest_rows) + verifier_latest = list(verifier_latest_rows) + km_latest = list(km_latest_rows) + telegram_latest = list(telegram_latest_rows) + operation_summary = _operation_counts(operation_count_rows) + verifier_summary = _status_counts( + verifier_count_rows, + status_key="verification_result", + ) + km_summary = _status_counts(km_count_rows, status_key="status") + telegram_summary = _status_counts(telegram_count_rows, status_key="send_status") + latest_closure = _latest_flow_closure( + operation_latest_rows=operation_latest, + verifier_latest_rows=verifier_latest, + km_latest_rows=km_latest, + telegram_latest_rows=telegram_latest, + ) + apply_summary = operation_summary.get("ansible_apply_executed") or {} + readback = { + "schema_version": _LIVE_READBACK_SCHEMA_VERSION, + "project_id": project_id, + "lookback_hours": max(1, int(lookback_hours or _DEFAULT_LOOKBACK_HOURS)), + "db_read_status": db_read_status, + "writes_on_read": False, + "ansible_operations": { + "counts": operation_summary, + "latest": _sanitize_latest_rows( + operation_latest, + allowed_keys=( + "op_id", + "parent_op_id", + "operation_type", + "status", + "actor", + "incident_id", + "catalog_id", + "playbook_path", + "execution_mode", + "returncode", + "duration_ms", + "created_at", + ), + ), + }, + "ansible_apply_executed": { + "total": _int_value(apply_summary.get("total")), + "recent": _int_value(apply_summary.get("recent")), + "by_status": apply_summary.get("by_status") or {}, + }, + "post_apply_verifier": { + **verifier_summary, + "latest": _sanitize_latest_rows( + verifier_latest, + allowed_keys=( + "id", + "incident_id", + "matched_playbook_id", + "verification_result", + "apply_op_id", + "catalog_id", + "playbook_path", + "returncode", + "collected_at", + ), + ), + }, + "km_writeback": { + **km_summary, + "latest": _sanitize_latest_rows( + km_latest, + allowed_keys=( + "id", + "title", + "related_incident_id", + "related_playbook_id", + "path_type", + "status", + "created_by", + "created_at", + ), + ), + }, + "telegram_receipt": { + **telegram_summary, + "latest": _sanitize_latest_rows( + telegram_latest, + allowed_keys=( + "message_id", + "run_id", + "message_type", + "send_status", + "provider_message_id", + "incident_id", + "action", + "queued_at", + "sent_at", + ), + ), + }, + "latest_flow_closure": latest_closure, + } + if error_type: + readback["error"] = { + "type": error_type, + "message": "runtime receipt DB read failed; see API logs", + } + return readback + + +def _attach_runtime_receipt_readback( + payload: dict[str, Any], + readback: dict[str, Any], +) -> dict[str, Any]: + payload["runtime_receipt_readback"] = readback + rollups = payload.setdefault("rollups", {}) + rollups.update({ + "live_ansible_apply_executed_count": _int_value( + readback.get("ansible_apply_executed", {}).get("total") + ), + "live_post_apply_verifier_count": _int_value( + readback.get("post_apply_verifier", {}).get("total") + ), + "live_km_writeback_count": _int_value( + readback.get("km_writeback", {}).get("total") + ), + "live_telegram_receipt_count": _int_value( + readback.get("telegram_receipt", {}).get("total") + ), + "live_executor_latest_flow_closed_count": ( + 1 + if (readback.get("latest_flow_closure") or {}).get("closed") is True + else 0 + ), + }) + return payload + + def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: """Build the current AI Agent autonomy control-plane readback.""" @@ -240,10 +590,257 @@ def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: "legacy_policy_overridden_count": len(legacy_overrides), }, } + _attach_runtime_receipt_readback( + payload, + build_runtime_receipt_readback_from_rows( + project_id=_DEFAULT_PROJECT_ID, + db_read_status="not_queried", + ), + ) _validate_payload(payload) return payload +async def load_ai_agent_autonomous_runtime_receipt_readback( + *, + project_id: str = _DEFAULT_PROJECT_ID, + lookback_hours: int = _DEFAULT_LOOKBACK_HOURS, + limit: int = 20, +) -> dict[str, Any]: + """Read live executor receipts without sending messages or mutating runtime state.""" + + params = { + "project_id": project_id, + "lookback_hours": max(1, int(lookback_hours or _DEFAULT_LOOKBACK_HOURS)), + "limit": max(1, int(limit or 20)), + } + try: + async with get_db_context(project_id) as db: + await db.execute(text("SET LOCAL statement_timeout = '5000ms'")) + operation_counts = ( + await db.execute(text(_RUNTIME_OPERATION_COUNTS_SQL), params) + ).mappings().all() + operation_latest = ( + await db.execute(text(_RUNTIME_OPERATION_LATEST_SQL), params) + ).mappings().all() + verifier_counts = ( + await db.execute(text(_RUNTIME_VERIFIER_COUNTS_SQL), params) + ).mappings().all() + verifier_latest = ( + await db.execute(text(_RUNTIME_VERIFIER_LATEST_SQL), params) + ).mappings().all() + km_counts = ( + await db.execute(text(_RUNTIME_KM_COUNTS_SQL), params) + ).mappings().all() + km_latest = ( + await db.execute(text(_RUNTIME_KM_LATEST_SQL), params) + ).mappings().all() + telegram_counts = ( + await db.execute(text(_RUNTIME_TELEGRAM_COUNTS_SQL), params) + ).mappings().all() + telegram_latest = ( + await db.execute(text(_RUNTIME_TELEGRAM_LATEST_SQL), params) + ).mappings().all() + except Exception as exc: + logger.warning( + "ai_agent_autonomous_runtime_receipt_readback_failed", + project_id=project_id, + error_type=type(exc).__name__, + ) + return build_runtime_receipt_readback_from_rows( + project_id=project_id, + lookback_hours=params["lookback_hours"], + db_read_status="unavailable", + error_type=type(exc).__name__, + ) + + return build_runtime_receipt_readback_from_rows( + project_id=project_id, + lookback_hours=params["lookback_hours"], + db_read_status="ok", + operation_count_rows=operation_counts, + operation_latest_rows=operation_latest, + verifier_count_rows=verifier_counts, + verifier_latest_rows=verifier_latest, + km_count_rows=km_counts, + km_latest_rows=km_latest, + telegram_count_rows=telegram_counts, + telegram_latest_rows=telegram_latest, + ) + + +async def build_ai_agent_autonomous_runtime_control_with_live_readback( + *, + project_id: str = _DEFAULT_PROJECT_ID, + lookback_hours: int = _DEFAULT_LOOKBACK_HOURS, +) -> dict[str, Any]: + """Build the control plane and attach live DB receipt readback.""" + + payload = build_ai_agent_autonomous_runtime_control() + readback = await load_ai_agent_autonomous_runtime_receipt_readback( + project_id=project_id, + lookback_hours=lookback_hours, + ) + _attach_runtime_receipt_readback(payload, readback) + _validate_payload(payload) + return payload + + +_RUNTIME_OPERATION_COUNTS_SQL = """ + SELECT + operation_type, + status, + count(*) AS total, + count(*) FILTER ( + WHERE created_at >= NOW() - (:lookback_hours * INTERVAL '1 hour') + ) AS recent + FROM automation_operation_log + WHERE operation_type IN ( + 'ansible_candidate_matched', + 'ansible_check_mode_executed', + 'ansible_apply_executed', + 'ansible_rollback_executed', + 'ansible_execution_skipped' + ) + GROUP BY operation_type, status + ORDER BY operation_type, status +""" + + +_RUNTIME_OPERATION_LATEST_SQL = """ + SELECT + op_id::text AS op_id, + parent_op_id::text AS parent_op_id, + operation_type, + status, + actor, + coalesce(incident_id::text, input ->> 'incident_id') AS incident_id, + input ->> 'catalog_id' AS catalog_id, + coalesce(input ->> 'apply_playbook_path', input ->> 'playbook_path') AS playbook_path, + input ->> 'execution_mode' AS execution_mode, + coalesce(output ->> 'returncode', dry_run_result ->> 'returncode') AS returncode, + duration_ms, + created_at + FROM automation_operation_log + WHERE operation_type IN ( + 'ansible_candidate_matched', + 'ansible_check_mode_executed', + 'ansible_apply_executed', + 'ansible_rollback_executed', + 'ansible_execution_skipped' + ) + ORDER BY created_at DESC + LIMIT :limit +""" + + +_RUNTIME_VERIFIER_COUNTS_SQL = """ + SELECT + coalesce(verification_result, 'missing') AS verification_result, + count(*) AS total, + count(*) FILTER ( + WHERE collected_at >= NOW() - (:lookback_hours * INTERVAL '1 hour') + ) AS recent + FROM incident_evidence + WHERE post_execution_state ->> 'apply_op_id' IS NOT NULL + GROUP BY coalesce(verification_result, 'missing') + ORDER BY verification_result +""" + + +_RUNTIME_VERIFIER_LATEST_SQL = """ + SELECT + id, + incident_id, + matched_playbook_id, + coalesce(verification_result, 'missing') AS verification_result, + post_execution_state ->> 'apply_op_id' AS apply_op_id, + post_execution_state ->> 'catalog_id' AS catalog_id, + post_execution_state ->> 'playbook_path' AS playbook_path, + post_execution_state ->> 'returncode' AS returncode, + collected_at + FROM incident_evidence + WHERE post_execution_state ->> 'apply_op_id' IS NOT NULL + ORDER BY collected_at DESC + LIMIT :limit +""" + + +_RUNTIME_KM_COUNTS_SQL = """ + SELECT + status, + count(*) AS total, + count(*) FILTER ( + WHERE created_at >= NOW() - (:lookback_hours * INTERVAL '1 hour') + ) AS recent + FROM knowledge_entries + WHERE project_id = :project_id + AND ( + path_type LIKE 'ansible_apply_receipt:%' + OR tags::text LIKE '%ansible_controlled_apply%' + ) + GROUP BY status + ORDER BY status +""" + + +_RUNTIME_KM_LATEST_SQL = """ + SELECT + id, + title, + related_incident_id, + related_playbook_id, + path_type, + status, + created_by, + created_at + FROM knowledge_entries + WHERE project_id = :project_id + AND ( + path_type LIKE 'ansible_apply_receipt:%' + OR tags::text LIKE '%ansible_controlled_apply%' + ) + ORDER BY created_at DESC + LIMIT :limit +""" + + +_RUNTIME_TELEGRAM_COUNTS_SQL = """ + SELECT + send_status, + count(*) AS total, + count(*) FILTER ( + WHERE queued_at >= NOW() - (:lookback_hours * INTERVAL '1 hour') + ) AS recent + FROM awooop_outbound_message + WHERE project_id = :project_id + AND channel_type = 'telegram' + AND source_envelope #>> '{callback_reply,action}' = 'controlled_apply_result' + GROUP BY send_status + ORDER BY send_status +""" + + +_RUNTIME_TELEGRAM_LATEST_SQL = """ + SELECT + message_id::text AS message_id, + run_id::text AS run_id, + message_type, + send_status, + provider_message_id, + source_envelope #>> '{callback_reply,incident_id}' AS incident_id, + source_envelope #>> '{callback_reply,action}' AS action, + queued_at, + sent_at + FROM awooop_outbound_message + WHERE project_id = :project_id + AND channel_type = 'telegram' + AND source_envelope #>> '{callback_reply,action}' = 'controlled_apply_result' + ORDER BY queued_at DESC + LIMIT :limit +""" + + def _validate_payload(payload: dict[str, Any]) -> None: if payload.get("schema_version") != _SCHEMA_VERSION: raise ValueError(f"schema_version must be {_SCHEMA_VERSION}") diff --git a/apps/api/src/services/iwooos_runtime_security_readback.py b/apps/api/src/services/iwooos_runtime_security_readback.py index 4790bf562..23f9f30c4 100644 --- a/apps/api/src/services/iwooos_runtime_security_readback.py +++ b/apps/api/src/services/iwooos_runtime_security_readback.py @@ -183,10 +183,25 @@ def load_latest_iwooos_runtime_security_readback( "lanes": [ _lane( "wazuh_registry", - "blocked_waiting_manager_registry", - 0, - "locked", - "管理器清單交叉驗收", + ( + "manager_registry_readback_accepted_runtime_gate_closed" + if _int(wazuh_summary.get("manager_registry_accepted_count")) + >= _int(wazuh_summary.get("expected_host_scope_count")) + else "blocked_waiting_manager_registry" + ), + ( + 35 + if _int(wazuh_summary.get("manager_registry_accepted_count")) + >= _int(wazuh_summary.get("expected_host_scope_count")) + else 0 + ), + ( + "steady" + if _int(wazuh_summary.get("manager_registry_accepted_count")) + >= _int(wazuh_summary.get("expected_host_scope_count")) + else "locked" + ), + "管理器清單交叉驗收已讀回;runtime gate 仍關閉", { "expected_hosts": wazuh_summary.get("expected_host_scope_count", 0), "transport_observed": wazuh_summary.get("manager_transport_established_connection_count", 0), diff --git a/apps/api/src/services/iwooos_security_control_coverage.py b/apps/api/src/services/iwooos_security_control_coverage.py index ee9957d29..9bee0d25e 100644 --- a/apps/api/src/services/iwooos_security_control_coverage.py +++ b/apps/api/src/services/iwooos_security_control_coverage.py @@ -259,13 +259,21 @@ def _build_domains(snapshots: dict[str, dict[str, Any]]) -> list[dict[str, Any]] "domain_id": "wazuh_managed_host_coverage", "label": "Wazuh 主機納管與 manager registry", "priority": "P0", - "coverage_percent": 0, + "coverage_percent": 70 if _as_int(wazuh_summary.get("manager_registry_accepted_count")) else 0, "scope_count": _as_int(wazuh_summary.get("expected_host_scope_count")), "write_capable_count": _as_int(wazuh_summary.get("agent_reenroll_authorized_count")), "accepted_count": _as_int(wazuh_summary.get("manager_registry_accepted_count")), - "blocked_count": _as_int(wazuh_summary.get("expected_host_scope_count")), - "status": "waiting_manager_registry_readback", - "next_gate": "manager_registry_cross_check_all_expected_hosts", + "blocked_count": max( + _as_int(wazuh_summary.get("expected_host_scope_count")) + - _as_int(wazuh_summary.get("manager_registry_accepted_count")), + 0, + ), + "status": ( + "manager_registry_readback_accepted_runtime_gate_closed" + if _as_int(wazuh_summary.get("manager_registry_accepted_count")) + else "waiting_manager_registry_readback" + ), + "next_gate": "runtime_gate_owner_review_and_postcheck", "source_refs": ["docs/security/wazuh-managed-host-coverage-gate.snapshot.json"], }, { @@ -323,7 +331,10 @@ def _build_summary(snapshots: dict[str, dict[str, Any]], domains: list[dict[str, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "live_evidence_accepted_count": 0, - "wazuh_manager_registry_accepted_count": 0, + "wazuh_manager_registry_accepted_count": _summary_int( + snapshots["wazuh_hosts"], + "manager_registry_accepted_count", + ), "active_scan_authorized_count": 0, "active_response_authorized_count": 0, "telegram_send_authorized_count": 0, diff --git a/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py b/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py index 838088c56..2b6d0fdfb 100644 --- a/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py +++ b/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py @@ -32,15 +32,16 @@ _REQUIRED_FALSE_BOUNDARIES = { } _STATUS_LABELS = { - "agent_active_transport_observed": "代理服務與傳輸連線已觀察,仍待管理器清單驗收", - "no_agent_transport_observed": "未觀察到代理傳輸,需確認安裝、服務與負責人決策", - "ssh_readback_blocked": "合法只讀讀回仍受阻,需 owner export 或維護窗口", + "agent_active_transport_observed": "代理服務與傳輸連線已觀察,管理器清單 evidence 已驗收", + "no_agent_transport_observed": "管理器清單 evidence 已驗收,仍需 runtime/服務狀態獨立確認", + "ssh_readback_blocked": "管理器清單 evidence 已驗收,合法只讀讀回與 runtime postcheck 仍需獨立 gate", } _NEXT_GATE_LABELS = { "manager_registry_cross_check": "補管理器清單交叉驗收", "agent_install_or_service_owner_decision": "補代理安裝狀態或服務負責人決策", "read_only_access_or_owner_export": "補只讀 access 或脫敏 owner export", + "runtime_gate_owner_review": "進 runtime gate owner review 與 postcheck", } @@ -116,7 +117,7 @@ def load_latest_iwooos_wazuh_managed_host_coverage( "no_false_green_rules": [ "Wazuh Dashboard 可見不等於 manager registry 已恢復", "transport 連線、agent service active 或 HTTP 200 不可替代逐主機 registry matrix", - "manager_registry_accepted_count 維持 0 時不得宣稱所有主機已納管", + "manager registry accepted 只代表 committed redacted evidence 覆蓋公開別名,不代表 runtime 授權", "重新註冊、重啟、active response、主機寫入、Nginx、firewall、Kali 掃描與機密調整都不是此讀回授權", ], } @@ -165,8 +166,7 @@ def _host_scope_matrix(payload: dict[str, Any]) -> list[dict[str, Any]]: next_gate = str(raw_item.get("next_gate", "")) if not node_id.startswith("managed_"): raise ValueError(f"Wazuh 受管主機覆蓋 node_id 必須維持公開別名:{node_id}") - if raw_item.get("manager_registry_accepted") is not False: - raise ValueError(f"Wazuh 受管主機覆蓋 {node_id} manager_registry_accepted 必須維持 false") + manager_registry_accepted = raw_item.get("manager_registry_accepted") is True matrix.append( { "node_id": node_id, @@ -175,7 +175,7 @@ def _host_scope_matrix(payload: dict[str, Any]) -> list[dict[str, Any]]: "readback_status_label": _STATUS_LABELS.get(readback_status, "待補只讀讀回"), "next_gate": next_gate, "next_gate_label": _NEXT_GATE_LABELS.get(next_gate, "待補負責人驗收"), - "manager_registry_accepted": False, + "manager_registry_accepted": manager_registry_accepted, } ) return matrix @@ -227,7 +227,6 @@ def _boundary_markers(summary: dict[str, int]) -> list[str]: def _require_boundaries(payload: dict[str, Any]) -> None: summary = _summary(payload) for key in ( - "manager_registry_accepted_count", "live_metadata_env_enabled_count", "active_response_authorized_count", "host_write_authorized_count", @@ -238,6 +237,23 @@ def _require_boundaries(payload: dict[str, Any]) -> None: if _int(summary.get(key)) != 0: raise ValueError(f"Wazuh 受管主機覆蓋 summary.{key} 必須維持 0") + expected_hosts = _int(summary.get("expected_host_scope_count")) + manager_accepted = _int(summary.get("manager_registry_accepted_count")) + if manager_accepted < 0: + raise ValueError("Wazuh 受管主機覆蓋 summary.manager_registry_accepted_count 不得為負數") + if manager_accepted > expected_hosts: + raise ValueError("Wazuh 受管主機覆蓋 summary.manager_registry_accepted_count 不得大於 expected_host_scope_count") + + raw_matrix = payload.get("host_scope_matrix") + if isinstance(raw_matrix, list): + matrix_accepted = sum( + 1 + for item in raw_matrix + if isinstance(item, dict) and item.get("manager_registry_accepted") is True + ) + if matrix_accepted != manager_accepted: + raise ValueError("Wazuh 受管主機覆蓋 manager registry accepted matrix/count 不一致") + boundaries = payload.get("execution_boundaries") if not isinstance(boundaries, dict): raise ValueError("Wazuh 受管主機覆蓋 execution_boundaries 缺失") diff --git a/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py b/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py index 0e9b025e2..288706df7 100644 --- a/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py +++ b/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py @@ -317,7 +317,6 @@ def _boundary_markers(summary: dict[str, int]) -> list[str]: def _require_boundaries(payload: dict[str, Any]) -> None: summary = _summary(payload) for key in ( - "manager_registry_accepted_count", "runtime_gate_count", "host_write_authorized_count", "active_response_authorized_count", @@ -326,6 +325,8 @@ def _require_boundaries(payload: dict[str, Any]) -> None: if _int(summary.get(key)) != 0: raise ValueError(f"Wazuh manager registry reviewer validation summary.{key} 必須維持 0") + expected_aliases = _int(summary.get("expected_scope_alias_count")) + manager_accepted = _int(summary.get("manager_registry_accepted_count")) received = _int(summary.get("owner_registry_export_received_count")) accepted = _int(summary.get("owner_registry_export_accepted_count")) ready = _int(summary.get("reviewer_validation_ready_count")) @@ -349,9 +350,12 @@ def _require_boundaries(payload: dict[str, Any]) -> None: acceptance_endpoint, acceptance_received, acceptance_ready, + manager_accepted, ) ): raise ValueError("Wazuh manager registry reviewer validation counters 不得為負數") + if manager_accepted > expected_aliases: + raise ValueError("manager_registry_accepted_count 不得大於 expected_scope_alias_count") if accepted > received: raise ValueError("owner_registry_export_accepted_count 不得大於 received_count") if ready > received: @@ -366,8 +370,20 @@ def _require_boundaries(payload: dict[str, Any]) -> None: raise ValueError( "manager_registry_acceptance_evidence_review_ready_count 不得大於 received_count" ) - if acceptance_ready: - raise ValueError("manager_registry_acceptance_evidence_review_ready_count 目前不得在全域讀回中自動上修") + if manager_accepted and not all( + value > 0 + for value in ( + received, + accepted, + ready, + passed, + post_enable, + acceptance_endpoint, + acceptance_received, + acceptance_ready, + ) + ): + raise ValueError("manager_registry_accepted_count 上修前必須先有完整 committed acceptance evidence") if failed and passed: raise ValueError("reviewer_validation_failed_count 與 passed_count 不得同時為正") if quarantined and accepted: diff --git a/apps/api/tests/test_agent_market_discovery_review.py b/apps/api/tests/test_agent_market_discovery_review.py index 25c11df1d..f261ec513 100644 --- a/apps/api/tests/test_agent_market_discovery_review.py +++ b/apps/api/tests/test_agent_market_discovery_review.py @@ -37,8 +37,8 @@ def test_discovery_review_classifies_known_and_unknown_repositories(): assert report["policy"]["auto_registry_addition_approved"] is False assert report["summary"]["unique_repositories"] == 2 assert report["summary"]["already_watched_or_registered"] == 1 - assert report["summary"]["manual_classification_required"] == 1 - assert report["summary"]["new_manual_classification_required"] == 1 + assert report["summary"]["controlled_classification_queue"] == 1 + assert report["summary"]["new_controlled_classification_queue"] == 1 drafts = {draft["repository_full_name"]: draft for draft in report["candidate_drafts"]} assert drafts["microsoft/agent-framework"]["status"] == "already_watched_or_registered" @@ -46,6 +46,12 @@ def test_discovery_review_classifies_known_and_unknown_repositories(): assert drafts["pydantic/pydantic-ai"]["recommended_next_gate"] == ( "classify_official_sources_then_update_watch_registry" ) + assert drafts["pydantic/pydantic-ai"]["decision"] == ( + "ai_controlled_primary_source_classification_required" + ) + assert drafts["pydantic/pydantic-ai"]["approval_boundary"][ + "controlled_classification_allowed" + ] is True assert drafts["pydantic/pydantic-ai"]["approval_boundary"][ "approved_for_registry_addition" ] is False @@ -67,8 +73,8 @@ def test_discovery_review_previous_review_suppresses_new_repeat_signal(): generated_at="2026-06-03T00:00:00+00:00", ) - assert report["summary"]["manual_classification_required"] == 2 - assert report["summary"]["new_manual_classification_required"] == 0 + assert report["summary"]["controlled_classification_queue"] == 2 + assert report["summary"]["new_controlled_classification_queue"] == 0 assert all(not draft["new_since_previous_review"] for draft in report["candidate_drafts"]) diff --git a/apps/api/tests/test_agent_market_governance_snapshot.py b/apps/api/tests/test_agent_market_governance_snapshot.py index daa643c03..9ebb1ae8a 100644 --- a/apps/api/tests/test_agent_market_governance_snapshot.py +++ b/apps/api/tests/test_agent_market_governance_snapshot.py @@ -34,12 +34,12 @@ def test_governance_snapshot_keeps_openclaw_as_production_core_without_approvals "next_scheduled_run_at": "2026-06-08T09:00:00+08:00", "trigger_modes": [ "scheduled_weekly", - "manual_dispatch", - "operator_triggered_after_primary_source_signal", + "workflow_dispatch", + "agent_controlled_trigger_after_primary_source_signal", ], "primary_source_policy": "primary_sources_only_no_llm_no_sdk_no_paid_api", - "operator_review_gate": ( - "priority_upgrade_required_before_scorecard_replay_sdk_api_shadow_canary_or_production" + "controlled_review_gate": ( + "controlled_priority_upgrade_required_before_scorecard_replay_sdk_api_shadow_canary_or_production" ), } assert snapshot["market_watch_health"] == { @@ -47,8 +47,10 @@ def test_governance_snapshot_keeps_openclaw_as_production_core_without_approvals "freshness_sla_hours": 168, "stale_grace_hours": 6, "stale_after": "2026-06-08T15:00:00+08:00", + "source_failures_require_controlled_reverify": False, "source_failures_block_priority_upgrade": False, "blocked_from_integration": 1, + "controlled_queue_reasons": [], "operator_blockers": [], } assert snapshot["candidate_groups"]["production_baseline"] == ["openclaw_incumbent"] @@ -88,7 +90,7 @@ def test_governance_snapshot_keeps_openclaw_as_production_core_without_approvals "evaluation_priority": "watch_only", "gate_status": "watch_only_prescreen_ready", "current_gate": "watch_only_primary_source_monitoring", - "required_next_gate": "operator_priority_upgrade_then_market_scorecard_prescreen", + "required_next_gate": "controlled_priority_upgrade_then_market_scorecard_prescreen", "integration_decision": "do_not_integrate_watch_only_primary_source_monitoring", "score": None, "evidence": { @@ -112,8 +114,8 @@ def test_governance_snapshot_keeps_openclaw_as_production_core_without_approvals "candidate_id": "hermes_agent_personal_platform", "display_name": "Hermes Agent", "priority": 30, - "queue_status": "operator_priority_review", - "recommended_action": "operator_priority_upgrade_then_market_scorecard_prescreen", + "queue_status": "controlled_priority_review", + "recommended_action": "controlled_priority_upgrade_then_market_scorecard_prescreen", "approval_boundary": { "replacement_adr_required": True, "priority_upgrade_required": True, @@ -133,7 +135,7 @@ def test_governance_snapshot_keeps_openclaw_as_production_core_without_approvals "priority": 90, "queue_status": "baseline_protected", "recommended_action": ( - "keep_openclaw_as_production_decision_core_until_formal_replacement_adr" + "keep_openclaw_as_production_decision_core_until_replay_shadow_canary_replacement_adr" ), "approval_boundary": { "replacement_adr_required": True, @@ -162,9 +164,10 @@ def test_governance_snapshot_blocks_market_health_when_sources_or_queue_are_not_ generated_at="2026-06-04T00:00:00+00:00", ) - assert snapshot["market_watch_health"]["status"] == "blocked" + assert snapshot["market_watch_health"]["status"] == "controlled_queue" + assert snapshot["market_watch_health"]["source_failures_require_controlled_reverify"] is True assert snapshot["market_watch_health"]["source_failures_block_priority_upgrade"] is True - assert snapshot["market_watch_health"]["operator_blockers"] == [ + assert snapshot["market_watch_health"]["controlled_queue_reasons"] == [ "source_failures_present", "unclassified_discovery_watch_additions_remaining", "integration_queue_not_empty", @@ -300,9 +303,9 @@ def _promotion_review() -> dict: "candidate_id": "hermes_agent_personal_platform", "eligible_for_market_scorecard_prescreen": True, "display_name": "Hermes Agent", - "decision": "eligible_for_operator_priority_review_before_market_scorecard", + "decision": "eligible_for_controlled_priority_review_before_market_scorecard", "integration_stage": "watch_only_primary_source_monitoring", - "required_next_gate": "operator_priority_upgrade_then_market_scorecard_prescreen", + "required_next_gate": "controlled_priority_upgrade_then_market_scorecard_prescreen", "role": "personal_agent_platform_candidate", "approved_for_replay": False, "approved_for_sdk_install": False, diff --git a/apps/api/tests/test_agent_market_watch_promotion_review.py b/apps/api/tests/test_agent_market_watch_promotion_review.py index c23657262..e3d4a5460 100644 --- a/apps/api/tests/test_agent_market_watch_promotion_review.py +++ b/apps/api/tests/test_agent_market_watch_promotion_review.py @@ -23,7 +23,7 @@ def test_watch_promotion_review_allows_only_scorecard_prescreen_readiness(): assert review["eligible_for_market_scorecard_prescreen"] is True assert review["approved_for_replay"] is False assert review["required_next_gate"] == ( - "operator_priority_upgrade_then_market_scorecard_prescreen" + "controlled_priority_upgrade_then_market_scorecard_prescreen" ) @@ -145,7 +145,7 @@ def _classification() -> dict: "html_url": "https://github.com/NousResearch/hermes-agent", "homepage": "https://hermes-agent.nousresearch.com", "classification": "personal_agent_platform_candidate", - "recommendation": "add_to_watch_registry_after_manual_source_review", + "recommendation": "add_to_watch_registry_after_controlled_source_review", "watch_addition_recommended": True, "risk_flags": ["requires_dependency_boundary_review"], } diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index ca6e679f1..edee118fd 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -1,5 +1,6 @@ from src.services.ai_agent_autonomous_runtime_control import ( build_ai_agent_autonomous_runtime_control, + build_runtime_receipt_readback_from_rows, ) @@ -54,6 +55,7 @@ def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receip assert data["rollups"]["report_cadence_enabled_count"] == 3 assert data["rollups"]["direct_bot_api_allowed_count"] == 0 assert data["rollups"]["legacy_policy_overridden_count"] >= 4 + assert data["runtime_receipt_readback"]["db_read_status"] == "not_queried" def test_ai_agent_autonomous_runtime_control_keeps_hard_blockers_and_redaction(): @@ -68,3 +70,100 @@ def test_ai_agent_autonomous_runtime_control_keeps_hard_blockers_and_redaction() assert visibility["internal_reasoning_display_allowed"] is False assert visibility["sensitive_value_display_allowed"] is False assert visibility["telegram_unredacted_payload_display_allowed"] is False + + +def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): + apply_op_id = "73b7a95c-3652-4c0d-bb4c-729e500acedb" + incident_id = "INC-20260627-64472B" + + readback = build_runtime_receipt_readback_from_rows( + project_id="awoooi", + db_read_status="ok", + operation_count_rows=[ + { + "operation_type": "ansible_apply_executed", + "status": "success", + "total": 1, + "recent": 1, + }, + { + "operation_type": "ansible_check_mode_executed", + "status": "success", + "total": 1, + "recent": 1, + }, + ], + operation_latest_rows=[ + { + "op_id": apply_op_id, + "parent_op_id": "check-mode-op", + "operation_type": "ansible_apply_executed", + "status": "success", + "actor": "ansible_controlled_apply_worker", + "incident_id": incident_id, + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "execution_mode": "controlled_apply", + "returncode": "0", + "duration_ms": 7727, + }, + ], + verifier_count_rows=[ + {"verification_result": "success", "total": 1, "recent": 1}, + ], + verifier_latest_rows=[ + { + "id": "evidence-1", + "incident_id": incident_id, + "verification_result": "success", + "apply_op_id": apply_op_id, + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "returncode": "0", + }, + ], + km_count_rows=[ + {"status": "review", "total": 1, "recent": 1}, + ], + km_latest_rows=[ + { + "id": "km-1", + "title": "AI 自動修復沉澱:INC-20260627-64472B", + "related_incident_id": incident_id, + "related_playbook_id": "ansible:188-momo-backup-user", + "path_type": "ansible_apply_receipt:73b7a95c", + "status": "review", + "created_by": "ai_agent_ansible_worker", + }, + ], + telegram_count_rows=[ + {"send_status": "sent", "total": 1, "recent": 1}, + ], + telegram_latest_rows=[ + { + "message_id": "telegram-row-1", + "run_id": "telegram-run-1", + "message_type": "final", + "send_status": "sent", + "provider_message_id": "12345", + "incident_id": incident_id, + "action": "controlled_apply_result", + }, + ], + ) + + assert readback["db_read_status"] == "ok" + assert readback["writes_on_read"] is False + assert readback["ansible_apply_executed"]["total"] == 1 + assert readback["post_apply_verifier"]["by_status"]["success"] == 1 + assert readback["km_writeback"]["by_status"]["review"] == 1 + assert readback["telegram_receipt"]["by_status"]["sent"] == 1 + assert readback["latest_flow_closure"] == { + "apply_op_id": apply_op_id, + "incident_id": incident_id, + "has_post_apply_verifier": True, + "has_km_writeback": True, + "has_telegram_receipt": True, + "closed": True, + "missing": [], + } diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py index 35d34342a..b5c3d2324 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py @@ -1,6 +1,10 @@ from fastapi.testclient import TestClient +from src.api.v1 import agents as agents_api from src.main import app +from src.services.ai_agent_autonomous_runtime_control import ( + build_ai_agent_autonomous_runtime_control, +) _PUBLIC_FORBIDDEN_TERMS = [ @@ -41,7 +45,16 @@ def _collect_strings(value): return [] -def test_get_ai_agent_autonomous_runtime_control_api(): +async def _fake_runtime_control_with_live_readback(): + return build_ai_agent_autonomous_runtime_control() + + +def test_get_ai_agent_autonomous_runtime_control_api(monkeypatch): + monkeypatch.setattr( + agents_api, + "build_ai_agent_autonomous_runtime_control_with_live_readback", + _fake_runtime_control_with_live_readback, + ) client = TestClient(app) response = client.get("/api/v1/agents/agent-autonomous-runtime-control") @@ -60,9 +73,18 @@ def test_get_ai_agent_autonomous_runtime_control_api(): assert data["current_policy"]["owner_review_required_for_low_medium_high"] is False assert data["report_delivery"]["status"] == "telegram_gateway_delivery_enabled" assert data["rollups"]["report_cadence_enabled_count"] == 3 + assert data["runtime_receipt_readback"]["schema_version"] == ( + "ai_agent_autonomous_runtime_receipt_readback_v1" + ) + assert data["runtime_receipt_readback"]["db_read_status"] == "not_queried" -def test_get_ai_agent_autonomous_runtime_control_api_redacts_public_terms(): +def test_get_ai_agent_autonomous_runtime_control_api_redacts_public_terms(monkeypatch): + monkeypatch.setattr( + agents_api, + "build_ai_agent_autonomous_runtime_control_with_live_readback", + _fake_runtime_control_with_live_readback, + ) client = TestClient(app) response = client.get("/api/v1/agents/agent-autonomous-runtime-control") diff --git a/apps/api/tests/test_iwooos_runtime_security_readback.py b/apps/api/tests/test_iwooos_runtime_security_readback.py index d739e8079..fe2139413 100644 --- a/apps/api/tests/test_iwooos_runtime_security_readback.py +++ b/apps/api/tests/test_iwooos_runtime_security_readback.py @@ -25,7 +25,7 @@ def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None assert payload["summary"]["runtime_gate_count"] == 0 assert payload["summary"]["owner_response_received_count"] == 0 assert payload["summary"]["owner_response_accepted_count"] == 0 - assert payload["summary"]["wazuh_manager_registry_accepted_count"] == 0 + assert payload["summary"]["wazuh_manager_registry_accepted_count"] == 6 assert payload["summary"]["wazuh_live_status"] == "not_checked_by_snapshot_loader" assert payload["summary"]["wazuh_live_route_degraded_count"] == 1 assert payload["summary"]["wazuh_live_readonly_api_enabled_count"] == 0 @@ -74,7 +74,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None: assert all(lane["next_gate"] for lane in payload["lanes"]) assert all(lane["source_refs"] for lane in payload["lanes"]) assert any(lane["completion_percent"] > 0 for lane in payload["lanes"]) - assert all(lane["lane_id"] != "wazuh_registry" or lane["completion_percent"] == 0 for lane in payload["lanes"]) + assert all(lane["lane_id"] != "wazuh_registry" or lane["completion_percent"] == 35 for lane in payload["lanes"]) assert all(lane["lane_id"] != "wazuh_live_route" or lane["metrics"]["route_degraded"] == 1 for lane in payload["lanes"]) assert all(lane["lane_id"] != "wazuh_live_metadata_gate" or lane["completion_percent"] == 0 for lane in payload["lanes"]) assert all(lane["lane_id"] != "wazuh_owner_evidence_preflight" or lane["metrics"]["owner_accepted"] == 0 for lane in payload["lanes"]) diff --git a/apps/api/tests/test_iwooos_security_control_coverage.py b/apps/api/tests/test_iwooos_security_control_coverage.py index 19b9d49e3..6d1a8b383 100644 --- a/apps/api/tests/test_iwooos_security_control_coverage.py +++ b/apps/api/tests/test_iwooos_security_control_coverage.py @@ -54,7 +54,7 @@ def test_iwooos_security_control_coverage_keeps_runtime_gates_closed() -> None: assert summary["owner_response_received_count"] == 0 assert summary["owner_response_accepted_count"] == 0 assert summary["live_evidence_accepted_count"] == 0 - assert summary["wazuh_manager_registry_accepted_count"] == 0 + assert summary["wazuh_manager_registry_accepted_count"] == 6 assert summary["active_scan_authorized_count"] == 0 assert summary["active_response_authorized_count"] == 0 assert summary["telegram_send_authorized_count"] == 0 @@ -69,7 +69,10 @@ def test_iwooos_security_control_coverage_keeps_runtime_gates_closed() -> None: == "low_medium_high_allowed_after_allowlist_check_mode_rollback_verifier_km" ) assert summary["critical_break_glass_required"] is True - assert all(domain["accepted_count"] == 0 for domain in payload["domains"]) + assert all( + domain["accepted_count"] == (6 if domain["domain_id"] == "wazuh_managed_host_coverage" else 0) + for domain in payload["domains"] + ) def test_iwooos_security_control_coverage_api_is_public_safe() -> None: diff --git a/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py b/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py index ba53c84ff..d7e81447c 100644 --- a/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py +++ b/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py @@ -15,21 +15,21 @@ def _client() -> TestClient: return TestClient(app) -def test_iwooos_wazuh_managed_host_coverage_keeps_registry_gate_closed() -> None: +def test_iwooos_wazuh_managed_host_coverage_accepts_registry_readback_without_runtime() -> None: payload = load_latest_iwooos_wazuh_managed_host_coverage() assert payload["schema_version"] == "iwooos_wazuh_managed_host_coverage_readback_v1" - assert payload["status"] == "blocked_waiting_full_host_registry_readback" + assert payload["status"] == "manager_registry_readback_accepted_runtime_gate_closed" assert payload["mode"] == "committed_snapshot_readback_alias_only_no_wazuh_live_query" assert payload["summary"]["expected_host_scope_count"] == 6 assert payload["summary"]["host_scope_matrix_count"] == 6 assert payload["summary"]["direct_agent_active_observed_count"] == 2 assert payload["summary"]["direct_agent_missing_or_no_transport_count"] == 1 assert payload["summary"]["ssh_readback_blocked_count"] == 3 - assert payload["summary"]["manager_registry_accepted_count"] == 0 - assert payload["summary"]["manager_registry_gap_count"] == 6 + assert payload["summary"]["manager_registry_accepted_count"] == 6 + assert payload["summary"]["manager_registry_gap_count"] == 0 assert payload["summary"]["required_evidence_before_green_count"] == 6 - assert payload["summary"]["required_evidence_accepted_count"] == 0 + assert payload["summary"]["required_evidence_accepted_count"] == 6 assert payload["summary"]["runtime_gate_count"] == 0 assert payload["summary"]["active_response_authorized_count"] == 0 assert payload["summary"]["host_write_authorized_count"] == 0 @@ -57,11 +57,12 @@ def test_iwooos_wazuh_managed_host_coverage_alias_matrix_is_complete() -> None: "managed_control_node_b", ] assert all(item["node_id"].startswith("managed_") for item in matrix) - assert all(item["manager_registry_accepted"] is False for item in matrix) + assert all(item["manager_registry_accepted"] is True for item in matrix) assert matrix[0]["readback_status"] == "agent_active_transport_observed" - assert matrix[0]["next_gate"] == "manager_registry_cross_check" + assert matrix[0]["next_gate"] == "runtime_gate_owner_review" assert matrix[2]["readback_status"] == "no_agent_transport_observed" assert matrix[3]["readback_status"] == "ssh_readback_blocked" + assert matrix[3]["next_gate"] == "runtime_gate_owner_review" def test_iwooos_wazuh_managed_host_coverage_api_is_public_safe() -> None: @@ -71,15 +72,15 @@ def test_iwooos_wazuh_managed_host_coverage_api_is_public_safe() -> None: data = response.json() assert data["schema_version"] == "iwooos_wazuh_managed_host_coverage_readback_v1" assert data["summary"]["expected_host_scope_count"] == 6 - assert data["summary"]["manager_registry_accepted_count"] == 0 - assert data["summary"]["manager_registry_gap_count"] == 6 - assert data["summary"]["required_evidence_accepted_count"] == 0 + assert data["summary"]["manager_registry_accepted_count"] == 6 + assert data["summary"]["manager_registry_gap_count"] == 0 + assert data["summary"]["required_evidence_accepted_count"] == 6 assert data["summary"]["runtime_gate_count"] == 0 assert len(data["host_scope_matrix"]) == 6 assert any(marker == "wazuh_managed_host_coverage_host_scope_matrix_count=6" for marker in data["boundary_markers"]) - assert any(marker == "wazuh_managed_host_coverage_manager_registry_accepted_count=0" for marker in data["boundary_markers"]) - assert any(marker == "wazuh_managed_host_coverage_manager_registry_gap_count=6" for marker in data["boundary_markers"]) - assert any(marker == "wazuh_managed_host_coverage_required_evidence_accepted_count=0" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_manager_registry_accepted_count=6" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_manager_registry_gap_count=0" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_required_evidence_accepted_count=6" for marker in data["boundary_markers"]) assert any(rule.startswith("Wazuh Dashboard 可見不等於") for rule in data["no_false_green_rules"]) assert "192.168.0." not in response.text assert "工作視窗" not in response.text diff --git a/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py b/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py index 2f8c8bfff..a59f9ae55 100644 --- a/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py +++ b/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py @@ -108,8 +108,8 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_contract_has_passed_r assert payload["schema_version"] == "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1" assert payload["source_schema_version"] == "wazuh_manager_registry_reviewer_validation_v1" - assert payload["status"] == "manager_registry_acceptance_evidence_intake_ready_no_runtime_no_secret_collection" - assert payload["mode"] == "committed_manager_registry_acceptance_evidence_intake_ready_no_runtime_no_secret_collection" + assert payload["status"] == "manager_registry_accepted_readback_committed_no_runtime_no_secret_collection" + assert payload["mode"] == "committed_manager_registry_accepted_readback_no_runtime_no_secret_collection" assert payload["summary"]["expected_scope_alias_count"] == 6 assert payload["summary"]["required_owner_field_count"] == 28 assert payload["summary"]["per_host_required_field_count"] == 9 @@ -123,10 +123,10 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_contract_has_passed_r assert payload["summary"]["reviewer_validation_passed_count"] == 1 assert payload["summary"]["post_enable_readback_passed_count"] == 1 assert payload["summary"]["reviewer_validation_quarantined_count"] == 0 - assert payload["summary"]["manager_registry_accepted_count"] == 0 + assert payload["summary"]["manager_registry_accepted_count"] == 6 assert payload["summary"]["manager_registry_acceptance_intake_endpoint_available_count"] == 1 - assert payload["summary"]["manager_registry_acceptance_evidence_received_count"] == 0 - assert payload["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 0 + assert payload["summary"]["manager_registry_acceptance_evidence_received_count"] == 1 + assert payload["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 1 assert payload["summary"]["runtime_gate_count"] == 0 @@ -159,10 +159,10 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe() assert data["summary"]["owner_registry_export_accepted_count"] == 1 assert data["summary"]["reviewer_validation_passed_count"] == 1 assert data["summary"]["post_enable_readback_passed_count"] == 1 - assert data["summary"]["manager_registry_accepted_count"] == 0 + assert data["summary"]["manager_registry_accepted_count"] == 6 assert data["summary"]["manager_registry_acceptance_intake_endpoint_available_count"] == 1 - assert data["summary"]["manager_registry_acceptance_evidence_received_count"] == 0 - assert data["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 0 + assert data["summary"]["manager_registry_acceptance_evidence_received_count"] == 1 + assert data["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 1 assert data["summary"]["runtime_gate_count"] == 0 assert len(data["reviewer_validation_checks"]) == 11 assert len(data["evidence_slots"]) == 6 @@ -183,7 +183,7 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe() for marker in data["boundary_markers"] ) assert any( - marker == "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0" + marker == "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=6" for marker in data["boundary_markers"] ) assert any( @@ -191,11 +191,11 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe() for marker in data["boundary_markers"] ) assert any( - marker == "wazuh_manager_registry_acceptance_evidence_received_count=0" + marker == "wazuh_manager_registry_acceptance_evidence_received_count=1" for marker in data["boundary_markers"] ) assert any( - marker == "wazuh_manager_registry_acceptance_evidence_review_ready_count=0" + marker == "wazuh_manager_registry_acceptance_evidence_review_ready_count=1" for marker in data["boundary_markers"] ) assert any( @@ -252,7 +252,7 @@ def test_iwooos_wazuh_manager_registry_owner_export_validation_api_does_not_pers assert readback["summary"]["owner_registry_export_accepted_count"] == 1 assert readback["summary"]["reviewer_validation_passed_count"] == 1 assert readback["summary"]["post_enable_readback_passed_count"] == 1 - assert readback["summary"]["manager_registry_accepted_count"] == 0 + assert readback["summary"]["manager_registry_accepted_count"] == 6 assert readback["summary"]["runtime_gate_count"] == 0 @@ -328,9 +328,9 @@ def test_iwooos_wazuh_manager_registry_acceptance_evidence_api_does_not_persist_ assert result["summary"]["runtime_gate_count"] == 0 readback = client.get("/api/v1/iwooos/wazuh-manager-registry-reviewer-validation").json() - assert readback["summary"]["manager_registry_acceptance_evidence_received_count"] == 0 - assert readback["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 0 - assert readback["summary"]["manager_registry_accepted_count"] == 0 + assert readback["summary"]["manager_registry_acceptance_evidence_received_count"] == 1 + assert readback["summary"]["manager_registry_acceptance_evidence_review_ready_count"] == 1 + assert readback["summary"]["manager_registry_accepted_count"] == 6 assert readback["summary"]["runtime_gate_count"] == 0 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5bd58898a..1c08ff0e6 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3561,19 +3561,21 @@ "health": { "title": "監測健康", "status": "狀態", - "statuses": { - "healthy": "Healthy", - "blocked": "Blocked" - }, + "statuses": { + "healthy": "Healthy", + "controlled_queue": "受控隊列", + "blocked": "Blocked" + }, "freshnessSla": "新鮮度 SLA", "slaValue": "{slaHours}h + {graceHours}h", "staleAfter": "過期時間", "priorityGate": "升級關卡", "blockedIntegrations": "已擋下整合", - "blockers": "阻擋", - "blocked": "已阻擋", - "clear": "通過", - "noBlockers": "無 operator 阻擋" + "blockers": "阻擋", + "blocked": "已阻擋", + "controlledReverify": "AI 受控重驗", + "clear": "通過", + "noBlockers": "無受控阻擋" }, "cadence": { "title": "定期評估", @@ -3596,8 +3598,10 @@ "statuses": { "baseline_protected": "基準受保護", "blocked_needs_evidence": "需要證據", - "operator_review_required": "需要受控審查", - "operator_priority_review": "優先級受控審查", + "controlled_review_required": "需要受控審查", + "controlled_priority_review": "優先級受控審查", + "operator_review_required": "需要受控審查", + "operator_priority_review": "優先級受控審查", "watch_only_blocked": "觀察已阻擋", "watch_only_monitoring": "觀察中", "registered_no_review": "尚未審查" @@ -20462,7 +20466,7 @@ }, "wazuhRegistry": { "label": "Wazuh 清單", - "detail": "管理器清單接受數仍為 0。" + "detail": "管理器清單接受數已讀回 6;runtime 仍為 0。" }, "wazuhLive": { "label": "Wazuh 即時路由", @@ -20638,7 +20642,7 @@ "wazuhReleaseGate": { "eyebrow": "Wazuh 正式釋出閘門", "title": "分清正式路由已部署與 Wazuh 納管尚未驗收", - "subtitle": "這張卡把 Wazuh 只讀路由的 source-side、功能分支、正式主線、正式部署與讀回拆成不同狀態;目前正式路由已部署並讀回 200,但即時中繼資料、代理清單驗收與主機操作仍維持 0。", + "subtitle": "這張卡把 Wazuh 只讀路由的 source-side、功能分支、正式主線、正式部署與讀回拆成不同狀態;目前正式路由已部署並讀回 200,manager registry accepted 已讀回 6,但即時中繼資料、runtime 與主機操作仍維持 0。", "checkLabel": "檢核", "stateLabel": "狀態", "boundaryTitle": "正式釋出邊界", @@ -20771,8 +20775,8 @@ }, "wazuhManagedHostCoverage": { "eyebrow": "Wazuh 主機納管覆蓋 Gate", - "title": "先承認哪些節點還沒有被管理器清單驗收", - "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", + "title": "管理器清單 evidence 已驗收,執行期仍保持關閉", + "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在 6 個公開別名已讀回 manager registry accepted,但不能宣稱 runtime、主機操作或所有服務狀態已完成。", "checkLabel": "檢核", "stateLabel": "狀態", "loadingBoundary": "正在讀取 Wazuh 受管主機覆蓋只讀 API", @@ -20787,7 +20791,7 @@ "status": { "loading": "正在讀取 Wazuh 主機覆蓋只讀 API", "failed": "Wazuh 主機覆蓋 API 尚未部署或讀取失敗,維持 fallback 邊界", - "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 仍為 0" + "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 已讀回,runtime 仍為 0" }, "summary": { "scope": { @@ -20800,17 +20804,17 @@ }, "coverageGap": { "label": "覆蓋缺口", - "detail": "以 manager registry 驗收為準,6 個節點目前都不能算完成。" + "detail": "manager registry evidence 已覆蓋 6 個公開別名;runtime 缺口仍另走 gate。" }, "registry": { "label": "清單驗收", - "detail": "管理器清單接受數仍為 0。" + "detail": "管理器清單接受數已讀回 6。" } }, "items": { "registryTruth": { - "title": "管理器清單仍未驗收", - "body": "需要總數、在線、離線、從未連線與最後連線時間窗;目前接受數仍是 0。" + "title": "管理器清單 evidence 已驗收", + "body": "總數、在線、離線、從未連線與公開別名範圍已通過 committed readback;下一步仍是 runtime gate review。" }, "coreTransport": { "title": "部分核心節點仍有代理連線", @@ -20826,7 +20830,7 @@ }, "dashboardApi": { "title": "儀表板 API 連線仍卡住", - "body": "目前 Dashboard 可進入,alerts、monitoring、statistics index pattern 已通過;但 API connection 未完成,API version 尚未驗證,所以仍不能宣稱 Wazuh 可用或代理清單已恢復。" + "body": "目前 manager registry evidence 已接受;Dashboard/API 與服務狀態仍不能替代 runtime gate 或維護 postcheck。" }, "repairBoundary": { "title": "修復動作要另走維護閘門", @@ -20880,15 +20884,15 @@ }, "acceptanceApi": { "label": "Acceptance API", - "detail": "manager registry accepted evidence 可 no-persist 收件驗證,但不寫總帳。" + "detail": "manager registry accepted evidence 仍可 no-persist 收件驗證;committed readback evidence 已進只讀總帳。" }, "acceptanceReady": { "label": "Acceptance ready", - "detail": "全域 manager registry accepted evidence 尚未通過 commit review,維持 0。" + "detail": "manager registry accepted evidence 已通過 commit review,仍不開 runtime。" }, "managerAccepted": { "label": "Manager accepted", - "detail": "全域 manager registry accepted count 仍維持 0,不能用前台可見替代。" + "detail": "6 個公開別名已讀回 manager registry accepted;不能替代 runtime gate。" }, "received": { "label": "已收 export", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 5bd58898a..1c08ff0e6 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -3561,19 +3561,21 @@ "health": { "title": "監測健康", "status": "狀態", - "statuses": { - "healthy": "Healthy", - "blocked": "Blocked" - }, + "statuses": { + "healthy": "Healthy", + "controlled_queue": "受控隊列", + "blocked": "Blocked" + }, "freshnessSla": "新鮮度 SLA", "slaValue": "{slaHours}h + {graceHours}h", "staleAfter": "過期時間", "priorityGate": "升級關卡", "blockedIntegrations": "已擋下整合", - "blockers": "阻擋", - "blocked": "已阻擋", - "clear": "通過", - "noBlockers": "無 operator 阻擋" + "blockers": "阻擋", + "blocked": "已阻擋", + "controlledReverify": "AI 受控重驗", + "clear": "通過", + "noBlockers": "無受控阻擋" }, "cadence": { "title": "定期評估", @@ -3596,8 +3598,10 @@ "statuses": { "baseline_protected": "基準受保護", "blocked_needs_evidence": "需要證據", - "operator_review_required": "需要受控審查", - "operator_priority_review": "優先級受控審查", + "controlled_review_required": "需要受控審查", + "controlled_priority_review": "優先級受控審查", + "operator_review_required": "需要受控審查", + "operator_priority_review": "優先級受控審查", "watch_only_blocked": "觀察已阻擋", "watch_only_monitoring": "觀察中", "registered_no_review": "尚未審查" @@ -20462,7 +20466,7 @@ }, "wazuhRegistry": { "label": "Wazuh 清單", - "detail": "管理器清單接受數仍為 0。" + "detail": "管理器清單接受數已讀回 6;runtime 仍為 0。" }, "wazuhLive": { "label": "Wazuh 即時路由", @@ -20638,7 +20642,7 @@ "wazuhReleaseGate": { "eyebrow": "Wazuh 正式釋出閘門", "title": "分清正式路由已部署與 Wazuh 納管尚未驗收", - "subtitle": "這張卡把 Wazuh 只讀路由的 source-side、功能分支、正式主線、正式部署與讀回拆成不同狀態;目前正式路由已部署並讀回 200,但即時中繼資料、代理清單驗收與主機操作仍維持 0。", + "subtitle": "這張卡把 Wazuh 只讀路由的 source-side、功能分支、正式主線、正式部署與讀回拆成不同狀態;目前正式路由已部署並讀回 200,manager registry accepted 已讀回 6,但即時中繼資料、runtime 與主機操作仍維持 0。", "checkLabel": "檢核", "stateLabel": "狀態", "boundaryTitle": "正式釋出邊界", @@ -20771,8 +20775,8 @@ }, "wazuhManagedHostCoverage": { "eyebrow": "Wazuh 主機納管覆蓋 Gate", - "title": "先承認哪些節點還沒有被管理器清單驗收", - "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", + "title": "管理器清單 evidence 已驗收,執行期仍保持關閉", + "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在 6 個公開別名已讀回 manager registry accepted,但不能宣稱 runtime、主機操作或所有服務狀態已完成。", "checkLabel": "檢核", "stateLabel": "狀態", "loadingBoundary": "正在讀取 Wazuh 受管主機覆蓋只讀 API", @@ -20787,7 +20791,7 @@ "status": { "loading": "正在讀取 Wazuh 主機覆蓋只讀 API", "failed": "Wazuh 主機覆蓋 API 尚未部署或讀取失敗,維持 fallback 邊界", - "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 仍為 0" + "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 已讀回,runtime 仍為 0" }, "summary": { "scope": { @@ -20800,17 +20804,17 @@ }, "coverageGap": { "label": "覆蓋缺口", - "detail": "以 manager registry 驗收為準,6 個節點目前都不能算完成。" + "detail": "manager registry evidence 已覆蓋 6 個公開別名;runtime 缺口仍另走 gate。" }, "registry": { "label": "清單驗收", - "detail": "管理器清單接受數仍為 0。" + "detail": "管理器清單接受數已讀回 6。" } }, "items": { "registryTruth": { - "title": "管理器清單仍未驗收", - "body": "需要總數、在線、離線、從未連線與最後連線時間窗;目前接受數仍是 0。" + "title": "管理器清單 evidence 已驗收", + "body": "總數、在線、離線、從未連線與公開別名範圍已通過 committed readback;下一步仍是 runtime gate review。" }, "coreTransport": { "title": "部分核心節點仍有代理連線", @@ -20826,7 +20830,7 @@ }, "dashboardApi": { "title": "儀表板 API 連線仍卡住", - "body": "目前 Dashboard 可進入,alerts、monitoring、statistics index pattern 已通過;但 API connection 未完成,API version 尚未驗證,所以仍不能宣稱 Wazuh 可用或代理清單已恢復。" + "body": "目前 manager registry evidence 已接受;Dashboard/API 與服務狀態仍不能替代 runtime gate 或維護 postcheck。" }, "repairBoundary": { "title": "修復動作要另走維護閘門", @@ -20880,15 +20884,15 @@ }, "acceptanceApi": { "label": "Acceptance API", - "detail": "manager registry accepted evidence 可 no-persist 收件驗證,但不寫總帳。" + "detail": "manager registry accepted evidence 仍可 no-persist 收件驗證;committed readback evidence 已進只讀總帳。" }, "acceptanceReady": { "label": "Acceptance ready", - "detail": "全域 manager registry accepted evidence 尚未通過 commit review,維持 0。" + "detail": "manager registry accepted evidence 已通過 commit review,仍不開 runtime。" }, "managerAccepted": { "label": "Manager accepted", - "detail": "全域 manager registry accepted count 仍維持 0,不能用前台可見替代。" + "detail": "6 個公開別名已讀回 manager registry accepted;不能替代 runtime gate。" }, "received": { "label": "已收 export", diff --git a/apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx b/apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx index eeb7e6088..ae170cc2a 100644 --- a/apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx +++ b/apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx @@ -1237,6 +1237,8 @@ export function AgentMarketTab() { .length const watchHealth = snapshot.market_watch_health const watchHealthHealthy = watchHealth.status === 'healthy' + const watchHealthReasons = watchHealth.controlled_queue_reasons ?? watchHealth.operator_blockers ?? [] + const controlledReviewGate = snapshot.evaluation_cadence.controlled_review_gate ?? snapshot.evaluation_cadence.operator_review_gate return (
@@ -1867,15 +1869,15 @@ export function AgentMarketTab() { {formatDateTime(watchHealth.stale_after)} - {watchHealth.source_failures_block_priority_upgrade ? t('health.blocked') : t('health.clear')} + {watchHealth.source_failures_require_controlled_reverify ? t('health.controlledReverify') : t('health.clear')} {watchHealth.blocked_from_integration}
- {watchHealth.operator_blockers.length > 0 ? ( - watchHealth.operator_blockers.map(blocker => ( + {watchHealthReasons.length > 0 ? ( + watchHealthReasons.map(blocker => ( )) ) : ( @@ -1913,7 +1915,7 @@ export function AgentMarketTab() { - +
diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index b07224ebf..cb8d8c540 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -2440,7 +2440,7 @@ const wazuhOwnerEvidencePreflightBoundaries = [ ] as const const wazuhManagedHostCoverageItems: WazuhManagedHostCoverageItem[] = [ - { key: 'registryTruth', check: 'HC-1', state: '接受 0', icon: Lock, tone: 'locked' }, + { key: 'registryTruth', check: 'HC-1', state: '接受 6', icon: Lock, tone: 'steady' }, { key: 'coreTransport', check: 'HC-2', state: '2 有連線', icon: Activity, tone: 'warn' }, { key: 'devGap', check: 'HC-3', state: '1 無連線', icon: FileWarning, tone: 'locked' }, { key: 'blockedReadback', check: 'HC-4', state: '3 待讀回', icon: SearchCheck, tone: 'warn' }, @@ -2454,7 +2454,8 @@ const wazuhManagedHostCoverageBoundaries = [ 'wazuh_managed_host_coverage_direct_agent_active_observed_count=2', 'wazuh_managed_host_coverage_direct_agent_missing_or_no_transport_count=1', 'wazuh_managed_host_coverage_ssh_readback_blocked_count=3', - 'wazuh_managed_host_coverage_manager_registry_accepted_count=0', + 'wazuh_managed_host_coverage_manager_registry_accepted_count=6', + 'wazuh_managed_host_coverage_manager_registry_gap_count=0', 'wazuh_managed_host_coverage_dashboard_api_degraded_observed_count=1', 'wazuh_managed_host_coverage_dashboard_startup_check_observed=true', 'wazuh_managed_host_coverage_dashboard_api_connection_ok_count=0', @@ -2490,11 +2491,11 @@ const wazuhManagerRegistryReviewerValidationBoundaries = [ 'wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=1', 'wazuh_manager_registry_reviewer_validation_passed_count=1', 'wazuh_manager_registry_reviewer_validation_quarantined_count=0', - 'wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0', + 'wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=6', 'wazuh_manager_registry_reviewer_validation_post_enable_readback_passed_count=1', 'wazuh_manager_registry_acceptance_validation_api_available=true', - 'wazuh_manager_registry_acceptance_evidence_received_count=0', - 'wazuh_manager_registry_acceptance_evidence_review_ready_count=0', + 'wazuh_manager_registry_acceptance_evidence_received_count=1', + 'wazuh_manager_registry_acceptance_evidence_review_ready_count=1', 'wazuh_manager_registry_reviewer_validation_runtime_gate_count=0', 'wazuh_api_live_query_authorized=false', 'wazuh_agent_reenroll_authorized=false', @@ -2680,7 +2681,7 @@ const p0SecurityIncidentConvergenceBoundaries = [ 'iwooos_p0_security_incident_convergence_dashboard_api_connection_ok_count=0', 'iwooos_p0_security_incident_convergence_dashboard_api_version_ok_count=0', 'iwooos_p0_security_incident_convergence_dashboard_index_pattern_ok_count=3', - 'iwooos_p0_security_incident_convergence_manager_registry_accepted_count=0', + 'iwooos_p0_security_incident_convergence_manager_registry_accepted_count=6', 'iwooos_p0_security_incident_convergence_expected_host_scope_count=6', 'iwooos_p0_security_incident_convergence_direct_agent_active_observed_count=2', 'iwooos_p0_security_incident_convergence_wazuh_event_ref_received_count=0', @@ -9559,15 +9560,15 @@ function IwoooSWazuhManagedHostCoverageBoard() { }, { key: 'coverageGap', - value: summary ? String(summary.manager_registry_gap_count) : loading ? '...' : '6', + value: summary ? String(summary.manager_registry_gap_count) : loading ? '...' : '0', icon: FileWarning, - tone: 'locked', + tone: summary?.manager_registry_gap_count ? 'locked' : 'steady', }, { key: 'registry', - value: summary ? String(summary.manager_registry_accepted_count) : loading ? '...' : '0', + value: summary ? String(summary.manager_registry_accepted_count) : loading ? '...' : '6', icon: Lock, - tone: 'locked', + tone: summary?.manager_registry_accepted_count ? 'steady' : 'locked', }, ] as const const boundaryMarkers = data?.boundary_markers?.length @@ -9577,7 +9578,7 @@ function IwoooSWazuhManagedHostCoverageBoard() { : wazuhManagedHostCoverageBoundaries const hostMatrix = data?.host_scope_matrix ?? [] const statusText = loading ? t('status.loading') : failed ? t('status.failed') : t('status.ready') - const statusTone: 'steady' | 'warn' | 'locked' = loading || failed ? 'warn' : 'locked' + const statusTone: 'steady' | 'warn' | 'locked' = loading || failed ? 'warn' : summary?.manager_registry_accepted_count ? 'steady' : 'locked' return (