feat(awooop): gate approved ssh execution
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
run-migration / migrate (push) Successful in 9s
CD Pipeline / tests (push) Successful in 1m22s
CD Pipeline / build-and-deploy (push) Successful in 6m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
run-migration / migrate (push) Successful in 9s
CD Pipeline / tests (push) Successful in 1m22s
CD Pipeline / build-and-deploy (push) Successful in 6m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
-- T9: approved SSH execution MCP Gateway seed
|
||||
-- 目的:讓 Telegram/Approval 已批准的 SSH 修復動作通過 AwoooP Gateway 五閘門。
|
||||
-- 邊界:只授權 approval_executor;write/admin 仍需 Gate 5 短效 approval key。
|
||||
|
||||
SELECT set_config('app.project_id', 'awoooi', FALSE);
|
||||
|
||||
WITH agent_body AS (
|
||||
SELECT jsonb_build_object(
|
||||
'schema_version', 'awooop_agent_contract_v1',
|
||||
'agent_id', 'approval_executor',
|
||||
'display_name', 'Approval Executor',
|
||||
'project_id', 'awoooi',
|
||||
'purpose', 'Approved SSH execution through AwoooP MCP Gateway',
|
||||
'allowed_scopes', jsonb_build_array('read', 'write', 'admin'),
|
||||
'requires_gate5_for_scopes', jsonb_build_array('write', 'admin'),
|
||||
'stage', 't9_ssh_approval_gateway'
|
||||
) AS body_json
|
||||
),
|
||||
inserted_revision AS (
|
||||
INSERT INTO awooop_contract_revisions (
|
||||
project_id,
|
||||
contract_family,
|
||||
contract_id,
|
||||
version_major,
|
||||
version_minor,
|
||||
lifecycle_status,
|
||||
body_json,
|
||||
body_hash,
|
||||
body_schema_version,
|
||||
publisher_id,
|
||||
published_at
|
||||
)
|
||||
SELECT
|
||||
'awoooi',
|
||||
'agent',
|
||||
'approval_executor',
|
||||
1,
|
||||
0,
|
||||
'active',
|
||||
body_json,
|
||||
encode(digest(body_json::text, 'sha256'), 'hex'),
|
||||
'v1.0',
|
||||
'migration:t9_ssh_approval_gateway',
|
||||
NOW()
|
||||
FROM agent_body
|
||||
ON CONFLICT (project_id, contract_family, contract_id, version_major, version_minor)
|
||||
DO NOTHING
|
||||
RETURNING revision_id, project_id, contract_family, contract_id
|
||||
),
|
||||
chosen_revision AS (
|
||||
SELECT revision_id, project_id, contract_family, contract_id
|
||||
FROM inserted_revision
|
||||
UNION ALL
|
||||
SELECT revision_id, project_id, contract_family, contract_id
|
||||
FROM awooop_contract_revisions
|
||||
WHERE project_id = 'awoooi'
|
||||
AND contract_family = 'agent'
|
||||
AND contract_id = 'approval_executor'
|
||||
AND version_major = 1
|
||||
AND version_minor = 0
|
||||
AND lifecycle_status = 'active'
|
||||
),
|
||||
upsert_pointer AS (
|
||||
INSERT INTO awooop_active_revisions (
|
||||
project_id,
|
||||
contract_family,
|
||||
contract_id,
|
||||
active_revision_id,
|
||||
updated_at
|
||||
)
|
||||
SELECT DISTINCT ON (project_id, contract_family, contract_id)
|
||||
project_id,
|
||||
contract_family,
|
||||
contract_id,
|
||||
revision_id,
|
||||
NOW()
|
||||
FROM chosen_revision
|
||||
ORDER BY project_id, contract_family, contract_id, revision_id
|
||||
ON CONFLICT (project_id, contract_family, contract_id)
|
||||
DO UPDATE SET
|
||||
active_revision_id = EXCLUDED.active_revision_id,
|
||||
updated_at = NOW()
|
||||
RETURNING contract_id
|
||||
)
|
||||
SELECT 'approval_executor_active_contracts', count(*) FROM upsert_pointer;
|
||||
|
||||
WITH gateway_tools(tool_name, description, required_scope) AS (
|
||||
VALUES
|
||||
('ssh_diagnose', 'SSH host diagnosis read', 'read'),
|
||||
('ssh_docker_restart', 'Approved Docker container restart over SSH', 'write'),
|
||||
('ssh_docker_compose_restart', 'Approved Docker Compose service restart over SSH', 'write'),
|
||||
('ssh_systemctl_restart', 'Approved systemd service restart over SSH', 'write'),
|
||||
('ssh_clear_docker_logs', 'Approved Docker log truncation over SSH', 'write'),
|
||||
('ssh_renew_ssl', 'Approved certbot renewal over SSH', 'write'),
|
||||
('ssh_reload_nginx', 'Approved nginx config test and reload over SSH', 'write'),
|
||||
('ssh_docker_prune', 'Approved Docker prune over SSH with provider disk guard', 'admin')
|
||||
),
|
||||
upsert_tools AS (
|
||||
INSERT INTO awooop_mcp_tool_registry (
|
||||
project_id,
|
||||
tool_name,
|
||||
tool_type,
|
||||
description,
|
||||
allowed_scopes,
|
||||
environment_tags,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
'awoooi',
|
||||
tool_name,
|
||||
'mcp_server',
|
||||
description,
|
||||
jsonb_build_array(required_scope),
|
||||
'{"env": "prod"}'::jsonb,
|
||||
TRUE,
|
||||
NOW()
|
||||
FROM gateway_tools
|
||||
ON CONFLICT (project_id, tool_name)
|
||||
DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
allowed_scopes = EXCLUDED.allowed_scopes,
|
||||
environment_tags = EXCLUDED.environment_tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW()
|
||||
RETURNING tool_id, tool_name, allowed_scopes
|
||||
),
|
||||
upsert_grants AS (
|
||||
INSERT INTO awooop_mcp_grants (
|
||||
project_id,
|
||||
agent_id,
|
||||
tool_id,
|
||||
granted_by,
|
||||
granted_scopes,
|
||||
expires_at,
|
||||
is_revoked,
|
||||
revoked_at,
|
||||
revoked_by
|
||||
)
|
||||
SELECT
|
||||
'awoooi',
|
||||
'approval_executor',
|
||||
tool_id,
|
||||
'migration:t9_ssh_approval_gateway',
|
||||
allowed_scopes,
|
||||
NULL,
|
||||
FALSE,
|
||||
NULL,
|
||||
NULL
|
||||
FROM upsert_tools
|
||||
ON CONFLICT (project_id, agent_id, tool_id)
|
||||
DO UPDATE SET
|
||||
granted_by = EXCLUDED.granted_by,
|
||||
granted_scopes = EXCLUDED.granted_scopes,
|
||||
expires_at = NULL,
|
||||
is_revoked = FALSE,
|
||||
revoked_at = NULL,
|
||||
revoked_by = NULL
|
||||
RETURNING grant_id
|
||||
)
|
||||
SELECT
|
||||
'approval_executor_ssh_gateway',
|
||||
(SELECT count(*) FROM upsert_tools) AS tool_rows,
|
||||
(SELECT count(*) FROM upsert_grants) AS grant_rows;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Rollback for T9 approved SSH execution MCP Gateway seed.
|
||||
-- Contract revisions are append-only; rollback revokes approval_executor grants
|
||||
-- and deactivates only the write/admin tools introduced here.
|
||||
|
||||
SELECT set_config('app.project_id', 'awoooi', FALSE);
|
||||
|
||||
UPDATE awooop_mcp_grants
|
||||
SET
|
||||
is_revoked = TRUE,
|
||||
revoked_at = NOW(),
|
||||
revoked_by = 'rollback:t9_ssh_approval_gateway'
|
||||
WHERE project_id = 'awoooi'
|
||||
AND agent_id = 'approval_executor'
|
||||
AND granted_by = 'migration:t9_ssh_approval_gateway'
|
||||
AND is_revoked = FALSE;
|
||||
|
||||
UPDATE awooop_mcp_tool_registry
|
||||
SET
|
||||
is_active = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE project_id = 'awoooi'
|
||||
AND tool_name IN (
|
||||
'ssh_docker_restart',
|
||||
'ssh_docker_compose_restart',
|
||||
'ssh_systemctl_restart',
|
||||
'ssh_clear_docker_logs',
|
||||
'ssh_renew_ssl',
|
||||
'ssh_reload_nginx',
|
||||
'ssh_docker_prune'
|
||||
);
|
||||
|
||||
DELETE FROM awooop_active_revisions
|
||||
WHERE project_id = 'awoooi'
|
||||
AND contract_family = 'agent'
|
||||
AND contract_id = 'approval_executor';
|
||||
|
||||
UPDATE awooop_contract_revisions
|
||||
SET lifecycle_status = 'revoked'
|
||||
WHERE project_id = 'awoooi'
|
||||
AND contract_family = 'agent'
|
||||
AND contract_id = 'approval_executor'
|
||||
AND publisher_id = 'migration:t9_ssh_approval_gateway'
|
||||
AND lifecycle_status = 'active';
|
||||
@@ -25,14 +25,19 @@ Approval Execution Service - Phase 16 R4.2 瘦身 Router 抽取
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
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.interfaces import MCPToolResult
|
||||
from src.services.approval_db import get_approval_service, get_timeline_service
|
||||
from src.services.executor import OperationType, get_executor
|
||||
from src.services.executor import ExecutionResult, OperationType, get_executor
|
||||
from src.services.operation_parser import parse_operation_from_action
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,6 +50,23 @@ logger = structlog.get_logger(__name__)
|
||||
# 上限 60s 涵蓋 verifier warmup(10s) + collect(30s) + 緩衝 20s.
|
||||
_VERIFIER_AWAIT_TIMEOUT_SEC = 60.0
|
||||
|
||||
# T9: approved SSH execution must go through AwoooP MCP Gateway.
|
||||
# ApprovalRequest itself is the human/multi-sig decision artifact; for write/admin
|
||||
# tools we project it into the short-lived Gate 5 Redis key expected by Gateway.
|
||||
_SSH_GATEWAY_AGENT_ID = "approval_executor"
|
||||
_SSH_GATEWAY_PROJECT_ID = "awoooi"
|
||||
_SSH_GATEWAY_APPROVAL_TTL_SECONDS = 600
|
||||
_SSH_GATEWAY_TOOL_SCOPES: dict[str, str] = {
|
||||
"ssh_diagnose": "read",
|
||||
"ssh_docker_restart": "write",
|
||||
"ssh_docker_compose_restart": "write",
|
||||
"ssh_systemctl_restart": "write",
|
||||
"ssh_clear_docker_logs": "write",
|
||||
"ssh_renew_ssl": "write",
|
||||
"ssh_reload_nginx": "write",
|
||||
"ssh_docker_prune": "admin",
|
||||
}
|
||||
|
||||
|
||||
class ApprovalExecutionService:
|
||||
"""
|
||||
@@ -638,7 +660,7 @@ class ApprovalExecutionService:
|
||||
self,
|
||||
approval: ApprovalRequest,
|
||||
host: str,
|
||||
) -> "ExecutionResult":
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
執行 SSH 主機 action(手動批准路徑專用)
|
||||
|
||||
@@ -653,8 +675,6 @@ class ApprovalExecutionService:
|
||||
- "ps aux" / "df -h" / "free -h" / "top" / "uptime" / 'echo' / 'ls -lah' → ssh_diagnose
|
||||
- 其他:回傳失敗,提示 LLM 改寫 action
|
||||
"""
|
||||
from src.services.executor import ExecutionResult
|
||||
|
||||
start = time.time()
|
||||
action = approval.action or ""
|
||||
action_lower = action.lower().strip()
|
||||
@@ -708,36 +728,19 @@ class ApprovalExecutionService:
|
||||
error=err,
|
||||
)
|
||||
|
||||
# 呼叫 SSH MCP Provider
|
||||
# 2026-05-06 Codex: approved execution 是高風險「實際執行」路徑。
|
||||
# 在 AwoooP MCP Gateway 完全接管前,至少必須經過 AuditedMCPToolProvider
|
||||
# 寫入 durable mcp_audit_log,並標記這仍是 legacy direct provider path。
|
||||
from src.plugins.mcp.providers.ssh_provider import SSHProvider
|
||||
from src.plugins.mcp.registry import AuditedMCPToolProvider
|
||||
|
||||
provider = AuditedMCPToolProvider(SSHProvider())
|
||||
params_with_audit = {
|
||||
**params,
|
||||
"_mcp_audit": {
|
||||
"session_id": f"approval:{approval.id}",
|
||||
"incident_id": approval.incident_id,
|
||||
"agent_role": "approval_executor",
|
||||
"flywheel_node": "execute",
|
||||
"gateway_path": "legacy_direct_provider",
|
||||
},
|
||||
}
|
||||
try:
|
||||
logger.warning(
|
||||
"mcp_gateway_legacy_direct_provider_path",
|
||||
"mcp_gateway_approved_ssh_execution_path",
|
||||
approval_id=str(approval.id),
|
||||
incident_id=approval.incident_id,
|
||||
tool=tool_name,
|
||||
host=host,
|
||||
reason="awooop_gateway_not_enforced_for_legacy_approval_execution",
|
||||
agent_id=_SSH_GATEWAY_AGENT_ID,
|
||||
)
|
||||
mcp_result = await provider.execute(
|
||||
mcp_result = await self._execute_ssh_tool_via_gateway(
|
||||
approval=approval,
|
||||
tool_name=tool_name,
|
||||
parameters=params_with_audit,
|
||||
params=params,
|
||||
)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
success = bool(mcp_result.success)
|
||||
@@ -769,6 +772,61 @@ class ApprovalExecutionService:
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def _execute_ssh_tool_via_gateway(
|
||||
self,
|
||||
approval: ApprovalRequest,
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
) -> MCPToolResult:
|
||||
required_scope = _SSH_GATEWAY_TOOL_SCOPES.get(tool_name, "read")
|
||||
run_id = approval.id if isinstance(approval.id, UUID) else UUID(str(approval.id))
|
||||
|
||||
if required_scope != "read":
|
||||
approval_key = (
|
||||
f"mcp_approval:{_SSH_GATEWAY_PROJECT_ID}:{_SSH_GATEWAY_AGENT_ID}:"
|
||||
f"{tool_name}:{run_id}"
|
||||
)
|
||||
try:
|
||||
redis = get_redis()
|
||||
await redis.set(
|
||||
approval_key,
|
||||
"approved",
|
||||
ex=_SSH_GATEWAY_APPROVAL_TTL_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"mcp_gateway_approval_projection_failed",
|
||||
approval_id=str(approval.id),
|
||||
tool=tool_name,
|
||||
approval_key=approval_key,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
params_with_audit = {
|
||||
**params,
|
||||
"_mcp_audit": {
|
||||
"session_id": f"approval:{approval.id}",
|
||||
"incident_id": approval.incident_id,
|
||||
"agent_role": _SSH_GATEWAY_AGENT_ID,
|
||||
"flywheel_node": "execute",
|
||||
"approval_id": str(approval.id),
|
||||
},
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
async def _push_execution_result_to_alert(
|
||||
self,
|
||||
approval: ApprovalRequest,
|
||||
|
||||
@@ -1,56 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.approval import ApprovalRequest, RiskLevel
|
||||
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
|
||||
from src.plugins.mcp.interfaces import MCPToolResult
|
||||
from src.services.approval_execution import ApprovalExecutionService
|
||||
|
||||
|
||||
class FakeSSHProvider(MCPToolProvider):
|
||||
name = "ssh"
|
||||
enabled = True
|
||||
seen_parameters: dict[str, Any] | None = None
|
||||
class FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.set_calls: list[tuple[str, str, int | None]] = []
|
||||
|
||||
async def list_tools(self) -> list[MCPTool]:
|
||||
return []
|
||||
|
||||
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
|
||||
self.seen_parameters = dict(parameters)
|
||||
return MCPToolResult(
|
||||
success=True,
|
||||
output={"tool": tool_name, "ok": True},
|
||||
execution_id="fake-ssh-exec",
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
async def set(self, key: str, value: str, ex: int | None = None) -> None:
|
||||
self.set_calls.append((key, value, ex))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_approval_execution_uses_audited_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_provider = FakeSSHProvider()
|
||||
audit_calls: list[dict[str, Any]] = []
|
||||
async def test_ssh_approval_execution_projects_approval_into_gateway(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_redis = FakeRedis()
|
||||
gateway_calls: list[dict[str, Any]] = []
|
||||
db_context_projects: list[str | None] = []
|
||||
fake_db = object()
|
||||
|
||||
class ProviderFactory:
|
||||
def __call__(self) -> FakeSSHProvider:
|
||||
return fake_provider
|
||||
@asynccontextmanager
|
||||
async def fake_db_context(project_id: str | None = None):
|
||||
db_context_projects.append(project_id)
|
||||
yield fake_db
|
||||
|
||||
async def fake_record_mcp_call(**kwargs: Any) -> None:
|
||||
audit_calls.append(kwargs)
|
||||
class FakeGateway:
|
||||
def __init__(self, db: object) -> None:
|
||||
self.db = db
|
||||
|
||||
monkeypatch.setattr("src.plugins.mcp.providers.ssh_provider.SSHProvider", ProviderFactory())
|
||||
monkeypatch.setattr("src.services.mcp_audit_service.record_mcp_call", fake_record_mcp_call)
|
||||
async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult:
|
||||
gateway_calls.append({"db": self.db, "ctx": ctx, "parameters": parameters})
|
||||
return MCPToolResult(
|
||||
success=True,
|
||||
output={"tool": ctx.tool_name, "ok": True},
|
||||
execution_id="fake-gateway-ssh-exec",
|
||||
)
|
||||
|
||||
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="測試 SSH approved execution audit",
|
||||
description="測試 SSH approved execution gateway audit",
|
||||
risk_level=RiskLevel.LOW,
|
||||
requested_by="test",
|
||||
required_signatures=0,
|
||||
incident_id="INC-TEST-AUDIT",
|
||||
incident_id="INC-TEST-GATEWAY",
|
||||
)
|
||||
|
||||
result = await ApprovalExecutionService()._execute_ssh_host_action(
|
||||
@@ -59,13 +64,84 @@ async def test_ssh_approval_execution_uses_audited_provider(monkeypatch: pytest.
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert fake_provider.seen_parameters is not None
|
||||
assert "_mcp_audit" not in fake_provider.seen_parameters
|
||||
assert audit_calls
|
||||
audit = audit_calls[0]
|
||||
assert audit["mcp_server"] == "ssh"
|
||||
assert audit["tool_name"] == "ssh_docker_restart"
|
||||
assert audit["incident_id"] == "INC-TEST-AUDIT"
|
||||
assert audit["agent_role"] == "approval_executor"
|
||||
assert audit["flywheel_node"] == "execute"
|
||||
assert audit["input_params"]["_mcp_audit"]["gateway_path"] == "legacy_direct_provider"
|
||||
assert db_context_projects == ["awoooi"]
|
||||
assert len(gateway_calls) == 1
|
||||
|
||||
call = gateway_calls[0]
|
||||
ctx = call["ctx"]
|
||||
assert ctx.project_id == "awoooi"
|
||||
assert ctx.agent_id == "approval_executor"
|
||||
assert ctx.tool_name == "ssh_docker_restart"
|
||||
assert ctx.required_scope == "write"
|
||||
assert ctx.is_shadow is False
|
||||
assert ctx.environment == {"env": "prod"}
|
||||
assert ctx.run_id == approval.id
|
||||
assert isinstance(ctx.run_id, UUID)
|
||||
assert ctx.trace_id == "INC-TEST-GATEWAY"
|
||||
|
||||
parameters = call["parameters"]
|
||||
assert parameters["host"] == "192.168.0.110"
|
||||
assert parameters["container_name"] == "sentry-worker"
|
||||
assert parameters["trust_score"] == 0.85
|
||||
assert parameters["_mcp_audit"]["agent_role"] == "approval_executor"
|
||||
assert parameters["_mcp_audit"]["flywheel_node"] == "execute"
|
||||
assert parameters["_mcp_audit"]["incident_id"] == "INC-TEST-GATEWAY"
|
||||
|
||||
assert fake_redis.set_calls == [
|
||||
(
|
||||
f"mcp_approval:awoooi:approval_executor:ssh_docker_restart:{approval.id}",
|
||||
"approved",
|
||||
600,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_diagnose_approval_uses_gateway_without_gate5_projection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_redis = FakeRedis()
|
||||
gateway_calls: list[dict[str, Any]] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_db_context(project_id: str | None = None):
|
||||
yield {"project_id": project_id}
|
||||
|
||||
class FakeGateway:
|
||||
def __init__(self, db: object) -> None:
|
||||
self.db = db
|
||||
|
||||
async def call(self, ctx: Any, parameters: dict[str, Any]) -> MCPToolResult:
|
||||
gateway_calls.append({"db": self.db, "ctx": ctx, "parameters": parameters})
|
||||
return MCPToolResult(
|
||||
success=True,
|
||||
output={"tool": ctx.tool_name, "ok": True},
|
||||
execution_id="fake-gateway-read-exec",
|
||||
)
|
||||
|
||||
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="df -h",
|
||||
description="測試 SSH diagnose gateway audit",
|
||||
risk_level=RiskLevel.LOW,
|
||||
requested_by="test",
|
||||
required_signatures=0,
|
||||
incident_id="INC-TEST-READ",
|
||||
)
|
||||
|
||||
result = await ApprovalExecutionService()._execute_ssh_host_action(
|
||||
approval=approval,
|
||||
host="192.168.0.110",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert fake_redis.set_calls == []
|
||||
assert len(gateway_calls) == 1
|
||||
ctx = gateway_calls[0]["ctx"]
|
||||
assert ctx.agent_id == "approval_executor"
|
||||
assert ctx.tool_name == "ssh_diagnose"
|
||||
assert ctx.required_scope == "read"
|
||||
assert ctx.is_shadow is False
|
||||
|
||||
@@ -7020,3 +7020,47 @@ first_gateway_tool=legacy:ssh_host:ssh_get_nginx_error_log result=failed
|
||||
gate_schema=legacy_mcp_bridge_v1 policy_enforced=False
|
||||
not_used_reason=legacy direct provider path; bridge audit only
|
||||
```
|
||||
|
||||
### 2026-05-13 — AwoooP MCP Gateway T9:approved SSH execution 接入五閘門(local green)
|
||||
|
||||
**目的**:
|
||||
|
||||
- 將 Telegram / Approval 已批准的 SSH 修復執行路徑,從 `legacy_direct_provider` 推進到 first-class `McpGateway`。
|
||||
- write/admin SSH tool 不自動放行;由已批准的 `ApprovalRequest` 投影短效 Gate 5 Redis key,再由 Gateway 驗證 agent/tool/grant/env/approval 並寫 `awooop_mcp_gateway_audit`。
|
||||
|
||||
**變更**:
|
||||
|
||||
- `ApprovalExecutionService._execute_ssh_host_action()` 改走 `approval_executor` + `McpGateway`。
|
||||
- `ssh_diagnose` 走 read scope,不投影 Gate 5 key。
|
||||
- `ssh_docker_restart` / `ssh_systemctl_restart` 等 write tool 走 write scope。
|
||||
- `ssh_docker_prune` 走 admin scope。
|
||||
- 新增 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`
|
||||
|
||||
**local verification**:
|
||||
|
||||
```text
|
||||
python -m pytest tests/test_mcp_gateway_gate5.py tests/test_approval_execution_mcp_audit.py -q
|
||||
5 passed
|
||||
|
||||
python -m ruff check --select F821 src/services/approval_execution.py tests/test_approval_execution_mcp_audit.py
|
||||
All checks passed
|
||||
|
||||
python -m py_compile src/services/approval_execution.py tests/test_approval_execution_mcp_audit.py
|
||||
OK
|
||||
|
||||
bash -n scripts/ops/notify-awoooi-ops.sh scripts/backup/backup-momo-188-pg.sh
|
||||
OK
|
||||
|
||||
ruby -e 'require "yaml"; YAML.load_file("infra/ansible/playbooks/188-ai-web.yml")'
|
||||
yaml ok
|
||||
```
|
||||
|
||||
**目前整體進度**:約 64%。
|
||||
|
||||
- Wave 0 backup 接線:完成並已部署。
|
||||
- T1-T6 truth-chain / run-state / alert event / bridge / status 收斂:完成。
|
||||
- T7 read-only pre-decision MCP Gateway:完成並已產線 smoke。
|
||||
- T8 post-execution verifier MCP Gateway:完成並已產線 smoke。
|
||||
- T9 approved SSH execution Gateway:local green,待 Gitea run-migration / CD / production smoke。
|
||||
|
||||
Reference in New Issue
Block a user