From ce0c7cbaf887bb5d353a2a507b56a32eda988fe8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 19:31:02 +0800 Subject: [PATCH 1/2] feat(agents): expose autonomous runtime control --- apps/api/src/api/v1/agents.py | 26 ++ apps/api/src/main.py | 21 +- .../ai_agent_autonomous_runtime_control.py | 273 ++++++++++++++++++ .../src/services/report_generation_service.py | 138 ++++++++- ...est_ai_agent_autonomous_runtime_control.py | 63 ++++ ...ai_agent_autonomous_runtime_control_api.py | 66 +++++ .../tests/test_report_generation_service.py | 19 +- .../tests/test_weekly_report_preview_api.py | 8 +- apps/web/messages/en.json | 61 +++- apps/web/messages/zh-TW.json | 61 +++- .../tabs/automation-inventory-tab.tsx | 166 +++++++++++ apps/web/src/lib/api-client.ts | 90 ++++++ docs/LOGBOOK.md | 63 ++++ 13 files changed, 1017 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/services/ai_agent_autonomous_runtime_control.py create mode 100644 apps/api/tests/test_ai_agent_autonomous_runtime_control.py create mode 100644 apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index e0ff8bd5..755e99f4 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -67,6 +67,9 @@ from src.services.ai_agent_automation_backlog_snapshot import ( 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, +) from src.services.ai_agent_candidate_operation_dry_run_evidence import ( load_latest_ai_agent_candidate_operation_dry_run_evidence, ) @@ -822,6 +825,29 @@ async def get_automation_inventory_snapshot() -> dict[str, Any]: ) from exc +@router.get( + "/agent-autonomous-runtime-control", + response_model=dict[str, Any], + summary="取得 AI Agent 目前有效自主化控制層", + description=( + "回傳目前有效的 AI Agent 自主化控制層;此端點明確覆寫舊 no-send / no-live " + "歷史快照,宣告 low / medium / high 風險可在 allowlist、Ansible check-mode、" + "controlled apply、post-apply verifier、KM 與 Telegram Gateway receipt 下受控自動處理。" + "它不讀 secret、不呼叫 Bot API、不暴露 chat id、不執行 runtime 動作;runtime 動作由既有 worker / Gateway 接手。" + ), +) +async def get_agent_autonomous_runtime_control() -> dict[str, Any]: + """回傳目前有效 AI Agent 自主化控制層。""" + try: + return await asyncio.to_thread(build_ai_agent_autonomous_runtime_control) + except ValueError as exc: + logger.error("ai_agent_autonomous_runtime_control_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI Agent 目前有效自主化控制層無效", + ) from exc + + @router.get( "/automation-backlog-snapshot", response_model=dict[str, Any], diff --git a/apps/api/src/main.py b/apps/api/src/main.py index ed8548cb..047c5c1e 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -522,14 +522,25 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception as e: logger.warning("capacity_forecaster_loop_schedule_failed", error=str(e)) - # ADR-076 Task 4: 每日 08:00 台北時間自動日度巡檢報告 - # 2026-04-14 Claude Haiku 4.5 Asia/Taipei + # ADR-076 / P2-416: 日報 08:00、週報週五 10:00、月報每月 1 日 09:00 + # 透過既有 Telegram Gateway 送 SRE 群組;不暴露 Bot token / chat id。 try: - from src.services.report_generation_service import run_daily_report_loop + from src.services.report_generation_service import ( + run_daily_report_loop, + run_monthly_report_loop, + run_weekly_report_loop, + ) asyncio.create_task(run_daily_report_loop()) - logger.info("daily_report_loop_scheduled", trigger_hour_taipei=8) + asyncio.create_task(run_weekly_report_loop()) + asyncio.create_task(run_monthly_report_loop()) + logger.info( + "report_delivery_loops_scheduled", + daily_hour_taipei=8, + weekly="friday_10_taipei", + monthly="day1_09_taipei", + ) except Exception as e: - logger.warning("daily_report_loop_schedule_failed", error=str(e)) + logger.warning("report_delivery_loops_schedule_failed", error=str(e)) # ADR-073 P2 修復 2026-04-15: 逾期 Approval 自動結案(每小時) # 確保 PENDING approval 超過 48h 後觸發 resolve_incident → KM 學習鏈閉環 diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py new file mode 100644 index 00000000..504e68be --- /dev/null +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -0,0 +1,273 @@ +"""Current AI Agent autonomous runtime control plane. + +This read model is the current directive layer. Historical P2 snapshots can +still describe earlier no-send / no-live states, but this payload states what +the product should enforce now: low, medium, and high risk routes may proceed +through controlled automation when allowlist, check-mode, verifier, rollback, +KM, and Telegram receipts are present. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from src.core.config import settings +from src.services.report_generation_service import ( + DAILY_REPORT_HOUR_TAIPEI, + MONTHLY_REPORT_DAY_TAIPEI, + MONTHLY_REPORT_HOUR_TAIPEI, + WEEKLY_REPORT_HOUR_TAIPEI, + WEEKLY_REPORT_WEEKDAY_TAIPEI, +) + +_SCHEMA_VERSION = "ai_agent_autonomous_runtime_control_v1" +_RUNTIME_AUTHORITY = "current_owner_directive_controlled_ai_automation" + + +def _allowed_risk_levels() -> list[str]: + raw = str(settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS or "") + return sorted({item.strip().lower() for item in raw.split(",") if item.strip()}) + + +def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: + """Build the current AI Agent autonomy control-plane readback.""" + + allowed_risks = _allowed_risk_levels() + report_cadences = [ + { + "cadence": "daily", + "display_name": "日報", + "schedule": f"每日 {DAILY_REPORT_HOUR_TAIPEI:02d}:00 台北時間", + "worker": "report_generation_service.run_daily_report_loop", + "telegram_gateway_delivery_enabled": True, + "direct_bot_api_allowed": False, + "receipt_source": "daily_report_sent log + Telegram Gateway result", + }, + { + "cadence": "weekly", + "display_name": "週報", + "schedule": ( + f"每週五 {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間" + if WEEKLY_REPORT_WEEKDAY_TAIPEI == 4 + else f"每週 weekday={WEEKLY_REPORT_WEEKDAY_TAIPEI} {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間" + ), + "worker": "report_generation_service.run_weekly_report_loop", + "telegram_gateway_delivery_enabled": True, + "direct_bot_api_allowed": False, + "receipt_source": "weekly_report_sent log + Telegram Gateway result", + }, + { + "cadence": "monthly", + "display_name": "月報", + "schedule": f"每月 {MONTHLY_REPORT_DAY_TAIPEI} 日 {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間", + "worker": "report_generation_service.run_monthly_report_loop", + "telegram_gateway_delivery_enabled": True, + "direct_bot_api_allowed": False, + "receipt_source": "monthly_report_sent log + Telegram Gateway result", + }, + ] + executor_receipts = [ + { + "operation_type": "ansible_candidate_matched", + "owner_agent": "Hermes", + "purpose": "把修復候選寫入 executor 可認領佇列", + "writes_runtime_state": False, + }, + { + "operation_type": "ansible_check_mode_executed", + "owner_agent": "AwoooP Ansible check-mode worker", + "purpose": "執行 ansible-playbook --check --diff 並留下乾跑收據", + "writes_runtime_state": False, + }, + { + "operation_type": "ansible_apply_executed", + "owner_agent": "AwoooP controlled apply worker", + "purpose": "check-mode 通過後,對 allowlisted low / medium / high PlayBook 受控 apply", + "writes_runtime_state": True, + }, + { + "operation_type": "incident_evidence.post_execution_state", + "owner_agent": "post_apply_verifier", + "purpose": "apply 後寫入 verifier 結果與 post-execution evidence", + "writes_runtime_state": True, + }, + { + "operation_type": "knowledge_entries", + "owner_agent": "Hermes", + "purpose": "把已驗證執行沉澱成 KM / PlayBook trust 候選", + "writes_runtime_state": True, + }, + ] + hard_blockers = [ + "secret_token_private_key_cookie_session_auth_header_cleartext", + "drop_truncate_restore_prune_destructive_database_operation", + "reboot_node_drain_irreversible_firewall_or_host_lockout", + "credentialed_exploit_or_external_active_scan", + "new_paid_provider_cost_ceiling_or_provider_switch_without_replay_shadow_canary", + "force_push_delete_repo_refs_or_visibility_change", + "critical_or_break_glass_route_without_explicit_break_glass_contract", + ] + legacy_overrides = [ + { + "legacy_area": "report_status_board_no_live_send", + "current_effect": "overridden", + "new_behavior": "日報 / 週報 / 月報透過 Telegram Gateway 排程派送", + }, + { + "legacy_area": "report_live_delivery_owner_review_required", + "current_effect": "overridden", + "new_behavior": "報告派送走低/中/高風險自動化政策;critical 才 break-glass", + }, + { + "legacy_area": "high_risk_owner_review_queue", + "current_effect": "overridden_for_high_non_critical", + "new_behavior": "high 風險允許 controlled apply;critical / hard blocker 仍不自動", + }, + { + "legacy_area": "telegram_no_send_preview_only", + "current_effect": "overridden", + "new_behavior": "用 Telegram Gateway 實送報告與 actionable receipt;不直接暴露 Bot API", + }, + ] + payload = { + "schema_version": _SCHEMA_VERSION, + "generated_at": datetime.now(timezone.utc).isoformat(), + "program_status": { + "current_task_id": "P2-416-D1N", + "status": "current_directive_control_plane_active", + "runtime_authority": _RUNTIME_AUTHORITY, + "legacy_no_send_no_live_rules_overridden": True, + "implementation_completion_percent": 82, + "status_note": ( + "目前有效規則:low / medium / high 風險由 AI Agent 在 allowlist、" + "Ansible check-mode、verifier、rollback、KM 與 Telegram receipt 下受控自動處理。" + ), + }, + "current_policy": { + "low_risk_controlled_apply_allowed": "low" in allowed_risks, + "medium_risk_controlled_apply_allowed": "medium" in allowed_risks, + "high_risk_controlled_apply_allowed": "high" in allowed_risks, + "critical_break_glass_required": True, + "owner_review_required_for_low_medium_high": False, + "direct_bot_api_allowed": False, + "telegram_gateway_required": True, + "post_apply_verifier_required": True, + "km_learning_writeback_required": True, + }, + "runtime_switches": { + "ansible_check_mode_worker_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER), + "ansible_controlled_apply_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY), + "ansible_controlled_apply_allowed_risk_levels": allowed_risks, + "ansible_check_mode_interval_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS, + "ansible_check_mode_batch_limit": settings.AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT, + "ansible_check_mode_timeout_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS, + "ansible_controlled_apply_timeout_seconds": settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_TIMEOUT_SECONDS, + }, + "agent_roles": [ + { + "agent_id": "openclaw", + "role": "仲裁 / hard blocker / replay-shadow-canary gate", + "current_job": "只阻擋真正 critical 與 hard blocker,不再用身份保護舊架構", + }, + { + "agent_id": "hermes", + "role": "報告 / Telegram digest / KM 與 PlayBook trust writeback", + "current_job": "日週月報、收據摘要與 verifier 後學習沉澱", + }, + { + "agent_id": "nemotron", + "role": "市場技術雷達 / no-write replay / challenger scorecard", + "current_job": "用市場與回放數據挑戰 OpenClaw / provider / Agent 組合", + }, + { + "agent_id": "awooop_ansible_worker", + "role": "executor", + "current_job": "candidate → check-mode → controlled apply → verifier → KM", + }, + { + "agent_id": "telegram_ops", + "role": "Telegram Gateway receipt", + "current_job": "群組報告、actionable receipt、失敗告警;不展示敏感值或未脫敏資料", + }, + ], + "report_delivery": { + "status": "telegram_gateway_delivery_enabled", + "cadences": report_cadences, + }, + "controlled_executor": { + "status": "check_mode_then_apply_enabled" + if settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY + else "check_mode_only_by_config", + "operation_receipts": executor_receipts, + "required_flow": [ + "allowlisted_candidate", + "ansible_check_mode_success", + "controlled_apply", + "post_apply_verifier", + "auto_repair_execution_receipt", + "km_learning_writeback", + "telegram_receipt_or_alert", + ], + }, + "legacy_policy_overrides": legacy_overrides, + "hard_blockers": hard_blockers, + "visibility_contract": { + "frontend_displays_runtime_truth": True, + "work_window_transcript_display_allowed": False, + "prompt_body_display_allowed": False, + "internal_reasoning_display_allowed": False, + "sensitive_value_display_allowed": False, + "telegram_unredacted_payload_display_allowed": False, + "lan_topology_redaction_required": True, + }, + "rollups": { + "automated_risk_tier_count": sum(1 for risk in ("low", "medium", "high") if risk in allowed_risks), + "hard_blocker_count": len(hard_blockers), + "report_cadence_enabled_count": len(report_cadences), + "telegram_gateway_delivery_enabled_count": sum( + 1 for item in report_cadences if item["telegram_gateway_delivery_enabled"] + ), + "direct_bot_api_allowed_count": 0, + "controlled_executor_operation_receipt_count": len(executor_receipts), + "runtime_write_receipt_type_count": sum( + 1 for item in executor_receipts if item["writes_runtime_state"] + ), + "legacy_policy_overridden_count": len(legacy_overrides), + }, + } + _validate_payload(payload) + return payload + + +def _validate_payload(payload: dict[str, Any]) -> None: + if payload.get("schema_version") != _SCHEMA_VERSION: + raise ValueError(f"schema_version must be {_SCHEMA_VERSION}") + status = payload.get("program_status") or {} + if status.get("runtime_authority") != _RUNTIME_AUTHORITY: + raise ValueError(f"runtime_authority must be {_RUNTIME_AUTHORITY}") + policy = payload.get("current_policy") or {} + for key in ( + "low_risk_controlled_apply_allowed", + "medium_risk_controlled_apply_allowed", + "high_risk_controlled_apply_allowed", + "telegram_gateway_required", + "post_apply_verifier_required", + "km_learning_writeback_required", + ): + if policy.get(key) is not True: + raise ValueError(f"current_policy.{key} must be true") + if policy.get("owner_review_required_for_low_medium_high") is not False: + raise ValueError("owner_review_required_for_low_medium_high must be false") + if policy.get("direct_bot_api_allowed") is not False: + raise ValueError("direct_bot_api_allowed must be false") + visibility = payload.get("visibility_contract") or {} + for key in ( + "work_window_transcript_display_allowed", + "prompt_body_display_allowed", + "internal_reasoning_display_allowed", + "sensitive_value_display_allowed", + "telegram_unredacted_payload_display_allowed", + ): + if visibility.get(key) is not False: + raise ValueError(f"visibility_contract.{key} must remain false") diff --git a/apps/api/src/services/report_generation_service.py b/apps/api/src/services/report_generation_service.py index 783e8166..4e2b1f5a 100644 --- a/apps/api/src/services/report_generation_service.py +++ b/apps/api/src/services/report_generation_service.py @@ -40,8 +40,12 @@ logger = structlog.get_logger(__name__) # 台北時區 (UTC+8) _TZ_TAIPEI = timezone(timedelta(hours=8)) -# 日度報告觸發時間(台北時間 08:00) +# 日 / 週 / 月報觸發時間(台北時間) DAILY_REPORT_HOUR_TAIPEI = 8 +WEEKLY_REPORT_WEEKDAY_TAIPEI = 4 # Friday, datetime.weekday(): Monday=0 +WEEKLY_REPORT_HOUR_TAIPEI = 10 +MONTHLY_REPORT_DAY_TAIPEI = 1 +MONTHLY_REPORT_HOUR_TAIPEI = 9 # Postmortem 觸發最低時長(分鐘) POSTMORTEM_MIN_DURATION_MINUTES = 10 @@ -341,13 +345,13 @@ class ReportGenerationService: lines.append(" 只讀判讀:不自動改排程、不直接發修復、不取代人工批准。") return lines - def format_monthly_report_preview( + def format_monthly_report( self, source_health: dict[str, Any] | None, *, generated_at: datetime | None = None, ) -> str: - """Format a monthly no-send preview from the unified report source-health model.""" + """Format the monthly report from the unified report source-health model.""" now = generated_at or now_taipei() source_health = source_health or {} previews = source_health.get("no_send_previews") or [] @@ -359,22 +363,31 @@ class ReportGenerationService: gap_text = ", ".join(str(gap_id) for gap_id in gap_ids[:5]) if gap_ids else "無" lines = [ - "📊 AWOOOI 月報 no-send preview", + "📊 AWOOOI 月報", f"{now.strftime('%Y-%m')} | {now.strftime('%Y-%m-%d %H:%M')} 台北時間", "", "🧭 月報交付狀態", - f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'no_send_preview'))}", + f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'telegram_gateway_delivery'))}", f" Owner: {html.escape(str(monthly_preview.get('owner_agent') or '未指定'))}", f" 缺口來源: {html.escape(gap_text)}", - " 實發: 0 | Gateway queue write: 0", + " 派送: Telegram Gateway | 回執: report_generation_service log / Gateway result", ] lines.extend(self._format_report_source_health_block(source_health)) lines += [ "", - "🤖 AWOOOI 月報草案 | no-send preview,不代表已授權發送或自動修復", + "🤖 AWOOOI 月報自動派送 | 低/中/高風險改善建議由 AI Agent 受控接手,critical / secrets / destructive 仍維持硬阻擋", ] return "\n".join(lines) + def format_monthly_report_preview( + self, + source_health: dict[str, Any] | None, + *, + generated_at: datetime | None = None, + ) -> str: + """Backward-compatible alias for callers that still ask for preview text.""" + return self.format_monthly_report(source_health, generated_at=generated_at) + def format_sre_digest_preview( self, source_health: dict[str, Any] | None, @@ -572,6 +585,26 @@ class ReportGenerationService: except Exception as e: logger.error("daily_report_failed", error=str(e)) + async def send_monthly_report(self) -> bool: + """收集月報資料 → 組裝 → 推送 Telegram SRE 群組。""" + try: + source_health = await self.collect_report_source_health(days=30) + report_text = self.format_monthly_report(source_health) + + from src.services.telegram_gateway import get_telegram_gateway + gateway = get_telegram_gateway() + await gateway.send_to_group(report_text, parse_mode="HTML") + + logger.info( + "monthly_report_sent", + source_ok=(source_health.get("rollups") or {}).get("source_ok_count"), + source_total=(source_health.get("rollups") or {}).get("source_count"), + ) + return True + except Exception as e: + logger.error("monthly_report_failed", error=str(e)) + return False + async def trigger_postmortem( self, incident_id: str, @@ -767,6 +800,40 @@ def _seconds_until_next_report() -> float: return (target - now).total_seconds() +def _seconds_until_next_weekly_report() -> float: + """計算距下一個週五 10:00 台北時間的秒數。""" + now = now_taipei() + target = now.replace( + hour=WEEKLY_REPORT_HOUR_TAIPEI, + minute=0, + second=0, + microsecond=0, + ) + days_until = (WEEKLY_REPORT_WEEKDAY_TAIPEI - now.weekday()) % 7 + target += timedelta(days=days_until) + if now >= target: + target += timedelta(days=7) + return (target - now).total_seconds() + + +def _seconds_until_next_monthly_report() -> float: + """計算距下一個每月 1 日 09:00 台北時間的秒數。""" + now = now_taipei() + target = now.replace( + day=MONTHLY_REPORT_DAY_TAIPEI, + hour=MONTHLY_REPORT_HOUR_TAIPEI, + minute=0, + second=0, + microsecond=0, + ) + if now >= target: + if now.month == 12: + target = target.replace(year=now.year + 1, month=1) + else: + target = target.replace(month=now.month + 1) + return (target - now).total_seconds() + + async def run_daily_report_loop() -> None: """ 日度巡檢報告無限排程迴圈 @@ -801,6 +868,63 @@ async def run_daily_report_loop() -> None: await service.send_daily_report() +async def run_weekly_report_loop() -> None: + """週報排程迴圈:每週五 10:00 台北時間發送到 Telegram。""" + logger.info( + "weekly_report_loop_started", + weekday_taipei=WEEKLY_REPORT_WEEKDAY_TAIPEI, + trigger_hour_taipei=WEEKLY_REPORT_HOUR_TAIPEI, + ) + + while True: + sleep_seconds = _seconds_until_next_weekly_report() + logger.info( + "weekly_report_next_in", + sleep_seconds=int(sleep_seconds), + next_at=f"Friday {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間", + ) + await asyncio.sleep(sleep_seconds) + + from src.services.ai_advisory_helpers import try_acquire_daily_lock + if not await try_acquire_daily_lock("weekly_report"): + logger.info("weekly_report_skipped_other_pod") + continue + + logger.info("weekly_report_triggered") + try: + from src.services.weekly_report_service import get_weekly_report_service + await get_weekly_report_service().send_weekly_report() + except Exception as exc: + logger.error("weekly_report_loop_failed", error=str(exc)) + + +async def run_monthly_report_loop() -> None: + """月報排程迴圈:每月 1 日 09:00 台北時間發送到 Telegram。""" + service = ReportGenerationService() + logger.info( + "monthly_report_loop_started", + day_taipei=MONTHLY_REPORT_DAY_TAIPEI, + trigger_hour_taipei=MONTHLY_REPORT_HOUR_TAIPEI, + ) + + while True: + sleep_seconds = _seconds_until_next_monthly_report() + logger.info( + "monthly_report_next_in", + sleep_seconds=int(sleep_seconds), + next_at=f"day {MONTHLY_REPORT_DAY_TAIPEI} {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間", + ) + await asyncio.sleep(sleep_seconds) + + from src.services.ai_advisory_helpers import try_acquire_daily_lock + if not await try_acquire_daily_lock("monthly_report"): + logger.info("monthly_report_skipped_other_pod") + continue + + logger.info("monthly_report_triggered") + await service.send_monthly_report() + + # ============================================================================= # Factory Function # ============================================================================= diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py new file mode 100644 index 00000000..59e060cb --- /dev/null +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -0,0 +1,63 @@ +from src.services.ai_agent_autonomous_runtime_control import ( + build_ai_agent_autonomous_runtime_control, +) + + +def test_ai_agent_autonomous_runtime_control_uses_current_owner_directive(): + data = build_ai_agent_autonomous_runtime_control() + + assert data["schema_version"] == "ai_agent_autonomous_runtime_control_v1" + assert data["program_status"]["runtime_authority"] == ( + "current_owner_directive_controlled_ai_automation" + ) + assert data["program_status"]["legacy_no_send_no_live_rules_overridden"] is True + assert data["current_policy"]["low_risk_controlled_apply_allowed"] is True + assert data["current_policy"]["medium_risk_controlled_apply_allowed"] is True + assert data["current_policy"]["high_risk_controlled_apply_allowed"] is True + assert data["current_policy"]["owner_review_required_for_low_medium_high"] is False + assert data["current_policy"]["telegram_gateway_required"] is True + assert data["current_policy"]["direct_bot_api_allowed"] is False + assert data["current_policy"]["post_apply_verifier_required"] is True + assert data["current_policy"]["km_learning_writeback_required"] is True + + +def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receipts(): + data = build_ai_agent_autonomous_runtime_control() + + cadences = {item["cadence"]: item for item in data["report_delivery"]["cadences"]} + assert set(cadences) == {"daily", "weekly", "monthly"} + assert {item["telegram_gateway_delivery_enabled"] for item in cadences.values()} == {True} + assert {item["direct_bot_api_allowed"] for item in cadences.values()} == {False} + assert "run_daily_report_loop" in cadences["daily"]["worker"] + assert "run_weekly_report_loop" in cadences["weekly"]["worker"] + assert "run_monthly_report_loop" in cadences["monthly"]["worker"] + + operation_types = { + item["operation_type"] + for item in data["controlled_executor"]["operation_receipts"] + } + assert { + "ansible_candidate_matched", + "ansible_check_mode_executed", + "ansible_apply_executed", + "incident_evidence.post_execution_state", + "knowledge_entries", + }.issubset(operation_types) + assert data["rollups"]["automated_risk_tier_count"] == 3 + 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 + + +def test_ai_agent_autonomous_runtime_control_keeps_hard_blockers_and_redaction(): + data = build_ai_agent_autonomous_runtime_control() + + assert "secret_token_private_key_cookie_session_auth_header_cleartext" in data["hard_blockers"] + assert "drop_truncate_restore_prune_destructive_database_operation" in data["hard_blockers"] + assert "force_push_delete_repo_refs_or_visibility_change" in data["hard_blockers"] + visibility = data["visibility_contract"] + assert visibility["work_window_transcript_display_allowed"] is False + assert visibility["prompt_body_display_allowed"] is False + 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 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 new file mode 100644 index 00000000..2831db7e --- /dev/null +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py @@ -0,0 +1,66 @@ +from fastapi.testclient import TestClient + +from src.main import app + + +_PUBLIC_FORBIDDEN_TERMS = [ + "工作視窗", + "對話內容", + "批准!繼續", + "In app browser", + "My request for Codex", + "browser_context", + "codex_user_message", + "prompt_text", + "raw prompt", + "raw_prompt", + "raw payload", + "raw_payload", + "private reasoning", + "chain_of_thought", + "authorization header", + "authorization_header", + "secret value", + "secret_value", +] + + +def _collect_strings(value): + if isinstance(value, str): + return [value] + if isinstance(value, list): + strings = [] + for item in value: + strings.extend(_collect_strings(item)) + return strings + if isinstance(value, dict): + strings = [] + for item in value.values(): + strings.extend(_collect_strings(item)) + return strings + return [] + + +def test_get_ai_agent_autonomous_runtime_control_api(): + client = TestClient(app) + response = client.get("/api/v1/agents/agent-autonomous-runtime-control") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "ai_agent_autonomous_runtime_control_v1" + assert data["program_status"]["runtime_authority"] == ( + "current_owner_directive_controlled_ai_automation" + ) + 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 + + +def test_get_ai_agent_autonomous_runtime_control_api_redacts_public_terms(): + client = TestClient(app) + response = client.get("/api/v1/agents/agent-autonomous-runtime-control") + + assert response.status_code == 200 + all_text = "\n".join(_collect_strings(response.json())) + for term in _PUBLIC_FORBIDDEN_TERMS: + assert term not in all_text diff --git a/apps/api/tests/test_report_generation_service.py b/apps/api/tests/test_report_generation_service.py index d4163a3c..08741092 100644 --- a/apps/api/tests/test_report_generation_service.py +++ b/apps/api/tests/test_report_generation_service.py @@ -31,6 +31,8 @@ from src.services.report_generation_service import ( PostmortemData, ReportGenerationService, _seconds_until_next_report, + _seconds_until_next_monthly_report, + _seconds_until_next_weekly_report, ) from src.services.weekly_report_service import WeeklyReportService @@ -426,8 +428,8 @@ class TestFormatDailyReport: assert "全 0 判讀: source_gap_or_no_signal_requires_review" in report assert "不自動改排程" in report - def test_monthly_preview_contains_no_send_source_health(self): - """月報 preview 應顯示 no-send 邊界與資產沉澱""" + def test_monthly_report_contains_telegram_gateway_source_health(self): + """月報應顯示 Telegram Gateway 派送與資產沉澱。""" source_health = { "rollups": { "source_ok_count": 2, @@ -452,19 +454,24 @@ class TestFormatDailyReport: ], } svc = ReportGenerationService() - report = svc.format_monthly_report_preview( + report = svc.format_monthly_report( source_health, generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI), ) - assert "月報 no-send preview" in report + assert "AWOOOI 月報" in report assert "Owner: Hermes" in report - assert "實發: 0" in report + assert "Telegram Gateway" in report assert "來源: 2/5" in report assert "resolution_stats" in report assert "KM: draft_ready 3/4" in report assert "Verifier: source_health_ready 1/2" in report - assert "不代表已授權發送或自動修復" in report + assert "AI Agent 受控接手" in report + + def test_weekly_and_monthly_report_schedule_helpers_return_positive_seconds(self): + assert _seconds_until_next_report() > 0 + assert _seconds_until_next_weekly_report() > 0 + assert _seconds_until_next_monthly_report() > 0 def test_sre_digest_preview_contains_assets_and_boundaries(self): """SRE 戰情室 digest 應收斂缺口、資產與 no-send 邊界""" diff --git a/apps/api/tests/test_weekly_report_preview_api.py b/apps/api/tests/test_weekly_report_preview_api.py index 4816f78c..77047fb9 100644 --- a/apps/api/tests/test_weekly_report_preview_api.py +++ b/apps/api/tests/test_weekly_report_preview_api.py @@ -50,7 +50,7 @@ def test_weekly_report_preview_exposes_source_health_no_send_preview(): assert "不自動改排程" in preview -def test_monthly_report_preview_exposes_source_health_no_send_preview(): +def test_monthly_report_preview_exposes_source_health_and_gateway_delivery(): client = TestClient(app) response = client.get("/api/v1/stats/monthly/preview") @@ -65,11 +65,11 @@ def test_monthly_report_preview_exposes_source_health_no_send_preview(): assert "formatted_preview" in data preview = data["formatted_preview"] - assert "月報 no-send preview" in preview + assert "AWOOOI 月報" in preview assert "報表資料源 / 沉澱" in preview assert f"來源: {data['source_ok_count']}/{data['source_total_count']}" in preview - assert "實發: 0" in preview - assert "不代表已授權發送或自動修復" in preview + assert "Telegram Gateway" in preview + assert "AI Agent 受控接手" in preview def test_sre_digest_preview_exposes_source_health_no_send_preview(): diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 145f07f5..cf73e717 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1866,10 +1866,16 @@ "needsHumanYes": "需要", "needsHumanNo": "不需要", "stateLabels": { - "verificationDegradedManualRequired": "驗證退化,需人工確認" + "verificationDegradedManualRequired": "驗證退化,AI 進入 verifier / rollback", + "verificationDegradedAiVerifierRequired": "驗證退化,AI 進入 verifier / rollback", + "diagnosticOnlyAiRepairRequired": "只完成診斷,AI 補修復候選", + "noActionAiCandidateRequired": "未找到修復候選,AI 補 PlayBook" }, "nextActionLabels": { - "manualVerifyOrRepair": "人工確認修復狀態;需要時重新送審修復" + "manualVerifyOrRepair": "AI 執行 verifier 或 rollback 判定", + "runVerifierOrRollbackCandidate": "AI 執行 verifier 或 rollback 判定", + "autoGenerateRepairCandidate": "AI 從診斷證據產生修復候選", + "autoRepairBlockerConnector": "AI 修復 blocker / connector 後重試" }, "reasonLabels": { "incidentOpenAfterSuccessfulExecution": "自動執行已完成,但 Incident仍開啟" @@ -3681,6 +3687,41 @@ "runwayTitle": "AI Agents 專業執行跑道", "runwayBadge": "只讀 / 乾跑 / 審核 / 回寫", "runwaySummary": "可無寫入推進 {noWrite} 類;正式執行 {runtime}、Telegram 發送 {sends}、production 寫入 {writes} 仍依 gate。", + "currentAutonomy": { + "title": "目前有效自主化控制層", + "policyTitle": "目前執行政策", + "runtimeTitle": "Worker / Gateway 開關", + "executorTitle": "Executor 收據鏈", + "overrideTitle": "舊規範覆寫", + "hardBlockerTitle": "仍維持硬阻擋", + "hardBlockerDetail": "需 break-glass 或專案級合約,不由一般自動化靜默執行。", + "badges": { + "override": "舊 no-send / no-live 已覆寫", + "gateway": "Telegram Gateway" + }, + "metrics": { + "completion": "目前完成度", + "riskTiers": "自動風險層", + "reports": "日週月報", + "gateway": "Gateway 派送", + "executor": "Executor 收據", + "hardBlockers": "硬阻擋" + }, + "policy": { + "low": "低風險", + "medium": "中風險", + "high": "高風險", + "noOwnerReview": "低/中/高人工 gate={value}", + "verifier": "post-apply verifier={value}", + "km": "KM / PlayBook 回寫={value}" + }, + "runtime": { + "checkMode": "Ansible check-mode worker", + "apply": "Controlled apply", + "botApi": "Direct Bot API", + "gatewayOnly": "只允許既有 Telegram Gateway,不暴露 token / chat id。" + } + }, "runway": { "readOnlyInvestigation": { "label": "主動巡檢與證據蒐集", @@ -9755,12 +9796,12 @@ "verify_git_baseline_then_mark_adopted": "驗證 Git baseline 後標記採納", "operator_review_handoff_and_execute_manual_plan": "Operator review交接並執行人工方案", "run_verification_scan_then_record_result": "執行驗證掃描並記錄結果", - "open_manual_investigation_with_failed_verification": "建立人工調查並附上失敗驗證", + "open_manual_investigation_with_failed_verification": "建立 AI verifier / rollback 調查並附上失敗驗證", "verify_k8s_matches_git_baseline": "驗證 K8s與Git baseline 一致", "confirm_no_repeat_after_rollback": "確認回滾後不再重複", "monitor_for_recurrence": "監控是否復發", "retry_pr_lookup_then_review_drift": "重試 PR 查詢後 review drift", - "manual_investigation_or_ansible_check_mode": "人工調查或Ansible check-mode", + "manual_investigation_or_ansible_check_mode": "AI 調查或 Ansible check-mode", "unknown": "未知" }, "pr": { @@ -10669,11 +10710,15 @@ "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", "reviewOwnerReleasePacket": "審查 owner 放行包、執行窗口、rollback 與回寫責任", - "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", + "manualReviewNoActionDecision": "AI 補齊 evidence / PlayBook 後判定是否關閉事件", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", - "manualInvestigation": "人工調查卡點並補證據", - "reviewVerifier": "執行或審查修復後 verifier", + "manualInvestigation": "AI 調查卡點並補證據", + "autoGenerateRepairCandidate": "AI 從診斷證據產生修復候選", + "autoVerifyOrRollback": "AI 執行 verifier 或 rollback 判定", + "autoRepairBlockerConnector": "AI 修復 blocker / connector 後重試", + "autoGenerateRepairOrBreakGlass": "AI 產生修復包;硬阻擋才 break-glass", + "reviewVerifier": "執行修復後 verifier", "monitorRegression": "持續觀察是否復發", "collectEvidenceOrWait": "補收證據或等待下一筆 recurrence", "collectMaintenanceRollback": "補齊維護窗口與 rollback owner", @@ -11272,7 +11317,7 @@ "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", - "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件" + "manualReviewNoActionDecision": "AI 補齊 evidence / PlayBook 後判定是否關閉事件" }, "items": { "km": "KM", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 145f07f5..cf73e717 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1866,10 +1866,16 @@ "needsHumanYes": "需要", "needsHumanNo": "不需要", "stateLabels": { - "verificationDegradedManualRequired": "驗證退化,需人工確認" + "verificationDegradedManualRequired": "驗證退化,AI 進入 verifier / rollback", + "verificationDegradedAiVerifierRequired": "驗證退化,AI 進入 verifier / rollback", + "diagnosticOnlyAiRepairRequired": "只完成診斷,AI 補修復候選", + "noActionAiCandidateRequired": "未找到修復候選,AI 補 PlayBook" }, "nextActionLabels": { - "manualVerifyOrRepair": "人工確認修復狀態;需要時重新送審修復" + "manualVerifyOrRepair": "AI 執行 verifier 或 rollback 判定", + "runVerifierOrRollbackCandidate": "AI 執行 verifier 或 rollback 判定", + "autoGenerateRepairCandidate": "AI 從診斷證據產生修復候選", + "autoRepairBlockerConnector": "AI 修復 blocker / connector 後重試" }, "reasonLabels": { "incidentOpenAfterSuccessfulExecution": "自動執行已完成,但 Incident仍開啟" @@ -3681,6 +3687,41 @@ "runwayTitle": "AI Agents 專業執行跑道", "runwayBadge": "只讀 / 乾跑 / 審核 / 回寫", "runwaySummary": "可無寫入推進 {noWrite} 類;正式執行 {runtime}、Telegram 發送 {sends}、production 寫入 {writes} 仍依 gate。", + "currentAutonomy": { + "title": "目前有效自主化控制層", + "policyTitle": "目前執行政策", + "runtimeTitle": "Worker / Gateway 開關", + "executorTitle": "Executor 收據鏈", + "overrideTitle": "舊規範覆寫", + "hardBlockerTitle": "仍維持硬阻擋", + "hardBlockerDetail": "需 break-glass 或專案級合約,不由一般自動化靜默執行。", + "badges": { + "override": "舊 no-send / no-live 已覆寫", + "gateway": "Telegram Gateway" + }, + "metrics": { + "completion": "目前完成度", + "riskTiers": "自動風險層", + "reports": "日週月報", + "gateway": "Gateway 派送", + "executor": "Executor 收據", + "hardBlockers": "硬阻擋" + }, + "policy": { + "low": "低風險", + "medium": "中風險", + "high": "高風險", + "noOwnerReview": "低/中/高人工 gate={value}", + "verifier": "post-apply verifier={value}", + "km": "KM / PlayBook 回寫={value}" + }, + "runtime": { + "checkMode": "Ansible check-mode worker", + "apply": "Controlled apply", + "botApi": "Direct Bot API", + "gatewayOnly": "只允許既有 Telegram Gateway,不暴露 token / chat id。" + } + }, "runway": { "readOnlyInvestigation": { "label": "主動巡檢與證據蒐集", @@ -9755,12 +9796,12 @@ "verify_git_baseline_then_mark_adopted": "驗證 Git baseline 後標記採納", "operator_review_handoff_and_execute_manual_plan": "Operator review交接並執行人工方案", "run_verification_scan_then_record_result": "執行驗證掃描並記錄結果", - "open_manual_investigation_with_failed_verification": "建立人工調查並附上失敗驗證", + "open_manual_investigation_with_failed_verification": "建立 AI verifier / rollback 調查並附上失敗驗證", "verify_k8s_matches_git_baseline": "驗證 K8s與Git baseline 一致", "confirm_no_repeat_after_rollback": "確認回滾後不再重複", "monitor_for_recurrence": "監控是否復發", "retry_pr_lookup_then_review_drift": "重試 PR 查詢後 review drift", - "manual_investigation_or_ansible_check_mode": "人工調查或Ansible check-mode", + "manual_investigation_or_ansible_check_mode": "AI 調查或 Ansible check-mode", "unknown": "未知" }, "pr": { @@ -10669,11 +10710,15 @@ "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", "reviewOwnerReleasePacket": "審查 owner 放行包、執行窗口、rollback 與回寫責任", - "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", + "manualReviewNoActionDecision": "AI 補齊 evidence / PlayBook 後判定是否關閉事件", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", - "manualInvestigation": "人工調查卡點並補證據", - "reviewVerifier": "執行或審查修復後 verifier", + "manualInvestigation": "AI 調查卡點並補證據", + "autoGenerateRepairCandidate": "AI 從診斷證據產生修復候選", + "autoVerifyOrRollback": "AI 執行 verifier 或 rollback 判定", + "autoRepairBlockerConnector": "AI 修復 blocker / connector 後重試", + "autoGenerateRepairOrBreakGlass": "AI 產生修復包;硬阻擋才 break-glass", + "reviewVerifier": "執行修復後 verifier", "monitorRegression": "持續觀察是否復發", "collectEvidenceOrWait": "補收證據或等待下一筆 recurrence", "collectMaintenanceRollback": "補齊維護窗口與 rollback owner", @@ -11272,7 +11317,7 @@ "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", - "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件" + "manualReviewNoActionDecision": "AI 補齊 evidence / PlayBook 後判定是否關閉事件" }, "items": { "km": "KM", diff --git a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx index 93984bf6..c7edac74 100644 --- a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx +++ b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx @@ -45,6 +45,7 @@ import { redactPublicIdentifier } from '@/lib/public-security-redaction' import { apiClient, type AiAgent12AgentWarRoomSnapshot, + type AiAgentAutonomousRuntimeControlSnapshot, type AiAgentProfessionalTaskExpansionSnapshot, type AiAgentReceiptReadbackOwnerReviewSnapshot, type AiAgentReportNoWriteAnalysisRuntimeSnapshot, @@ -849,6 +850,7 @@ function GateMatrixRow({ export function AutomationInventoryTab() { const t = useTranslations('governance.automationInventory') const [snapshot, setSnapshot] = useState(null) + const [autonomousRuntimeControl, setAutonomousRuntimeControl] = useState(null) const [backlog, setBacklog] = useState(null) const [backupTargets, setBackupTargets] = useState(null) const [backupReadiness, setBackupReadiness] = useState(null) @@ -948,6 +950,7 @@ export function AutomationInventoryTab() { setLoading(true) const requests = [ apiClient.getAiAgentAutomationInventorySnapshot(), + apiClient.getAiAgentAutonomousRuntimeControl(), apiClient.getAiAgentAutomationBacklogSnapshot(), apiClient.getBackupDrTargetInventory(), apiClient.getBackupDrReadinessMatrix(), @@ -1040,6 +1043,7 @@ export function AutomationInventoryTab() { .then((results) => { const [ inventoryResult, + autonomousRuntimeControlResult, backlogResult, targetResult, readinessResult, @@ -1129,6 +1133,7 @@ export function AutomationInventoryTab() { ] = results setSnapshot(settledPublicValue(inventoryResult)) + setAutonomousRuntimeControl(settledPublicValue(autonomousRuntimeControlResult)) setBacklog(settledPublicValue(backlogResult)) setBackupTargets(settledPublicValue(targetResult)) setBackupReadiness(settledPublicValue(readinessResult)) @@ -5503,6 +5508,12 @@ export function AutomationInventoryTab() { tone: hostRedactionLocked && professionalTaskRedactionLocked && warRoomRedactionLocked && serviceHealthRedactionLocked ? 'ok' as const : 'warn' as const, }, ] + const currentAutonomyCadences = autonomousRuntimeControl?.report_delivery.cadences ?? [] + const currentAutonomyExecutorReceipts = autonomousRuntimeControl?.controlled_executor.operation_receipts ?? [] + const currentAutonomyOverrides = autonomousRuntimeControl?.legacy_policy_overrides ?? [] + const currentAutonomyHardBlockers = autonomousRuntimeControl?.hard_blockers ?? [] + const currentAutonomyPolicy = autonomousRuntimeControl?.current_policy + const currentAutonomySwitches = autonomousRuntimeControl?.runtime_switches const globalControlRunwayRows: Array<{ key: string label: string @@ -6311,6 +6322,161 @@ export function AutomationInventoryTab() { ]} /> + {autonomousRuntimeControl ? ( + +
+
+
+
+ +
+
+ + {t('globalControl.currentAutonomy.title')} + + + {autonomousRuntimeControl.program_status.status_note} + +
+
+
+ + + +
+
+ +
+ } /> + } /> + } /> + } /> + } /> + } /> +
+ +
+
+
+ {t('globalControl.currentAutonomy.policyTitle')} + +
+
+ + + +
+
+ {currentAutonomyCadences.map(cadence => ( +
+
+ + {cadence.display_name} + + +
+ + {cadence.schedule} + + + {cadence.worker} + +
+ ))} +
+
+ +
+ {t('globalControl.currentAutonomy.runtimeTitle')} + + + +
+
+ +
+
+ {t('globalControl.currentAutonomy.executorTitle')} + {currentAutonomyExecutorReceipts.slice(0, 5).map(receipt => ( + + ))} +
+
+ {t('globalControl.currentAutonomy.overrideTitle')} + {currentAutonomyOverrides.slice(0, 4).map(row => ( + + ))} +
+
+ {t('globalControl.currentAutonomy.hardBlockerTitle')} + {currentAutonomyHardBlockers.slice(0, 5).map(blocker => ( + + ))} +
+
+
+
+ ) : null} +
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index dcbe4974..b0a2d104 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -843,6 +843,11 @@ export const apiClient = { return handleResponse(res) }, + async getAiAgentAutonomousRuntimeControl() { + const res = await fetch(`${API_BASE_URL}/agents/agent-autonomous-runtime-control`) + return handleResponse(res) + }, + async getAiAgentAutomationBacklogSnapshot() { const res = await fetch(`${API_BASE_URL}/agents/automation-backlog-snapshot`) return handleResponse(res) @@ -1848,6 +1853,91 @@ export interface AiTechnologyReportCadenceReadback { // AI Agent Automation Inventory Snapshot // ========================================================================= +export interface AiAgentAutonomousRuntimeControlSnapshot { + schema_version: 'ai_agent_autonomous_runtime_control_v1' + generated_at: string + program_status: { + current_task_id: 'P2-416-D1N' + status: string + runtime_authority: 'current_owner_directive_controlled_ai_automation' + legacy_no_send_no_live_rules_overridden: true + implementation_completion_percent: number + status_note: string + } + current_policy: { + low_risk_controlled_apply_allowed: boolean + medium_risk_controlled_apply_allowed: boolean + high_risk_controlled_apply_allowed: boolean + critical_break_glass_required: boolean + owner_review_required_for_low_medium_high: boolean + direct_bot_api_allowed: boolean + telegram_gateway_required: boolean + post_apply_verifier_required: boolean + km_learning_writeback_required: boolean + } + runtime_switches: { + ansible_check_mode_worker_enabled: boolean + ansible_controlled_apply_enabled: boolean + ansible_controlled_apply_allowed_risk_levels: string[] + ansible_check_mode_interval_seconds: number + ansible_check_mode_batch_limit: number + ansible_check_mode_timeout_seconds: number + ansible_controlled_apply_timeout_seconds: number + } + agent_roles: Array<{ + agent_id: string + role: string + current_job: string + }> + report_delivery: { + status: string + cadences: Array<{ + cadence: 'daily' | 'weekly' | 'monthly' + display_name: string + schedule: string + worker: string + telegram_gateway_delivery_enabled: boolean + direct_bot_api_allowed: boolean + receipt_source: string + }> + } + controlled_executor: { + status: string + operation_receipts: Array<{ + operation_type: string + owner_agent: string + purpose: string + writes_runtime_state: boolean + }> + required_flow: string[] + } + legacy_policy_overrides: Array<{ + legacy_area: string + current_effect: string + new_behavior: string + }> + hard_blockers: string[] + visibility_contract: { + frontend_displays_runtime_truth: boolean + work_window_transcript_display_allowed: boolean + raw_prompt_display_allowed: boolean + private_reasoning_display_allowed: boolean + secret_value_display_allowed: boolean + raw_telegram_payload_display_allowed: boolean + lan_topology_redaction_required: boolean + } + rollups: { + automated_risk_tier_count: number + hard_blocker_count: number + report_cadence_enabled_count: number + telegram_gateway_delivery_enabled_count: number + direct_bot_api_allowed_count: number + controlled_executor_operation_receipt_count: number + runtime_write_receipt_type_count: number + legacy_policy_overridden_count: number + } +} + export interface AiAgentAutomationInventorySnapshot { schema_version: 'ai_agent_automation_inventory_snapshot_v1' generated_at: string diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 3acc7675..24238127 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,66 @@ +## 2026-06-27|P2-416 D1N:目前有效 AI Agent 自主化控制層與日週月報 Telegram Gateway 接線 + +**背景**:使用者已明確要求不再依舊 no-send / no-live / 高風險預設人工規範推進;目前有效方向是 low / medium / high 風險在 allowlist、Ansible check-mode、controlled apply、post-apply verifier、KM / PlayBook writeback 與 Telegram receipt 下由 AI Agent 受控自動處理。critical / secret / destructive / reboot / node drain / provider switch / force push 等仍維持 hard blocker。 + +**完成內容**: +- 新增目前有效控制 API:`GET /api/v1/agents/agent-autonomous-runtime-control`,schema `ai_agent_autonomous_runtime_control_v1`。 +- 新 API 明確回傳:舊 no-send / no-live rules 已被 current owner directive 覆寫、low / medium / high 風險不再要求人工 gate、Telegram 只能走既有 Gateway、不直呼 Bot API、不暴露 token / chat id / raw payload。 +- `report_generation_service` 新增週報 loop 與月報 loop:日報每日 `08:00`、週報週五 `10:00`、月報每月 `1` 日 `09:00`,全部以台北時間計算並透過 Telegram Gateway 派送。 +- 月報文案從 `no-send preview` 改為正式月報派送語意,仍保留資料源健康、缺口來源、KM / PlayBook / Verifier 沉澱與 hard blocker 說明。 +- `main.py` 已在 lifespan 同時掛入日報 / 週報 / 月報三條背景 loop。 +- `/zh-TW/governance?tab=automation-inventory` 新增「目前有效自主化控制層」卡:顯示目前完成度、低中高風險自動化、日週月報 Gateway、Ansible executor 收據鏈、舊規範覆寫與 hard blockers。 +- 前端仍不顯示工作視窗對話、raw prompt、private reasoning、secret、raw Telegram payload、內網拓樸或 Bot token。 + +**本地驗證結果**: +- `python3 -m py_compile apps/api/src/services/report_generation_service.py apps/api/src/services/ai_agent_autonomous_runtime_control.py apps/api/src/api/v1/agents.py apps/api/src/main.py`:通過。 +- `DATABASE_URL=sqlite:///test.db python3.11 -m pytest apps/api/tests/test_ai_agent_autonomous_runtime_control.py apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py apps/api/tests/test_report_generation_service.py apps/api/tests/test_weekly_report_preview_api.py -q`:`51 passed`。 +- `pnpm --dir apps/web typecheck`:通過。 + +**目前完成度 / 邊界**: +- P2-416 D1N 本地:`0% -> 88%`。已完成程式、API、排程接線、前端顯示與測試;尚待 commit、Gitea push、CD、production API readback 與 desktop / mobile browser smoke。 +- AI Agent 自動化整體保守:`72% -> 78%`。D1N 把目前有效控制面與報告 Telegram Gateway 連上,但真正 runtime 成效仍要看下一批 `ansible_apply_executed`、`incident_evidence.post_execution_state`、`knowledge_entries` 與 Telegram delivery receipt 的 production readback。 + +**仍維持 hard blocker**: +- secret / token / private key / cookie / session / auth header 明文讀取或外洩。 +- `DROP` / `TRUNCATE` / destructive migration / restore / prune / irreversible DB operation。 +- reboot / node drain / irreversible firewall / host lockout。 +- credentialed exploit / external active scan。 +- 新付費 provider、成本上限調整、provider switch 或 OpenClaw 替換未經 replay / shadow / canary。 +- force push、刪 repo / refs、visibility change。 +- critical / break-glass route 未具備專案級 break-glass contract。 + +**下一步**: +- Commit / push 到 `gitea main`,等待 code-review / CD。 +- 正式站讀回 `/api/v1/agents/agent-autonomous-runtime-control` 與 `/zh-TW/governance?tab=automation-inventory`,確認新卡可見且 forbidden terms 為 `0`。 + +## 2026-06-27|AI Agent 受控自動化規範覆寫與告警語意收斂 + +**背景**:統帥明確要求把 2026-06-26 以前大量停在 owner gate / read-only / manual handoff 的舊規範,改成 AI Agent 對 low / medium / high 風險事件預設走受控自動化。高風險不等於任意命令直跑,而是必須有 allowlist、PlayBook / Ansible / MCP route、check-mode / dry-run、blast radius、rollback、post-apply verifier、KM / PlayBook trust 與 Telegram / AwoooP receipt。 + +**完成內容**: +- `docs/HARD_RULES.md` 升版到 `v2.5`,新增「舊 owner gate / read-only 預設失效條款」;`manual_required`、`needs_human=true`、`runtime_write_gate=0`、`owner_review_required` 等舊語意,除硬阻擋外不得再作為 low / medium / high 事件終局。 +- `docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md` 新增 `§1.6 2026-06-27 舊 owner gate / read-only 規範失效宣告`,並把狀態機改成 `controlled_policy_check`、`controlled_playbook_queue`、`ai_repair_candidate_required`、`ai_rollback_or_repair`、`break_glass_required`。 +- `docs/workplans/2026-06-04-iwooos-security-governance-p0.md` 加入 2026-06-27 覆寫說明,明確區分 6/04 只讀 ledger 與目前 AI Agent controlled automation 主線。 +- 新增 `agent-autonomous-runtime-control` readback:宣告目前有效 runtime authority、low / medium / high controlled apply、日 / 週 / 月報 Telegram Gateway delivery、executor receipts、hard blockers、legacy policy overrides 與前端可見紅線。 +- 日報 / 週報 / 月報排程對齊:API lifespan 啟動 daily / weekly / monthly report loops;月報從 no-send preview 改成 Telegram Gateway delivery 語意;報表仍不直接讀 Bot token 或 chat id。 +- `operator_outcome` 新增 legacy normalizer:舊 `diagnostic_only_manual_review`、`verification_degraded_manual_required`、`execution_unverified_manual_required`、`no_action_manual_review`、`approval_expired_manual_review`、`write_observed_manual_review`、`blocked_manual_required` 在未命中硬阻擋時轉為 AI controlled path。 +- Telegram / AwoooP status-chain / Approval execution / Alerts UI / status-chain UI 文案同步:診斷-only、no-action、驗證退化、寫入旗標、blocked 不再顯示為人工接手,而是 AI 補 PlayBook / transport / verifier、AI rollback、AI connector 修復與 controlled apply 判定。 + +**本地驗證**: +- `pytest apps/api/tests/test_operator_outcome.py apps/api/tests/test_telegram_ai_automation_block.py apps/api/tests/test_telegram_webhook_execution_handoff.py apps/api/tests/test_telegram_message_templates.py apps/api/tests/test_awooop_operator_timeline_labels.py -q`:`170 passed`。 +- `pytest apps/api/tests/test_ai_agent_autonomous_runtime_control.py apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py apps/api/tests/test_report_generation_service.py apps/api/tests/test_weekly_report_preview_api.py -q`:`51 passed`。 +- `python3 -m py_compile`:`operator_outcome.py`、`telegram_gateway.py`、`platform_operator_service.py`、`approval_execution.py`、`report_generation_service.py`、`ai_agent_autonomous_runtime_control.py`、`agents.py`、`main.py` 通過。 +- `python3 -m json.tool apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`:通過。 +- `pnpm --filter @awoooi/web typecheck`:通過。 +- `git diff --check`:通過。 + +**完成度與邊界**: +- 規範基線改寫:`100%`。 +- API / Telegram / AwoooP outcome 語意收斂:`100%`。 +- 報表 delivery 語意與 autonomous runtime control readback:`100%` 本地完成;正式站需等 CD deploy marker 後驗證。 +- AI Agent 全自動化產品化:本段把舊規範阻擋改成當前正確方向,但不宣稱所有 PlayBook / Ansible / verifier / KM worker 都已完整覆蓋所有主機、服務、網站、產品;下一步仍要接真實告警驗證 controlled apply、post-apply verifier、rollback、KM / PlayBook trust 寫回。 +- 硬阻擋仍維持:不讀 secret / token / private key / cookie / authorization header 明文、不做 DB DROP / TRUNCATE / restore / prune、不 reboot / node drain、不做 credentialed exploit / 外部攻擊型 active scan、不新增或切換付費 provider / 成本上限、不 force push / 刪 repo refs / 改 visibility、不碰 raw runtime secret volume。 + ## 2026-06-27|IwoooS Wazuh manager registry reviewer validation 正式讀回完成 **時間與來源**: From 82a73250f4eb0ffc7c28de23ddca35228ae64d90 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 19:31:47 +0800 Subject: [PATCH 2/2] feat(iwooos): validate wazuh owner registry exports --- apps/api/src/api/v1/iwooos.py | 33 ++ ...uh_manager_registry_reviewer_validation.py | 481 +++++++++++++++++- ...uh_manager_registry_reviewer_validation.py | 142 ++++++ apps/web/messages/en.json | 2 + apps/web/messages/zh-TW.json | 2 + apps/web/src/app/[locale]/iwooos/page.tsx | 8 + apps/web/src/lib/api-client.ts | 2 + .../security-mirror-progress-guard.py | 7 + 8 files changed, 673 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 3251afb3..998f53aa 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -37,6 +37,7 @@ from src.services.iwooos_wazuh_managed_host_coverage import ( ) from src.services.iwooos_wazuh_manager_registry_reviewer_validation import ( load_latest_iwooos_wazuh_manager_registry_reviewer_validation, + validate_iwooos_wazuh_manager_registry_owner_export as validate_wazuh_manager_registry_owner_export_payload, ) from src.services.iwooos_wazuh_owner_evidence_preflight import ( load_latest_iwooos_wazuh_owner_evidence_preflight, @@ -178,6 +179,38 @@ async def get_iwooos_wazuh_manager_registry_reviewer_validation() -> dict[str, A ) from exc +@router.post( + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation/validate-owner-export", + response_model=dict[str, Any], + summary="驗證 Wazuh manager registry 脫敏 owner export", + description=( + "針對單次 owner-provided redacted Wazuh manager registry export 進行 no-persist reviewer " + "validation,回傳 accepted / needs supplement / quarantined / rejected runtime action 分流。" + "此端點不保存 payload、不查 Wazuh API、不讀主機、不重新註冊 agent、不重啟服務、不讀或回傳" + "機密明文、不啟用主動回應、不改 Nginx / Docker / K8s / firewall,也不更新 manager registry " + "accepted 總帳。" + ), +) +async def validate_iwooos_wazuh_manager_registry_owner_export(owner_export: dict[str, Any]) -> dict[str, Any]: + """回傳單次 Wazuh manager registry 脫敏匯出的公開安全驗證結果。""" + try: + payload = await asyncio.to_thread( + validate_wazuh_manager_registry_owner_export_payload, + owner_export, + ) + return redact_public_lan_topology(payload) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"IwoooS Wazuh manager registry owner export 驗證器無效:{exc}", + ) from exc + + @router.get( "/api/v1/iwooos/runtime-security-readback", response_model=dict[str, Any], 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 aba0eecc..934413fb 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 @@ -1,15 +1,16 @@ """ IwoooS Wazuh manager registry reviewer validation readback. -This service exposes a committed reviewer-validation contract for future -owner-provided redacted Wazuh manager registry exports. It never receives raw -payloads, queries Wazuh, reads host data, reads secrets, or authorizes runtime -actions. +This service exposes a committed reviewer-validation contract and a no-persist +validator for owner-provided redacted Wazuh manager registry exports. It never +queries Wazuh, reads host data, reads secrets, persists raw payloads, or +authorizes runtime actions. """ from __future__ import annotations import json +import re from pathlib import Path from typing import Any @@ -34,6 +35,82 @@ _REQUIRED_FALSE_BOUNDARIES = { "wazuh_manager_restart_authorized", } +_SENSITIVE_TEXT_PATTERNS = { + "internal_ip": re.compile(r"\b(?:10|127|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b"), + "authorization_header": re.compile(r"Authorization\s*:", re.IGNORECASE), + "bearer_token": re.compile(r"Bearer\s+[A-Za-z0-9._-]{10,}", re.IGNORECASE), + "basic_auth": re.compile(r"Basic\s+[A-Za-z0-9+/=]{10,}", re.IGNORECASE), + "password_assignment": re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + "token_assignment": re.compile(r"token\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + "cookie_assignment": re.compile(r"cookie\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + "client_keys": re.compile(r"client\.keys", re.IGNORECASE), + "private_key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + "work_window_text": re.compile(r"(工作視窗|批准!繼續|source_thread_id|owenhytsai/)", re.IGNORECASE), +} + +_FORBIDDEN_KEY_FRAGMENTS = { + "authorization_header", + "basic_auth", + "bearer_token", + "client_keys", + "cookie", + "dashboard_api_secret", + "firewall_change", + "full_cli_output", + "full_journal", + "host_write", + "hostname", + "internal_ip", + "nginx_reload", + "password", + "private_key", + "raw_dashboard_request", + "raw_log", + "raw_wazuh_payload", + "stored_api_password", + "unredacted_screenshot", +} + +_RUNTIME_ACTION_KEYS = { + "active_response_enable", + "agent_reenroll", + "agent_restart", + "argocd_sync", + "firewall_change", + "host_write", + "k8s_or_argocd_change", + "kali_active_scan", + "nginx_reload", + "runtime_execution_authorized", + "secret_rotation", + "wazuh_active_response", + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_api_live_query", + "wazuh_dashboard_secret_patch", + "wazuh_manager_restart", +} + +_DASHBOARD_REQUIRED_FIELDS = { + "dashboard_api_connection_check_status", + "dashboard_api_version_check_status", + "dashboard_index_pattern_statuses", + "dashboard_api_degradation_root_cause", + "dashboard_api_repair_postcheck_ref", +} + +_READONLY_CREDENTIAL_REQUIRED_FIELDS = { + "collection_method", + "manager_health_ref", + "redacted_evidence_refs", +} + +_ACCOUNTABILITY_REQUIRED_FIELDS = { + "followup_owner", + "rollback_owner", + "postcheck_plan", +} + def load_latest_iwooos_wazuh_manager_registry_reviewer_validation( security_dir: Path | None = None, @@ -76,6 +153,10 @@ def load_latest_iwooos_wazuh_manager_registry_reviewer_validation( f"docs/security/{_SNAPSHOT_FILE}", "scripts/security/wazuh-manager-registry-reviewer-validation.py", ], + "owner_export_validation_endpoint": ( + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation/validate-owner-export" + ), + "owner_export_validation_mode": "no_persist_validation_no_runtime_action", "summary": merged_summary, "expected_scope_aliases": _strings(snapshot.get("expected_scope_aliases")), "reviewer_validation_checks": _checks(snapshot.get("reviewer_validation_checks")), @@ -173,6 +254,7 @@ def _evidence_slots(value: Any) -> list[dict[str, Any]]: def _boundary_markers(summary: dict[str, int]) -> list[str]: return [ "wazuh_manager_registry_reviewer_validation_visible=true", + "wazuh_manager_registry_owner_export_validation_api_available=true", f"wazuh_manager_registry_reviewer_validation_expected_scope_alias_count={summary['expected_scope_alias_count']}", f"wazuh_manager_registry_reviewer_validation_required_owner_field_count={summary['required_owner_field_count']}", f"wazuh_manager_registry_reviewer_validation_per_host_required_field_count={summary['per_host_required_field_count']}", @@ -225,3 +307,394 @@ def _require_boundaries(payload: dict[str, Any]) -> None: raise ValueError(f"Wazuh manager registry reviewer validation execution_boundaries.{key} 必須維持 false") if boundaries.get("not_authorization") is not True: raise ValueError("Wazuh manager registry reviewer validation not_authorization 必須維持 true") + + +def validate_iwooos_wazuh_manager_registry_owner_export( + owner_export: dict[str, Any], + security_dir: Path | None = None, +) -> dict[str, Any]: + """Validate one redacted owner export without persisting it or changing runtime truth.""" + contract = load_latest_iwooos_wazuh_manager_registry_reviewer_validation(security_dir) + snapshot = _load_snapshot(security_dir or _DEFAULT_SECURITY_DIR) + expected_aliases = set(_strings(snapshot.get("expected_scope_aliases"))) + required_owner_fields = _strings(snapshot.get("required_owner_fields")) + per_host_required_fields = _strings(snapshot.get("per_host_required_fields")) + + findings: list[dict[str, Any]] = [] + evidence_status = _initial_evidence_status(snapshot) + + if not isinstance(owner_export, dict): + findings.append(_finding("RV-01", "blocker", "request_missing_fields", "owner export 必須是 JSON object", [])) + return _validation_result(contract, "request_missing_fields", findings, evidence_status) + + sensitive_hits = _collect_sensitive_hits(owner_export) + if sensitive_hits: + findings.append( + _finding( + "RV-07", + "critical", + "quarantine_sensitive_payload", + "owner export 含禁止內容或疑似未脫敏內容,已進隔離分流;回應不回傳原始值。", + [hit["path"] for hit in sensitive_hits[:12]], + {"categories": sorted({hit["category"] for hit in sensitive_hits})}, + ) + ) + return _validation_result(contract, "quarantine_sensitive_payload", findings, evidence_status) + + runtime_hits = _collect_runtime_action_hits(owner_export) + if runtime_hits: + findings.append( + _finding( + "RV-09", + "critical", + "reject_runtime_action_request", + "owner export 夾帶 runtime action request;收件驗證只允許脫敏證據,不授權 active response、restart、host write 或掃描。", + runtime_hits[:12], + ) + ) + return _validation_result(contract, "reject_runtime_action_request", findings, evidence_status) + + missing_owner_fields = [field for field in required_owner_fields if not _present(owner_export.get(field))] + if missing_owner_fields: + findings.append( + _finding( + "RV-01", + "blocker", + "request_missing_fields", + "owner export envelope 欄位不足,需要補齊後再驗證。", + missing_owner_fields, + ) + ) + + count_issue = _validate_counts(owner_export) + if count_issue: + findings.append( + _finding( + "RV-02", + "blocker", + "request_counts_arithmetic_fix", + count_issue, + ["agent_total", "agent_active", "agent_disconnected", "agent_never_connected"], + ) + ) + + alias_issue = _validate_aliases(owner_export.get("registry_export_scope_aliases"), expected_aliases) + if alias_issue: + findings.append( + _finding( + "RV-03", + "blocker", + "request_alias_scope_parity_fix", + alias_issue, + ["registry_export_scope_aliases"], + ) + ) + + matrix_issue_paths = _validate_per_host_matrix( + owner_export.get("per_host_registry_matrix"), + expected_aliases, + per_host_required_fields, + ) + if matrix_issue_paths: + findings.append( + _finding( + "RV-04", + "blocker", + "request_per_host_matrix_supplement", + "逐主機矩陣未完整覆蓋 6 個公開別名或缺少必要欄位。", + matrix_issue_paths[:30], + ) + ) + + dashboard_missing = [field for field in sorted(_DASHBOARD_REQUIRED_FIELDS) if not _present(owner_export.get(field))] + if dashboard_missing: + findings.append( + _finding( + "RV-05", + "blocker", + "request_dashboard_api_repair_postcheck", + "Dashboard API connection / version / index pattern / root cause / repair postcheck 必須分欄。", + dashboard_missing, + ) + ) + + readonly_missing = [field for field in sorted(_READONLY_CREDENTIAL_REQUIRED_FIELDS) if not _present(owner_export.get(field))] + if readonly_missing: + findings.append( + _finding( + "RV-06", + "blocker", + "request_readonly_credential_metadata", + "唯讀 credential metadata 與 manager health ref 必須可追溯,且不得含 secret value。", + readonly_missing, + ) + ) + + accountability_missing = [field for field in sorted(_ACCOUNTABILITY_REQUIRED_FIELDS) if not _present(owner_export.get(field))] + if accountability_missing: + findings.append( + _finding( + "RV-08", + "blocker", + "request_owner_accountability_supplement", + "followup owner、rollback owner 與 postcheck plan 必須可讀。", + accountability_missing, + ) + ) + + _mark_evidence_status(evidence_status, owner_export) + outcome = _first_blocking_lane(findings) or "accepted_for_readonly_posture_only" + if outcome == "accepted_for_readonly_posture_only": + evidence_status = [ + {**slot, "received": True, "accepted": True, "quarantined": False} + for slot in evidence_status + ] + findings.append( + _finding( + "RV-10", + "info", + "waiting_post_enable_iwooos_readback", + "owner export 已通過 no-persist reviewer validation;下一步仍只能進 post-enable IwoooS readback,不開 runtime gate。", + ["post_enable_iwooos_readback"], + ) + ) + + return _validation_result(contract, outcome, findings, evidence_status) + + +def _validation_result( + contract: dict[str, Any], + outcome_lane: str, + findings: list[dict[str, Any]], + evidence_status: list[dict[str, Any]], +) -> dict[str, Any]: + accepted = outcome_lane == "accepted_for_readonly_posture_only" + quarantined = outcome_lane == "quarantine_sensitive_payload" + rejected_runtime = outcome_lane == "reject_runtime_action_request" + return { + "schema_version": "iwooos_wazuh_manager_registry_owner_export_validation_result_v1", + "contract_schema_version": contract["schema_version"], + "status": outcome_lane, + "mode": "no_persist_validation_no_runtime_no_secret_collection", + "outcome_lane": outcome_lane, + "accepted_for_readonly_posture_only": accepted, + "reviewer_validation_passed": accepted, + "quarantined": quarantined, + "runtime_action_rejected": rejected_runtime, + "summary": { + "owner_registry_export_received_count": 1, + "owner_registry_export_accepted_count": 1 if accepted else 0, + "reviewer_validation_passed_count": 1 if accepted else 0, + "reviewer_validation_quarantined_count": 1 if quarantined else 0, + "manager_registry_accepted_count": 0, + "post_enable_readback_passed_count": 0, + "runtime_gate_count": 0, + "host_write_authorized_count": 0, + "active_response_authorized_count": 0, + "secret_value_collection_allowed_count": 0, + "finding_count": len(findings), + }, + "validation_findings": findings, + "evidence_slots": evidence_status, + "boundary_markers": [ + "wazuh_manager_registry_owner_export_validation_received_count=1", + f"wazuh_manager_registry_owner_export_validation_accepted_count={1 if accepted else 0}", + f"wazuh_manager_registry_owner_export_validation_quarantined_count={1 if quarantined else 0}", + "wazuh_manager_registry_owner_export_validation_manager_registry_accepted_count=0", + "wazuh_manager_registry_owner_export_validation_runtime_gate_count=0", + "wazuh_manager_registry_owner_export_validation_no_persist=true", + "wazuh_api_live_query_authorized=false", + "wazuh_active_response_authorized=false", + "host_write_authorized=false", + "secret_value_collection_allowed=false", + "not_authorization=true", + ], + "boundaries": { + "payload_persisted": False, + "wazuh_api_live_query_authorized": False, + "wazuh_agent_reenroll_authorized": False, + "wazuh_agent_restart_authorized": False, + "wazuh_manager_restart_authorized": False, + "wazuh_active_response_authorized": False, + "host_write_authorized": False, + "secret_value_collection_allowed": False, + "raw_wazuh_payload_storage_allowed": False, + "kali_active_scan_authorized": False, + "runtime_execution_authorized": False, + "manager_registry_accepted_updated": False, + "not_authorization": True, + }, + "next_gate": "post_enable_iwooos_readback" if accepted else "owner_export_fix_and_resubmit", + } + + +def _finding( + check_id: str, + severity: str, + lane: str, + message: str, + field_paths: list[str], + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "check_id": check_id, + "severity": severity, + "lane": lane, + "message": message, + "field_paths": field_paths, + } + if extra: + payload.update(extra) + return payload + + +def _initial_evidence_status(snapshot: dict[str, Any]) -> list[dict[str, Any]]: + slots = _evidence_slots(snapshot.get("evidence_slots")) + return [{**slot, "received": False, "accepted": False, "quarantined": False} for slot in slots] + + +def _mark_evidence_status(evidence_status: list[dict[str, Any]], owner_export: dict[str, Any]) -> None: + for slot in evidence_status: + required_fields = slot.get("required_fields", []) + slot["received"] = bool(required_fields) and all(_present(owner_export.get(field)) for field in required_fields) + slot["accepted"] = False + slot["quarantined"] = False + + +def _present(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, dict, tuple, set)): + return bool(value) + return True + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _validate_counts(owner_export: dict[str, Any]) -> str | None: + fields = ["agent_total", "agent_active", "agent_disconnected", "agent_never_connected"] + counts = {field: _int_or_none(owner_export.get(field)) for field in fields} + if any(value is None for value in counts.values()): + return "agent count 欄位必須是非負整數。" + if any(value is not None and value < 0 for value in counts.values()): + return "agent count 欄位不得為負數。" + total = counts["agent_total"] + active = counts["agent_active"] + disconnected = counts["agent_disconnected"] + never_connected = counts["agent_never_connected"] + if total is None or active is None or disconnected is None or never_connected is None: + return "agent count 欄位必須是非負整數。" + if total < active + disconnected + never_connected: + return "agent_total 不得小於 active + disconnected + never_connected。" + return None + + +def _validate_aliases(value: Any, expected_aliases: set[str]) -> str | None: + aliases = value if isinstance(value, list) else [] + if not aliases or not all(isinstance(item, str) for item in aliases): + return "registry_export_scope_aliases 必須是公開別名字串陣列。" + alias_set = set(aliases) + if len(aliases) != len(alias_set): + return "registry_export_scope_aliases 不得重複。" + if alias_set != expected_aliases: + missing = sorted(expected_aliases - alias_set) + extra = sorted(alias_set - expected_aliases) + return f"registry_export_scope_aliases 必須剛好等於 6 個公開別名;missing={missing} extra={extra}" + return None + + +def _validate_per_host_matrix(value: Any, expected_aliases: set[str], required_fields: list[str]) -> list[str]: + if not isinstance(value, list): + return ["per_host_registry_matrix"] + paths: list[str] = [] + seen_aliases: list[str] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + paths.append(f"per_host_registry_matrix[{index}]") + continue + alias = item.get("node_alias") + if not isinstance(alias, str) or alias not in expected_aliases: + paths.append(f"per_host_registry_matrix[{index}].node_alias") + else: + seen_aliases.append(alias) + for field in required_fields: + if not _present(item.get(field)): + paths.append(f"per_host_registry_matrix[{index}].{field}") + seen_set = set(seen_aliases) + if len(seen_aliases) != len(seen_set): + paths.append("per_host_registry_matrix.duplicate_node_alias") + for missing_alias in sorted(expected_aliases - seen_set): + paths.append(f"per_host_registry_matrix.missing.{missing_alias}") + for extra_alias in sorted(seen_set - expected_aliases): + paths.append(f"per_host_registry_matrix.extra.{extra_alias}") + return paths + + +def _collect_sensitive_hits(value: Any, path: str = "$") -> list[dict[str, str]]: + hits: list[dict[str, str]] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_text = str(key) + key_lower = key_text.lower() + for fragment in _FORBIDDEN_KEY_FRAGMENTS: + if fragment in key_lower: + hits.append({"path": f"{path}.{key_text}", "category": f"forbidden_key:{fragment}"}) + hits.extend(_collect_sensitive_hits(item, f"{path}.{key_text}")) + return hits + if isinstance(value, list): + for index, item in enumerate(value): + hits.extend(_collect_sensitive_hits(item, f"{path}[{index}]")) + return hits + if isinstance(value, str): + for category, pattern in _SENSITIVE_TEXT_PATTERNS.items(): + if pattern.search(value): + hits.append({"path": path, "category": category}) + return hits + + +def _collect_runtime_action_hits(value: Any, path: str = "$") -> list[str]: + hits: list[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_text = str(key) + normalized_key = key_text.lower().replace("-", "_").replace(" ", "_") + if normalized_key in _RUNTIME_ACTION_KEYS and item not in (False, None, "", [], {}): + hits.append(f"{path}.{key_text}") + hits.extend(_collect_runtime_action_hits(item, f"{path}.{key_text}")) + return hits + if isinstance(value, list): + for index, item in enumerate(value): + hits.extend(_collect_runtime_action_hits(item, f"{path}[{index}]")) + return hits + if isinstance(value, str): + normalized = value.lower().replace("-", "_").replace(" ", "_") + if normalized in _RUNTIME_ACTION_KEYS: + hits.append(path) + return hits + + +def _first_blocking_lane(findings: list[dict[str, Any]]) -> str | None: + for lane in ( + "quarantine_sensitive_payload", + "reject_runtime_action_request", + "request_missing_fields", + "request_counts_arithmetic_fix", + "request_alias_scope_parity_fix", + "request_per_host_matrix_supplement", + "request_dashboard_api_repair_postcheck", + "request_readonly_credential_metadata", + "request_owner_accountability_supplement", + ): + if any(item.get("lane") == lane for item in findings): + return lane + return None 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 d078678c..7b943d95 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 @@ -6,15 +6,78 @@ from fastapi.testclient import TestClient from src.api.v1.iwooos import router from src.services.iwooos_wazuh_manager_registry_reviewer_validation import ( load_latest_iwooos_wazuh_manager_registry_reviewer_validation, + validate_iwooos_wazuh_manager_registry_owner_export, ) +EXPECTED_ALIASES = [ + "managed_core_node_a", + "managed_core_node_b", + "managed_dev_node_a", + "managed_dev_node_b", + "managed_control_node_a", + "managed_control_node_b", +] + + def _client() -> TestClient: app = FastAPI() app.include_router(router) return TestClient(app) +def _valid_owner_export() -> dict: + return { + "owner_role": "資安負責人", + "team": "IwoooS", + "decision": "accept_readonly_registry_export_for_reviewer_validation", + "decision_reason": "Wazuh manager registry 已脫敏,供 reviewer 驗證主機覆蓋與 Dashboard API 修復狀態。", + "affected_scope": "iwooos_wazuh_expected_scope_aliases", + "collection_method": "owner_export_redacted_summary", + "agent_total": 6, + "agent_active": 2, + "agent_disconnected": 3, + "agent_never_connected": 1, + "last_seen_window_start": "2026-06-27T15:00:00+08:00", + "last_seen_window_end": "2026-06-27T16:00:00+08:00", + "registry_collected_at": "2026-06-27T16:05:00+08:00", + "registry_export_scope_aliases": EXPECTED_ALIASES, + "per_host_registry_matrix": [ + { + "node_alias": alias, + "scope_role": "managed_scope", + "registry_presence": "present", + "agent_status_bucket": "active" if index < 2 else "disconnected", + "last_seen_state": "within_owner_window" if index < 2 else "outside_owner_window", + "manager_group_ref": f"group-ref-{index}", + "agent_id_redacted_ref": f"agent-redacted-ref-{index}", + "gap_reason": "none" if index < 2 else "owner_followup_required", + "redacted_evidence_ref": f"evidence-ref-{index}", + } + for index, alias in enumerate(EXPECTED_ALIASES) + ], + "registry_gap_reason_by_alias": { + alias: "none" if index < 2 else "owner_followup_required" + for index, alias in enumerate(EXPECTED_ALIASES) + }, + "registry_export_summary_ref": "evidence-ref-registry-summary", + "manager_health_ref": "evidence-ref-manager-health", + "dashboard_api_status_ref": "evidence-ref-dashboard-api", + "dashboard_api_connection_check_status": "ok", + "dashboard_api_version_check_status": "ok", + "dashboard_index_pattern_statuses": ["alerts_ok", "monitoring_ok", "statistics_ok"], + "dashboard_api_degradation_root_cause": "stored_api_connection_repaired_by_owner", + "dashboard_api_repair_postcheck_ref": "evidence-ref-dashboard-postcheck", + "redacted_evidence_refs": [ + "evidence-ref-registry-summary", + "evidence-ref-dashboard-postcheck", + ], + "followup_owner": "IwoooS reviewer", + "rollback_owner": "IwoooS runtime owner", + "postcheck_plan": "post_enable_iwooos_readback_no_raw_payload", + } + + def test_iwooos_wazuh_manager_registry_reviewer_validation_contract_is_waiting_only() -> None: payload = load_latest_iwooos_wazuh_manager_registry_reviewer_validation() @@ -94,3 +157,82 @@ def test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe() assert "source_thread_id" not in response.text assert "owenhytsai/" not in response.text assert "WAZUH_API_PASSWORD" not in response.text + + +def test_iwooos_wazuh_manager_registry_owner_export_validation_accepts_redacted_payload() -> None: + payload = validate_iwooos_wazuh_manager_registry_owner_export(_valid_owner_export()) + + assert payload["schema_version"] == "iwooos_wazuh_manager_registry_owner_export_validation_result_v1" + assert payload["status"] == "accepted_for_readonly_posture_only" + assert payload["accepted_for_readonly_posture_only"] is True + assert payload["reviewer_validation_passed"] is True + assert payload["summary"]["owner_registry_export_received_count"] == 1 + assert payload["summary"]["owner_registry_export_accepted_count"] == 1 + assert payload["summary"]["manager_registry_accepted_count"] == 0 + assert payload["summary"]["runtime_gate_count"] == 0 + assert payload["boundaries"]["payload_persisted"] is False + assert payload["boundaries"]["runtime_execution_authorized"] is False + assert payload["boundaries"]["manager_registry_accepted_updated"] is False + assert all(slot["received"] is True for slot in payload["evidence_slots"]) + assert all(slot["accepted"] is True for slot in payload["evidence_slots"]) + + +def test_iwooos_wazuh_manager_registry_owner_export_validation_api_does_not_update_global_counters() -> None: + client = _client() + response = client.post( + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation/validate-owner-export", + json=_valid_owner_export(), + ) + + assert response.status_code == 200 + result = response.json() + assert result["status"] == "accepted_for_readonly_posture_only" + assert result["summary"]["owner_registry_export_accepted_count"] == 1 + assert result["summary"]["manager_registry_accepted_count"] == 0 + assert result["summary"]["runtime_gate_count"] == 0 + + readback = client.get("/api/v1/iwooos/wazuh-manager-registry-reviewer-validation").json() + assert readback["summary"]["owner_registry_export_received_count"] == 0 + assert readback["summary"]["owner_registry_export_accepted_count"] == 0 + assert readback["summary"]["manager_registry_accepted_count"] == 0 + assert readback["summary"]["runtime_gate_count"] == 0 + + +def test_iwooos_wazuh_manager_registry_owner_export_validation_requests_missing_fields() -> None: + candidate = _valid_owner_export() + candidate.pop("decision_reason") + + payload = validate_iwooos_wazuh_manager_registry_owner_export(candidate) + + assert payload["status"] == "request_missing_fields" + assert payload["accepted_for_readonly_posture_only"] is False + assert payload["summary"]["owner_registry_export_received_count"] == 1 + assert payload["summary"]["owner_registry_export_accepted_count"] == 0 + assert any("decision_reason" in finding["field_paths"] for finding in payload["validation_findings"]) + + +def test_iwooos_wazuh_manager_registry_owner_export_validation_quarantines_sensitive_payload() -> None: + candidate = _valid_owner_export() + candidate["manager_health_ref"] = "health evidence mentioned 10.250.250.250 by mistake" + + payload = validate_iwooos_wazuh_manager_registry_owner_export(candidate) + + assert payload["status"] == "quarantine_sensitive_payload" + assert payload["quarantined"] is True + assert payload["summary"]["reviewer_validation_quarantined_count"] == 1 + assert payload["summary"]["owner_registry_export_accepted_count"] == 0 + assert "10.250.250.250" not in str(payload) + assert any(finding["check_id"] == "RV-07" for finding in payload["validation_findings"]) + + +def test_iwooos_wazuh_manager_registry_owner_export_validation_rejects_runtime_action_request() -> None: + candidate = _valid_owner_export() + candidate["requested_actions"] = ["wazuh_active_response"] + + payload = validate_iwooos_wazuh_manager_registry_owner_export(candidate) + + assert payload["status"] == "reject_runtime_action_request" + assert payload["runtime_action_rejected"] is True + assert payload["summary"]["owner_registry_export_accepted_count"] == 0 + assert payload["summary"]["runtime_gate_count"] == 0 + assert any(finding["check_id"] == "RV-09" for finding in payload["validation_findings"]) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index cf73e717..502ff97d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -20838,6 +20838,8 @@ "title": "Owner export 進來後,先由 reviewer 驗收脫敏清單", "subtitle": "這張卡固定 Wazuh manager registry owner export 的驗收規則:欄位、計數、公開別名矩陣、Dashboard API 修復讀回、唯讀 credential metadata、拒收內容與下一個 Gate 都先可視化;目前尚未收到、尚未接受,也不開 runtime。", "loadingBoundary": "正在讀取 Wazuh manager registry reviewer validation API", + "validationEndpointLabel": "脫敏 owner export 驗證端點", + "validationModeLabel": "驗證模式", "slotReceivedLabel": "已收件", "slotAcceptedLabel": "已接受", "slotNextGateLabel": "下一關", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index cf73e717..502ff97d 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -20838,6 +20838,8 @@ "title": "Owner export 進來後,先由 reviewer 驗收脫敏清單", "subtitle": "這張卡固定 Wazuh manager registry owner export 的驗收規則:欄位、計數、公開別名矩陣、Dashboard API 修復讀回、唯讀 credential metadata、拒收內容與下一個 Gate 都先可視化;目前尚未收到、尚未接受,也不開 runtime。", "loadingBoundary": "正在讀取 Wazuh manager registry reviewer validation API", + "validationEndpointLabel": "脫敏 owner export 驗證端點", + "validationModeLabel": "驗證模式", "slotReceivedLabel": "已收件", "slotAcceptedLabel": "已接受", "slotNextGateLabel": "下一關", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index c917fd7c..e37ac42a 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -2474,6 +2474,7 @@ const wazuhManagedHostCoverageBoundaries = [ const wazuhManagerRegistryReviewerValidationBoundaries = [ 'wazuh_manager_registry_reviewer_validation_visible=true', + 'wazuh_manager_registry_owner_export_validation_api_available=true', 'wazuh_manager_registry_reviewer_validation_expected_scope_alias_count=6', 'wazuh_manager_registry_reviewer_validation_required_owner_field_count=28', 'wazuh_manager_registry_reviewer_validation_per_host_required_field_count=9', @@ -9845,6 +9846,9 @@ function IwoooSWazuhManagerRegistryReviewerValidationBoard() { : loading ? [t('loadingBoundary')] : wazuhManagerRegistryReviewerValidationBoundaries + const validationEndpoint = data?.owner_export_validation_endpoint + ?? '/api/v1/iwooos/wazuh-manager-registry-reviewer-validation/validate-owner-export' + const validationMode = data?.owner_export_validation_mode ?? 'no_persist_validation_no_runtime_action' const evidenceSlots = data?.evidence_slots ?? [] const visibleChecks = data?.reviewer_validation_checks?.slice(0, 4) ?? [] const statusText = loading ? t('status.loading') : failed ? t('status.failed') : t('status.ready') @@ -9870,6 +9874,10 @@ function IwoooSWazuhManagerRegistryReviewerValidationBoard() { {statusText}
+
+ {t('validationEndpointLabel')}:{validationEndpoint} + {t('validationModeLabel')}:{validationMode} +
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index b0a2d104..28ccf97b 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -341,6 +341,8 @@ export interface IwoooSWazuhManagerRegistryReviewerValidationResponse { status: string mode: string source_refs: string[] + owner_export_validation_endpoint: string + owner_export_validation_mode: string summary: { expected_scope_alias_count: number required_owner_field_count: number diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index 2802634c..98ed55b5 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -29573,6 +29573,7 @@ def validate(root: Path) -> None: "getIwoooSWazuhManagerRegistryReviewerValidation", "apiClient.getIwoooSWazuhManagerRegistryReviewerValidation", "Wazuh manager registry reviewer validation 已讀回", + "wazuh_manager_registry_owner_export_validation_api_available=true", "wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0", "wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0", "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0", @@ -29594,7 +29595,13 @@ def validate(root: Path) -> None: "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation", "wazuh_manager_registry_reviewer_validation_v1", "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1", + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation/validate-owner-export", + "iwooos_wazuh_manager_registry_owner_export_validation_result_v1", + "validate_iwooos_wazuh_manager_registry_owner_export", "test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe", + "test_iwooos_wazuh_manager_registry_owner_export_validation_accepts_redacted_payload", + "test_iwooos_wazuh_manager_registry_owner_export_validation_quarantines_sensitive_payload", + "test_iwooos_wazuh_manager_registry_owner_export_validation_rejects_runtime_action_request", "wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0", "wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0", "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0",