163 lines
5.5 KiB
Python
163 lines
5.5 KiB
Python
"""安全 API(api-spec §16)。
|
||
|
||
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 负责
|
||
高风险工具确认:通过 ConfirmationRegistry 更新工具确认状态唤醒 Agent 循环。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||
|
||
from app.api.v1.deps import get_confirmation_registry, get_optional_tool_runtime
|
||
from app.application.agent.confirmation import ConfirmationRegistry
|
||
from app.application.tool_runtime.runtime import ToolRuntime
|
||
from app.schemas.admin import AuditEntry
|
||
from app.schemas.common import Page
|
||
from app.schemas.safety import (
|
||
CreateToolPolicyRequest,
|
||
SafetyPolicyItem,
|
||
ToolPolicyResponse,
|
||
UpdateToolPolicyRequest,
|
||
)
|
||
from app.services import audit as audit_service
|
||
from app.services import governance as governance_service
|
||
|
||
router = APIRouter(prefix="/safety", tags=["safety"])
|
||
|
||
|
||
# —— §16 审计事件查询 ——
|
||
|
||
|
||
@router.get("/audit-events", response_model=Page[AuditEntry])
|
||
async def list_audit_events(
|
||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=50, ge=1, le=200),
|
||
) -> Page[AuditEntry]:
|
||
return await audit_service.query_audit_events(
|
||
character_id=character_id, pivot=pivot, page=page, page_size=page_size
|
||
)
|
||
|
||
|
||
# —— §16 工具策略治理 ——
|
||
|
||
|
||
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
|
||
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
|
||
if runtime is not None:
|
||
await runtime.reload_governor()
|
||
|
||
|
||
@router.get("/policies", response_model=list[SafetyPolicyItem])
|
||
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
||
return await governance_service.list_safety_policies()
|
||
|
||
|
||
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_safety_policy(
|
||
payload: CreateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.create_tool_policy(payload)
|
||
await _refresh_governor(runtime)
|
||
return response
|
||
|
||
|
||
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
|
||
async def update_safety_policy(
|
||
tool: str,
|
||
payload: UpdateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.update_tool_policy(tool, payload)
|
||
await _refresh_governor(runtime)
|
||
return response
|
||
|
||
|
||
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_safety_policy(
|
||
tool: str,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> Response:
|
||
await governance_service.delete_tool_policy(tool)
|
||
await _refresh_governor(runtime)
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
# —— §16 高风险工具确认 ——
|
||
|
||
|
||
def _not_found(confirmation_id: str) -> HTTPException:
|
||
return HTTPException(
|
||
status_code=404,
|
||
detail={
|
||
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
|
||
},
|
||
)
|
||
|
||
|
||
def _cancelled(confirmation_id: str) -> dict[str, str]:
|
||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||
|
||
|
||
@router.post("/confirmations/{confirmation_id}/approve")
|
||
async def approve(
|
||
confirmation_id: str,
|
||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||
) -> dict[str, bool | str]:
|
||
"""批准工具执行。通过 ConfirmationRegistry 唤醒等待的 Agent 循环。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.safety import PendingConfirmationRecord
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
if record is None or record.run_id is None:
|
||
raise _not_found(confirmation_id)
|
||
|
||
# 更新 resolution 并唤醒进程内 Event
|
||
ok = await registry.resolve(
|
||
record.run_id,
|
||
approved=True,
|
||
confirmation_id=confirmation_id,
|
||
)
|
||
if not ok:
|
||
raise _not_found(confirmation_id)
|
||
|
||
return {"status": "resolved", "approved": True, "run_id": record.run_id}
|
||
|
||
|
||
@router.post("/confirmations/{confirmation_id}/deny")
|
||
async def deny(
|
||
confirmation_id: str,
|
||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||
) -> dict[str, str]:
|
||
"""拒绝工具执行。通过 ConfirmationRegistry 通知 Agent 循环。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.safety import PendingConfirmationRecord
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
if record is None or record.run_id is None:
|
||
raise _not_found(confirmation_id)
|
||
|
||
ok = await registry.resolve(
|
||
record.run_id,
|
||
approved=False,
|
||
confirmation_id=confirmation_id,
|
||
)
|
||
if not ok:
|
||
raise _not_found(confirmation_id)
|
||
|
||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|