From 4c951b299638a62f1b627f5e8915c4ba3ff972b4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 20:15:01 +0800 Subject: [PATCH 1/7] fix(ci): keep 110 runner inactive until pressure clears --- .gitea/workflows/ansible-lint.yml | 2 +- .gitea/workflows/cd.yaml | 6 ++++ docs/LOGBOOK.md | 29 +++++++++++++++ ops/runner/README.md | 36 +++++++++++++------ ops/runner/audit-workflow-labels.py | 10 ++++-- .../check-runner-isolation-readiness.sh | 2 +- scripts/reboot-recovery/awoooi-startup-110.sh | 29 +++++++++------ 7 files changed, 90 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/ansible-lint.yml b/.gitea/workflows/ansible-lint.yml index 78ee0792..8644e8ca 100644 --- a/.gitea/workflows/ansible-lint.yml +++ b/.gitea/workflows/ansible-lint.yml @@ -26,7 +26,7 @@ on: jobs: validate: - runs-on: self-hosted + runs-on: awoooi-ubuntu timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index ec1403aa..011e388e 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1245,6 +1245,12 @@ jobs: - uses: actions/checkout@v4 + - name: Wait for Host Web Build Pressure + # 2026-06-27 Codex: post-deploy Playwright smoke is browser-heavy too. + # Refuse to add another smoke run while 110 already has CI/build/smoke + # pressure; this gate is read-only and never kills other repo work. + run: bash scripts/ci/wait-host-web-build-pressure.sh + - name: Get Commit Info id: commit run: | diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 24238127..dd1188f0 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,32 @@ +## 2026-06-27|110 Gitea runner 降壓防回彈與 workflow label 收斂 + +**背景**:110 CPU 事故已確認主因是 Gitea runner 反覆拉起 StockPlatform headless Chrome smoke;前一輪已停止 `gitea-act-runner-host.service`、清掉 Actions / smoke,並把 live runner labels 收斂為 `awoooi-ubuntu` / `awoooi-host`。本輪目標是防止 cold-start / startup 流程把 runner 又自動拉起,並補齊 AWOOI workflow label 與 post-deploy pressure gate。 + +**完成內容**: +- `.gitea/workflows/cd.yaml` 的 `post-deploy-checks` 在 checkout 後新增 `Wait for Host Web Build Pressure`,避免 Alert Chain / Source Link / Monitoring / Playwright smoke 疊到 110 既有 build / smoke / load 壓力。 +- `.gitea/workflows/ansible-lint.yml` 從 `self-hosted` 收斂為 `awoooi-ubuntu`;AWOOI workflows 目前只剩 `awoooi-ubuntu` / `awoooi-host` 兩類 label。 +- `scripts/reboot-recovery/awoooi-startup-110.sh` 改成預設不自動啟動 Gitea host runner;只有明確設定 `AWOOOI_START_GITEA_RUNNER_ON_BOOT=1` 才允許 startup 拉起 runner。 +- live `/usr/local/bin/awoooi-startup-110.sh` 已安裝新版,舊檔備份為 `/usr/local/bin/awoooi-startup-110.sh.bak-20260627-runner-inactive`;本輪沒有執行 startup script,也沒有重啟 runner。 +- `ops/runner/audit-workflow-labels.py` 修正 local fallback,沒有 Gitea auth 但指定 `--local-repo` 時不再輸出假空白。 +- `ops/runner/check-runner-isolation-readiness.sh` 認得 `awoooi-ubuntu`,避免把新 label 誤判成 unknown / mixed owner。 +- `ops/runner/README.md` 更新 2026-06-27 runner 降壓狀態、hard-fail pressure gate、startup 開關與 workflow label 邊界。 + +**驗證結果**: +- `bash -n scripts/reboot-recovery/awoooi-startup-110.sh scripts/ci/wait-host-web-build-pressure.sh ops/runner/check-runner-isolation-readiness.sh ops/runner/audit-runner-pool.sh`:通過。 +- `python3 -m py_compile ops/runner/audit-workflow-labels.py scripts/ops/host-runaway-process-exporter.py`:通過。 +- Gitea workflow YAML parse:10 個 workflow 全部通過。 +- `rg "runs-on: (ubuntu-latest|self-hosted|ubuntu-22.04|ubuntu-24.04)" .gitea/workflows`:無命中。 +- `ops/runner/audit-workflow-labels.py --repo wooo/awoooi --local-repo wooo/awoooi=/Users/ogt/awoooi`:labels 只剩 `awoooi-host` / `awoooi-ubuntu`。 +- 110 readback:`gitea-act-runner-host.service=inactive`、Actions containers `0`、active CI groups `0`、StockPlatform orphan groups `0`。 +- 110 readiness:primary labels `awoooi-ubuntu` / `awoooi-host` 均為 `awoooi_dedicated`,`mixed_owner_classes=0`,active action containers `none`。 +- 110 pressure gate 目前 `GATE_RC=1`,原因是 `load5/core 0.886667 > 0.85`;top process 顯示主要是 `restic` 6h backup,不是 Gitea Actions / Chrome smoke 事故復燃。 +- 110 local Gitea / Sentry / Alertmanager / Grafana health readback:`200 / 302 / 200 / 200`。 + +**邊界與下一步**: +- runner inactive 是刻意降壓;未完成限流 / 搬遷前不可直接重開。 +- 本輪未重啟 Docker / Nginx / firewall / K3s,未 kill process,未讀 raw sessions / SQLite / auth / secret。 +- 下一個 P0:把 StockPlatform smoke 改成排程限流或搬到非 110 runner;再做全主機 cold-start scorecard 與資料 freshness readback。 + ## 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。 diff --git a/ops/runner/README.md b/ops/runner/README.md index 7aa2d855..bd1d015b 100644 --- a/ops/runner/README.md +++ b/ops/runner/README.md @@ -132,9 +132,9 @@ runner: | Job | runner label | 用途 | |-----|--------------|------| -| `tests` | `ubuntu-latest` | API unit + B5 integration tests,仍跑在 ci-runner container | +| `tests` | `awoooi-host` | API unit + B5 integration tests,直接跑在 110 host runner | | `build-and-deploy` | `awoooi-host` | Harbor login、API/Web image build/push、GitOps deploy,直接跑在 110 host | -| `post-deploy-checks` | `ubuntu-latest` | Alert chain、monitoring coverage、Playwright smoke | +| `post-deploy-checks` | `awoooi-host` | Alert chain、monitoring coverage、Playwright smoke | 110 只保留 host-level `act_runner` daemon,並在同一份 config 宣告兩類 label: @@ -143,9 +143,7 @@ runner: capacity: 1 shutdown_timeout: 1h labels: - - "ubuntu-latest:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04" - - "ubuntu-22.04:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04" - - "ubuntu-24.04:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04" + - "awoooi-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04" - "awoooi-host:host" ``` @@ -208,15 +206,27 @@ AWOOI 的 Docker lock,會和 AWOOI Web image 內的 Next production build 疊 - 只讀取 `ps`,不 kill / renice / reset 任何外部 process。 - 排除 AWOOI 自身 checkout、local worktree 與 Web Docker build 內的 `/app/apps/web` process,避免誤判自己的部署。 -- 預設最多等待 60 次、每次 10 秒;若仍有外部 build,先以 warning 放行, - 避免 CD 永久卡住。 -- 可用 `HOST_WEB_BUILD_PRESSURE_WARN_ONLY=0` 改成 hard fail,但必須先確認 - runner 隔離與其他 repo build 排程已收斂,避免把 shared runner 壓力轉成 - 部署中斷。 +- 預設最多等待 60 次、每次 10 秒;若仍有外部 build / smoke / CI 壓力, + hard fail,避免繼續把新的 browser smoke 疊到 production host。 +- 只有明確設定 `HOST_WEB_BUILD_PRESSURE_WARN_ONLY=1` 才 warning 放行;這只能 + 用在已確認壓力來源可接受的受控補跑。 長期方向仍是 runner 隔離或 build offload;此 gate 是在 shared runner 尚未 拆分前,降低重型前端 build 互相踩踏的保守保護層。 +### 第四層補充: startup 不自動重開 Gitea runner + +2026-06-27 110 CPU 事故止血後,`gitea-act-runner-host.service` 維持 inactive 是 +刻意降壓狀態。`scripts/reboot-recovery/awoooi-startup-110.sh` 仍可修正 runner +`shutdown_timeout` 與 labels,也會停用 legacy Docker runner,但預設不會啟動 +host runner。只有明確設定下列開關時才允許 startup 拉起 runner: + +```bash +AWOOOI_START_GITEA_RUNNER_ON_BOOT=1 /usr/local/bin/awoooi-startup-110.sh +``` + +未完成 runner 限流 / 搬遷前,不要把這個開關加入 systemd environment。 + ### 第五層修復: legacy Docker runner drain 2026-05-21 再次確認 110 同時存在兩個 runner: @@ -370,6 +380,12 @@ runner registration / service: 三個 split runner smoke 都通過後,才 drain primary runner 並移除混合 labels。 +2026-06-27 live update:110 的 `gitea-act-runner-host.service` 已刻意停在 +`inactive`;`/home/wooo/act-runner/config.yaml` labels 已收斂為 +`awoooi-ubuntu` 與 `awoooi-host`,capacity 仍為 `1`。這是降壓與 label isolation +狀態;AWOOI workflows 也應只使用 `awoooi-ubuntu` 或 `awoooi-host`,不可再使用 +`ubuntu-latest` / `self-hosted` 這類泛用 label。這不代表 runner 搬遷完成,也不代表可以直接重開 runner。 + --- 版本: v2.0 | 更新: 2026-03-29 | 作者: Claude Code 變更: v1.0→v2.0 序列建構取代 Job Concurrency Groups diff --git a/ops/runner/audit-workflow-labels.py b/ops/runner/audit-workflow-labels.py index 37a3db34..e1b8c201 100755 --- a/ops/runner/audit-workflow-labels.py +++ b/ops/runner/audit-workflow-labels.py @@ -179,7 +179,7 @@ def fetch_local_labels(repo: str, branch: str, repo_path: Path) -> tuple[list[Wo def label_owner(label: str) -> str: value = label.strip().strip("'\"") - if value == "awoooi-host": + if value in {"awoooi-host", "awoooi-ubuntu"}: return "awoooi_dedicated" if value == "ewoooc-host": return "foreign_dedicated" @@ -234,7 +234,13 @@ def main() -> int: error: str | None = None if auth is not None: repo_labels, error = fetch_gitea_labels(repo, args.branch, auth) - elif repo not in local_paths: + elif repo in local_paths: + repo_labels, local_error = fetch_local_labels(repo, args.branch, local_paths[repo]) + if local_error: + errors.append(f"{repo}: {local_error}") + labels.extend(repo_labels) + continue + else: error = "gitea_auth_unavailable" if error and repo in local_paths: diff --git a/ops/runner/check-runner-isolation-readiness.sh b/ops/runner/check-runner-isolation-readiness.sh index 7e24ae7b..d68d21a6 100755 --- a/ops/runner/check-runner-isolation-readiness.sh +++ b/ops/runner/check-runner-isolation-readiness.sh @@ -70,7 +70,7 @@ label_owner() { local label="$1" local label_name="${label%%:*}" case "$label_name" in - awoooi-host) + awoooi-host|awoooi-ubuntu|awoooi-*) printf 'awoooi_dedicated' ;; ewoooc-host) diff --git a/scripts/reboot-recovery/awoooi-startup-110.sh b/scripts/reboot-recovery/awoooi-startup-110.sh index ec566614..607e7464 100644 --- a/scripts/reboot-recovery/awoooi-startup-110.sh +++ b/scripts/reboot-recovery/awoooi-startup-110.sh @@ -184,15 +184,18 @@ fi # ────────────────────────────────────────────── # STEP 6: Gitea Act Runner(CI/CD 核心) # 2026-04-05 Claude Code: 加入 — 解決重開機後 Gitea runner 離線、CD 失效 -# 重要:必須在 Gitea server 啟動後才能啟動 runner +# 2026-06-27 Codex: 110 是 production / registry / observability 主機; +# runner 預設維持停用降壓,未完成限流 / 搬遷前不可在 startup 自動拉起。 # ────────────────────────────────────────────── -log "[6/6] 啟動 Gitea Act Runner..." +log "[6/6] 檢查 Gitea Act Runner(預設不自動啟動)..." RUNNER_DIR="/home/wooo/act-runner" RUNNER_SERVICE="gitea-act-runner-host.service" +START_GITEA_RUNNER_ON_BOOT="${AWOOOI_START_GITEA_RUNNER_ON_BOOT:-0}" if [ -x "$RUNNER_DIR/act_runner" ] && [ -f "$RUNNER_DIR/config.yaml" ]; then - # 若舊的 .runner 配置指向過期 hostname,先清除讓 runner 重新註冊 + # 若舊的 .runner 配置指向過期 hostname,只有在明確允許啟動 runner + # 時才清除重新註冊;預設降壓模式不得碰 registration 狀態。 RUNNER_FILE="$RUNNER_DIR/data/.runner" - if [ -f "$RUNNER_FILE" ]; then + if [ "$START_GITEA_RUNNER_ON_BOOT" = "1" ] && [ -f "$RUNNER_FILE" ]; then OLD_URL=$(python3 -c "import json; d=json.load(open('$RUNNER_FILE')); print(d.get('address',''))" 2>/dev/null || echo "") if [ "$OLD_URL" != "http://192.168.0.110:3001" ]; then log "⚠️ runner 配置過期 ($OLD_URL),清除重新註冊..." @@ -248,10 +251,14 @@ while idx < len(lines): path.write_text("\n".join(output) + "\n") PY - if systemctl list-unit-files "$RUNNER_SERVICE" >/dev/null 2>&1; then - systemctl enable --now "$RUNNER_SERVICE" >/dev/null 2>&1 || true - elif ! pgrep -f "$RUNNER_DIR/act_runner daemon" >/dev/null; then - nohup "$RUNNER_DIR/run-host-runner.sh" >> "$RUNNER_DIR/host-runner.log" 2>&1 & + if [ "$START_GITEA_RUNNER_ON_BOOT" = "1" ]; then + if systemctl list-unit-files "$RUNNER_SERVICE" >/dev/null 2>&1; then + systemctl enable --now "$RUNNER_SERVICE" >/dev/null 2>&1 || true + elif ! pgrep -f "$RUNNER_DIR/act_runner daemon" >/dev/null; then + nohup "$RUNNER_DIR/run-host-runner.sh" >> "$RUNNER_DIR/host-runner.log" 2>&1 & + fi + else + log "⏸️ Gitea host runner 維持停用;設定 AWOOOI_START_GITEA_RUNNER_ON_BOOT=1 才允許 startup 啟動" fi # 已停用 Docker-wrapped runner;避免它搶走 host label job。 @@ -269,9 +276,11 @@ PY # 驗證 runner 已連線 Gitea if pgrep -f "$RUNNER_DIR/act_runner daemon" >/dev/null; then - log "✅ Gitea host act_runner 已啟動" - else + log "⚠️ Gitea host act_runner 目前正在執行;請確認是否為受控限流 / 搬遷後狀態" + elif [ "$START_GITEA_RUNNER_ON_BOOT" = "1" ]; then log "⚠️ Gitea host act_runner 可能尚未啟動,查看: $RUNNER_DIR/host-runner.log" + else + log "✅ Gitea host act_runner 維持 inactive 降壓狀態" fi else log "⚠️ 找不到 act-runner binary/config: $RUNNER_DIR" From 073141abcb7091718d49362a7efa1615c08f3b03 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 20:17:14 +0800 Subject: [PATCH 2/7] docs(ops): record 110 runner pressure closeout --- docs/LOGBOOK.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index dd1188f0..53e5be79 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -7,6 +7,7 @@ - `.gitea/workflows/ansible-lint.yml` 從 `self-hosted` 收斂為 `awoooi-ubuntu`;AWOOI workflows 目前只剩 `awoooi-ubuntu` / `awoooi-host` 兩類 label。 - `scripts/reboot-recovery/awoooi-startup-110.sh` 改成預設不自動啟動 Gitea host runner;只有明確設定 `AWOOOI_START_GITEA_RUNNER_ON_BOOT=1` 才允許 startup 拉起 runner。 - live `/usr/local/bin/awoooi-startup-110.sh` 已安裝新版,舊檔備份為 `/usr/local/bin/awoooi-startup-110.sh.bak-20260627-runner-inactive`;本輪沒有執行 startup script,也沒有重啟 runner。 +- closeout 時發現 Docker-wrapped `gitea-runner` 短暫回彈為 running;確認 active task containers `0` 後,已只針對 `gitea-runner` 執行 `docker update --restart=no` 與 `docker stop -t 60`,恢復 `Restart=no Status=exited Running=false`。 - `ops/runner/audit-workflow-labels.py` 修正 local fallback,沒有 Gitea auth 但指定 `--local-repo` 時不再輸出假空白。 - `ops/runner/check-runner-isolation-readiness.sh` 認得 `awoooi-ubuntu`,避免把新 label 誤判成 unknown / mixed owner。 - `ops/runner/README.md` 更新 2026-06-27 runner 降壓狀態、hard-fail pressure gate、startup 開關與 workflow label 邊界。 @@ -18,6 +19,7 @@ - `rg "runs-on: (ubuntu-latest|self-hosted|ubuntu-22.04|ubuntu-24.04)" .gitea/workflows`:無命中。 - `ops/runner/audit-workflow-labels.py --repo wooo/awoooi --local-repo wooo/awoooi=/Users/ogt/awoooi`:labels 只剩 `awoooi-host` / `awoooi-ubuntu`。 - 110 readback:`gitea-act-runner-host.service=inactive`、Actions containers `0`、active CI groups `0`、StockPlatform orphan groups `0`。 +- Docker-wrapped `gitea-runner`:`Restart=no Status=exited Running=false`。 - 110 readiness:primary labels `awoooi-ubuntu` / `awoooi-host` 均為 `awoooi_dedicated`,`mixed_owner_classes=0`,active action containers `none`。 - 110 pressure gate 目前 `GATE_RC=1`,原因是 `load5/core 0.886667 > 0.85`;top process 顯示主要是 `restic` 6h backup,不是 Gitea Actions / Chrome smoke 事故復燃。 - 110 local Gitea / Sentry / Alertmanager / Grafana health readback:`200 / 302 / 200 / 200`。 From 03f39d3c58699dd798aecf96c7eb56fb43b1565a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 21:02:10 +0800 Subject: [PATCH 3/7] chore(recovery): add momo source arrival gate --- docs/LOGBOOK.md | 48 ++++ docs/runbooks/FULL-STACK-COLD-START-SOP.md | 2 + .../momo-source-arrival-gate.py | 253 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100755 scripts/reboot-recovery/momo-source-arrival-gate.py diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 53e5be79..cd81a854 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,51 @@ +## 2026-06-27|MOMO daily-sales source absence readback 與 cold-start blocker + +**背景**:110 runner / StockPlatform smoke 壓力已止血後,重新跑全主機 cold-start scorecard 與資料 freshness。AWOOOI / IwoooS / Stock / 188 主要 public routes 可用,但整體 cold-start 仍不能宣告 full green;目前主要業務資料 blocker 是 188 MOMO daily sales freshness。 + +**執行邊界**: +- 本輪只做 read-only preflight、log readback、檔名 / mtime / size 層級來源搜尋與 scorecard 彙整。 +- 未做 DB write / truncate / restore / manual import,未移動 Drive 檔案,未重啟 Docker / Nginx / K3s / scheduler,未讀 token value、raw session、SQLite、`.env` 或 secret。 + +**cold-start / scorecard 結果**: +- `scripts/reboot-recovery/post-reboot-readiness-summary.sh` artifact:`/tmp/awoooi-post-reboot-readiness-20260627-codex-rerun/summary.txt`。 +- `POST_START_RESULT=BLOCKED`、`POST_START_PASS=37`、`POST_START_WARN=3`、`POST_START_BLOCKED=2`、`SERVICE_GREEN=0`。 +- `PRODUCT_DATA_GREEN=1`、Stock freshness `ok`,latest trading date `2026-06-26`,`STOCK_BLOCKERS=none`。 +- `BACKUP_CORE_GREEN=1`,但 `DR_ESCROW_BLOCKED=1`、`ESCROW_MISSING_COUNT=5`。 +- Wazuh route `200`,但 `WAZUH_MANAGER_REGISTRY_ACCEPTED=0`、`WAZUH_RUNTIME_GATE=0`、`RUNTIME_ACTION_AUTHORIZED=0`。 +- 直接 cold-start rerun:`PASS=88`、`WARN=0`、`BLOCKED=1`;唯一 blocker 是 `188 momo daily sales data stale beyond 3 days`。 +- 20:48 next-gate dispatch 使用同一份 summary 回傳 `DISPATCH_RC=2`、`SERVICE_GREEN=0`、`NEXT_REQUIRED_GATES=credential_escrow_evidence,wazuh_manager_registry_export`、`DISPATCH_AUTHORIZED=0`、`REQUEST_SENT_COUNT=0`、`HOST_WRITE_AUTHORIZED=0`、`SECRET_VALUE_COLLECTION_ALLOWED=0`,並停在 `NEXT_STEP=restore_service_before_boundary_dispatch`;因此目前不可把 escrow / Wazuh gates 當成已可送出的 owner packet。 + +**MOMO readback 結果**: +- `scripts/reboot-recovery/momo-drive-token-source-recovery-preflight.sh` 結果:`PASS=20`、`WARN=3`、`BLOCKED=2`。 +- MOMO health:local / public health 皆 `200`,runtime version `V10.725`,app health `healthy`。 +- DB daily range:`109061|2025-07-01|2026-06-24`;freshness `3|2026-06-24`。 +- current monthly 與 sync snapshot parity:`15383|15383|2026-06-01|2026-06-24|2026-06-01|2026-06-24`。 +- latest import job `57`:`completed|即時業績_當日.xlsx|15383|15383|0`,表示 2026-06-25 13:16-13:18 的已匯入來源處理乾淨,但資料仍只到 `2026-06-24`。 +- Drive pending intake:`LOCAL_EXACT_DAILY_SOURCE_COUNT=0`;archive / global latest evidence 仍停在 `2026-06-25T04:21:47.000Z`。 +- `momo-scheduler` 36h log 顯示 Google Drive 連線成功、定期檢查 `當日業績匯入`,但多次回報找不到 Excel;scheduler 是 healthy / registered,不是目前 freshness blocker 的主因。 +- 188 與本機安全範圍檔名搜尋只找到舊 `即時業績_當日_20260112.xlsx` 候選,未找到可用於 2026-06-26 / 2026-06-27 的合法 daily-sales source。 +- 20:54 二次 preflight 仍為 `DRIVE_INTAKE_COUNT=0`、archive / global latest `2026-06-25T04:21:47.000Z`、DB daily freshness `3|2026-06-24`、latest import job `57 completed`;`momo-pro-system` / `momo-scheduler` containers 仍 healthy,且最近 scheduler log 只有排程註冊與一般 warning,沒有新 Excel 入站或成功匯入證據。 + +**DR / Wazuh gate readback**: +- 110 `/backup/scripts/offsite-escrow-evidence-report.sh --no-color` 顯示 rclone offsite configured、full offsite marker fresh、local backup repos checkable,但 5 個 credential escrow marker 全缺:`restic_repository_password`、`offsite_provider_credentials`、`break_glass_admin_credentials`、`dns_registrar_recovery`、`oauth_ai_provider_recovery`。 +- `scripts/security/wazuh-manager-registry-reviewer-validation.py` 通過 repo contract validation,但 snapshot 仍是 `received=0 accepted=0 runtime_gate=0`;route / transport / index pattern 不能替代 manager registry accepted。 +- 本輪沒有寫 escrow marker,沒有產生 owner response,沒有查 Wazuh live API / secret,也沒有 Wazuh active response、agent re-enroll、restart、host write 或 Kali active scan。 + +**2026-06-27 21:00 gate 補強**: +- 新增 `scripts/reboot-recovery/momo-source-arrival-gate.py`,只解析 `momo-drive-token-source-recovery-preflight.sh` 產出的 log 或 stdin,不連線、不查 token、不 import、不移動 Drive、不寫 DB。 +- 真實 20:54 preflight log 驗證:`MOMO_SOURCE_ARRIVAL_GATE status=blocked_source_absent_fail_closed source_intake=0 freshness=3|2026-06-24 safe_import_preflight_allowed=0 runtime_write_authorized=0 db_write_authorized=0 drive_move_authorized=0 next_step=wait_for_legitimate_daily_sales_source_then_rerun_gate`,exit code `2`。 +- 合成 source-arrived case 驗證:Drive intake count `1` 且 freshness stale 時,只回 `source_arrived_ready_for_safe_import_preflight`、`safe_import_preflight_allowed=1`,仍固定 `runtime_write_authorized=0`、`db_write_authorized=0`、`drive_move_authorized=0`。 +- 合成 freshness-green case 驗證:freshness `1|2026-06-26` 時回 `freshness_already_green_recheck_cold_start`,下一步仍是重跑 post-reboot summary,不得直接宣告 full green。 + +**結論**: +- 目前狀態是 `SERVICE_BLOCKED_MOMO_SOURCE_ABSENCE` / `SOURCE_ABSENT_FAIL_CLOSED`,不是 runner、Docker、Nginx、K3s 或 scheduler 事故。 +- 禁止用舊 archive、舊 sample、本機舊檔、手寫 DB、truncate / restore 或 manual Drive movement 製造 freshness 假綠。 +- 解除 blocker 需要新的合法 `即時業績_當日` source 出現在 `當日業績匯入`,或 owner-approved safe source evidence ref;之後才可在 maintenance-safe path 執行匯入,並要求 `sync_success=true`、source 只在成功後移動、daily snapshot / realtime monthly bounds 一致、freshness `<=2`,再重跑 cold-start scorecard。 + +**下一步**: +- 保持 fail-closed,等待合法來源到位後做 read-only preflight recheck。 +- 若有 owner-approved source evidence ref,另開 maintenance window 走安全匯入路徑;仍不得在沒有來源證據時宣告 all-green。 + ## 2026-06-27|110 Gitea runner 降壓防回彈與 workflow label 收斂 **背景**:110 CPU 事故已確認主因是 Gitea runner 反覆拉起 StockPlatform headless Chrome smoke;前一輪已停止 `gitea-act-runner-host.service`、清掉 Actions / smoke,並把 live runner labels 收斂為 `awoooi-ubuntu` / `awoooi-host`。本輪目標是防止 cold-start / startup 流程把 runner 又自動拉起,並補齊 AWOOI workflow label 與 post-deploy pressure gate。 diff --git a/docs/runbooks/FULL-STACK-COLD-START-SOP.md b/docs/runbooks/FULL-STACK-COLD-START-SOP.md index 4c2122fb..fc7896ec 100644 --- a/docs/runbooks/FULL-STACK-COLD-START-SOP.md +++ b/docs/runbooks/FULL-STACK-COLD-START-SOP.md @@ -296,6 +296,8 @@ NO-GO: truncate, whole-DB restore, manual Drive movement, or manual import witho UNBLOCK: new legitimate PChome daily-sales source appears in 當日業績匯入 or an owner-approved safe import path; import job succeeds with sync_success=true; source file moves only after success; daily_sales_snapshot and realtime_sales_monthly bounds match; MOMO_DAILY_FRESHNESS <= 2. ``` +2026-06-27 起,若已有 `momo-drive-token-source-recovery-preflight.sh` log,先跑 `python3 scripts/reboot-recovery/momo-source-arrival-gate.py --preflight-log ` 做機器判讀:`blocked_source_absent_fail_closed` 代表繼續等合法來源;`source_arrived_ready_for_safe_import_preflight` 只代表可進另一個 safe import preflight,不代表 DB write、Drive move、manual import 或 runtime write 已授權;`freshness_already_green_recheck_cold_start` 仍必須重跑同一 evidence chain 的 post-reboot summary 後才能更新恢復宣告。 + 所有回報必須使用這組詞,避免把「服務面可用」誤報成「整體 DR 完成」。 ### 0.3 Codex 工作站交接判定 diff --git a/scripts/reboot-recovery/momo-source-arrival-gate.py b/scripts/reboot-recovery/momo-source-arrival-gate.py new file mode 100755 index 00000000..35496f55 --- /dev/null +++ b/scripts/reboot-recovery/momo-source-arrival-gate.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Classify MOMO daily-sales source arrival from a read-only preflight log. + +This parser never connects to MOMO, never imports files, never moves Drive +artifacts, and never authorizes DB / host / Drive writes. It turns the existing +`momo-drive-token-source-recovery-preflight.sh` evidence into a compact gate so +operators can tell whether they should keep waiting for a legitimate source or +start a separate safe-import preflight. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +EXPECTED_IMPORT_CONFIG = "當日業績匯入|即時業績_當日" +SUMMARY_RE = re.compile( + r"^MOMO_DRIVE_TOKEN_SOURCE_PREFLIGHT " + r"PASS=(?P\d+) WARN=(?P\d+) BLOCKED=(?P\d+) " + r"HOST=(?P\S+) FRESHNESS_MAX_DAYS=(?P\d+)" +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Classify MOMO source-arrival readiness from preflight output.", + ) + parser.add_argument( + "--preflight-log", + required=True, + help="Path to momo-drive-token-source-recovery-preflight output, or '-' for stdin.", + ) + parser.add_argument("--json", action="store_true", help="Print JSON result.") + return parser.parse_args() + + +def load_text(source: str) -> str: + if source == "-": + return sys.stdin.read() + return Path(source).read_text(encoding="utf-8") + + +def parse_int(value: Any, default: int | None = None) -> int | None: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def parse_pipe(value: str, expected_parts: int) -> list[str]: + parts = str(value or "").split("|") + if len(parts) < expected_parts: + parts.extend([""] * (expected_parts - len(parts))) + return parts[:expected_parts] + + +def parse_preflight(text: str) -> dict[str, Any]: + values: dict[str, str] = {} + messages = {"ok": [], "warn": [], "blocked": []} + summary: dict[str, Any] = {} + + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + summary_match = SUMMARY_RE.match(line) + if summary_match: + summary = { + key: parse_int(value) if key != "host" else value + for key, value in summary_match.groupdict().items() + } + continue + if line.startswith("OK: "): + messages["ok"].append(line[4:]) + continue + if line.startswith("WARN: "): + messages["warn"].append(line[6:]) + continue + if line.startswith("BLOCKED: "): + messages["blocked"].append(line[9:]) + continue + if re.match(r"^[A-Z][A-Z0-9_]+(?:\s|$)", line): + key, _, value = line.partition(" ") + values[key] = value.strip() + + return {"values": values, "messages": messages, "summary": summary} + + +def monthly_sync_ok(value: str) -> bool: + snapshot_count, monthly_count, dmin, dmax, mmin, mmax = parse_pipe(value, 6) + snapshot_n = parse_int(snapshot_count, 0) or 0 + return ( + snapshot_n > 0 + and snapshot_count == monthly_count + and bool(dmin) + and bool(dmax) + and dmin == mmin + and dmax == mmax + ) + + +def latest_import_clean(value: str) -> bool: + job_id, status, _file_name, _created, _completed, total, success, errors = parse_pipe( + value, 8 + ) + return ( + parse_int(job_id) is not None + and status == "completed" + and parse_int(total, -1) == parse_int(success, -2) + and parse_int(errors, -1) == 0 + ) + + +def classify(parsed: dict[str, Any]) -> dict[str, Any]: + values = parsed["values"] + summary = parsed["summary"] + messages = parsed["messages"] + + freshness_days_text, latest_daily_date = parse_pipe(values.get("DB_DAILY_FRESHNESS", ""), 2) + freshness_days = parse_int(freshness_days_text) + freshness_max_days = parse_int(summary.get("freshness_max_days"), 2) or 2 + drive_intake_count = parse_int(values.get("DRIVE_INTAKE_COUNT"), 0) or 0 + drive_failed_count = parse_int(values.get("DRIVE_FAILED_COUNT"), 0) or 0 + drive_archive_latest = values.get("DRIVE_ARCHIVE_LATEST_MODIFIED", "none") or "none" + drive_global_latest = values.get("DRIVE_GLOBAL_LATEST_MODIFIED", "none") or "none" + + service_ready = ( + values.get("MOMO_PUBLIC_HEALTH_CODE") == "200" + and values.get("MOMO_HEALTH_CODE") == "200" + and values.get("MOMO_APP_HEALTH") == "healthy" + and values.get("SCHEDULER_RUNNING") == "true" + and values.get("SCHEDULER_HEALTH") == "healthy" + ) + import_config_ok = EXPECTED_IMPORT_CONFIG in values.get("IMPORT_CONFIG", "") + sync_ok = monthly_sync_ok(values.get("DB_MONTHLY_SYNC", "")) + clean_import = latest_import_clean(values.get("DB_LATEST_DAILY_IMPORT_JOB", "")) + freshness_green = ( + freshness_days is not None and 0 <= freshness_days <= freshness_max_days + ) + freshness_stale = freshness_days is not None and freshness_days > freshness_max_days + + blockers: list[str] = [] + warnings: list[str] = [] + status = "blocked_preflight_evidence_incomplete" + next_step = "rerun_momo_drive_token_source_recovery_preflight" + safe_import_preflight_allowed = False + exit_code = 2 + + if not summary: + blockers.append("preflight_summary_missing") + if not service_ready: + blockers.append("momo_service_or_scheduler_not_ready") + if not import_config_ok: + blockers.append("drive_import_config_not_expected_intake") + if not sync_ok: + blockers.append("current_month_snapshot_realtime_sync_not_proven") + if drive_failed_count > 0: + warnings.append("drive_failed_folder_has_matching_candidates") + + if blockers: + status = "blocked_service_or_evidence_not_ready" + next_step = "repair_readonly_preflight_evidence_before_source_or_import_decision" + elif freshness_green: + status = "freshness_already_green_recheck_cold_start" + next_step = "rerun_post_reboot_readiness_summary_with_same_evidence_chain" + exit_code = 0 + elif drive_intake_count > 0 and freshness_stale: + status = "source_arrived_ready_for_safe_import_preflight" + next_step = "run_owner_approved_safe_import_preflight_no_db_or_drive_write_yet" + safe_import_preflight_allowed = True + exit_code = 0 + elif drive_intake_count > 0: + status = "source_arrived_freshness_unknown_recheck_before_import" + next_step = "rerun_momo_preflight_and_validate_freshness_before_import" + safe_import_preflight_allowed = True + exit_code = 1 + elif freshness_stale: + status = "blocked_source_absent_fail_closed" + next_step = "wait_for_legitimate_daily_sales_source_then_rerun_gate" + else: + status = "blocked_freshness_unknown_fail_closed" + next_step = "rerun_preflight_or_repair_readonly_freshness_readback" + + if not clean_import: + warnings.append("latest_daily_import_job_not_clean_completed") + + return { + "schema_version": "momo_source_arrival_gate_v1", + "status": status, + "exit_code": exit_code, + "next_step": next_step, + "safe_import_preflight_allowed": safe_import_preflight_allowed, + "runtime_write_authorized": False, + "db_write_authorized": False, + "drive_move_authorized": False, + "manual_import_authorized": False, + "secret_value_collection_allowed": False, + "service_ready": service_ready, + "import_config_ok": import_config_ok, + "current_month_sync_ok": sync_ok, + "latest_import_clean": clean_import, + "freshness_days": freshness_days, + "freshness_latest_date": latest_daily_date or "unknown", + "freshness_max_days": freshness_max_days, + "drive_intake_count": drive_intake_count, + "drive_archive_latest_modified": drive_archive_latest, + "drive_global_latest_modified": drive_global_latest, + "drive_failed_count": drive_failed_count, + "preflight_pass": summary.get("pass", 0), + "preflight_warn": summary.get("warn", len(messages["warn"])), + "preflight_blocked": summary.get("blocked", len(messages["blocked"])), + "blockers": blockers, + "warnings": warnings, + "no_false_green_rules": [ + "source_arrived_does_not_authorize_import", + "safe_import_preflight_allowed_does_not_authorize_db_write", + "freshness_green_requires_post_reboot_summary_recheck", + "archive_or_local_old_file_does_not_count_as_new_source", + ], + } + + +def print_human(result: dict[str, Any]) -> None: + print( + "MOMO_SOURCE_ARRIVAL_GATE " + f"status={result['status']} " + f"source_intake={result['drive_intake_count']} " + f"freshness={result['freshness_days']}|{result['freshness_latest_date']} " + f"safe_import_preflight_allowed={int(result['safe_import_preflight_allowed'])} " + "runtime_write_authorized=0 " + "db_write_authorized=0 " + "drive_move_authorized=0 " + f"next_step={result['next_step']}" + ) + + +def main() -> int: + args = parse_args() + result = classify(parse_preflight(load_text(args.preflight_log))) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print_human(result) + return int(result["exit_code"]) + + +if __name__ == "__main__": + raise SystemExit(main()) From 18fa182bce0b1108d426e3632247be778fc86e0a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 21:14:42 +0800 Subject: [PATCH 4/7] fix(recovery): surface momo source arrival gate in quick check --- scripts/reboot-recovery/post-start-quick-check.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/reboot-recovery/post-start-quick-check.sh b/scripts/reboot-recovery/post-start-quick-check.sh index d54feb7a..2a783c94 100755 --- a/scripts/reboot-recovery/post-start-quick-check.sh +++ b/scripts/reboot-recovery/post-start-quick-check.sh @@ -287,6 +287,20 @@ if [[ "$RUN_MOMO" -eq 1 ]]; then ;; esac grep -E 'MOMO_DRIVE_TOKEN_SOURCE_PREFLIGHT|MOMO_HEALTH_VERSION|DB_MONTHLY_SYNC|DB_DAILY_FRESHNESS|DB_LATEST_DAILY_IMPORT_JOB' "$momo_tmp" || true + source_gate_output="$("$ROOT_DIR/scripts/reboot-recovery/momo-source-arrival-gate.py" --preflight-log "$momo_tmp" 2>&1)" + source_gate_rc=$? + printf '%s\n' "$source_gate_output" + if grep -q 'status=blocked_source_absent_fail_closed' <<<"$source_gate_output"; then + evidence_warn "MOMO source-arrival gate confirms source absent fail-closed" + elif grep -q 'status=source_arrived_ready_for_safe_import_preflight' <<<"$source_gate_output"; then + boundary_warn "MOMO source arrived; safe import preflight only, DB/Drive/runtime writes remain unauthorized" + elif grep -q 'status=freshness_already_green_recheck_cold_start' <<<"$source_gate_output"; then + ok "MOMO source-arrival gate reports freshness green; rerun post-reboot summary before updating declaration" + elif [[ "$source_gate_rc" -ne 0 ]]; then + service_warn "MOMO source-arrival gate did not produce a known state rc=$source_gate_rc" + else + evidence_warn "MOMO source-arrival gate produced a non-terminal state" + fi rm -f "$momo_tmp" fi From df498e55b1269a92e3f7c3b4d54d3220787498a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 21:20:34 +0800 Subject: [PATCH 5/7] test(recovery): cover momo source arrival gate --- .../tests/test_momo_source_arrival_gate.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py diff --git a/scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py b/scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py new file mode 100644 index 00000000..d8c59fe6 --- /dev/null +++ b/scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "reboot-recovery" / "momo-source-arrival-gate.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("momo_source_arrival_gate", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +BASE_PREFLIGHT = """ +HOST ollama +MOMO_HEALTH_CODE 200 +MOMO_PUBLIC_HEALTH_CODE 200 +MOMO_APP_HEALTH healthy +SCHEDULER_RUNNING true +SCHEDULER_HEALTH healthy +DRIVE_ARCHIVE_LATEST_MODIFIED 2026-06-25T04:21:47.000Z +DRIVE_GLOBAL_LATEST_MODIFIED 2026-06-25T04:21:47.000Z +DRIVE_FAILED_COUNT 0 +DB_MONTHLY_SYNC 15383|15383|2026-06-01|2026-06-24|2026-06-01|2026-06-24 +DB_LATEST_DAILY_IMPORT_JOB 57|completed|即時業績_當日.xlsx|2026-06-25T13:16:47.359958|2026-06-25T13:18:02.964985|15383|15383|0 +IMPORT_CONFIG 當日業績匯入|即時業績_當日 +MOMO_DRIVE_TOKEN_SOURCE_PREFLIGHT PASS=20 WARN=3 BLOCKED=2 HOST=ollama@192.168.0.188 FRESHNESS_MAX_DAYS=2 +""" + + +def classify(extra_lines: str): + module = load_module() + return module.classify(module.parse_preflight(BASE_PREFLIGHT + extra_lines)) + + +def assert_no_write_authorization(result): + assert result["runtime_write_authorized"] is False + assert result["db_write_authorized"] is False + assert result["drive_move_authorized"] is False + assert result["manual_import_authorized"] is False + assert result["secret_value_collection_allowed"] is False + + +def test_source_absent_fail_closed(): + result = classify( + """ +DRIVE_INTAKE_COUNT 0 +DB_DAILY_FRESHNESS 3|2026-06-24 +""" + ) + + assert result["status"] == "blocked_source_absent_fail_closed" + assert result["exit_code"] == 2 + assert result["safe_import_preflight_allowed"] is False + assert_no_write_authorization(result) + + +def test_source_arrived_allows_only_safe_import_preflight(): + result = classify( + """ +DRIVE_INTAKE_COUNT 1 +DB_DAILY_FRESHNESS 3|2026-06-24 +""" + ) + + assert result["status"] == "source_arrived_ready_for_safe_import_preflight" + assert result["exit_code"] == 0 + assert result["safe_import_preflight_allowed"] is True + assert_no_write_authorization(result) + + +def test_freshness_green_requires_cold_start_recheck(): + result = classify( + """ +DRIVE_INTAKE_COUNT 0 +DB_DAILY_FRESHNESS 1|2026-06-26 +""" + ) + + assert result["status"] == "freshness_already_green_recheck_cold_start" + assert result["next_step"] == "rerun_post_reboot_readiness_summary_with_same_evidence_chain" + assert result["exit_code"] == 0 + assert result["safe_import_preflight_allowed"] is False + assert_no_write_authorization(result) From a68d9e40a709c7afe004b8620843ac0d2ff3ec5f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 21:44:54 +0800 Subject: [PATCH 6/7] feat(github): preflight owner response intake --- apps/api/src/api/v1/agents.py | 73 +++- ...hub_target_private_backup_evidence_gate.py | 347 +++++++++++++++++- ...hub_target_private_backup_evidence_gate.py | 88 +++++ ...target_private_backup_evidence_gate_api.py | 57 +++ 4 files changed, 546 insertions(+), 19 deletions(-) diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 755e99f4..92ecc076 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -38,15 +38,6 @@ from src.core.sse import get_publisher from src.services.agent_market_governance_snapshot import ( load_latest_agent_market_governance_snapshot, ) -from src.services.ai_agent_market_radar_readback import ( - load_latest_ai_agent_market_radar_readback, -) -from src.services.ai_technology_radar_readback import ( - load_latest_ai_technology_radar_readback, -) -from src.services.ai_technology_report_cadence_readback import ( - load_latest_ai_technology_report_cadence_readback, -) from src.services.agent_service import ( AgentService, TaskState, @@ -88,12 +79,6 @@ from src.services.ai_agent_critic_reviewer_result_capture import ( from src.services.ai_agent_deployment_layout import ( load_latest_ai_agent_deployment_layout, ) -from src.services.awoooi_status_cleanup_dashboard import ( - load_latest_awoooi_status_cleanup_dashboard, -) -from src.services.github_target_private_backup_evidence_gate import ( - load_latest_github_target_private_backup_evidence_gate, -) from src.services.ai_agent_failure_receipt_no_send_replay import ( load_latest_ai_agent_failure_receipt_no_send_replay, ) @@ -118,6 +103,9 @@ from src.services.ai_agent_live_read_model_gate import ( from src.services.ai_agent_low_medium_risk_whitelist import ( load_latest_ai_agent_low_medium_risk_whitelist, ) +from src.services.ai_agent_market_radar_readback import ( + load_latest_ai_agent_market_radar_readback, +) from src.services.ai_agent_matched_playbook_learning_gap import ( load_latest_ai_agent_matched_playbook_learning_gap, ) @@ -307,6 +295,15 @@ from src.services.ai_agent_version_lifecycle_update_proposal import ( from src.services.ai_provider_route_matrix import ( load_latest_ai_provider_route_matrix, ) +from src.services.ai_technology_radar_readback import ( + load_latest_ai_technology_radar_readback, +) +from src.services.ai_technology_report_cadence_readback import ( + load_latest_ai_technology_report_cadence_readback, +) +from src.services.awoooi_status_cleanup_dashboard import ( + load_latest_awoooi_status_cleanup_dashboard, +) from src.services.backup_dr_readiness_matrix import ( load_latest_backup_dr_readiness_matrix, ) @@ -319,6 +316,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.delivery_closure_workbench import ( + load_delivery_closure_workbench, +) from src.services.dependency_drift_check_plan import ( load_latest_dependency_drift_check_plan, ) @@ -331,15 +331,16 @@ from src.services.dependency_supply_chain_drift_monitor import ( from src.services.dependency_upgrade_approval_package_template import ( load_latest_dependency_upgrade_approval_package_template, ) -from src.services.delivery_closure_workbench import ( - load_delivery_closure_workbench, -) from src.services.docker_build_surface_inventory import ( load_latest_docker_build_surface_inventory, ) from src.services.gitea_workflow_runner_health import ( load_latest_gitea_workflow_runner_health, ) +from src.services.github_target_private_backup_evidence_gate import ( + load_latest_github_target_private_backup_evidence_gate, + preflight_github_target_owner_response_submission, +) from src.services.host_runaway_aiops_loop_readiness import ( load_latest_host_runaway_aiops_loop_readiness, ) @@ -990,6 +991,42 @@ async def get_github_target_private_backup_evidence_gate() -> dict[str, Any]: ) from exc +@router.post( + "/github-target-owner-response-intake-preflight", + response_model=dict[str, Any], + summary="預檢 GitHub target owner response candidate", + description=( + "只驗證一份 GitHub target owner response candidate 是否符合 read-only intake 規則;" + "此端點不持久化 submission、不呼叫 GitHub live API、不建立 repo、不改 visibility、不同步 refs、" + "不觸發 workflow、不收 private clone URL credential 或任何 secret value。" + ), +) +async def preflight_github_target_owner_response_intake( + submission: dict[str, Any], +) -> dict[str, Any]: + """Validate a GitHub target owner response candidate without persisting it.""" + try: + payload = await asyncio.to_thread( + preflight_github_target_owner_response_submission, + submission, + ) + 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: + logger.error( + "github_target_owner_response_intake_preflight_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="GitHub target owner response intake preflight 無效", + ) from exc + + @router.get( "/agent-12-agent-war-room", response_model=dict[str, Any], diff --git a/apps/api/src/services/github_target_private_backup_evidence_gate.py b/apps/api/src/services/github_target_private_backup_evidence_gate.py index 0e034119..f8b6f92e 100644 --- a/apps/api/src/services/github_target_private_backup_evidence_gate.py +++ b/apps/api/src/services/github_target_private_backup_evidence_gate.py @@ -8,6 +8,7 @@ because AWOOOI policy requires GitHub backup targets to be private. from __future__ import annotations import json +import re from pathlib import Path from typing import Any @@ -22,6 +23,39 @@ _APPROVAL_PACKAGE_FILE = "github-target-repo-approval-package.snapshot.json" _PROBE_FILE = "github-target-probe.snapshot.json" _CONNECTOR_READBACK_FILE = "github-target-connector-readback.snapshot.json" _MISSING_SOURCE_READINESS_FILE = "github-target-missing-source-readiness.snapshot.json" +_PREFLIGHT_SCHEMA_VERSION = "github_target_owner_response_intake_preflight_v1" +_PREFLIGHT_MODE = "validate_owner_response_only_no_persist_no_github_write" +_SUBMISSION_METADATA_FIELDS = { + "github_repo", + "response_id", + "submission_mode", + "template_id", +} +_FORBIDDEN_KEY_FRAGMENTS = { + "api_request_body", + "authorization_header", + "cookie", + "credential", + "db_dump", + "deploy_key", + "git_object_pack", + "password", + "private_clone_url", + "private_key", + "repo_archive", + "repo_creation_command", + "secret_value", + "session", + "token_value", + "visibility_change_command", +} +_SENSITIVE_VALUE_PATTERNS = ( + ("github_token", re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b")), + ("github_fine_grained_token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b")), + ("private_key_block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), + ("credentialed_url", re.compile(r"[a-z][a-z0-9+.-]*://[^/\s:@]+:[^@\s]+@")), + ("raw_internal_lan_address", re.compile(r"\b192\.168\.0\.\d+\b")), +) def load_latest_github_target_private_backup_evidence_gate( @@ -56,6 +90,127 @@ def load_latest_github_target_private_backup_evidence_gate( ) +def preflight_github_target_owner_response_submission( + submission: dict[str, Any], + security_dir: Path | None = None, +) -> dict[str, Any]: + """Validate an owner response candidate without storing or executing it.""" + gate = load_latest_github_target_private_backup_evidence_gate(security_dir) + intake = _dict(gate.get("owner_response_intake_readiness")) + target_by_template = { + str(target.get("owner_response_template_id")): _dict(target) + for target in _list(gate.get("targets")) + if target.get("owner_response_template_id") + } + payload = _dict(submission) + allowed_modes = set(_strings(intake.get("allowed_submission_modes"))) + allowed_fields = set(_strings(intake.get("allowed_response_fields"))) + forbidden_payloads = set(_strings(intake.get("forbidden_payloads"))) + still_forbidden = set(_strings(intake.get("still_forbidden"))) + + submission_mode = str(payload.get("submission_mode") or "") + candidate_responses = [_dict(row) for row in _list(payload.get("responses"))] + mode_allowed = submission_mode in allowed_modes + global_scan_payload = { + key: value for key, value in payload.items() if key != "responses" + } + global_hits = _forbidden_payload_hits( + global_scan_payload, + forbidden_payloads=forbidden_payloads | still_forbidden, + ) + response_results = [ + _preflight_owner_response_item( + response=response, + submission_mode=submission_mode, + mode_allowed=mode_allowed, + target_by_template=target_by_template, + allowed_fields=allowed_fields, + forbidden_payloads=forbidden_payloads | still_forbidden, + ) + for response in candidate_responses + ] + passed_count = sum( + 1 for row in response_results if row["accepted_for_read_only_intake"] is True + ) + blocked_count = len(response_results) - passed_count + global_blockers: list[str] = [] + if not mode_allowed: + global_blockers.append("submission_mode_not_allowed") + if not candidate_responses: + global_blockers.append("response_items_missing") + if global_hits: + global_blockers.append("forbidden_payload_detected") + + preflight_passed = ( + not global_blockers and candidate_responses and blocked_count == 0 + ) + return { + "schema_version": _PREFLIGHT_SCHEMA_VERSION, + "generated_at": gate.get("generated_at", ""), + "status": "ready_for_read_only_owner_response_intake" + if preflight_passed + else "blocked_owner_response_intake_preflight", + "mode": _PREFLIGHT_MODE, + "summary": { + "candidate_response_item_count": len(candidate_responses), + "preflight_passed_response_item_count": passed_count, + "preflight_blocked_response_item_count": blocked_count, + "forbidden_payload_hit_count": len(global_hits) + + sum(len(row["forbidden_hits"]) for row in response_results), + "unsupported_field_count": sum( + len(row["unsupported_fields"]) for row in response_results + ), + "missing_required_field_count": sum( + len(row["missing_required_fields"]) for row in response_results + ), + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "safe_credential_accepted_evidence_count": 0, + "github_missing_target_create_private_repo_ready_count": gate["summary"][ + "github_missing_target_create_private_repo_ready_count" + ], + "github_missing_target_refs_sync_ready_count": gate["summary"][ + "github_missing_target_refs_sync_ready_count" + ], + "execution_authorized": False, + "write_performed": False, + "github_api_write_allowed": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + "secret_value_collection_allowed": False, + }, + "submission_mode": submission_mode, + "submission_mode_allowed": mode_allowed, + "allowed_submission_modes": sorted(allowed_modes), + "global_blockers": global_blockers, + "global_forbidden_hits": global_hits, + "responses": response_results, + "operation_boundaries": { + "preflight_only": True, + "persist_submission_allowed": False, + "read_only_markdown_response_allowed": True, + "redacted_metadata_pointer_allowed": True, + "github_api_write_allowed": False, + "repo_creation_allowed": False, + "visibility_change_allowed": False, + "refs_sync_allowed": False, + "workflow_trigger_allowed": False, + "private_clone_url_collection_allowed": False, + "secret_value_collection_allowed": False, + }, + "authorization_flags": { + "owner_response_execution_authorized": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + "workflow_trigger_authorized": False, + "secret_value_collection_allowed": False, + "private_clone_url_collection_allowed": False, + }, + } + + def build_github_target_private_backup_evidence_gate( *, decision: dict[str, Any], @@ -293,6 +448,89 @@ def build_github_target_private_backup_evidence_gate( } +def _preflight_owner_response_item( + *, + response: dict[str, Any], + submission_mode: str, + mode_allowed: bool, + target_by_template: dict[str, dict[str, Any]], + allowed_fields: set[str], + forbidden_payloads: set[str], +) -> dict[str, Any]: + template_id = str(response.get("template_id") or "") + target = target_by_template.get(template_id, {}) + required_fields = set(_strings(target.get("owner_response_required_fields"))) + acceptable_decisions = set( + _strings(target.get("owner_response_acceptable_decisions")) + ) + response_fields = set(response) + supported_fields = allowed_fields | _SUBMISSION_METADATA_FIELDS + unsupported_fields = sorted(response_fields - supported_fields) + missing_required_fields = sorted( + field + for field in required_fields + if not _has_response_value(response.get(field)) + ) + evidence_refs = sorted( + set(_response_strings(response.get("redacted_evidence_refs"))) + | set(_response_strings(response.get("evidence_refs"))) + ) + decision = str(response.get("decision") or "") + blockers: list[str] = [] + if not mode_allowed: + blockers.append("submission_mode_not_allowed") + if not target: + blockers.append("unknown_or_unrequested_template_id") + if unsupported_fields: + blockers.append("unsupported_response_fields") + if missing_required_fields: + blockers.append("required_fields_missing") + if acceptable_decisions and decision not in acceptable_decisions: + blockers.append("decision_not_allowed_for_template") + if not evidence_refs: + blockers.append("redacted_evidence_refs_missing") + + forbidden_hits = _forbidden_payload_hits( + response, + forbidden_payloads=forbidden_payloads, + ) + evidence_ref_hits = _evidence_ref_hits(evidence_refs) + if forbidden_hits: + blockers.append("forbidden_payload_detected") + if evidence_ref_hits: + blockers.append("unsafe_evidence_ref_detected") + + accepted = not blockers + return { + "template_id": template_id, + "github_repo": str( + response.get("github_repo") or target.get("github_repo") or "" + ), + "submission_mode": submission_mode, + "status": "preflight_passed_read_only_intake_candidate" + if accepted + else "blocked_owner_response_candidate", + "accepted_for_read_only_intake": accepted, + "decision": decision, + "allowed_decision_count": len(acceptable_decisions), + "required_field_count": len(required_fields), + "missing_required_fields": missing_required_fields, + "unsupported_fields": unsupported_fields, + "redacted_evidence_ref_count": len(evidence_refs), + "redacted_evidence_refs": evidence_refs, + "forbidden_hits": forbidden_hits, + "evidence_ref_hits": evidence_ref_hits, + "blockers": sorted(set(blockers)), + "owner_response_received": False, + "owner_response_accepted": False, + "safe_credential_evidence_accepted": False, + "execution_authorized": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + } + + def _build_target( *, decision: dict[str, Any], @@ -873,6 +1111,113 @@ def _rejection_rules(owner_response: dict[str, Any]) -> list[str]: ] +def _has_response_value(value: Any) -> bool: + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, list): + return any(_has_response_value(item) for item in value) + return value is not None + + +def _response_strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, list): + return [str(item) for item in value if str(item).strip()] + return [] + + +def _forbidden_payload_hits( + value: Any, + *, + forbidden_payloads: set[str], + path: str = "$", +) -> list[dict[str, str]]: + hits: list[dict[str, str]] = [] + if isinstance(value, dict): + for key, child in value.items(): + key_text = str(key) + key_lower = key_text.lower() + if ( + key_lower in forbidden_payloads + or any(fragment in key_lower for fragment in _FORBIDDEN_KEY_FRAGMENTS) + ): + hits.append( + { + "path": f"{path}.{key_text}", + "kind": "forbidden_field", + "match": key_text, + } + ) + hits.extend( + _forbidden_payload_hits( + child, + forbidden_payloads=forbidden_payloads, + path=f"{path}.{key_text}", + ) + ) + return hits + if isinstance(value, list): + for index, child in enumerate(value): + hits.extend( + _forbidden_payload_hits( + child, + forbidden_payloads=forbidden_payloads, + path=f"{path}[{index}]", + ) + ) + return hits + if isinstance(value, str): + lowered = value.lower() + for forbidden in sorted(forbidden_payloads): + if forbidden and forbidden.lower() in lowered: + hits.append( + { + "path": path, + "kind": "forbidden_payload_label", + "match": forbidden, + } + ) + for label, pattern in _SENSITIVE_VALUE_PATTERNS: + if pattern.search(value): + hits.append( + { + "path": path, + "kind": label, + "match": label, + } + ) + return hits + + +def _evidence_ref_hits(evidence_refs: list[str]) -> list[dict[str, str]]: + hits: list[dict[str, str]] = [] + for index, evidence_ref in enumerate(evidence_refs): + if not ( + evidence_ref.startswith("docs/") + or evidence_ref.startswith("reports/") + or evidence_ref.startswith("owner-metadata:") + or evidence_ref.startswith("redacted:") + ): + hits.append( + { + "path": f"evidence_refs[{index}]", + "kind": "unsupported_evidence_ref_scheme", + "match": evidence_ref, + } + ) + for label, pattern in _SENSITIVE_VALUE_PATTERNS: + if pattern.search(evidence_ref): + hits.append( + { + "path": f"evidence_refs[{index}]", + "kind": label, + "match": label, + } + ) + return hits + + def _dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} @@ -888,6 +1233,6 @@ def _strings(value: Any) -> list[str]: def _int(value: Any) -> int: if isinstance(value, bool): return int(value) - if isinstance(value, (int, float)): + if isinstance(value, int | float): return int(value) return 0 diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate.py b/apps/api/tests/test_github_target_private_backup_evidence_gate.py index 7c0f81f0..e4ecbecc 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate.py @@ -8,6 +8,7 @@ import pytest from src.services.github_target_private_backup_evidence_gate import ( load_latest_github_target_private_backup_evidence_gate, + preflight_github_target_owner_response_submission, ) from src.services.snapshot_paths import default_security_dir @@ -208,6 +209,62 @@ def test_github_target_private_backup_gate_rejects_missing_source_write_flags(tm load_latest_github_target_private_backup_evidence_gate(tmp_path) +def test_github_target_owner_response_preflight_accepts_redacted_evidence_refs(): + preflight = preflight_github_target_owner_response_submission( + _valid_owner_response_submission() + ) + + assert ( + preflight["schema_version"] + == "github_target_owner_response_intake_preflight_v1" + ) + assert preflight["status"] == "ready_for_read_only_owner_response_intake" + assert preflight["mode"] == "validate_owner_response_only_no_persist_no_github_write" + assert preflight["summary"]["candidate_response_item_count"] == 1 + assert preflight["summary"]["preflight_passed_response_item_count"] == 1 + assert preflight["summary"]["preflight_blocked_response_item_count"] == 0 + assert preflight["summary"]["owner_response_received_count"] == 0 + assert preflight["summary"]["owner_response_accepted_count"] == 0 + assert preflight["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert preflight["summary"]["github_api_write_allowed"] is False + assert preflight["summary"]["repo_creation_authorized"] is False + assert preflight["summary"]["refs_sync_authorized"] is False + assert preflight["operation_boundaries"]["persist_submission_allowed"] is False + assert preflight["operation_boundaries"]["github_api_write_allowed"] is False + assert preflight["operation_boundaries"]["private_clone_url_collection_allowed"] is False + assert preflight["authorization_flags"]["owner_response_execution_authorized"] is False + assert preflight["responses"][0]["accepted_for_read_only_intake"] is True + assert preflight["responses"][0]["owner_response_received"] is False + assert preflight["responses"][0]["owner_response_accepted"] is False + + +def test_github_target_owner_response_preflight_blocks_credentials_and_commands(): + submission = _valid_owner_response_submission() + submission["responses"][0]["private_clone_url_credential"] = ( + "https://owner:ghp_1234567890abcdefghijklmnopqrstu@github.com/owenhytsai/awoooi.git" + ) + submission["responses"][0]["repo_creation_command"] = ( + "gh repo create owenhytsai/awoooi --private" + ) + + preflight = preflight_github_target_owner_response_submission(submission) + + assert preflight["status"] == "blocked_owner_response_intake_preflight" + assert preflight["summary"]["candidate_response_item_count"] == 1 + assert preflight["summary"]["preflight_passed_response_item_count"] == 0 + assert preflight["summary"]["preflight_blocked_response_item_count"] == 1 + assert preflight["summary"]["forbidden_payload_hit_count"] >= 3 + assert preflight["summary"]["owner_response_received_count"] == 0 + assert preflight["summary"]["owner_response_accepted_count"] == 0 + assert preflight["summary"]["github_api_write_allowed"] is False + response = preflight["responses"][0] + assert response["accepted_for_read_only_intake"] is False + assert "forbidden_payload_detected" in response["blockers"] + assert "unsupported_response_fields" in response["blockers"] + assert response["execution_authorized"] is False + assert response["repo_creation_authorized"] is False + + def _copy_security_snapshots(tmp_path: Path) -> None: source_dir = default_security_dir(Path(__file__)) for filename in ( @@ -219,3 +276,34 @@ def _copy_security_snapshots(tmp_path: Path) -> None: "github-target-missing-source-readiness.snapshot.json", ): shutil.copy(source_dir / filename, tmp_path / filename) + + +def _valid_owner_response_submission() -> dict[str, object]: + return { + "submission_mode": "read_only_markdown_response", + "responses": [ + { + "template_id": "target-awoooi-refs-blocked", + "github_repo": "owenhytsai/awoooi", + "owner_role_or_team": "platform-owner", + "decision": "hold_pending_refs_truth", + "decision_reason": "Need refs truth review before any sync action.", + "affected_scope": "awoooi github backup target", + "redacted_evidence_refs": [ + "docs/security/GITEA-GITHUB-MIGRATION-SNAPSHOT.md", + "docs/security/source-control-ref-detail-diff.snapshot.json", + ], + "evidence_refs": [ + "docs/security/source-control-workflow-secret-name-inventory.snapshot.json" + ], + "followup_owner": "platform-owner", + "rollback_owner": "platform-owner", + "maintenance_window": "not_authorized", + "validation_plan": "read-only refs truth review only", + "canonical_source": "gitea_main", + "github_target_disposition": "existing_private_candidate", + "visibility_review_owner": "platform-owner", + "refs_truth_review_owner": "platform-owner", + } + ], + } diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py index 9fdeb536..a4e8e851 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py @@ -59,3 +59,60 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g assert intake["not_approval"] is True assert data["targets"][0]["owner_response_execution_authorized"] is False assert "192.168.0." not in response.text + + +def test_github_target_owner_response_intake_preflight_endpoint_blocks_secrets(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.post( + "/api/v1/agents/github-target-owner-response-intake-preflight", + json={ + "submission_mode": "read_only_markdown_response", + "responses": [ + { + "template_id": "target-awoooi-refs-blocked", + "github_repo": "owenhytsai/awoooi", + "owner_role_or_team": "platform-owner", + "decision": "hold_pending_refs_truth", + "decision_reason": "Need refs truth review before sync.", + "affected_scope": "awoooi github backup target", + "redacted_evidence_refs": [ + "docs/security/GITEA-GITHUB-MIGRATION-SNAPSHOT.md" + ], + "evidence_refs": [ + "docs/security/source-control-ref-detail-diff.snapshot.json" + ], + "followup_owner": "platform-owner", + "rollback_owner": "platform-owner", + "maintenance_window": "not_authorized", + "validation_plan": "read-only refs truth review only", + "canonical_source": "gitea_main", + "github_target_disposition": "existing_private_candidate", + "visibility_review_owner": "platform-owner", + "refs_truth_review_owner": "platform-owner", + "private_clone_url_credential": ( + "https://owner:ghp_1234567890abcdefghijklmnopqrstu@github.com/owenhytsai/awoooi.git" + ), + } + ], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "github_target_owner_response_intake_preflight_v1" + assert data["status"] == "blocked_owner_response_intake_preflight" + assert data["summary"]["preflight_passed_response_item_count"] == 0 + assert data["summary"]["preflight_blocked_response_item_count"] == 1 + assert data["summary"]["owner_response_received_count"] == 0 + assert data["summary"]["owner_response_accepted_count"] == 0 + assert data["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert data["operation_boundaries"]["persist_submission_allowed"] is False + assert data["operation_boundaries"]["github_api_write_allowed"] is False + assert data["operation_boundaries"]["private_clone_url_collection_allowed"] is False + assert data["authorization_flags"]["owner_response_execution_authorized"] is False + assert data["responses"][0]["accepted_for_read_only_intake"] is False + assert "forbidden_payload_detected" in data["responses"][0]["blockers"] + assert "192.168.0." not in response.text From b5bf42bf0a243333caf28de2f18874b77ae367d1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 21:45:13 +0800 Subject: [PATCH 7/7] docs(iwooos): record wazuh reviewer post-enable readback [skip ci] --- docs/LOGBOOK.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index b67d17b0..9afa0064 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,54 @@ +## 2026-06-27 — 21:45 IwoooS Wazuh reviewer post-enable readback 正式讀回完成 + +**時間與來源**: +- 2026-06-27 21:24-21:45 Asia/Taipei。 +- 來源:feature branch `codex/iwooos-post-enable-readback-20260627`、Gitea main、Gitea Actions public HTML、production API / `/zh-TW/iwooos` desktop / mobile smoke。 + +**完成內容**: +- `GET /api/v1/iwooos/wazuh-manager-registry-reviewer-validation` 已正式讀回 `post_enable_iwooos_readback_passed_no_runtime_no_secret_collection`。 +- API service 放寬 `post_enable_readback_passed_count` 從 0 更新為 1,但仍強制 `manager_registry_accepted_count`、`runtime_gate_count`、`host_write_authorized_count`、`active_response_authorized_count` 與 `secret_value_collection_allowed_count` 維持 0。 +- 前台 `/zh-TW/iwooos` Wazuh manager registry reviewer validation 卡片新增 `Post-enable = 1`,文案明確標示這不是 live Wazuh 查詢授權。 +- security guard 與 contract tests 已同步 lane count `13 -> 14`,新增 `post_enable_iwooos_readback_passed` 與 `manager_registry_acceptance_evidence_review` 下一關。 + +**Gitea / deploy 狀態**: +- code commit:`c73ce995e feat(iwooos): mark wazuh reviewer post-enable readback`。 +- 後續 main commit:`1a8613c9e fix(governance): stabilize automation tab deep link`,包含 `c73ce995e`。 +- deploy marker:`1a6f8f427 chore(cd): deploy 1a8613c [skip ci]`。 +- `c73ce995e` 單獨 `code-review.yaml #3693` / `cd.yaml #3692` 因後續 main push 被 concurrency 取消;最新 main `code-review.yaml #3695` 成功,`cd.yaml #3694` 成功。 + +**本地驗證結果**: +- `DATABASE_URL=sqlite:///test.db python3.11 -m pytest apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py apps/api/tests/test_iwooos_wazuh_api.py -q`:`23 passed`。 +- `python3 scripts/security/wazuh-manager-registry-reviewer-validation.py --root .`:`post_enable=1 runtime_gate=0`。 +- `python3 scripts/security/iwooos-frontend-display-redaction-guard.py --root .`:通過。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:通過。 +- `python3 -m py_compile ...`、JSON parse、`git diff --check`、`pnpm --dir apps/web typecheck`:通過。 + +**production API readback**: +- `/api/v1/health?_v=1a6f8f427`:HTTP `200`、`status=healthy`、`environment=prod`、`mock_mode=false`。 +- `GET /api/v1/iwooos/wazuh-manager-registry-reviewer-validation?_v=1a6f8f427-final`:HTTP `200`。 +- schema:`iwooos_wazuh_manager_registry_reviewer_validation_readback_v1`。 +- status:`post_enable_iwooos_readback_passed_no_runtime_no_secret_collection`。 +- mode:`committed_post_enable_iwooos_readback_passed_no_runtime_no_secret_collection`。 +- summary:`outcome_lane_count=14`、`owner_registry_export_received_count=1`、`owner_registry_export_accepted_count=1`、`reviewer_validation_passed_count=1`、`post_enable_readback_passed_count=1`。 +- valid redacted sample POST:`accepted_for_readonly_posture_only`、`mode=no_persist_validation_no_runtime_no_secret_collection`;POST-local `post_enable_readback_passed_count=0`,POST 後 GET global 仍維持 `post_enable_readback_passed_count=1`,沒有累加或保存 payload。 + +**production browser smoke**: +- Desktop `1360x900`:`/zh-TW/iwooos?_v=1a6f8f427-desktop-dom` HTTP `200`、console error `0`、水平溢出 `0`、forbidden hits `0`。 +- Mobile `384x900`:`/zh-TW/iwooos?_v=1a6f8f427-mobile-dom` HTTP `200`、console error `0`、水平溢出 `0`、forbidden hits `0`。 +- 前台可見片段:`Post-enable` / `1` / `正式 API 與前台已讀回 reviewer passed;這不是 live Wazuh 查詢授權。` + +**仍維持 0 / false**: +- `manager_registry_accepted_count=0`、`runtime_gate_count=0`、`host_write_authorized_count=0`、`active_response_authorized_count=0`、`secret_value_collection_allowed_count=0`。 +- `wazuh_api_live_query_authorized=false`、`wazuh_agent_reenroll_authorized=false`、`wazuh_agent_restart_authorized=false`、`wazuh_active_response_authorized=false`、`raw_wazuh_payload_storage_allowed=false`、`not_authorization=true`。 + +**未做**: +- 沒有 host / Docker / systemd / Nginx / firewall / K8s / DB / Wazuh runtime 寫操作;沒有讀 secret 明文;沒有重新註冊 agent;沒有 Wazuh restart;沒有 Wazuh active response;沒有 Kali active scan;沒有 force push。 + +**完成度 / 下一步**: +- Wazuh reviewer post-enable readback:`85% -> 100%`。 +- IwoooS 整體:保守 `70% -> 72%`。此段只完成 production API / 前台 readback,不代表 Wazuh 全主機納管或 manager registry accepted 已完成。 +- 下一個 P0:`manager_registry_acceptance_evidence_review`,必須以 manager registry accepted evidence 與 host/product/agent scope 對帳推進;Dashboard 200、前台可見或 API 200 仍不可當成全主機納管完成。 + ## 2026-06-27 — 21:24 GitHub backup owner response intake readiness 正式讀回完成 **時間與來源**: