feat: add secure agent and MCP extensions
This commit is contained in:
273
backend/apps/tools/api.py
Normal file
273
backend/apps/tools/api.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from django.db import transaction
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.skills.services import SkillValidationError
|
||||
from apps.tools.models import (
|
||||
ToolAuthorization,
|
||||
ToolCall,
|
||||
ToolProvider,
|
||||
UnifiedTool,
|
||||
)
|
||||
from apps.tools.schemas import (
|
||||
ToolAuthorizationIn,
|
||||
ToolAuthorizationOut,
|
||||
ToolCallIn,
|
||||
ToolCallOut,
|
||||
ToolConfirmationIn,
|
||||
ToolProviderIn,
|
||||
ToolProviderOut,
|
||||
ToolProviderUpdateIn,
|
||||
UnifiedToolOut,
|
||||
UnifiedToolUpdateIn,
|
||||
)
|
||||
from apps.tools.services import (
|
||||
ToolExecutionError,
|
||||
decide_tool_confirmation,
|
||||
request_tool_call,
|
||||
sync_mcp_tools,
|
||||
)
|
||||
|
||||
router = Router(tags=["tools"])
|
||||
|
||||
|
||||
def serialize_provider(provider: ToolProvider) -> dict:
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url,
|
||||
"credential_set": bool(provider.token_encrypted),
|
||||
"enabled": provider.enabled,
|
||||
"timeout_seconds": provider.timeout_seconds,
|
||||
"created_at": provider.created_at,
|
||||
"updated_at": provider.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_call(call: ToolCall) -> dict:
|
||||
confirmation = getattr(call, "confirmation", None)
|
||||
return {
|
||||
"public_id": call.public_id,
|
||||
"tool_code": call.tool.code,
|
||||
"requested_by_id": call.requested_by_id,
|
||||
"requested_by_name": (
|
||||
call.requested_by.get_full_name() or call.requested_by.username
|
||||
if call.requested_by
|
||||
else ""
|
||||
),
|
||||
"status": call.status,
|
||||
"input_payload": call.input_payload,
|
||||
"output_payload": call.output_payload,
|
||||
"authorization_snapshot": call.authorization_snapshot,
|
||||
"risk_snapshot": call.risk_snapshot,
|
||||
"error_code": call.error_code,
|
||||
"error_message": call.error_message,
|
||||
"confirmation_status": confirmation.status if confirmation else None,
|
||||
"confirmation_reason": confirmation.reason if confirmation else "",
|
||||
"created_at": call.created_at,
|
||||
"started_at": call.started_at,
|
||||
"finished_at": call.finished_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/providers", response=list[ToolProviderOut])
|
||||
def list_tool_providers(request):
|
||||
require_permission(request, "tool.manage")
|
||||
return [serialize_provider(item) for item in ToolProvider.objects.all()]
|
||||
|
||||
|
||||
@router.post("/providers", response=ToolProviderOut)
|
||||
def create_tool_provider(request, payload: ToolProviderIn):
|
||||
require_permission(request, "tool.manage")
|
||||
if payload.provider_type == "mcp_http" and not payload.base_url:
|
||||
raise HttpError(400, "HTTP MCP 必须配置服务地址")
|
||||
provider = ToolProvider.objects.create(
|
||||
name=payload.name,
|
||||
provider_type=payload.provider_type,
|
||||
base_url=payload.base_url,
|
||||
token_encrypted=encrypt_secret(payload.token) if payload.token else "",
|
||||
enabled=payload.enabled,
|
||||
timeout_seconds=min(max(payload.timeout_seconds, 1), 300),
|
||||
created_by=request.user,
|
||||
)
|
||||
return serialize_provider(provider)
|
||||
|
||||
|
||||
@router.patch("/providers/{provider_id}", response=ToolProviderOut)
|
||||
def update_tool_provider(
|
||||
request,
|
||||
provider_id: int,
|
||||
payload: ToolProviderUpdateIn,
|
||||
):
|
||||
require_permission(request, "tool.manage")
|
||||
provider = ToolProvider.objects.filter(id=provider_id).first()
|
||||
if provider is None:
|
||||
raise HttpError(404, "工具服务不存在")
|
||||
values = payload.dict(exclude_unset=True)
|
||||
if "base_url" in values and provider.provider_type == "mcp_http":
|
||||
if not values["base_url"]:
|
||||
raise HttpError(400, "HTTP MCP 必须配置服务地址")
|
||||
token = values.pop("token", None)
|
||||
if token:
|
||||
provider.token_encrypted = encrypt_secret(token)
|
||||
if "timeout_seconds" in values:
|
||||
values["timeout_seconds"] = min(max(values["timeout_seconds"], 1), 300)
|
||||
for field, value in values.items():
|
||||
setattr(provider, field, value)
|
||||
provider.save()
|
||||
return serialize_provider(provider)
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/sync", response=list[UnifiedToolOut])
|
||||
def sync_tool_provider(request, provider_id: int):
|
||||
require_permission(request, "tool.manage")
|
||||
provider = ToolProvider.objects.filter(id=provider_id).first()
|
||||
if provider is None:
|
||||
raise HttpError(404, "工具服务不存在")
|
||||
try:
|
||||
return sync_mcp_tools(provider)
|
||||
except ToolExecutionError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
|
||||
|
||||
@router.get("", response=list[UnifiedToolOut])
|
||||
def list_tools(request):
|
||||
require_permission(request, "tool.view")
|
||||
return UnifiedTool.objects.select_related("provider").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tool_id}/authorizations",
|
||||
response=list[ToolAuthorizationOut],
|
||||
)
|
||||
def list_tool_authorizations(request, tool_id: int):
|
||||
require_permission(request, "tool.manage")
|
||||
if not UnifiedTool.objects.filter(id=tool_id).exists():
|
||||
raise HttpError(404, "工具不存在")
|
||||
return ToolAuthorization.objects.filter(tool_id=tool_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tool_id}/authorizations",
|
||||
response=ToolAuthorizationOut,
|
||||
)
|
||||
def create_tool_authorization(request, tool_id: int, payload: ToolAuthorizationIn):
|
||||
require_permission(request, "tool.manage")
|
||||
if payload.principal_type not in {"user", "group", "agent"}:
|
||||
raise HttpError(400, "授权主体必须是 user、group 或 agent")
|
||||
tool = UnifiedTool.objects.filter(id=tool_id).first()
|
||||
if tool is None:
|
||||
raise HttpError(404, "工具不存在")
|
||||
authorization, _ = ToolAuthorization.objects.update_or_create(
|
||||
tool=tool,
|
||||
principal_type=payload.principal_type,
|
||||
principal_id=payload.principal_id,
|
||||
defaults={"allowed": payload.allowed, "created_by": request.user},
|
||||
)
|
||||
return authorization
|
||||
|
||||
|
||||
@router.get("/calls", response=list[ToolCallOut])
|
||||
def list_tool_calls(request, status: str = "", tool_code: str = ""):
|
||||
require_permission(request, "tool.invoke")
|
||||
queryset = ToolCall.objects.select_related(
|
||||
"tool",
|
||||
"requested_by",
|
||||
"confirmation",
|
||||
)
|
||||
can_confirm = request.user.is_superuser or request.user.has_perm(
|
||||
"system.tool.confirm"
|
||||
)
|
||||
if not can_confirm:
|
||||
queryset = queryset.filter(requested_by=request.user)
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if tool_code:
|
||||
queryset = queryset.filter(tool__code=tool_code)
|
||||
return [serialize_call(call) for call in queryset[:100]]
|
||||
|
||||
|
||||
@router.post("/calls", response=ToolCallOut)
|
||||
def create_tool_call(request, payload: ToolCallIn):
|
||||
require_permission(request, "tool.invoke")
|
||||
tool = UnifiedTool.objects.filter(code=payload.tool_code).first()
|
||||
if tool is None:
|
||||
raise HttpError(404, "工具不存在")
|
||||
run = None
|
||||
if payload.agent_run_id:
|
||||
run = AgentRun.objects.filter(public_id=payload.agent_run_id).first()
|
||||
if run is None:
|
||||
raise HttpError(404, "Agent 运行不存在")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
call = request_tool_call(
|
||||
tool,
|
||||
payload.input,
|
||||
user=request.user,
|
||||
run=run,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
except (ToolExecutionError, SkillValidationError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
call = ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
|
||||
return serialize_call(call)
|
||||
|
||||
|
||||
@router.get("/calls/{public_id}", response=ToolCallOut)
|
||||
def get_tool_call(request, public_id: str):
|
||||
require_permission(request, "tool.invoke")
|
||||
call = ToolCall.objects.select_related("tool", "confirmation").filter(
|
||||
public_id=public_id
|
||||
).first()
|
||||
if call is None:
|
||||
raise HttpError(404, "工具调用不存在")
|
||||
if not request.user.is_superuser and call.requested_by_id != request.user.id:
|
||||
require_permission(request, "tool.confirm")
|
||||
return serialize_call(call)
|
||||
|
||||
|
||||
@router.post("/calls/{public_id}/confirmation", response=ToolCallOut)
|
||||
def confirm_tool_call(request, public_id: str, payload: ToolConfirmationIn):
|
||||
require_permission(request, "tool.confirm")
|
||||
call = ToolCall.objects.select_related("tool", "confirmation").filter(
|
||||
public_id=public_id
|
||||
).first()
|
||||
if call is None:
|
||||
raise HttpError(404, "工具调用不存在")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
decide_tool_confirmation(
|
||||
call,
|
||||
reviewer=request.user,
|
||||
decision=payload.decision,
|
||||
reason=payload.reason,
|
||||
)
|
||||
except ToolExecutionError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
call.refresh_from_db()
|
||||
return serialize_call(
|
||||
ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{tool_id}", response=UnifiedToolOut)
|
||||
def update_tool(request, tool_id: int, payload: UnifiedToolUpdateIn):
|
||||
require_permission(request, "tool.manage")
|
||||
tool = UnifiedTool.objects.filter(id=tool_id).first()
|
||||
if tool is None:
|
||||
raise HttpError(404, "工具不存在")
|
||||
values = payload.dict(exclude_unset=True)
|
||||
if "risk_level" in values and values["risk_level"] not in {
|
||||
"read_only",
|
||||
"low_write",
|
||||
"high_risk",
|
||||
}:
|
||||
raise HttpError(400, "未知工具风险等级")
|
||||
for field, value in values.items():
|
||||
setattr(tool, field, value)
|
||||
tool.save()
|
||||
return tool
|
||||
Reference in New Issue
Block a user