diff --git a/apps/api/migrations/awooop_phase5b_mcp_gateway_audit_nullable_tool_2026-05-06.sql b/apps/api/migrations/awooop_phase5b_mcp_gateway_audit_nullable_tool_2026-05-06.sql new file mode 100644 index 00000000..1d3a75f0 --- /dev/null +++ b/apps/api/migrations/awooop_phase5b_mcp_gateway_audit_nullable_tool_2026-05-06.sql @@ -0,0 +1,14 @@ +-- AwoooP Phase 5b:MCP Gateway blocked call 稽核覆蓋 +-- 日期:2026-05-06 +-- 維護者:Codex +-- +-- Gate 1 / Gate 2 / 未知工具的 blocked call 可能發生在 tool registry row +-- 取得之前。這些安全決策仍必須落稽核紀錄,因此 tool_id 允許為 NULL, +-- 但 tool_name 仍維持必填,作為未知工具與早期 gate block 的追蹤線索。 + +BEGIN; + +ALTER TABLE awooop_mcp_gateway_audit + ALTER COLUMN tool_id DROP NOT NULL; + +COMMIT; diff --git a/apps/api/src/db/awooop_models.py b/apps/api/src/db/awooop_models.py index 802a33cc..9b906218 100644 --- a/apps/api/src/db/awooop_models.py +++ b/apps/api/src/db/awooop_models.py @@ -10,7 +10,7 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal from typing import Any -from uuid import UUID, uuid4 +from uuid import UUID from sqlalchemy import ( Boolean, @@ -577,8 +577,8 @@ class AwoooPMcpGatewayAudit(Base): run_id: Mapped[UUID | None] = mapped_column(nullable=True) trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True) - tool_id: Mapped[UUID] = mapped_column( - ForeignKey("awooop_mcp_tool_registry.tool_id"), nullable=False + tool_id: Mapped[UUID | None] = mapped_column( + ForeignKey("awooop_mcp_tool_registry.tool_id"), nullable=True ) tool_name: Mapped[str] = mapped_column(String(128), nullable=False) credential_ref: Mapped[str | None] = mapped_column(String(256), nullable=True) diff --git a/apps/api/src/plugins/mcp/gateway.py b/apps/api/src/plugins/mcp/gateway.py index b1dd2a56..aed41cfa 100644 --- a/apps/api/src/plugins/mcp/gateway.py +++ b/apps/api/src/plugins/mcp/gateway.py @@ -39,7 +39,7 @@ import hashlib import json import time from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import UUID @@ -47,6 +47,7 @@ import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.core.redis_client import get_redis from src.db.awooop_models import ( AwoooPActiveRevision, AwoooPMcpGatewayAudit, @@ -54,7 +55,6 @@ from src.db.awooop_models import ( AwoooPMcpToolRegistry, AwoooPProject, ) -from src.core.redis_client import get_redis from src.plugins.mcp.interfaces import MCPToolResult from src.plugins.mcp.registry import get_provider_registry @@ -278,7 +278,7 @@ class McpGateway: self, ctx: GatewayContext, gate_result: GateCheckResult ) -> tuple[AwoooPMcpToolRegistry, AwoooPMcpGrant]: """Gate 3:tool 在白名單 + grant 有效(未到期、未撤銷)""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) # 查 tool registry tool_result = await self._db.execute( @@ -458,9 +458,8 @@ class McpGateway: latency_ms=latency_ms, ) - if tool_row is not None: - self._db.add(audit) - await self._db.flush() + self._db.add(audit) + await self._db.flush() except Exception as exc: logger.warning( "mcp_gateway_audit_write_failed", diff --git a/apps/api/tests/test_mcp_gateway_audit.py b/apps/api/tests/test_mcp_gateway_audit.py new file mode 100644 index 00000000..11b27150 --- /dev/null +++ b/apps/api/tests/test_mcp_gateway_audit.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import uuid + +import pytest + +from src.plugins.mcp.gateway import GateCheckResult, GatewayContext, McpGateway + + +class FakeDb: + def __init__(self) -> None: + self.added: list[object] = [] + self.flush_count = 0 + + def add(self, item: object) -> None: + self.added.append(item) + + async def flush(self) -> None: + self.flush_count += 1 + + +@pytest.mark.asyncio +async def test_write_audit_persists_blocked_gate_without_tool_row() -> None: + db = FakeDb() + run_id = uuid.uuid4() + + await McpGateway(db)._write_audit( + ctx=GatewayContext( + project_id="awoooi", + agent_id="openclaw-sre", + tool_name="missing_tool", + run_id=run_id, + trace_id="trace-audit-gap", + ), + tool_row=None, + parameters={"namespace": "awoooi-prod"}, + result=None, + gate_result=GateCheckResult(), + result_status="blocked", + block_gate=1, + block_reason="E-MCP-GATE-001: project blocked", + latency_ms=12, + ) + + assert db.flush_count == 1 + assert len(db.added) == 1 + audit = db.added[0] + assert audit.project_id == "awoooi" + assert audit.run_id == run_id + assert audit.tool_id is None + assert audit.tool_name == "missing_tool" + assert audit.result_status == "blocked" + assert audit.block_gate == 1 diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 469195f9..eb3033e0 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -3744,3 +3744,36 @@ Sentry consumers reset 後狀態 - `DockerContainerRestartSpike` 使用 15 分鐘窗口,已發生的 restart spike 會在 Prometheus 窗口過去後退火;若短時間仍看到舊訊息,優先查 live `ALERTS{alertname="DockerContainerRestartSpike"}` 是否已歸零。 - Alertmanager 本身不支援「webhook send failed 後再 fallback receiver」語義;因此 direct Telegram 只能以明確的 API/AlertChain 健康告警作為 emergency gate。 + +--- + +## 2026-05-06(台北)— MCP Gateway blocked audit 缺口修補 + +**觸發**:AwoooP / AI 自動化飛輪整合審查指出 MCP Gateway Gate 1 / Gate 2 / 未註冊工具被攔截時,可能因尚未取得 `tool_id` 而沒有落 `awooop_mcp_gateway_audit`,造成安全決策不可追溯。 + +### 已修正 + +| 範圍 | 結果 | +|------|------| +| ORM | `AwoooPMcpGatewayAudit.tool_id` 改為可空,保留 `tool_name` 作為未知工具或早期 gate blocked call 的稽核線索 | +| DB migration | 新增 `awooop_phase5b_mcp_gateway_audit_nullable_tool_2026-05-06.sql`,對既有表執行 `ALTER COLUMN tool_id DROP NOT NULL` | +| Gateway audit | `_write_audit()` 不再只於 `tool_row is not None` 時 add/flush;blocked call 一律嘗試落 audit | +| 回歸測試 | 新增 `test_mcp_gateway_audit.py`,驗證沒有 `tool_row` 的 Gate blocked call 仍會寫入 audit row | + +### 驗證 + +```text +pytest apps/api/tests/test_mcp_gateway_audit.py apps/api/tests/test_mcp_gateway_gate5.py apps/api/tests/test_mcp_credential_isolation.py apps/api/tests/test_mcp_tool_registry.py -q +# 43 passed + +py_compile apps/api/src/plugins/mcp/gateway.py apps/api/src/db/awooop_models.py apps/api/tests/test_mcp_gateway_audit.py +# 通過 + +ruff check apps/api/src/plugins/mcp/gateway.py apps/api/src/db/awooop_models.py apps/api/tests/test_mcp_gateway_audit.py +# All checks passed +``` + +### 後續 + +- 部署後必須確認 DB migration 有被套用,否則 production 仍會因 `tool_id NOT NULL` 擋住 Gate 1 / Gate 2 blocked audit。 +- 下一步繼續收斂 direct provider / legacy MCP caller,讓 MCP Gateway 成為真正 choke point。