"""安全 API(api-spec §16)。 audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 落库裁决 高风险工具确认(pending → approved/denied),客户端随后以 confirmed=True 重新调用。 """ from __future__ import annotations from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import select, update from app.api.v1.deps import get_optional_tool_runtime from app.application.tool_runtime.runtime import ToolRuntime from app.db.session import AsyncSessionLocal from app.models.safety import PendingConfirmationRecord 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) -> dict[str, bool | str]: """批准工具执行:pending 记录落库为 approved,客户端以 confirmed=True 重新调用。""" record = await _resolve_confirmation_record(confirmation_id, approved=True) return {"status": "resolved", "approved": True, "run_id": record.run_id} @router.post("/confirmations/{confirmation_id}/deny") async def deny(confirmation_id: str) -> dict[str, str]: """拒绝工具执行:pending 记录落库为 denied。""" await _resolve_confirmation_record(confirmation_id, approved=False) return {"status": "cancelled", "confirmation_id": confirmation_id} async def _resolve_confirmation_record( confirmation_id: str, *, approved: bool ) -> 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) async with AsyncSessionLocal() as session: stmt = ( update(PendingConfirmationRecord) .where( PendingConfirmationRecord.id == confirmation_id, PendingConfirmationRecord.status == "pending", ) .values( status="approved" if approved else "denied", resolved_at=datetime.now(UTC), ) ) updated = await session.execute(stmt) await session.commit() if updated.rowcount == 0: raise _not_found(confirmation_id) return record