diff --git a/apps/api/src/api/v1/telegram.py b/apps/api/src/api/v1/telegram.py index 00e87bda..9fdb5981 100644 --- a/apps/api/src/api/v1/telegram.py +++ b/apps/api/src/api/v1/telegram.py @@ -186,12 +186,18 @@ def _build_no_action_manual_handoff_payload(approval) -> dict: ) promotion_summary = _safe_str(metadata.get("repair_candidate_promotion_summary")) if not promotion_summary and promotion_contract: + runtime_state = ( + "controlled" + if promotion_contract.get("runtime_execution_authorized") is True + or promotion_contract.get("runtime_write_allowed") is True + else "false" + ) promotion_summary = ( f"route={promotion_contract.get('route_id') or '--'}; " f"promotion={promotion_contract.get('ready_count') or 0}/" f"{promotion_contract.get('total_count') or 0}; " f"blocked={promotion_contract.get('blocked_count') or 0}; " - f"runtime=false" + f"runtime={runtime_state}" ) return { diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 3d015452..e69ec587 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -5973,9 +5973,15 @@ def _repair_candidate_promotion_summary(contract: Mapping[str, Any]) -> str: total = _safe_int(contract.get("total_count")) blocked = _safe_int(contract.get("blocked_count")) status_value = str(contract.get("status") or "unknown") + runtime_state = ( + "controlled" + if contract.get("runtime_execution_authorized") is True + or contract.get("runtime_write_allowed") is True + else "false" + ) return ( f"route={route}; promotion={ready}/{total}; " - f"blocked={blocked}; status={status_value}; runtime=false" + f"blocked={blocked}; status={status_value}; runtime={runtime_state}" ) diff --git a/apps/api/src/services/repair_candidate_service.py b/apps/api/src/services/repair_candidate_service.py index 049dbdc4..ec9e2737 100644 --- a/apps/api/src/services/repair_candidate_service.py +++ b/apps/api/src/services/repair_candidate_service.py @@ -867,9 +867,15 @@ class RepairCandidateService: total = int(contract.get("total_count") or 0) blocked = int(contract.get("blocked_count") or 0) status = str(contract.get("status") or "unknown") + runtime_state = ( + "controlled" + if contract.get("runtime_execution_authorized") is True + or contract.get("runtime_write_allowed") is True + else "false" + ) return ( f"route={route}; promotion={ready}/{total}; " - f"blocked={blocked}; status={status}; runtime=false" + f"blocked={blocked}; status={status}; runtime={runtime_state}" ) def _draft_summary_for_operator(self, template: dict[str, Any]) -> str: diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index ffaabda6..ee17dcd8 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -61,6 +61,23 @@ from src.services.platform_operator_service import ( ) +def test_repair_candidate_promotion_summary_marks_controlled_runtime() -> None: + summary = platform_operator_service._repair_candidate_promotion_summary({ + "route_id": "ansible:188-ai-web", + "ready_count": 11, + "total_count": 11, + "blocked_count": 0, + "status": "controlled_apply_auto_authorized", + "runtime_execution_authorized": True, + "runtime_write_allowed": True, + }) + + assert summary == ( + "route=ansible:188-ai-web; promotion=11/11; " + "blocked=0; status=controlled_apply_auto_authorized; runtime=controlled" + ) + + def test_outbound_timeline_title_labels_runbook_review() -> None: title = _outbound_timeline_title( "telegram", diff --git a/apps/api/tests/test_repair_candidate_service.py b/apps/api/tests/test_repair_candidate_service.py index b7cab6ff..b2b76f1e 100644 --- a/apps/api/tests/test_repair_candidate_service.py +++ b/apps/api/tests/test_repair_candidate_service.py @@ -361,6 +361,7 @@ async def test_candidate_blocked_observe_only_prompts_repair_playbook_draft() -> assert promotion_contract["runtime_write_allowed"] is False assert result.metadata["repair_candidate_promotion_contract"] == promotion_contract assert "promotion=6/11" in result.metadata["repair_candidate_promotion_summary"] + assert "runtime=false" in result.metadata["repair_candidate_promotion_summary"] work_item = draft_package["awooop_work_item"] assert work_item["status"] == "owner_review_ready" assert work_item["next_action"] == "owner_review_repair_candidate_draft" @@ -375,6 +376,25 @@ async def test_candidate_blocked_observe_only_prompts_repair_playbook_draft() -> assert work_item["coverage_gap"]["next_owner_lane"] == "promote_diagnostic_to_repair_playbook" +def test_promotion_summary_marks_controlled_runtime_when_apply_gate_passes() -> None: + service = RepairCandidateService() + + summary = service._promotion_summary_for_operator({ + "route_id": "ansible:188-ai-web", + "ready_count": 11, + "total_count": 11, + "blocked_count": 0, + "status": "controlled_apply_auto_authorized", + "runtime_execution_authorized": True, + "runtime_write_allowed": True, + }) + + assert summary == ( + "route=ansible:188-ai-web; promotion=11/11; " + "blocked=0; status=controlled_apply_auto_authorized; runtime=controlled" + ) + + @pytest.mark.asyncio async def test_postgres_slow_query_gap_prefills_database_owner_review_not_restart() -> None: incident = _incident() diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 67638805..85780de2 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,72 @@ +## 2026-06-26|D1G IwoooS Wazuh live route 紅燈前移:Runtime board 與正式站讀回完成 + +**背景**:正式站已確認 `/api/iwooos/wazuh` 不是 registry empty,而是 `disabled_waiting_iwooos_wazuh_owner_gate`;過去這個狀態只在頁面下方 Wazuh 卡片可見,容易讓 Runtime 資安總板看起來像只剩靜態 snapshot。此段把 Wazuh 只讀路由的公開安全 aggregate 狀態接進 Runtime 資安讀回首屏,讓 disabled、misconfigured、empty、below expected、unavailable 都成為 P0 紅燈。 + +**完成內容**: +- 新增 `iwooos_wazuh_readonly_status.py`,將 Wazuh 只讀 metadata 邏輯從 FastAPI router 拆成可重用 service;仍只回 aggregate 與 agent alias,不保存 raw Wazuh payload、不公開 agent 原名、內網位址、token、password 或 secret。 +- `GET /api/v1/iwooos/runtime-security-readback` 新增 `wazuh_live_route` lane 與 `wazuh_live_*` summary;`p0_lane_count` 從 `6` 變 `7`。 +- `/zh-TW/iwooos` Runtime board 首屏新增 `Wazuh live` 摘要,顯示 `agent_total / status`,並把 disabled / empty / below expected 顯示為警示,不用 route 200 蓋過。 +- `wazuh-readonly-route-boundary-guard.py` 從掃 2 個 route 擴充為掃 3 個 source:Next route、FastAPI route、FastAPI Wazuh service;硬編內網 URL、Wazuh port、帳密、關 TLS、raw payload、假 SOC 文案仍全部阻擋。 + +**Commit / deploy**: +- Code commit:`9778cc22f feat(iwooos): surface Wazuh live route in runtime readback`。 +- 本段 deploy marker:`aa1e79ba5 chore(cd): deploy 9778cc2 [skip ci]`。 +- 最新正式 marker:`99cbe5022 chore(cd): deploy 4013c6a [skip ci]`,包含 `9778cc22f` 與後續 `4013c6a1a`。 +- Gitea:`#3539` code-review success;`#3538` CD 的 `tests` 與 `build-and-deploy` success 後被 deploy-marker / 後續 push 取消 post-check;最新 `#3542` code-review success、`#3541` CD success。額外 `#3540` validate 仍 queued,不阻擋 production deploy truth。 + +**正式 API 讀回**: +- `/api/v1/iwooos/runtime-security-readback?_v=4013c6a-wazuh-live-final`:`200`,`schema_version=iwooos_runtime_security_readback_v1`,`mode=committed_snapshot_readback_with_public_safe_wazuh_route_metadata`,`p0_lane_count=7`,`wazuh_live_status=disabled_waiting_iwooos_wazuh_owner_gate`,`wazuh_live_route_http_status=200`,`wazuh_live_route_degraded_count=1`,`wazuh_live_readonly_api_enabled_count=0`,`wazuh_live_agent_total=0`,`wazuh_live_metadata_available_count=0`,`runtime_gate_count=0`,`owner_response_accepted_count=0`,`wazuh_manager_registry_accepted_count=0`,`wazuh_live_route` lane 存在。 +- `/api/iwooos/wazuh?_v=4013c6a-final` 與 `/api/v1/iwooos/wazuh?_v=4013c6a-final`:`200 disabled_waiting_iwooos_wazuh_owner_gate`,`configured=false`,`readonly_api_enabled_count=0`,`runtime_gate_count=0`。 +- API response 均未含 `192.168.0.`、`工作視窗`、`批准!繼續`、`My request for Codex`、`In app browser`。 + +**正式站瀏覽器驗證**: +- Desktop `1280x900`:`/zh-TW/iwooos?_v=9778cc2-wazuh-live-route-desktop` 可見 `七條 P0 資安線`、`Wazuh live0/disabled_waiting_iwooos_wazuh_owner_gate`、`Wazuh 正式只讀路由`;console error `0`、horizontal overflow `false`、未出現內網 IP 或工作視窗內容。 +- Mobile `390x844`:`/zh-TW/iwooos?_v=4013c6a-wazuh-live-final-mobile` 可見 `七條 P0 資安線`、`Wazuh live`、`disabled_waiting_iwooos_wazuh_owner_gate`、`Wazuh 正式只讀路由`;`clientWidth=390`、`scrollWidth=384`、horizontal overflow `false`、console error `0`、未出現內網 IP 或工作視窗內容。 + +**驗證**: +- `pytest apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_api.py -q`:`10 passed`。 +- IwoooS / Telegram / operator 關鍵子集:`255 passed`。 +- `wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。 +- `security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。 +- `doc-secrets-sanity-check.py ...`:`DOC_SECRET_SANITY_OK scanned_files=274`。 +- `py_compile`、`json.tool`、`git diff --check`:通過。 +- `pnpm --dir apps/web typecheck`:本臨時 worktree 缺 `apps/web/node_modules/typescript`,未能本地執行;已由 Gitea CD 與 production browser readback 補正式驗證。 + +**完成度**: +- Wazuh live route 接入 Runtime board:正式站 `100%`。 +- IwoooS Runtime 資安讀回層:`94% -> 95%`。 +- IwoooS 整體資安推進:維持 `65%`;不因 route 可見、lane 接上或 CD success 虛增 runtime acceptance。 +- Wazuh live metadata enable:仍 `0%`。 +- Wazuh manager registry accepted:仍 `0`。 +- Owner response accepted、runtime acceptance、active response、host write、Kali active scan、Telegram send、secret collection:仍全部 `0 / false`。 + +**下一個 P0**:補 Wazuh live metadata owner gate、server-side secret source metadata、readonly account scope、manager health ref、TLS validation ref、post-enable readback,之後才可進 manager registry 全量交叉驗收;仍不得直接寫 secret、重啟 Wazuh、重新註冊 agent、改 Nginx / firewall、SSH 主機或啟用 active response。 + +## 2026-06-26|D1H 修復候選 promotion summary:受控執行不再誤顯示 runtime=false + +**背景**:使用者持續指出 Telegram / AwoooP 告警看起來幾乎都停在人工處理。D1F 已把低 / 中 / 高風險受控自動化契約更新為 controlled apply,但程式內仍有三個摘要路徑把所有 `repair_candidate_promotion_contract` 固定輸出成 `runtime=false`,導致即使合約已帶有 `runtime_execution_authorized=true` 或 `runtime_write_allowed=true`,Telegram / AwoooP operator summary 仍會被誤讀為完全沒有自動化。 + +**完成內容**: +- `platform_operator_service` 的 `_repair_candidate_promotion_summary` 改為讀取 promotion contract 的 `runtime_execution_authorized` / `runtime_write_allowed`。 +- `repair_candidate_service` 的 `_promotion_summary_for_operator` 同步採用同一判讀。 +- Telegram fallback handoff payload 在缺少既有 summary 時,也依 promotion contract 產生 `runtime=controlled` 或 `runtime=false`。 +- blocked / owner review 草案仍維持 `runtime=false`;只有已通過受控執行條件的合約才顯示 `runtime=controlled`,避免把未過安全門的事件誤標成可執行。 + +**驗證**: +- `apps/api/venv/bin/python -m pytest apps/api/tests/test_repair_candidate_service.py apps/api/tests/test_awooop_operator_timeline_labels.py -q`:`77 passed`。 +- `apps/api/venv/bin/python -m py_compile apps/api/src/services/platform_operator_service.py apps/api/src/services/repair_candidate_service.py apps/api/src/api/v1/telegram.py`:通過。 +- `git diff --check`:通過。 +- `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`。 +- 本機 Telegram 目標測試因 `apps/api/venv` 缺少 `opentelemetry`,在 collection 階段停止;需以 Gitea CD 完整測試環境作最終驗證。 + +**完成度 / 邊界**: +- Promotion summary runtime truthfulness:`100%`。 +- Telegram / AwoooP 告警自動化可追蹤性:`99% -> 99.5%`。 +- 真正 AI 自動化 runtime 閉環:仍需新 incident / 重診驗證 controlled apply worker、post-apply verifier、KM / PlayBook trust writeback。 +- 本段沒有開啟 runtime gate、沒有執行 Ansible apply、沒有 SSH、沒有 service restart、沒有 Telegram live send、沒有 secret read、沒有 provider switch。 + ## 2026-06-26|D1F AI Agent 受控自動化契約:低 / 中 / 高風險不再停在人工審核 **背景**:使用者明確修正方向:低、中、高風險都必須由 AI Agent 走受控自動化處理,高風險不再預設等待人工審核;只有 critical / secret / destructive / paid / force-push 等 break-glass 邊界需保留。盤點後確認部分報表、Schema、API 型別與 AI 技術雷達日週月報仍殘留 `high risk owner review`、`current_execution_enabled=false` 或「高風險必須人工」語意。