feat(awooop): harden outbound truth chain mirror
Some checks failed
Code Review / ai-code-review (push) Successful in 10s
run-migration / migrate (push) Failing after 8s
CD Pipeline / tests (push) Successful in 1m4s
CD Pipeline / build-and-deploy (push) Successful in 3m27s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s

This commit is contained in:
Your Name
2026-05-12 23:21:45 +08:00
parent c652f37b69
commit 24b15f4ad2
9 changed files with 237 additions and 6 deletions

View File

@@ -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.';

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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<redacted>" 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)

View File

@@ -6239,3 +6239,76 @@ completed_shadow_run_created / outbound_message_recorded present
避免自我匹配。
- AwoooP Run Detail timeline 追加 `[AWOOOI CI/CD]` 語義分類CI/CD outbound 不再落到
泛用 `TELEGRAM處置結果`,改顯示 `TELEGRAMCI/CD 狀態通知`
## 2026-05-12台北— T1 Channel Event hardeningTelegram 出站真相鏈全文稽核
**背景**
- 統帥指出 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 結果。

View File

@@ -370,6 +370,8 @@ source_event_received
**當前紅線**:在 T0-T2 未完成前,任何「中低風險告警已有 AI 自動修復」都只能逐案查證不能全域宣稱。Telegram 卡片必須誠實顯示 degraded / failed / pending_human而不是只顯示 AI 研判摘要。
**T1 first implementation2026-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 = 12AwoooP 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