diff --git a/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12.sql b/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12.sql new file mode 100644 index 00000000..40f354ab --- /dev/null +++ b/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12.sql @@ -0,0 +1,21 @@ +-- AwoooP Phase 7 T1: outbound message truth-chain columns +-- +-- Purpose: +-- Telegram must remain a summary channel, but the operator console needs a +-- complete redacted replay of the rendered card and the source envelope that +-- produced it. Store redacted content only; raw unredacted Telegram text stays +-- out of PostgreSQL. + +ALTER TABLE awooop_outbound_message + ADD COLUMN IF NOT EXISTS content_redacted TEXT, + ADD COLUMN IF NOT EXISTS redaction_version VARCHAR(32) NOT NULL DEFAULT 'audit_sink_v1', + ADD COLUMN IF NOT EXISTS source_envelope JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN awooop_outbound_message.content_redacted IS + 'Full rendered outbound content after audit_sink redaction; raw unredacted text is not stored.'; + +COMMENT ON COLUMN awooop_outbound_message.redaction_version IS + 'Redaction algorithm/version used for content_redacted and source_envelope.'; + +COMMENT ON COLUMN awooop_outbound_message.source_envelope IS + 'Redacted source metadata for replay/audit, including payload hash and adapter context.'; diff --git a/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12_down.sql b/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12_down.sql new file mode 100644 index 00000000..23828500 --- /dev/null +++ b/apps/api/migrations/awooop_phase7_outbound_truth_chain_columns_2026-05-12_down.sql @@ -0,0 +1,6 @@ +-- Rollback for AwoooP Phase 7 T1 outbound truth-chain columns. +-- Safe only if no consumers depend on the redacted replay fields. + +ALTER TABLE awooop_outbound_message DROP COLUMN IF EXISTS source_envelope; +ALTER TABLE awooop_outbound_message DROP COLUMN IF EXISTS redaction_version; +ALTER TABLE awooop_outbound_message DROP COLUMN IF EXISTS content_redacted; diff --git a/apps/api/src/db/awooop_models.py b/apps/api/src/db/awooop_models.py index 9b906218..d04b1d57 100644 --- a/apps/api/src/db/awooop_models.py +++ b/apps/api/src/db/awooop_models.py @@ -680,6 +680,13 @@ class AwoooPOutboundMessage(Base): message_type: Mapped[str] = mapped_column(String(32), nullable=False) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) content_preview: Mapped[str | None] = mapped_column(String(256), nullable=True) + content_redacted: Mapped[str | None] = mapped_column(Text, nullable=True) + redaction_version: Mapped[str] = mapped_column( + String(32), nullable=False, server_default=text("'audit_sink_v1'") + ) + source_envelope: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, server_default=text("'{}'::jsonb") + ) provider_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True) send_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") send_error: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 2d88101e..d1e669dd 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -534,6 +534,9 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ message_type, content_hash, content_preview, + content_redacted, + redaction_version, + source_envelope, provider_message_id, send_status, queued_at, diff --git a/apps/api/src/services/channel_hub.py b/apps/api/src/services/channel_hub.py index e98f0eed..ff60ea5e 100644 --- a/apps/api/src/services/channel_hub.py +++ b/apps/api/src/services/channel_hub.py @@ -39,13 +39,14 @@ from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from src.db.awooop_models import AwoooPRunState -from src.services.audit_sink import _redact_string +from src.services.audit_sink import _redact_string, sanitize from src.services.platform_runtime import create_run logger = structlog.get_logger(__name__) # Progressive Feedback Policy:等待超過此秒數才發 interim 訊息 _INTERIM_WAIT_SECONDS = 30 +_OUTBOUND_REDACTION_VERSION = "audit_sink_v1" def _input_sha256(input_payload: dict[str, Any] | None) -> str | None: @@ -457,6 +458,7 @@ async def record_outbound_message( channel_chat_id: str, message_type: str, # 'interim' | 'final' | 'error' | 'approval_request' content: str | None = None, + source_envelope: dict[str, Any] | None = None, provider_message_id: str | None = None, send_status: str = "pending", conversation_event_id: UUID | None = None, @@ -471,10 +473,20 @@ async def record_outbound_message( """ content_hash: str | None = None content_preview: str | None = None + content_redacted: str | None = None if content is not None: content_hash = hashlib.sha256(content.encode()).hexdigest() - redacted = _redact_string(content) - content_preview = redacted[:256] + content_redacted = _redact_string(content) + content_preview = content_redacted[:256] + + envelope: dict[str, Any] = sanitize(source_envelope or {}) + envelope.update({ + "schema_version": "outbound_source_envelope_v1", + "redaction_version": _OUTBOUND_REDACTION_VERSION, + "content_sha256": content_hash, + "content_length": len(content) if content is not None else 0, + }) + source_envelope_json = json.dumps(envelope, ensure_ascii=False, default=str) actual_status = "shadow" if is_shadow else send_status @@ -499,13 +511,17 @@ async def record_outbound_message( INSERT INTO awooop_outbound_message ( project_id, run_id, conversation_event_id, channel_type, channel_chat_id, message_type, - content_hash, content_preview, provider_message_id, + content_hash, content_preview, content_redacted, + redaction_version, source_envelope, + provider_message_id, send_status, queued_at, triggered_by_state, waiting_since ) VALUES ( :project_id, :run_id, :conversation_event_id, :channel_type, :channel_chat_id, :message_type, - :content_hash, :content_preview, :provider_message_id, + :content_hash, :content_preview, :content_redacted, + :redaction_version, CAST(:source_envelope AS jsonb), + :provider_message_id, :send_status, NOW(), :triggered_by_state, :waiting_since ) @@ -520,6 +536,9 @@ async def record_outbound_message( "message_type": message_type, "content_hash": content_hash, "content_preview": content_preview, + "content_redacted": content_redacted, + "redaction_version": _OUTBOUND_REDACTION_VERSION, + "source_envelope": source_envelope_json, "provider_message_id": provider_message_id, "send_status": actual_status, "triggered_by_state": triggered_by_state, diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 1c64227d..da509954 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -25,6 +25,7 @@ SOUL.md 鐵律 (4.1 Telegram 訊息壓縮原則): import asyncio import hashlib import html +import json import os import re from dataclasses import dataclass @@ -119,6 +120,54 @@ def _infer_outbound_message_type(text: str, payload: dict) -> str: return "error" return "final" + +def _outbound_payload_hash(payload: dict) -> str: + """Stable hash for Telegram payload replay without storing raw payload.""" + canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def _reply_markup_summary(payload: dict) -> dict[str, object]: + """Summarize Telegram buttons without turning callback payloads into policy.""" + reply_markup = payload.get("reply_markup") + if not isinstance(reply_markup, dict): + return {"present": False, "button_count": 0} + + buttons: list[dict[str, object]] = [] + for row in reply_markup.get("inline_keyboard") or []: + if not isinstance(row, list): + continue + for button in row: + if not isinstance(button, dict): + continue + callback_data = str(button.get("callback_data") or "") + buttons.append({ + "text": str(button.get("text") or ""), + "callback_prefix": callback_data.split(":", 1)[0] if callback_data else "", + "has_url": bool(button.get("url")), + }) + + return { + "present": True, + "button_count": len(buttons), + "buttons": buttons[:12], + "truncated": len(buttons) > 12, + } + + +def _outbound_source_envelope(method: str, payload: dict) -> dict[str, object]: + """Build a redaction-friendly source envelope for Channel Hub replay.""" + return { + "adapter": "legacy_telegram_gateway", + "method": method, + "payload_sha256": _outbound_payload_hash(payload), + "payload_keys": sorted(str(key) for key in payload.keys()), + "parse_mode": payload.get("parse_mode"), + "disable_web_page_preview": payload.get("disable_web_page_preview"), + "has_reply_context": _has_reply_context(payload), + "reply_markup": _reply_markup_summary(payload), + } + # 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態 Telegram 按鈕 Feature Flag # true → 優先使用 ActionPlan.recommended_actions 動態生成按鈕 # false → 維持現有 callback_action_spec.yaml 路徑(預設,向下相容) @@ -1678,6 +1727,7 @@ class TelegramGateway: channel_chat_id=chat_id, message_type=_infer_outbound_message_type(text, payload), content=text, + source_envelope=_outbound_source_envelope(method, payload), provider_message_id=provider_message_id, send_status="sent", triggered_by_state="legacy_gateway", diff --git a/apps/api/tests/test_telegram_gateway_error_sanitizer.py b/apps/api/tests/test_telegram_gateway_error_sanitizer.py index 984dac7d..913a0848 100644 --- a/apps/api/tests/test_telegram_gateway_error_sanitizer.py +++ b/apps/api/tests/test_telegram_gateway_error_sanitizer.py @@ -1,6 +1,9 @@ from __future__ import annotations -from src.services.telegram_gateway import _sanitize_telegram_error +from src.services.telegram_gateway import ( + _outbound_source_envelope, + _sanitize_telegram_error, +) def test_telegram_gateway_sanitizes_bot_token_url() -> None: @@ -10,3 +13,33 @@ def test_telegram_gateway_sanitizes_bot_token_url() -> None: assert "SECRET" not in sanitized assert "bot" in sanitized + + +def test_outbound_source_envelope_keeps_replay_context_without_raw_payload() -> None: + payload = { + "chat_id": "-100123", + "text": "ACTION REQUIRED token 1234567890:abcdefghijklmnopqrstuvwxyzABCDEFGH", + "parse_mode": "HTML", + "reply_markup": { + "inline_keyboard": [ + [ + {"text": "批准", "callback_data": "approve:approval-id-secret"}, + {"text": "詳情", "callback_data": "details:approval-id-secret"}, + ] + ] + }, + } + + envelope = _outbound_source_envelope("sendMessage", payload) + + assert envelope["adapter"] == "legacy_telegram_gateway" + assert envelope["method"] == "sendMessage" + assert envelope["payload_sha256"] + assert envelope["payload_keys"] == ["chat_id", "parse_mode", "reply_markup", "text"] + assert envelope["parse_mode"] == "HTML" + assert envelope["reply_markup"]["button_count"] == 2 + assert envelope["reply_markup"]["buttons"][0]["callback_prefix"] == "approve" + assert envelope["reply_markup"]["buttons"][1]["callback_prefix"] == "details" + assert "approval-id-secret" not in str(envelope) + assert "1234567890:" not in str(envelope) + assert "ACTION REQUIRED" not in str(envelope) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 0d24ab0c..ad134a99 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -6239,3 +6239,76 @@ completed_shadow_run_created / outbound_message_recorded present 避免自我匹配。 - AwoooP Run Detail timeline 追加 `[AWOOOI CI/CD]` 語義分類,CI/CD outbound 不再落到 泛用 `TELEGRAM:處置結果`,改顯示 `TELEGRAM:CI/CD 狀態通知`。 + +## 2026-05-12(台北)— T1 Channel Event hardening:Telegram 出站真相鏈全文稽核 + +**背景**: + +- 統帥指出 Telegram 卡片看不出是否重複、跑到哪個流程、是否真的 AI 自動修復、是否需要人工介入。 +- T0 已有 read-only truth-chain API,但 `awooop_outbound_message` 只有 `content_preview` / hash,不能完整回放卡片,也不能審計「這張卡為何這樣寫」。 +- T1 的目標是先補資料真相鏈,不改自動修復決策、不粉飾 Telegram 文案。 + +**改動**: + +- 新增 migration: + - `content_redacted`:完整 redacted rendered outbound content。 + - `redaction_version`:記錄 redaction algorithm/version。 + - `source_envelope`:保存 redacted source metadata、payload hash、adapter、reply markup 摘要。 +- `ChannelHub.record_outbound_message()` 統一計算: + - raw content hash。 + - redacted full content。 + - redacted preview。 + - source envelope JSONB。 +- `TelegramGateway` mirror 新增 source envelope: + - 只存 payload hash / keys / parse_mode / reply context / button 摘要。 + - 不存 raw Telegram payload,不存完整 callback data。 +- truth-chain API outbound query 追加回傳 `content_redacted` / `redaction_version` / `source_envelope`。 + +**本地驗證**: + +```text +git diff --check +# OK + +python -m py_compile \ + apps/api/src/db/awooop_models.py \ + apps/api/src/services/channel_hub.py \ + apps/api/src/services/telegram_gateway.py \ + apps/api/src/services/awooop_truth_chain_service.py \ + apps/api/tests/test_telegram_gateway_error_sanitizer.py +# OK + +/Users/ogt/awoooi/apps/api/.venv/bin/ruff check --select F,E9 \ + apps/api/src/db/awooop_models.py \ + apps/api/src/services/channel_hub.py \ + apps/api/src/services/telegram_gateway.py \ + apps/api/src/services/awooop_truth_chain_service.py \ + apps/api/tests/test_telegram_gateway_error_sanitizer.py +# All checks passed + +DATABASE_URL='postgresql+asyncpg://awoooi:awoooi_test_2026@localhost:5432/awoooi_test?ssl=disable' \ + python -m pytest \ + apps/api/tests/test_awooop_truth_chain_service.py \ + apps/api/tests/test_platform_router_order.py \ + apps/api/tests/test_awooop_operator_auth.py \ + apps/api/tests/test_telegram_gateway_error_sanitizer.py -q +# 12 passed +``` + +**生產 migration 預套用**: + +```text +awooop_outbound_message columns: +content_redacted|text|nullable=YES|default=None +redaction_version|character varying|nullable=NO|default='audit_sink_v1'::character varying +source_envelope|jsonb|nullable=NO|default='{}'::jsonb + +RLS app context: +project_context=awoooi total=312 redacted_total=0 envelope_total=0 +``` + +**下一步**: + +- 推 Gitea main,讓 API image 部署 T1 程式碼。 +- 部署後用 rollback transaction smoke 驗證新 outbound mirror 會寫入 redacted full content + source envelope,不污染 production DB。 +- 再更新本 LOGBOOK 的 production smoke 結果。 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 39734abd..0bed9947 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 @@ -370,6 +370,8 @@ source_event_received **當前紅線**:在 T0-T2 未完成前,任何「中低風險告警已有 AI 自動修復」都只能逐案查證,不能全域宣稱。Telegram 卡片必須誠實顯示 degraded / failed / pending_human,而不是只顯示 AI 研判摘要。 +**T1 first implementation(2026-05-12 23:20 台北)**:開始補 `awooop_outbound_message` 的真相鏈欄位:`content_redacted`、`redaction_version`、`source_envelope`。設計邊界是只保存 redacted rendered card 與 source metadata 摘要;raw Telegram payload、完整 callback data、未遮蔽 token 不入庫。production DB migration 已預套用,API app role 在 `app.project_id=awoooi` 下可讀 outbound rows(`total=312`),代表 T1 的 RLS visibility 紅燈已先驗證可見;新欄位需等 T1 API image 上線後才會產生非空資料。 + --- ## §3 6 大設計維度全展開 @@ -1840,6 +1842,23 @@ Phase 6 完成後 - `INC-20260512-B6C589` → `manual_required/blocked`,blockers include all evidence sensors failed, NO_ACTION without execution, incident still investigating after approval, AwoooP MCP Gateway audit empty。 - `7f858956` → `dedup_or_repeat_updated/pending`,12h repeat count = 12,AwoooP MCP Gateway audit empty。 +### 2026-05-12 晚 (台北) — T1 Channel Event hardening — 出站訊息全文稽核欄位 + +**範圍**: +- 新增 `awooop_outbound_message.content_redacted` / `redaction_version` / `source_envelope` migration。 +- `ChannelHub.record_outbound_message()` 統一寫入 redacted full content、preview、content hash、source envelope。 +- `TelegramGateway` mirror 只傳 source envelope 摘要:adapter、method、payload hash、payload keys、parse mode、reply context、button callback prefix;不保存 raw payload 或完整 callback data。 +- truth-chain API outbound 區塊追加回傳上述欄位,讓 Operator Console 可回放 Telegram 卡片的紅acted 版本。 + +**已驗證**: +- 本地 `git diff --check` / `py_compile` / `ruff F,E9` 通過。 +- truth-chain / router / operator auth / Telegram envelope 測試共 12 passed。 +- production DB migration 已預套用;`app.project_id=awoooi` 下 `awooop_outbound_message total=312` 可見,舊資料 `redacted_total=0` 合理。 + +**仍未宣稱完成**: +- T1 API image 尚需部署後 smoke,確認新 outbound mirror 實際寫入 `content_redacted` 與 `source_envelope`。 +- T2 MCP Gateway mandatory audit 未完成,因此不能宣稱所有 MCP / 自建 MCP 都已經過 AwoooP Gateway。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)