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

This commit is contained in:
Your Name
2026-05-13 11:02:24 +08:00
parent 85a1bcef52
commit a0a2a5b1f0
5 changed files with 452 additions and 67 deletions

View File

@@ -0,0 +1,164 @@
-- T9: approved SSH execution MCP Gateway seed
-- 目的:讓 Telegram/Approval 已批准的 SSH 修復動作通過 AwoooP Gateway 五閘門。
-- 邊界:只授權 approval_executorwrite/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;

View File

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

View File

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

View File

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

View File

@@ -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 T9approved 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 Gatewaylocal green待 Gitea run-migration / CD / production smoke。