From 34bfe56f53a87ac96dae9e502a2e954adc6e654b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 13 May 2026 11:15:43 +0800 Subject: [PATCH] fix(awooop): persist approved ssh gateway blocks --- apps/api/src/services/approval_execution.py | 40 ++++++++----- .../test_approval_execution_mcp_audit.py | 56 +++++++++++++++++++ docs/LOGBOOK.md | 3 +- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index 961d70f3..79712413 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -34,7 +34,7 @@ from src.core.config import settings from src.core.redis_client import get_redis from src.db.base import get_db_context from src.models.approval import ApprovalRequest -from src.plugins.mcp.gateway import GatewayContext, McpGateway +from src.plugins.mcp.gateway import GatewayContext, McpGateway, McpGatewayError from src.plugins.mcp.interfaces import MCPToolResult from src.services.approval_db import get_approval_service, get_timeline_service from src.services.executor import ExecutionResult, OperationType, get_executor @@ -813,19 +813,33 @@ class ApprovalExecutionService: }, } async with get_db_context(_SSH_GATEWAY_PROJECT_ID) as db: - return await McpGateway(db).call( - GatewayContext( - project_id=_SSH_GATEWAY_PROJECT_ID, - agent_id=_SSH_GATEWAY_AGENT_ID, - tool_name=tool_name, - run_id=run_id, - trace_id=approval.incident_id or str(approval.id), - is_shadow=False, - environment={"env": "prod"}, - required_scope=required_scope, - ), - params_with_audit, + ctx = GatewayContext( + project_id=_SSH_GATEWAY_PROJECT_ID, + agent_id=_SSH_GATEWAY_AGENT_ID, + tool_name=tool_name, + run_id=run_id, + trace_id=approval.incident_id or str(approval.id), + is_shadow=False, + environment={"env": "prod"}, + required_scope=required_scope, ) + try: + return await McpGateway(db).call(ctx, params_with_audit) + except McpGatewayError as exc: + logger.warning( + "mcp_gateway_approved_ssh_blocked", + approval_id=str(approval.id), + incident_id=approval.incident_id, + tool=tool_name, + gate=exc.gate, + error_code=exc.error_code, + error=str(exc), + ) + return MCPToolResult( + success=False, + execution_id=f"blocked:{tool_name}:{run_id}", + error=f"{exc.error_code}: {exc}", + ) async def _push_execution_result_to_alert( self, diff --git a/apps/api/tests/test_approval_execution_mcp_audit.py b/apps/api/tests/test_approval_execution_mcp_audit.py index d63ac640..207e0c5e 100644 --- a/apps/api/tests/test_approval_execution_mcp_audit.py +++ b/apps/api/tests/test_approval_execution_mcp_audit.py @@ -7,6 +7,7 @@ from uuid import UUID import pytest from src.models.approval import ApprovalRequest, RiskLevel +from src.plugins.mcp.gateway import GateApprovalError from src.plugins.mcp.interfaces import MCPToolResult from src.services.approval_execution import ApprovalExecutionService @@ -145,3 +146,58 @@ async def test_ssh_diagnose_approval_uses_gateway_without_gate5_projection( assert ctx.tool_name == "ssh_diagnose" assert ctx.required_scope == "read" assert ctx.is_shadow is False + + +@pytest.mark.asyncio +async def test_gateway_block_returns_failed_execution_result_without_rollback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_redis = FakeRedis() + db_context_exits: list[str] = [] + + @asynccontextmanager + async def fake_db_context(project_id: str | None = None): + try: + yield {"project_id": project_id} + except Exception: + db_context_exits.append("raised") + raise + else: + db_context_exits.append("normal") + + class FakeGateway: + def __init__(self, db: object) -> None: + self.db = db + + async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult: + raise GateApprovalError("approval missing in smoke path") + + monkeypatch.setattr("src.services.approval_execution.get_redis", lambda: fake_redis) + monkeypatch.setattr("src.services.approval_execution.get_db_context", fake_db_context) + monkeypatch.setattr("src.services.approval_execution.McpGateway", FakeGateway) + + approval = ApprovalRequest( + action="docker restart sentry-worker", + description="測試 Gateway blocked audit 不被 rollback", + risk_level=RiskLevel.LOW, + requested_by="test", + required_signatures=0, + incident_id="INC-TEST-BLOCKED", + ) + + result = await ApprovalExecutionService()._execute_ssh_host_action( + approval=approval, + host="192.168.0.110", + ) + + assert result.success is False + assert result.error is not None + assert "E-MCP-GATE-005" in result.error + assert db_context_exits == ["normal"] + assert fake_redis.set_calls == [ + ( + f"mcp_approval:awoooi:approval_executor:ssh_docker_restart:{approval.id}", + "approved", + 600, + ) + ] diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 5a9675ee..be7fcb69 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -7034,6 +7034,7 @@ not_used_reason=legacy direct provider path; bridge audit only - `ssh_diagnose` 走 read scope,不投影 Gate 5 key。 - `ssh_docker_restart` / `ssh_systemctl_restart` 等 write tool 走 write scope。 - `ssh_docker_prune` 走 admin scope。 +- Gateway Gate block 轉為 failed `ExecutionResult`,避免 `get_db_context` 因 exception rollback 而丟失 blocked audit。 - 新增 migration seed: - `awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13.sql` - `awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13_down.sql` @@ -7042,7 +7043,7 @@ not_used_reason=legacy direct provider path; bridge audit only ```text python -m pytest tests/test_mcp_gateway_gate5.py tests/test_approval_execution_mcp_audit.py -q -5 passed +6 passed python -m ruff check --select F821 src/services/approval_execution.py tests/test_approval_execution_mcp_audit.py All checks passed