Files
hunjing/backend/app/integrations/sandbox/audit_sink.py
T
2026-08-21 16:00:13 +08:00

100 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""审计事件 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)