feat: add secure agent and MCP extensions
This commit is contained in:
284
backend/apps/tools/services.py
Normal file
284
backend/apps/tools/services.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""统一 Tool/MCP 同步、授权、风险确认和调用审计服务。"""
|
||||
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import decrypt_secret
|
||||
from apps.skills.services import validate_payload
|
||||
from apps.tools.models import (
|
||||
ToolAuthorization,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolConfirmation,
|
||||
ToolProvider,
|
||||
ToolProviderType,
|
||||
ToolRiskLevel,
|
||||
UnifiedTool,
|
||||
)
|
||||
|
||||
|
||||
class ToolExecutionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_http_url(value: str) -> None:
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
|
||||
raise ToolExecutionError("MCP 地址必须是无内嵌凭证的 HTTP(S) URL")
|
||||
|
||||
|
||||
def _json_rpc(provider: ToolProvider, method: str, params: dict) -> dict:
|
||||
_validate_http_url(provider.base_url)
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if provider.token_encrypted:
|
||||
headers["Authorization"] = f"Bearer {decrypt_secret(provider.token_encrypted)}"
|
||||
payload = json.dumps(
|
||||
{"jsonrpc": "2.0", "id": "ai-ticket-platform", "method": method, "params": params}
|
||||
).encode()
|
||||
request = Request(provider.base_url, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urlopen(request, timeout=provider.timeout_seconds) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
|
||||
raise ToolExecutionError("HTTP MCP 调用失败") from error
|
||||
if not isinstance(result, dict) or result.get("error"):
|
||||
raise ToolExecutionError("HTTP MCP 返回错误")
|
||||
rpc_result = result.get("result")
|
||||
if not isinstance(rpc_result, dict):
|
||||
raise ToolExecutionError("HTTP MCP 返回结构无效")
|
||||
return rpc_result
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_mcp_tools(provider: ToolProvider) -> list[UnifiedTool]:
|
||||
"""从 HTTP MCP 同步工具定义,但保留本地风险等级、开关和授权治理。"""
|
||||
|
||||
provider = ToolProvider.objects.select_for_update().get(id=provider.id)
|
||||
if not provider.enabled or provider.provider_type != ToolProviderType.MCP_HTTP:
|
||||
raise ToolExecutionError("MCP 服务未启用或类型不正确")
|
||||
result = _json_rpc(provider, "tools/list", {})
|
||||
remote_tools = result.get("tools")
|
||||
if not isinstance(remote_tools, list):
|
||||
raise ToolExecutionError("MCP tools/list 未返回工具列表")
|
||||
synced = []
|
||||
now = timezone.now()
|
||||
for item in remote_tools:
|
||||
if not isinstance(item, dict) or not str(item.get("name") or "").strip():
|
||||
continue
|
||||
external_name = str(item["name"]).strip()
|
||||
code = f"mcp.{provider.id}.{external_name}"
|
||||
tool, _ = UnifiedTool.objects.update_or_create(
|
||||
code=code,
|
||||
defaults={
|
||||
"provider": provider,
|
||||
"external_name": external_name,
|
||||
"name": str(item.get("title") or external_name),
|
||||
"description": str(item.get("description") or ""),
|
||||
"input_schema": item.get("inputSchema")
|
||||
if isinstance(item.get("inputSchema"), dict)
|
||||
else {"type": "object"},
|
||||
"synced_at": now,
|
||||
},
|
||||
)
|
||||
synced.append(tool)
|
||||
OperationAuditLog.objects.create(
|
||||
module="tools",
|
||||
action="mcp_tools_synced",
|
||||
target_type="tool_provider",
|
||||
target_id=str(provider.id),
|
||||
request_snapshot={"tool_count": len(synced)},
|
||||
result="success",
|
||||
)
|
||||
return synced
|
||||
|
||||
|
||||
def _authorization_snapshot(tool: UnifiedTool, user, run: AgentRun | None) -> dict:
|
||||
"""计算并冻结用户、用户组、Agent 动作与 Agent 授权的交集。"""
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
raise ToolExecutionError("工具调用需要登录用户")
|
||||
if not (user.is_superuser or user.has_perm("system.tool.invoke")):
|
||||
raise ToolExecutionError("用户没有工具调用权限")
|
||||
authorizations = ToolAuthorization.objects.filter(tool=tool)
|
||||
group_ids = user.groups.values_list("id", flat=True)
|
||||
explicitly_denied = authorizations.filter(
|
||||
allowed=False,
|
||||
principal_type="user",
|
||||
principal_id=user.id,
|
||||
).exists() or authorizations.filter(
|
||||
allowed=False,
|
||||
principal_type="group",
|
||||
principal_id__in=group_ids,
|
||||
).exists()
|
||||
grants = authorizations.filter(allowed=True)
|
||||
user_allowed = not explicitly_denied and (
|
||||
user.is_superuser or grants.filter(
|
||||
principal_type="user", principal_id=user.id
|
||||
).exists() or grants.filter(
|
||||
principal_type="group", principal_id__in=group_ids
|
||||
).exists())
|
||||
if not user_allowed:
|
||||
raise ToolExecutionError("用户或用户组未获得该工具授权")
|
||||
agent_allowed = True
|
||||
agent_grant = True
|
||||
if run is not None:
|
||||
action = f"tool.call:{tool.code}"
|
||||
agent_allowed = action in run.config_snapshot.get("agent", {}).get(
|
||||
"allowed_actions", []
|
||||
)
|
||||
agent_grant = grants.filter(
|
||||
principal_type="agent",
|
||||
principal_id=run.definition_id,
|
||||
).exists()
|
||||
if not agent_allowed or not agent_grant:
|
||||
raise ToolExecutionError("Agent 版本或 Agent 授权未允许该工具")
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"user_permission": True,
|
||||
"principal_grant": user_allowed,
|
||||
"agent_run_id": str(run.public_id) if run else None,
|
||||
"agent_action_allowed": agent_allowed,
|
||||
"agent_grant": agent_grant,
|
||||
}
|
||||
|
||||
|
||||
def execute_tool_call(call: ToolCall) -> ToolCall:
|
||||
"""执行已通过授权和风险确认的工具调用,并持久化终态审计。"""
|
||||
|
||||
call = ToolCall.objects.select_related("tool__provider").get(id=call.id)
|
||||
if call.status not in {ToolCallStatus.RUNNING, ToolCallStatus.PENDING_CONFIRMATION}:
|
||||
return call
|
||||
call.status = ToolCallStatus.RUNNING
|
||||
call.started_at = timezone.now()
|
||||
call.save(update_fields=["status", "started_at"])
|
||||
try:
|
||||
if not call.tool.provider_id:
|
||||
raise ToolExecutionError("原生工具尚未注册执行器")
|
||||
result = _json_rpc(
|
||||
call.tool.provider,
|
||||
"tools/call",
|
||||
{"name": call.tool.external_name, "arguments": call.input_payload},
|
||||
)
|
||||
except Exception as error:
|
||||
call.status = ToolCallStatus.FAILED
|
||||
call.error_code = "tool_call_failed"
|
||||
call.error_message = str(error)
|
||||
else:
|
||||
call.status = ToolCallStatus.SUCCEEDED
|
||||
call.output_payload = result
|
||||
call.finished_at = timezone.now()
|
||||
call.save()
|
||||
OperationAuditLog.objects.create(
|
||||
user=call.requested_by,
|
||||
module="tools",
|
||||
action="tool_call_finished",
|
||||
target_type="tool_call",
|
||||
target_id=str(call.public_id),
|
||||
request_snapshot={
|
||||
"tool_code": call.tool.code,
|
||||
"status": call.status,
|
||||
"risk_level": call.tool.risk_level,
|
||||
"agent_run_id": str(call.agent_run.public_id) if call.agent_run_id else None,
|
||||
},
|
||||
result="success" if call.status == ToolCallStatus.SUCCEEDED else "failed",
|
||||
)
|
||||
return call
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def request_tool_call(
|
||||
tool: UnifiedTool,
|
||||
payload: dict,
|
||||
*,
|
||||
user,
|
||||
run: AgentRun | None = None,
|
||||
idempotency_key: str = "",
|
||||
) -> ToolCall:
|
||||
"""创建幂等工具调用;高风险调用先进入独立人工确认,不直接执行。"""
|
||||
|
||||
tool = UnifiedTool.objects.select_for_update().select_related("provider").get(id=tool.id)
|
||||
if not tool.enabled or (tool.provider_id and not tool.provider.enabled):
|
||||
raise ToolExecutionError("工具或工具服务未启用")
|
||||
if idempotency_key:
|
||||
existing = ToolCall.objects.filter(tool=tool, idempotency_key=idempotency_key).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
validate_payload(tool.input_schema, payload, "工具输入")
|
||||
authorization = _authorization_snapshot(tool, user, run)
|
||||
needs_confirmation = tool.risk_level == ToolRiskLevel.HIGH_RISK
|
||||
call_values = {
|
||||
"agent_run": run,
|
||||
"requested_by": user,
|
||||
"status": (
|
||||
ToolCallStatus.PENDING_CONFIRMATION
|
||||
if needs_confirmation
|
||||
else ToolCallStatus.RUNNING
|
||||
),
|
||||
"input_payload": payload,
|
||||
"authorization_snapshot": authorization,
|
||||
"risk_snapshot": {
|
||||
"risk_level": tool.risk_level,
|
||||
"confirmation_required": needs_confirmation,
|
||||
},
|
||||
}
|
||||
normalized_key = idempotency_key.strip()
|
||||
if normalized_key:
|
||||
call, created = ToolCall.objects.get_or_create(
|
||||
tool=tool,
|
||||
idempotency_key=normalized_key,
|
||||
defaults=call_values,
|
||||
)
|
||||
if not created:
|
||||
return call
|
||||
else:
|
||||
call = ToolCall.objects.create(
|
||||
tool=tool,
|
||||
idempotency_key="",
|
||||
**call_values,
|
||||
)
|
||||
if needs_confirmation:
|
||||
ToolConfirmation.objects.create(call=call)
|
||||
from apps.workitems.services import sync_tool_confirmation_work_item
|
||||
|
||||
sync_tool_confirmation_work_item(call, actor=user)
|
||||
return call
|
||||
transaction.on_commit(lambda: execute_tool_call(call))
|
||||
return call
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def decide_tool_confirmation(call: ToolCall, *, reviewer, decision: str, reason: str = ""):
|
||||
"""由非申请人审批高风险调用,并仅在事务提交后开始执行。"""
|
||||
|
||||
call = ToolCall.objects.select_for_update().select_related("confirmation").get(id=call.id)
|
||||
if call.status != ToolCallStatus.PENDING_CONFIRMATION:
|
||||
raise ToolExecutionError("工具调用不在待确认状态")
|
||||
if call.requested_by_id == reviewer.id:
|
||||
raise ToolExecutionError("高风险工具调用不能由申请人自审")
|
||||
if decision not in {"approve", "reject"}:
|
||||
raise ToolExecutionError("确认决定必须是 approve 或 reject")
|
||||
confirmation = call.confirmation
|
||||
confirmation.status = "approved" if decision == "approve" else "rejected"
|
||||
confirmation.reason = reason.strip()
|
||||
confirmation.decided_by = reviewer
|
||||
confirmation.decided_at = timezone.now()
|
||||
confirmation.save()
|
||||
if decision == "reject":
|
||||
call.status = ToolCallStatus.REJECTED
|
||||
call.finished_at = timezone.now()
|
||||
call.save(update_fields=["status", "finished_at"])
|
||||
else:
|
||||
call.status = ToolCallStatus.RUNNING
|
||||
call.save(update_fields=["status"])
|
||||
transaction.on_commit(lambda: execute_tool_call(call))
|
||||
from apps.workitems.services import sync_tool_confirmation_work_item
|
||||
|
||||
sync_tool_confirmation_work_item(call, actor=reviewer)
|
||||
return call
|
||||
Reference in New Issue
Block a user