fix(awooop): persist approved ssh gateway blocks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user