"""审计事件 sink:把沙箱 tool.completed / tool.failed 事件写入 AuditEvent 表(起居注)。 与 EventStoreSink 并列——EventStoreSink 落一炁流程事件(events 表,run 内 sequence), AuditEventSink 落治理审计(audit_events 表,含 decision / risk_level / argv 脱敏摘要)。 两者经 CompositeEventSink 同时注入 SandboxClient,互不感知。 审计只记终态(completed/failed);started 不记(审计关注决策结果,非中间态)。 argv 经 record_audit_event 的 ``_summary`` → ``redact_text`` + 截断 1000 脱敏,作为 input_summary(审计核心价值:谁执行了什么命令;脱敏打码密钥/token 等敏感模式后保留可读性)。 per-call/per-run 绑定 run_id + character_id(与 EventStoreSink 构造一致);risk_level + argv 由 started 事件 payload 携带,completed/failed 时消费。 """ from __future__ import annotations from typing import Any from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.integrations.sandbox.events import SandboxEvent from app.services.audit import record_audit_event class AuditEventSink: """把沙箱事件转写为 AuditEvent(审计表),与 EventStoreSink 并列注入。""" def __init__( self, session_factory: async_sessionmaker[AsyncSession], *, run_id: str | None = None, character_id: str | None = None, ) -> None: self._session_factory = session_factory self._run_id = run_id self._character_id = character_id # 缓存最近一次 started 的上下文(risk_level + argv),completed/failed 写审计时消费。 # sink 为 per-call 实例(随一次 exec 生命周期),状态隔离安全。 self._pending_risk: str | None = None self._pending_argv: list[str] = [] async def emit(self, event: SandboxEvent) -> None: etype = event.type payload = event.payload if etype == "tool.started": # 缓存 risk_level + argv(started 携带),completed/failed 写审计时消费 self._pending_risk = str(payload.get("risk_level") or "L1") self._pending_argv = list(payload.get("argv") or []) return if etype not in ("tool.completed", "tool.failed"): return tool = str(payload.get("tool") or "") risk_level = self._pending_risk or "L1" argv = self._pending_argv if etype == "tool.completed": action = "tool.executed" decision = "allow" reason: str | None = None output_summary = self._completed_summary(payload) else: # tool.failed action = "tool.failed" decision = "deny" reason = str(payload.get("error_code") or "") or None output_summary = str(payload.get("error_message") or "") or None # input_summary = argv 空格拼接(record_audit_event 内部 _summary 会 redact + 截断 1000) input_summary = " ".join(argv) if argv else None async with self._session_factory() as session: await record_audit_event( session, action=action, pivot="tool_runtime", character_id=self._character_id, run_id=self._run_id, risk_level=risk_level, decision=decision, reason=reason, input_summary=input_summary, output_summary=output_summary, resource_type="tool", resource_id=tool or None, trace_id=event.trace_id, ) await session.commit() @staticmethod def _completed_summary(payload: dict[str, Any]) -> str: parts = [f"exit={payload.get('exit_code')}", f"dur={payload.get('duration_ms')}ms"] if payload.get("timeout"): parts.append("timeout") if payload.get("truncated"): parts.append("truncated") return " ".join(parts)