diff --git a/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13.sql b/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13.sql new file mode 100644 index 00000000..9803fb1a --- /dev/null +++ b/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13.sql @@ -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; diff --git a/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13_down.sql b/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13_down.sql new file mode 100644 index 00000000..a57c3f2f --- /dev/null +++ b/apps/api/migrations/awooop_awoooi_mcp_approval_executor_ssh_gateway_2026-05-13_down.sql @@ -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'; diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index 22390ebc..961d70f3 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -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, diff --git a/apps/api/tests/test_approval_execution_mcp_audit.py b/apps/api/tests/test_approval_execution_mcp_audit.py index c8f6aea4..d63ac640 100644 --- a/apps/api/tests/test_approval_execution_mcp_audit.py +++ b/apps/api/tests/test_approval_execution_mcp_audit.py @@ -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 diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index efe12296..d9483f0d 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -7185,3 +7185,47 @@ Readiness: - 沒有新增 runtime endpoint、DB migration、model 或 execution action。 - 沒有啟動 scan、呼叫 `/execute`、建立 repo、修改 visibility、sync refs 或切 GitHub primary。 - 沒有保存 raw secret、token、cookie、private key 或 exploit payload。 + +### 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。 diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index b6ca3166..8e83c0d5 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -40,7 +40,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: 1a03bceb5c57bc906b6b95acc3947ea71dcd7927 + newTag: a0a2a5b1f07f86fef104e7950e16f32f3f7cad2e - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: 1a03bceb5c57bc906b6b95acc3947ea71dcd7927 + newTag: a0a2a5b1f07f86fef104e7950e16f32f3f7cad2e