diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 097e7815..31fa0dbd 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -56,6 +56,9 @@ from src.services.backup_notification_policy import ( from src.services.backup_restore_drill_approval_package_template import ( load_latest_backup_restore_drill_approval_package_template, ) +from src.services.offsite_escrow_readiness_status import ( + load_latest_offsite_escrow_readiness_status, +) from src.services.package_supply_chain_inventory import ( load_latest_package_supply_chain_inventory, ) @@ -583,6 +586,34 @@ async def get_backup_restore_drill_approval_package_template() -> dict[str, Any] ) from exc +@router.get( + "/offsite-escrow-readiness-status", + response_model=dict[str, Any], + summary="取得異地 / Escrow 準備度狀態", + description=( + "讀取最新已提交的異地備份、credential escrow 與 K8s resource offsite readiness 狀態;" + "此端點只回傳 read-only status,不執行 backup、restore、offsite sync、" + "不寫 credential marker、不讀 credential、不輸出 secret 明文、不改排程、不寫 workflow、" + "不送 Telegram 測試通知、不做破壞性 prune、不改生產路由。" + ), +) +async def get_offsite_escrow_readiness_status() -> dict[str, Any]: + """Return the latest read-only offsite / escrow readiness status.""" + try: + return await asyncio.to_thread(load_latest_offsite_escrow_readiness_status) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error("offsite_escrow_readiness_status_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="異地 / Escrow 準備度狀態快照無效", + ) from exc + + @router.get( "/package-supply-chain-inventory", response_model=dict[str, Any], diff --git a/apps/api/src/services/offsite_escrow_readiness_status.py b/apps/api/src/services/offsite_escrow_readiness_status.py new file mode 100644 index 00000000..1e82abd9 --- /dev/null +++ b/apps/api/src/services/offsite_escrow_readiness_status.py @@ -0,0 +1,160 @@ +""" +Offsite / escrow readiness status snapshot. + +Loads the latest committed, read-only offsite / escrow readiness status. The +status view never runs backups, restores, offsite sync, credential marker +writes, credential reads, schedule changes, workflow writes, Telegram test +notifications, destructive prune, or production routing changes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.snapshot_paths import default_evaluations_dir + +_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__)) +_SNAPSHOT_PATTERN = "offsite_escrow_readiness_status_*.json" +_SCHEMA_VERSION = "offsite_escrow_readiness_status_v1" + + +def load_latest_offsite_escrow_readiness_status( + evaluations_dir: Path | None = None, +) -> dict[str, Any]: + """Load the newest committed offsite / escrow readiness status snapshot.""" + directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR + candidates = sorted(directory.glob(_SNAPSHOT_PATTERN)) + if not candidates: + raise FileNotFoundError(f"no offsite / escrow readiness status snapshots found in {directory}") + + latest = candidates[-1] + with latest.open(encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + raise ValueError(f"{latest}: expected JSON object") + _require_schema(payload, _SCHEMA_VERSION, str(latest)) + _require_read_only_boundaries(payload, str(latest)) + _require_operation_boundaries(payload, str(latest)) + _require_rollup_consistency(payload, str(latest)) + _require_redacted_cards(payload, str(latest)) + return payload + + +def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None: + actual = payload.get("schema_version") + if actual != expected: + raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}") + + +def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None: + program_status = payload.get("program_status") or {} + if program_status.get("read_only_mode") is not True: + raise ValueError(f"{label}: program_status.read_only_mode must be true") + + boundaries = payload.get("approval_boundaries") or {} + blocked_flags = { + "sdk_installation_allowed", + "paid_api_call_allowed", + "shadow_or_canary_allowed", + "production_routing_allowed", + "destructive_operation_allowed", + "restore_execution_allowed", + "offsite_sync_execution_allowed", + "credential_marker_write_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: approval boundaries must remain false: {allowed}") + + +def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: + boundaries = payload.get("operation_boundaries") or {} + if boundaries.get("read_only_status_allowed") is not True: + raise ValueError(f"{label}: read_only_status_allowed must be true") + + blocked_flags = { + "backup_execution_allowed", + "restore_execution_allowed", + "offsite_sync_execution_allowed", + "credential_marker_write_allowed", + "credential_read_allowed", + "secret_plaintext_allowed", + "schedule_change_allowed", + "workflow_write_allowed", + "telegram_test_notification_allowed", + "destructive_prune_allowed", + "production_routing_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: operation boundaries must remain false: {allowed}") + + +def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: + cards = payload.get("readiness_cards") or [] + rollups = payload.get("rollups") or {} + if rollups.get("total_cards") != len(cards): + raise ValueError(f"{label}: rollups.total_cards must match readiness_cards") + + verified_offsite = { + card.get("card_id") + for card in cards + if card.get("kind") == "offsite_mirror" and card.get("readiness") == "verified" + } + if set(rollups.get("verified_offsite_card_ids") or []) != verified_offsite: + raise ValueError(f"{label}: rollups.verified_offsite_card_ids must match verified offsite cards") + + blocked_escrow = { + card.get("card_id") + for card in cards + if card.get("kind") == "credential_escrow" and card.get("readiness") == "blocked" + } + if set(rollups.get("blocked_escrow_card_ids") or []) != blocked_escrow: + raise ValueError(f"{label}: rollups.blocked_escrow_card_ids must match blocked escrow cards") + + action_required = { + card.get("card_id") + for card in cards + if card.get("readiness") == "action_required" + } + if set(rollups.get("action_required_card_ids") or []) != action_required: + raise ValueError(f"{label}: rollups.action_required_card_ids must match action_required cards") + + execution_blocked = { + card.get("card_id") + for card in cards + if any(operation.endswith("_execution") or operation == "credential_marker_write" for operation in card.get("blocked_operations", [])) + } + if set(rollups.get("execution_blocked_card_ids") or []) != execution_blocked: + raise ValueError(f"{label}: rollups.execution_blocked_card_ids must match cards with blocked execution operations") + + +def _require_redacted_cards(payload: dict[str, Any], label: str) -> None: + cards = payload.get("readiness_cards") or [] + forbidden_exposure = { + "plaintext", + "secret_plaintext", + "credential_plaintext", + "token_visible", + } + exposed = sorted( + card.get("card_id") + for card in cards + if card.get("credential_exposure_status") in forbidden_exposure + ) + if exposed: + raise ValueError(f"{label}: credential exposure must stay redacted: {exposed}") + + contract = payload.get("operator_contract") or {} + must_not_interpret_as = set(contract.get("must_not_interpret_as") or []) + required_denials = { + "復原批准", + "異地同步批准", + "credential marker 寫入批准", + "完整 DR 綠燈", + } + if not required_denials.issubset(must_not_interpret_as): + raise ValueError(f"{label}: operator_contract.must_not_interpret_as is missing required denials") diff --git a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py index f474a177..17e26da9 100644 --- a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py @@ -18,12 +18,12 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert data["schema_version"] == "ai_agent_automation_backlog_v1" assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-105" - assert data["program_status"]["next_task_id"] == "P1-106" - assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 20 - assert data["rollups"]["by_priority"]["P1"] == 18 - assert data["rollups"]["by_status"]["done"] == 13 - assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 17 + assert data["program_status"]["current_task_id"] == "P1-106" + assert data["program_status"]["next_task_id"] == "P1-305" + assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 21 + assert data["rollups"]["by_priority"]["P1"] == 19 + assert data["rollups"]["by_status"]["done"] == 14 + assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 18 assert data["approval_boundaries"]["sdk_installation_allowed"] is False assert data["approval_boundaries"]["paid_api_call_allowed"] is False assert data["approval_boundaries"]["production_routing_allowed"] is False @@ -33,4 +33,5 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert any(item["item_id"] == "AUTO-P1-103" for item in data["backlog_items"]) assert any(item["item_id"] == "AUTO-P1-104" for item in data["backlog_items"]) assert any(item["item_id"] == "AUTO-P1-105" for item in data["backlog_items"]) + assert any(item["item_id"] == "AUTO-P1-106" for item in data["backlog_items"]) assert any(item["item_id"] == "AUTO-P3-001" for item in data["backlog_items"]) diff --git a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py index ba6cb896..26aa1608 100644 --- a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py @@ -18,8 +18,8 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1" assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-105" - assert data["program_status"]["next_task_id"] == "P1-106" + assert data["program_status"]["current_task_id"] == "P1-106" + assert data["program_status"]["next_task_id"] == "P1-305" assert data["approval_boundaries"]["sdk_installation_allowed"] is False assert data["approval_boundaries"]["paid_api_call_allowed"] is False assert data["approval_boundaries"]["production_routing_allowed"] is False @@ -30,6 +30,7 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert any(task["task_id"] == "P1-103" for task in data["tasks"]) assert any(task["task_id"] == "P1-104" for task in data["tasks"]) assert any(task["task_id"] == "P1-105" for task in data["tasks"]) + assert any(task["task_id"] == "P1-106" for task in data["tasks"]) assert any(evidence["evidence_id"] == "dependency_risk_policy_api" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "dependency_drift_check_plan_api" for evidence in data["evidence"]) assert any( @@ -42,3 +43,4 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps evidence["evidence_id"] == "backup_restore_drill_approval_package_template_api" for evidence in data["evidence"] ) + assert any(evidence["evidence_id"] == "offsite_escrow_readiness_status_api" for evidence in data["evidence"]) diff --git a/apps/api/tests/test_offsite_escrow_readiness_status.py b/apps/api/tests/test_offsite_escrow_readiness_status.py new file mode 100644 index 00000000..1eef5541 --- /dev/null +++ b/apps/api/tests/test_offsite_escrow_readiness_status.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json + +import pytest + +from src.services.offsite_escrow_readiness_status import ( + load_latest_offsite_escrow_readiness_status, +) + + +def test_load_latest_offsite_escrow_readiness_status_reads_newest_file(tmp_path): + older = _snapshot(generated_at="2026-06-04T00:00:00+08:00", completion=40) + newer = _snapshot(generated_at="2026-06-05T00:00:00+08:00", completion=100) + (tmp_path / "offsite_escrow_readiness_status_2026-06-04.json").write_text( + json.dumps(older), + encoding="utf-8", + ) + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(newer), + encoding="utf-8", + ) + + loaded = load_latest_offsite_escrow_readiness_status(tmp_path) + + assert loaded["generated_at"] == "2026-06-05T00:00:00+08:00" + assert loaded["program_status"]["overall_completion_percent"] == 100 + assert loaded["rollups"]["total_cards"] == 3 + + +def test_offsite_escrow_readiness_status_requires_read_only_mode(tmp_path): + snapshot = _snapshot() + snapshot["program_status"]["read_only_mode"] = False + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="read_only_mode"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_requires_blocked_approval_boundaries(tmp_path): + snapshot = _snapshot() + snapshot["approval_boundaries"]["credential_marker_write_allowed"] = True + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="approval boundaries"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_requires_blocked_operation_boundaries(tmp_path): + snapshot = _snapshot() + snapshot["operation_boundaries"]["offsite_sync_execution_allowed"] = True + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="operation boundaries"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_requires_total_consistency(tmp_path): + snapshot = _snapshot() + snapshot["rollups"]["total_cards"] = 999 + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="total_cards"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_requires_escrow_blocked_consistency(tmp_path): + snapshot = _snapshot() + snapshot["rollups"]["blocked_escrow_card_ids"] = [] + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="blocked_escrow_card_ids"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_rejects_credential_plaintext(tmp_path): + snapshot = _snapshot() + snapshot["readiness_cards"][1]["credential_exposure_status"] = "credential_plaintext" + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="credential exposure"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_requires_operator_denials(tmp_path): + snapshot = _snapshot() + snapshot["operator_contract"]["must_not_interpret_as"] = ["復原批准"] + (tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="must_not_interpret_as"): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def test_offsite_escrow_readiness_status_fails_when_missing(tmp_path): + with pytest.raises(FileNotFoundError): + load_latest_offsite_escrow_readiness_status(tmp_path) + + +def _snapshot( + *, + generated_at: str = "2026-06-05T00:00:00+08:00", + completion: int = 100, +) -> dict: + return { + "schema_version": "offsite_escrow_readiness_status_v1", + "generated_at": generated_at, + "source_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"], + "program_status": { + "overall_completion_percent": completion, + "current_priority": "P1", + "current_task_id": "P1-106", + "next_task_id": "P1-305", + "read_only_mode": True, + }, + "rollups": { + "total_cards": 3, + "by_readiness": {"verified": 1, "action_required": 1, "blocked": 1}, + "by_kind": { + "offsite_mirror": 1, + "credential_escrow": 1, + "k8s_resource_offsite": 1, + }, + "verified_offsite_card_ids": ["offsite_rclone_full_sync"], + "blocked_escrow_card_ids": ["credential_escrow_markers"], + "action_required_card_ids": ["velero_k8s_resources"], + "execution_blocked_card_ids": [ + "offsite_rclone_full_sync", + "credential_escrow_markers", + "velero_k8s_resources", + ], + }, + "readiness_cards": [ + _card("offsite_rclone_full_sync", "offsite_mirror", "verified"), + _card("credential_escrow_markers", "credential_escrow", "blocked"), + _card("velero_k8s_resources", "k8s_resource_offsite", "action_required"), + ], + "operator_contract": { + "display_mode": "read_only_status", + "success_notification_policy": "成功狀態不得即時通知洗版。", + "failure_notification_policy": "失敗或阻擋維持 action-required。", + "credential_display_policy": "只顯示 redacted metadata。", + "must_not_interpret_as": [ + "復原批准", + "異地同步批准", + "credential marker 寫入批准", + "完整 DR 綠燈", + ], + }, + "operation_boundaries": { + "read_only_status_allowed": True, + "backup_execution_allowed": False, + "restore_execution_allowed": False, + "offsite_sync_execution_allowed": False, + "credential_marker_write_allowed": False, + "credential_read_allowed": False, + "secret_plaintext_allowed": False, + "schedule_change_allowed": False, + "workflow_write_allowed": False, + "telegram_test_notification_allowed": False, + "destructive_prune_allowed": False, + "production_routing_allowed": False, + }, + "approval_boundaries": { + "sdk_installation_allowed": False, + "paid_api_call_allowed": False, + "shadow_or_canary_allowed": False, + "production_routing_allowed": False, + "destructive_operation_allowed": False, + "restore_execution_allowed": False, + "offsite_sync_execution_allowed": False, + "credential_marker_write_allowed": False, + }, + } + + +def _card(card_id: str, kind: str, readiness: str) -> dict: + return { + "card_id": card_id, + "target_id": card_id, + "display_name": card_id, + "kind": kind, + "readiness": readiness, + "offsite_status": "verified" if readiness == "verified" else "not_applicable", + "escrow_status": "missing_markers" if kind == "credential_escrow" else "not_applicable", + "restore_drill_status": "approval_required", + "credential_exposure_status": "redacted_only", + "automation_gate_status": "read_only_allowed", + "operator_summary": "只讀狀態。", + "next_action": "維持只讀。", + "evidence_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"], + "blocked_operations": ["offsite_sync_execution", "credential_marker_write"], + } diff --git a/apps/api/tests/test_offsite_escrow_readiness_status_api.py b/apps/api/tests/test_offsite_escrow_readiness_status_api.py new file mode 100644 index 00000000..b748065c --- /dev/null +++ b/apps/api/tests/test_offsite_escrow_readiness_status_api.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router + + +def test_offsite_escrow_readiness_status_endpoint_returns_committed_snapshot(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/offsite-escrow-readiness-status") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "offsite_escrow_readiness_status_v1" + assert data["program_status"]["overall_completion_percent"] == 100 + assert data["program_status"]["read_only_mode"] is True + assert data["program_status"]["current_task_id"] == "P1-106" + assert data["program_status"]["next_task_id"] == "P1-305" + assert data["rollups"]["total_cards"] == len(data["readiness_cards"]) == 3 + assert data["rollups"]["verified_offsite_card_ids"] == ["offsite_rclone_full_sync"] + assert data["rollups"]["blocked_escrow_card_ids"] == ["credential_escrow_markers"] + assert data["rollups"]["action_required_card_ids"] == ["velero_k8s_resources"] + assert data["operation_boundaries"]["read_only_status_allowed"] is True + assert data["operation_boundaries"]["restore_execution_allowed"] is False + assert data["operation_boundaries"]["offsite_sync_execution_allowed"] is False + assert data["operation_boundaries"]["credential_marker_write_allowed"] is False + assert data["operation_boundaries"]["credential_read_allowed"] is False + assert data["operation_boundaries"]["secret_plaintext_allowed"] is False + assert data["operation_boundaries"]["telegram_test_notification_allowed"] is False + assert data["approval_boundaries"]["credential_marker_write_allowed"] is False + assert any( + card["card_id"] == "credential_escrow_markers" and card["readiness"] == "blocked" + for card in data["readiness_cards"] + ) + assert any( + card["card_id"] == "velero_k8s_resources" and card["readiness"] == "action_required" + for card in data["readiness_cards"] + ) + assert "完整 DR 綠燈" in data["operator_contract"]["must_not_interpret_as"] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 76bbb50d..e0a2ba6a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3022,7 +3022,31 @@ "deferred_until_service_active": "等服務啟用", "suppress_immediate_success": "成功不即時通知", "escalate_immediate": "立即升級", - "create_action_required": "建立待處置" + "create_action_required": "建立待處置", + "missing_markers": "缺 marker", + "redacted_only": "僅脫敏", + "read_only_allowed": "只讀允許" + } + }, + "offsiteEscrow": { + "title": "異地 / Escrow 準備度", + "source": "{generated} · {current} → {next}", + "contractTitle": "顯示契約", + "metrics": { + "total": "狀態卡", + "verified": "異地已驗證", + "actionRequired": "需處置", + "blocked": "Escrow 阻擋", + "executionBlocked": "執行阻擋" + }, + "labels": { + "escrow": "Escrow", + "credential": "Credential" + }, + "kinds": { + "offsite_mirror": "異地鏡像", + "credential_escrow": "Credential escrow", + "k8s_resource_offsite": "K8s offsite" } }, "boundaries": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 76bbb50d..e0a2ba6a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -3022,7 +3022,31 @@ "deferred_until_service_active": "等服務啟用", "suppress_immediate_success": "成功不即時通知", "escalate_immediate": "立即升級", - "create_action_required": "建立待處置" + "create_action_required": "建立待處置", + "missing_markers": "缺 marker", + "redacted_only": "僅脫敏", + "read_only_allowed": "只讀允許" + } + }, + "offsiteEscrow": { + "title": "異地 / Escrow 準備度", + "source": "{generated} · {current} → {next}", + "contractTitle": "顯示契約", + "metrics": { + "total": "狀態卡", + "verified": "異地已驗證", + "actionRequired": "需處置", + "blocked": "Escrow 阻擋", + "executionBlocked": "執行阻擋" + }, + "labels": { + "escrow": "Escrow", + "credential": "Credential" + }, + "kinds": { + "offsite_mirror": "異地鏡像", + "credential_escrow": "Credential escrow", + "k8s_resource_offsite": "K8s offsite" } }, "boundaries": { 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 28a34158..ebd85a81 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 @@ -33,6 +33,7 @@ import { type BackupDrReadinessMatrixSnapshot, type BackupDrTargetInventorySnapshot, type BackupNotificationPolicySnapshot, + type OffsiteEscrowReadinessStatusSnapshot, } from '@/lib/api-client' function formatDateTime(value: string): string { @@ -203,6 +204,7 @@ export function AutomationInventoryTab() { const [backupTargets, setBackupTargets] = useState(null) const [backupReadiness, setBackupReadiness] = useState(null) const [backupPolicy, setBackupPolicy] = useState(null) + const [offsiteEscrow, setOffsiteEscrow] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -214,13 +216,15 @@ export function AutomationInventoryTab() { apiClient.getBackupDrTargetInventory(), apiClient.getBackupDrReadinessMatrix(), apiClient.getBackupNotificationPolicy(), + apiClient.getOffsiteEscrowReadinessStatus(), ]) - .then(([inventoryData, backlogData, targetData, readinessData, policyData]) => { + .then(([inventoryData, backlogData, targetData, readinessData, policyData, offsiteEscrowData]) => { setSnapshot(inventoryData) setBacklog(backlogData) setBackupTargets(targetData) setBackupReadiness(readinessData) setBackupPolicy(policyData) + setOffsiteEscrow(offsiteEscrowData) setError(false) }) .catch(() => setError(true)) @@ -282,6 +286,17 @@ export function AutomationInventoryTab() { .slice(0, 6) }, [backupTargets]) + const visibleOffsiteEscrowCards = useMemo(() => { + if (!offsiteEscrow) return [] + const priority = { blocked: 0, action_required: 1, verified: 2 } as Record + return [...offsiteEscrow.readiness_cards].sort((a, b) => { + const left = priority[a.readiness] ?? 3 + const right = priority[b.readiness] ?? 3 + if (left !== right) return left - right + return a.card_id.localeCompare(b.card_id) + }) + }, [offsiteEscrow]) + if (loading) { return (
@@ -295,7 +310,7 @@ export function AutomationInventoryTab() { ) } - if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy) { + if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow) { return (
@@ -338,6 +353,10 @@ export function AutomationInventoryTab() { const blockedBackupRows = backupReadiness.rollups.by_overall_readiness.blocked ?? 0 const suppressedSuccessRules = backupPolicy.rollups.by_decision.suppress_immediate_success ?? 0 const immediateEscalationRules = backupPolicy.rollups.by_decision.escalate_immediate ?? 0 + const verifiedOffsiteCards = offsiteEscrow.rollups.by_readiness.verified ?? 0 + const actionRequiredOffsiteCards = offsiteEscrow.rollups.by_readiness.action_required ?? 0 + const blockedEscrowCards = offsiteEscrow.rollups.by_readiness.blocked ?? 0 + const executionBlockedCards = offsiteEscrow.rollups.execution_blocked_card_ids.length const blockedApprovals = Object.entries(snapshot.approval_boundaries) .filter(([, allowed]) => allowed === false) .map(([key]) => key) @@ -350,6 +369,14 @@ export function AutomationInventoryTab() { } } + const kindLabel = (value: string) => { + try { + return t(`offsiteEscrow.kinds.${value}` as never) + } catch { + return value + } + } + return (
@@ -640,6 +667,94 @@ export function AutomationInventoryTab() {
+ +
+
+
+ + + {t('offsiteEscrow.title')} + +
+
+ {t('offsiteEscrow.source', { + generated: formatDateTime(offsiteEscrow.generated_at), + current: offsiteEscrow.program_status.current_task_id, + next: offsiteEscrow.program_status.next_task_id, + })} +
+
+ +
+ } /> + } /> + } /> + } /> + } /> +
+ +
+
+ {visibleOffsiteEscrowCards.map(card => ( +
+
+
+ + {card.display_name} + + +
+
+ + + + + +
+
+ {card.operator_summary} +
+
+ {card.next_action} +
+
+ + +
+
+
+ ))} +
+ +
+ + {t('offsiteEscrow.contractTitle')} + +
+ {offsiteEscrow.operator_contract.credential_display_policy} +
+
+ + +
+
+ {offsiteEscrow.operator_contract.must_not_interpret_as.slice(0, 6).map(item => ( + + ))} +
+
+
+
+
+
@@ -704,6 +819,9 @@ export function AutomationInventoryTab() { .automation-inventory-backup-kpi-grid, .automation-inventory-backup-evidence-grid, .automation-inventory-backup-readiness-grid, + .automation-inventory-offsite-kpi-grid, + .automation-inventory-offsite-grid, + .automation-inventory-offsite-card-grid, .automation-inventory-bottom-grid, .automation-inventory-task-grid { grid-template-columns: 1fr !important; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6301d968..fa4ef2c0 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -276,6 +276,11 @@ export const apiClient = { const res = await fetch(`${API_BASE_URL}/agents/backup-notification-policy`) return handleResponse(res) }, + + async getOffsiteEscrowReadinessStatus() { + const res = await fetch(`${API_BASE_URL}/agents/offsite-escrow-readiness-status`) + return handleResponse(res) + }, } // ========================================================================= @@ -859,3 +864,50 @@ export interface BackupNotificationPolicySnapshot { approval_boundaries: Record operation_boundaries: Record } + +export interface OffsiteEscrowReadinessStatusSnapshot { + schema_version: 'offsite_escrow_readiness_status_v1' + generated_at: string + source_refs: string[] + program_status: { + overall_completion_percent: number + current_priority: 'P0' | 'P1' | 'P2' | 'P3' + current_task_id: string + next_task_id: string + read_only_mode: true + } + rollups: { + total_cards: number + by_readiness: Record + by_kind: Record + verified_offsite_card_ids: string[] + blocked_escrow_card_ids: string[] + action_required_card_ids: string[] + execution_blocked_card_ids: string[] + } + readiness_cards: Array<{ + card_id: string + target_id: string + display_name: string + kind: 'offsite_mirror' | 'credential_escrow' | 'k8s_resource_offsite' + readiness: 'verified' | 'action_required' | 'blocked' + offsite_status: string + escrow_status: string + restore_drill_status: string + credential_exposure_status: string + automation_gate_status: string + operator_summary: string + next_action: string + evidence_refs: string[] + blocked_operations: string[] + }> + operator_contract: { + display_mode: 'read_only_status' + success_notification_policy: string + failure_notification_policy: string + credential_display_policy: string + must_not_interpret_as: string[] + } + approval_boundaries: Record + operation_boundaries: Record +} diff --git a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md index ad007029..66b98eff 100644 --- a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md +++ b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md @@ -10,7 +10,7 @@ |---|---:|---|---| | Agent 市場治理 | 72% | 進行中 | `agent_market_governance_snapshot_v1`、API、UI 分頁、每週觀察流程 | | Nemotron 實際整合應用 | 30% | 完整回放前仍被關卡擋下 | `blocked_needs_evidence`,下一關是 `refresh_source_evidence_then_5_record_smoke_only` | -| 工具 / 服務 / 套件 AI 自動化 | 100% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到復原演練批准包,下一主線是異地 / escrow 準備度顯示 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板已完成 | +| 工具 / 服務 / 套件 AI 自動化 | 100% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示,下一主線是 P1-305 / P1-306 任務批准邊界與進度彙總細節 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板已完成 | | 本工作清單與分析報告 | 100% | 已完成 | 本 MD 文件 | 整體計畫完成度:**100%**。 @@ -713,7 +713,7 @@ API: 核心裁決: - UI 只顯示備份目標、readiness matrix、通知政策、關鍵 blocker 與 evidence ref。 -- `configs_capture` 與 `credential_escrow_markers` 仍為 blocked,不得宣稱 full DR green。 +- `configs_capture` 與 `credential_escrow_markers` 仍為 blocked,不得宣稱完整 DR 綠燈。 - `signoz` 顯示 disruptive backup guard;Agent 不得任意觸發會短暫停止 collector 的備份。 - 成功備份仍不即時送 Telegram / AwoooP;成功狀態由每日摘要與查詢承載。 - warning、failed、action-required、core blocker 才能進即時升級。 @@ -786,6 +786,69 @@ API: - `PYTHONDONTWRITEBYTECODE=1 apps/api/.venv/bin/python -m pytest apps/api/tests/test_backup_restore_drill_approval_package_template.py apps/api/tests/test_backup_restore_drill_approval_package_template_api.py -q`:`11 passed`。 - `python3 -m py_compile apps/api/src/services/backup_restore_drill_approval_package_template.py apps/api/src/api/v1/agents.py apps/api/tests/test_backup_restore_drill_approval_package_template.py apps/api/tests/test_backup_restore_drill_approval_package_template_api.py` 通過。 +### P1-106 異地 / Escrow 準備度狀態摘要 + +正式 JSON Schema: + +- `docs/schemas/offsite_escrow_readiness_status_v1.schema.json` + +正式 JSON Snapshot: + +- `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json` + +API: + +- `GET /api/v1/agents/offsite-escrow-readiness-status` + +UI: + +- `/zh-TW/governance?tab=automation-inventory` + +狀態內容: + +- 狀態卡:`3` +- 異地已驗證:`1`,`offsite_rclone_full_sync` +- 需處置:`1`,`velero_k8s_resources` +- Escrow 阻擋:`1`,`credential_escrow_markers` +- 執行阻擋:`3` + +核心裁決: + +- P1-106 只顯示異地 / escrow readiness,不批准 offsite sync、restore、credential marker write 或 secret read。 +- `offsite_rclone_full_sync` 可顯示 verified,但 sync execution 仍 blocked;成功不即時送 Telegram / AwoooP 洗版。 +- `credential_escrow_markers` 必須維持 blocked;UI 只能顯示 redacted marker metadata 與 evidence refs,不得顯示 token、password、private key、cookie、authorization header、runner token、webhook secret、rclone credential 或 secret payload value。 +- `velero_k8s_resources` 必須維持 action_required;restore drill 仍需 OpenClaw 仲裁與 HITL。 +- P1-106 UI 可見不得被 Agent 或 operator 解讀為完整 DR 綠燈。 + +實作邊界: + +- 不執行 backup。 +- 不執行 restore。 +- 不執行 offsite sync。 +- 不寫 credential marker。 +- 不讀 credential。 +- 不輸出 secret 明文。 +- 不改排程、不寫 workflow。 +- 不發 Telegram 測試訊息。 +- 不做 destructive prune。 +- 不改生產路由。 + +驗證: + +- `python3 -m json.tool docs/schemas/offsite_escrow_readiness_status_v1.schema.json` 通過。 +- `python3 -m json.tool docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json` 通過。 +- `python3 -m json.tool docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json` 通過。 +- `python3 -m json.tool docs/evaluations/ai_agent_automation_backlog_2026-06-04.json` 通過。 +- `python3 -m json.tool apps/web/messages/zh-TW.json apps/web/messages/en.json` 通過。 +- `PYTHONDONTWRITEBYTECODE=1 /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_offsite_escrow_readiness_status.py apps/api/tests/test_offsite_escrow_readiness_status_api.py apps/api/tests/test_ai_agent_automation_inventory_snapshot.py apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py apps/api/tests/test_ai_agent_automation_backlog_snapshot.py apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py -q`:`21 passed`。 +- `python3 -m py_compile apps/api/src/services/offsite_escrow_readiness_status.py apps/api/src/api/v1/agents.py apps/api/tests/test_offsite_escrow_readiness_status.py apps/api/tests/test_offsite_escrow_readiness_status_api.py apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py` 通過。 +- `/Users/ogt/awoooi/apps/web/node_modules/.bin/tsc --noEmit --tsBuildInfoFile /tmp/awoooi-p1-106-offsite-escrow-readiness-after-ff2.tsbuildinfo -p apps/web/tsconfig.json` 在 clean worktree 透過既有 node_modules toolchain 通過,不執行 `pnpm install`、不改 lockfile。 +- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work NEXT_PRIVATE_BUILD_WORKER_COUNT=1 SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 pnpm --filter @awoooi/web build` 通過;`/zh-TW/governance` First Load JS `381 kB`。 +- `python3 scripts/security/source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `git diff --check` 通過。 +- Production desktop / 390px mobile browser checks 需等 Gitea CD 正式部署後回寫 `docs/LOGBOOK.md`。 + ### P0 - 治理與 Inventory 基礎 | ID | 狀態 | % | 負責 Agent | 任務 | 產出 | 關卡 | @@ -820,7 +883,7 @@ API: | P1-103 | 完成 | 100 | Hermes | 對齊備份通知政策 | `docs/evaluations/backup_notification_policy_2026-06-04.json` | 不發成功洗版 | | P1-104 | 完成 | 100 | OpenClaw | 在 AwoooP / governance UI 加備份證據 | `/zh-TW/governance?tab=automation-inventory` | 只讀 + 瀏覽器驗證 | | P1-105 | 完成 | 100 | OpenClaw | 定義復原演練批准包 | `docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json` | 只讀模板 + 人工批准 | -| P1-106 | 待辦 | 0 | Hermes | 顯示異地 / escrow 準備度狀態 | DR 準備度區塊 | 不暴露 credential | +| P1-106 | 完成 | 100 | Hermes | 顯示異地 / escrow 準備度狀態 | `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json` + `/zh-TW/governance?tab=automation-inventory` | 不暴露 credential | ### P1 - 套件與供應鏈自動化 @@ -965,17 +1028,17 @@ API: ```text 進度:100%。 目前優先級:P1。 -目前任務:P1-105 定義復原演練批准包。 +目前任務:P1-106 顯示異地 / escrow 準備度狀態。 狀態變更:待辦 -> 完成。 -證據:backup_restore_drill_approval_package_template schema / snapshot JSON parse 通過;service + API tests 11 passed;py_compile 通過;API 端點為 GET /api/v1/agents/backup-restore-drill-approval-package-template。 -阻擋:無;backup、restore、offsite sync、credential marker、排程、workflow、Telegram 測試通知、secret 明文、生產路由仍未批准。 -下一步:P1-106 顯示異地 / escrow 準備度狀態。 +證據:offsite_escrow_readiness_status schema / snapshot JSON parse 通過;service + API + inventory + backlog 目標測試 21 passed;web typecheck / build 通過;API 端點為 GET /api/v1/agents/offsite-escrow-readiness-status;UI 已接入 governance automation inventory tab。 +阻擋:無;backup、restore、offsite sync、credential marker、credential read、排程、workflow、Telegram 測試通知、secret 明文、生產路由仍未批准。 +下一步:P1-305 / P1-306 補每個任務的批准邊界與進度彙總細節。 ``` ## 13. 立即執行順序 -1. P1-106:顯示異地 / escrow 準備度狀態。 -2. P1-305 / P1-306:補每個任務的批准邊界與進度彙總細節。 +1. P1-305 / P1-306:補每個任務的批准邊界與進度彙總細節。 +2. P1-001:盤點 API / Web / Worker / K8s runtime surface。 3. P2 / P3 必須等 P1 可見且關卡穩定後再做。 ## 14. 目前風險 diff --git a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json index 930b5cd8..c6511558 100644 --- a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json +++ b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json @@ -1,33 +1,33 @@ { "schema_version": "ai_agent_automation_backlog_v1", - "generated_at": "2026-06-05T05:36:00+08:00", + "generated_at": "2026-06-05T08:40:00+08:00", "source_inventory_snapshot_ref": "docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", - "current_task_id": "P1-105", - "next_task_id": "P1-106", + "current_task_id": "P1-106", + "next_task_id": "P1-305", "read_only_mode": true }, "rollups": { - "total_items": 20, + "total_items": 21, "by_priority": { - "P1": 18, + "P1": 19, "P2": 1, "P3": 1 }, "by_status": { "planned": 7, - "done": 13 + "done": 14 }, "by_gate_status": { - "read_only_allowed": 17, + "read_only_allowed": 18, "production_change_blocked": 1, "cost_approval_required": 1, "blocked_by_evidence": 1 }, "by_owner_agent": { - "hermes": 10, + "hermes": 11, "openclaw": 9, "nemotron": 1 } @@ -334,6 +334,34 @@ ], "next_review": "P1-105" }, + { + "item_id": "AUTO-P1-106", + "priority": "P1", + "status": "done", + "workstream_id": "WS4", + "source_asset_id": "offsite_escrow_readiness_status", + "source_signal_kind": "ui_visibility_gap", + "title": "顯示異地 / escrow 準備度狀態", + "owner_agent": "hermes", + "recommended_action": "建立 read-only offsite / escrow readiness status 與治理頁狀態區塊,顯示 offsite verified、credential escrow blocked、Velero action-required 與 credential redaction policy。", + "action_class": "execute_read_only", + "gate_status": "read_only_allowed", + "risk_level": "critical", + "evidence_refs": [ + "docs/schemas/offsite_escrow_readiness_status_v1.schema.json", + "docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json", + "GET /api/v1/agents/offsite-escrow-readiness-status", + "apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx" + ], + "acceptance_criteria": [ + "不執行 offsite sync、backup、restore 或 Velero restore", + "不寫 credential marker、不讀 credential、不輸出 secret 明文", + "UI 必須把 credential_escrow_markers 維持 blocked,不能解讀成 full DR green", + "成功 offsite evidence 不即時送 Telegram / AwoooP 洗版", + "desktop 與 390px mobile 無橫向溢出" + ], + "next_review": "P1-106" + }, { "item_id": "AUTO-P1-201", "priority": "P1", diff --git a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json index d8a83626..f58952e7 100644 --- a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json +++ b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json @@ -1,11 +1,11 @@ { "schema_version": "ai_agent_automation_inventory_snapshot_v1", - "generated_at": "2026-06-05T05:36:00+08:00", + "generated_at": "2026-06-05T08:40:00+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", - "current_task_id": "P1-105", - "next_task_id": "P1-106", + "current_task_id": "P1-106", + "next_task_id": "P1-305", "read_only_mode": true }, "status_taxonomy": { @@ -423,9 +423,9 @@ { "workstream_id": "WS4", "display_name": "備份與 DR 自動化", - "completion_percent": 83, - "status": "in_progress", - "next_task_id": "P1-106" + "completion_percent": 100, + "status": "done", + "next_task_id": "P1-305" }, { "workstream_id": "WS5", @@ -451,7 +451,7 @@ { "workstream_id": "WS8", "display_name": "產品 UI", - "completion_percent": 82, + "completion_percent": 86, "status": "in_progress", "next_task_id": "P1-305" } @@ -644,6 +644,17 @@ "gate_status": "read_only_allowed", "next_action": "完成,P1-106 顯示異地 / escrow 準備度狀態。" }, + { + "task_id": "P1-106", + "priority": "P1", + "status": "done", + "completion_percent": 100, + "owner_agent": "hermes", + "title": "顯示異地 / escrow 準備度狀態", + "output": "docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json + /zh-TW/governance?tab=automation-inventory", + "gate_status": "read_only_allowed", + "next_action": "完成,P1-305 / P1-306 補任務批准邊界與進度彙總細節。" + }, { "task_id": "P1-201", "priority": "P1", @@ -856,6 +867,24 @@ "ref": "GET /api/v1/agents/backup-restore-drill-approval-package-template", "result": "復原演練批准包模板只讀 API 已新增,只回傳 committed template,不執行 backup、restore、offsite sync、不寫 credential marker、不送 Telegram 測試通知、不改生產路由。" }, + { + "evidence_id": "offsite_escrow_readiness_status_schema", + "kind": "schema", + "ref": "docs/schemas/offsite_escrow_readiness_status_v1.schema.json", + "result": "異地 / Escrow 準備度狀態 schema 已建立,明確禁止 offsite sync、credential marker 寫入、credential read、secret 明文、restore、workflow 寫入與 Telegram 測試通知。" + }, + { + "evidence_id": "offsite_escrow_readiness_status_snapshot", + "kind": "doc", + "ref": "docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json", + "result": "異地 / Escrow 準備度快照已建立;offsite_rclone_full_sync verified、credential_escrow_markers blocked、velero_k8s_resources action_required,全部執行型操作仍 blocked。" + }, + { + "evidence_id": "offsite_escrow_readiness_status_api", + "kind": "api", + "ref": "GET /api/v1/agents/offsite-escrow-readiness-status", + "result": "異地 / Escrow 準備度只讀 API 已新增,只回傳 committed status,不執行 backup、restore、offsite sync、不寫 credential marker、不讀 credential、不輸出 secret 明文。" + }, { "evidence_id": "package_supply_chain_inventory_schema", "kind": "schema", diff --git a/docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json b/docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json new file mode 100644 index 00000000..3362c8b5 --- /dev/null +++ b/docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json @@ -0,0 +1,163 @@ +{ + "schema_version": "offsite_escrow_readiness_status_v1", + "generated_at": "2026-06-05T08:40:00+08:00", + "source_refs": [ + "docs/evaluations/backup_dr_target_inventory_2026-06-04.json", + "docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json", + "docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json", + "docs/runbooks/OFFSITE-BACKUP-ESCROW-RUNBOOK.md", + "docs/HARD_RULES.md" + ], + "program_status": { + "overall_completion_percent": 100, + "current_priority": "P1", + "current_task_id": "P1-106", + "next_task_id": "P1-305", + "read_only_mode": true + }, + "rollups": { + "total_cards": 3, + "by_readiness": { + "verified": 1, + "action_required": 1, + "blocked": 1 + }, + "by_kind": { + "offsite_mirror": 1, + "credential_escrow": 1, + "k8s_resource_offsite": 1 + }, + "verified_offsite_card_ids": [ + "offsite_rclone_full_sync" + ], + "blocked_escrow_card_ids": [ + "credential_escrow_markers" + ], + "action_required_card_ids": [ + "velero_k8s_resources" + ], + "execution_blocked_card_ids": [ + "offsite_rclone_full_sync", + "credential_escrow_markers", + "velero_k8s_resources" + ] + }, + "readiness_cards": [ + { + "card_id": "offsite_rclone_full_sync", + "target_id": "offsite_rclone_full_sync", + "display_name": "Google Drive / rclone offsite mirror", + "kind": "offsite_mirror", + "readiness": "verified", + "offsite_status": "verified", + "escrow_status": "not_applicable", + "restore_drill_status": "not_applicable", + "credential_exposure_status": "not_applicable", + "automation_gate_status": "read_only_allowed", + "operator_summary": "latest-only remote mirror 證據已可見且已驗證,但 Agent 觸發異地同步仍維持阻擋。", + "next_action": "持續顯示 verify freshness;任何新的 sync 執行都需要獨立人工批准。", + "evidence_refs": [ + "scripts/backup/sync-offsite-backups.sh", + "scripts/backup/verify-offsite-full-sync.sh", + "docs/runbooks/BACKUP-STATUS.md" + ], + "blocked_operations": [ + "offsite_sync_execution", + "schedule_change", + "workflow_write", + "telegram_test_notification" + ] + }, + { + "card_id": "credential_escrow_markers", + "target_id": "credential_escrow_markers", + "display_name": "Credential escrow evidence markers", + "kind": "credential_escrow", + "readiness": "blocked", + "offsite_status": "not_applicable", + "escrow_status": "missing_markers", + "restore_drill_status": "blocked", + "credential_exposure_status": "redacted_only", + "automation_gate_status": "credential_approval_required", + "operator_summary": "5 個 escrow evidence marker 仍缺失;UI 必須維持 blocked,且不得暴露任何 credential value。", + "next_action": "顯示 blocked 狀態;任何 marker 更新都必須走 P1-105 credential escrow review package 與 HITL。", + "evidence_refs": [ + "scripts/backup/mark-credential-escrow-verified.sh", + "scripts/backup/offsite-escrow-evidence-report.sh", + "docs/runbooks/BACKUP-STATUS.md", + "docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json" + ], + "blocked_operations": [ + "credential_marker_write", + "credential_read", + "secret_plaintext_export", + "restore_execution", + "telegram_test_notification" + ] + }, + { + "card_id": "velero_k8s_resources", + "target_id": "velero_k8s_resources", + "display_name": "Velero K8s resource snapshots", + "kind": "k8s_resource_offsite", + "readiness": "action_required", + "offsite_status": "needs_metric_binding", + "escrow_status": "not_applicable", + "restore_drill_status": "approval_required", + "credential_exposure_status": "redacted_only", + "automation_gate_status": "restore_approval_required", + "operator_summary": "Velero / MinIO freshness 與 independent offsite evidence 仍需 metric binding,才能進入 restore drill 升級判定。", + "next_action": "顯示 action-required 狀態;restore drill 仍由 OpenClaw 仲裁與 HITL 批准阻擋。", + "evidence_refs": [ + "docs/runbooks/BACKUP-STATUS.md", + "k8s/awoooi-prod/16-cronjob-backup-restore-test.yaml", + "docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json" + ], + "blocked_operations": [ + "velero_restore", + "kubectl_apply", + "secret_restore", + "offsite_sync_execution", + "production_routing_change" + ] + } + ], + "operator_contract": { + "display_mode": "read_only_status", + "success_notification_policy": "已驗證的異地證據可進每日摘要;成功狀態不得觸發即時 Telegram / AwoooP 洗版。", + "failure_notification_policy": "escrow marker blocked、metric binding gap、verify failure 或 approval-required restore attempt 必須維持 action-required。", + "credential_display_policy": "只能顯示 redacted marker metadata 與 evidence refs;禁止顯示 token、password、private key、cookie、authorization header、runner token、webhook secret、rclone credential 與 secret payload value。", + "must_not_interpret_as": [ + "復原批准", + "異地同步批准", + "credential marker 寫入批准", + "secret 讀取批准", + "完整 DR 綠燈", + "生產路由批准" + ] + }, + "operation_boundaries": { + "read_only_status_allowed": true, + "backup_execution_allowed": false, + "restore_execution_allowed": false, + "offsite_sync_execution_allowed": false, + "credential_marker_write_allowed": false, + "credential_read_allowed": false, + "secret_plaintext_allowed": false, + "schedule_change_allowed": false, + "workflow_write_allowed": false, + "telegram_test_notification_allowed": false, + "destructive_prune_allowed": false, + "production_routing_allowed": false + }, + "approval_boundaries": { + "sdk_installation_allowed": false, + "paid_api_call_allowed": false, + "shadow_or_canary_allowed": false, + "production_routing_allowed": false, + "destructive_operation_allowed": false, + "restore_execution_allowed": false, + "offsite_sync_execution_allowed": false, + "credential_marker_write_allowed": false + } +} diff --git a/docs/schemas/offsite_escrow_readiness_status_v1.schema.json b/docs/schemas/offsite_escrow_readiness_status_v1.schema.json new file mode 100644 index 00000000..4d16baf6 --- /dev/null +++ b/docs/schemas/offsite_escrow_readiness_status_v1.schema.json @@ -0,0 +1,324 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:awoooi:offsite-escrow-readiness-status-v1", + "title": "AWOOOI 異地 / Escrow 準備度狀態 v1", + "description": "異地備份、credential escrow 與 K8s resource offsite readiness 的只讀狀態。此 schema 不授權 offsite sync、credential marker 寫入、secret 讀取、restore、workflow 寫入、Telegram 測試通知或任何生產操作。", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "generated_at", + "source_refs", + "program_status", + "rollups", + "readiness_cards", + "operator_contract", + "operation_boundaries", + "approval_boundaries" + ], + "properties": { + "schema_version": { + "const": "offsite_escrow_readiness_status_v1" + }, + "generated_at": { + "type": "string" + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "program_status": { + "type": "object", + "additionalProperties": false, + "required": [ + "overall_completion_percent", + "current_priority", + "current_task_id", + "next_task_id", + "read_only_mode" + ], + "properties": { + "overall_completion_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "current_priority": { + "enum": ["P0", "P1", "P2", "P3"] + }, + "current_task_id": { + "const": "P1-106" + }, + "next_task_id": { + "type": "string" + }, + "read_only_mode": { + "const": true + } + } + }, + "rollups": { + "type": "object", + "additionalProperties": false, + "required": [ + "total_cards", + "by_readiness", + "by_kind", + "verified_offsite_card_ids", + "blocked_escrow_card_ids", + "action_required_card_ids", + "execution_blocked_card_ids" + ], + "properties": { + "total_cards": { + "type": "integer", + "minimum": 0 + }, + "by_readiness": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "by_kind": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "verified_offsite_card_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_escrow_card_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "action_required_card_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "execution_blocked_card_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "readiness_cards": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "card_id", + "target_id", + "display_name", + "kind", + "readiness", + "offsite_status", + "escrow_status", + "restore_drill_status", + "credential_exposure_status", + "automation_gate_status", + "operator_summary", + "next_action", + "evidence_refs", + "blocked_operations" + ], + "properties": { + "card_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "kind": { + "enum": ["offsite_mirror", "credential_escrow", "k8s_resource_offsite"] + }, + "readiness": { + "enum": ["verified", "action_required", "blocked"] + }, + "offsite_status": { + "enum": ["verified", "needs_metric_binding", "blocked", "not_applicable"] + }, + "escrow_status": { + "enum": ["verified", "missing_markers", "blocked", "not_applicable"] + }, + "restore_drill_status": { + "enum": ["approval_required", "blocked", "not_applicable"] + }, + "credential_exposure_status": { + "enum": ["redacted_only", "not_applicable"] + }, + "automation_gate_status": { + "type": "string" + }, + "operator_summary": { + "type": "string" + }, + "next_action": { + "type": "string" + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "blocked_operations": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + }, + "operator_contract": { + "type": "object", + "additionalProperties": false, + "required": [ + "display_mode", + "success_notification_policy", + "failure_notification_policy", + "credential_display_policy", + "must_not_interpret_as" + ], + "properties": { + "display_mode": { + "const": "read_only_status" + }, + "success_notification_policy": { + "type": "string" + }, + "failure_notification_policy": { + "type": "string" + }, + "credential_display_policy": { + "type": "string" + }, + "must_not_interpret_as": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "operation_boundaries": { + "type": "object", + "additionalProperties": false, + "required": [ + "read_only_status_allowed", + "backup_execution_allowed", + "restore_execution_allowed", + "offsite_sync_execution_allowed", + "credential_marker_write_allowed", + "credential_read_allowed", + "secret_plaintext_allowed", + "schedule_change_allowed", + "workflow_write_allowed", + "telegram_test_notification_allowed", + "destructive_prune_allowed", + "production_routing_allowed" + ], + "properties": { + "read_only_status_allowed": { + "const": true + }, + "backup_execution_allowed": { + "const": false + }, + "restore_execution_allowed": { + "const": false + }, + "offsite_sync_execution_allowed": { + "const": false + }, + "credential_marker_write_allowed": { + "const": false + }, + "credential_read_allowed": { + "const": false + }, + "secret_plaintext_allowed": { + "const": false + }, + "schedule_change_allowed": { + "const": false + }, + "workflow_write_allowed": { + "const": false + }, + "telegram_test_notification_allowed": { + "const": false + }, + "destructive_prune_allowed": { + "const": false + }, + "production_routing_allowed": { + "const": false + } + } + }, + "approval_boundaries": { + "type": "object", + "additionalProperties": false, + "required": [ + "sdk_installation_allowed", + "paid_api_call_allowed", + "shadow_or_canary_allowed", + "production_routing_allowed", + "destructive_operation_allowed", + "restore_execution_allowed", + "offsite_sync_execution_allowed", + "credential_marker_write_allowed" + ], + "properties": { + "sdk_installation_allowed": { + "const": false + }, + "paid_api_call_allowed": { + "const": false + }, + "shadow_or_canary_allowed": { + "const": false + }, + "production_routing_allowed": { + "const": false + }, + "destructive_operation_allowed": { + "const": false + }, + "restore_execution_allowed": { + "const": false + }, + "offsite_sync_execution_allowed": { + "const": false + }, + "credential_marker_write_allowed": { + "const": false + } + } + } + } +} diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 9660092b..c0afabdd 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -3392,7 +3392,7 @@ Phase 6 完成後 2. P1-106:顯示異地 / escrow 準備度狀態。 3. P1-305 / P1-306:補任務批准邊界與進度彙總細節。 -**裁決:** P1-104 已完成,但仍只屬於 read-only evidence surface。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入或 Telegram 測試通知;不得把 Backup / DR UI 可見解讀成 full DR green。下一步只能產生復原演練與 escrow review 的批准包,必須保留 OpenClaw 仲裁與人工批准邊界。 +**裁決:** P1-104 已完成,但仍只屬於 read-only evidence surface。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入或 Telegram 測試通知;不得把 Backup / DR UI 可見解讀成完整 DR 綠燈。下一步只能產生復原演練與 escrow review 的批准包,必須保留 OpenClaw 仲裁與人工批准邊界。 ### 2026-06-05 凌晨 (台北) — P1-105 復原演練批准包模板完成 @@ -3418,3 +3418,28 @@ Phase 6 完成後 2. P1-305 / P1-306:補任務批准邊界與進度彙總細節。 **裁決:** P1-105 只完成批准包模板與只讀 API,不代表已批准任何 restore drill。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入、Telegram 測試通知、secret 明文輸出、destructive prune 或 production routing;blocked / action_required 目標不得被 UI 或 Agent 解讀成 ready。 + +### 2026-06-05 上午 (台北) — P1-106 異地 / Escrow 準備度狀態完成 + +**觸發**:統帥批准繼續,要求依 `docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md` 的優先順序推進,並同步工作完成度與狀態。 + +**已推進:** +- P1-106:建立異地 / Escrow 準備度只讀狀態,將 `offsite_rclone_full_sync`、`credential_escrow_markers`、`velero_k8s_resources` 從 Backup / DR readiness matrix 中抽成 operator 可掃描狀態卡。 +- 新增 `docs/schemas/offsite_escrow_readiness_status_v1.schema.json`,明確禁止 offsite sync、credential marker 寫入、credential read、secret 明文、restore、workflow 寫入、Telegram 測試通知、destructive prune 與 production routing。 +- 新增 `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json`;3 張狀態卡中 `offsite_rclone_full_sync=verified`、`credential_escrow_markers=blocked`、`velero_k8s_resources=action_required`,三者執行型操作全部 blocked。 +- 新增 `GET /api/v1/agents/offsite-escrow-readiness-status`,只讀取 committed snapshot,不呼叫外部來源、不碰 DB/Redis、不執行 backup / restore / offsite sync。 +- `/zh-TW/governance?tab=automation-inventory` 已接入異地 / Escrow 準備度區塊,顯示狀態卡、blocked execution、credential redaction policy 與不可解讀為完整 DR 綠燈的契約。 +- `docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json` 已將 `current_task_id` 推進到 `P1-106`、`next_task_id` 推進到 `P1-305`;WS4 備份與 DR 自動化推進到 `100%`。 +- `docs/evaluations/ai_agent_automation_backlog_2026-06-04.json` 新增 `AUTO-P1-106` done item,rollup 更新為 total `21`、P1 `19`、done `14`、read_only_allowed `18`、Hermes owner `11`。 + +**驗證:** +- P1-106 schema / snapshot JSON parse 通過。 +- P1-106 service / API / inventory / backlog 目標測試 `21 passed`。 +- `py_compile`、web typecheck、Next build、security guards 與 `git diff --check` 通過。 +- Production browser smoke 需等 Gitea CD 正式部署後回寫 `docs/LOGBOOK.md`。 + +**下一步:** +1. P1-305 / P1-306:補任務批准邊界與進度彙總細節。 +2. P1-001:盤點 API / Web / Worker / K8s runtime surface。 + +**裁決:** P1-106 只完成異地 / Escrow readiness 的 read-only status 與 UI 顯示,不代表 offsite sync、credential marker、restore drill 或完整 DR 綠燈已批准。成功 offsite evidence 不即時通知洗版;blocked / action-required 仍只能進 action-required 與人工批准流程。