十一、审计与 Run Session——每一步操作都被记录

2 阅读1分钟

本篇讲解 src/audit.tssrc/runSession.ts——沙箱包的"黑匣子"。每条命令的执行过程(开始、完成、失败、拒绝等)都会被记录到审计日志中。

1. 为什么需要审计?

如果 AI 助手偷偷执行了 rm -rf /,你事后怎么知道?

审计日志就是干这个的。每一条命令执行,都会在 audit.jsonl 里留下记录:

{"runId":"abc-123","time":"2024-01-15T10:30:00Z","event":"sandbox.command.started","command":"ls","mode":"workspace-write","approved":true}
{"runId":"abc-123","time":"2024-01-15T10:30:00Z","event":"sandbox.command.finished","command":"ls","exitCode":0,"durationMs":50,"stdoutBytes":1024}

2. 审计目录结构

${元数据目录}/audit/
├── audit.jsonl                    ← 所有事件的 JSONL 日志
├── abc-123/                       ← 一次 run 的目录,以 runId 为目录名
│   ├── stdout.txt                 ← 命令的 stdout 输出
│   ├── stderr.txt                 ← 命令的 stderr 输出
│   └── metadata.json              ← run 摘要(风险等级、耗时等)
├── def-456/
│   ├── stdout.txt
│   ├── stderr.txt
│   └── metadata.json
└── ...

3. SandboxRunSession——单次 Run 的会话

export class SandboxRunSession {
  readonly ctx: AuditContext;
  readonly paths: RunFilePaths;

  private constructor(
    private readonly auditRecorder: SandboxAuditRecorder,
    ctx: AuditContext,
    paths: RunFilePaths,
  ) {
    this.ctx = ctx;
    this.paths = paths;
  }

  get runId(): string {
    return this.ctx.runId;
  }

  static open(options: OpenSandboxRunOptions): SandboxRunSession {
    const runId = createUUID();
    fs.mkdirSync(options.auditDir, { recursive: true });
    fs.mkdirSync(path.join(options.auditDir, runId), { recursive: true });

    const ctx: AuditContext = {
      auditDir: options.auditDir,
      runId,
      projectRoot: options.projectRoot,
      executionRoot: options.executionRoot,
    };

    return new SandboxRunSession(options.auditRecorder, ctx, getRunFilePaths(ctx));
  }

  async record(partial: SandboxRunRecordInput): Promise<string> {
    return this.auditRecorder.record({ ...partial, runId: this.ctx.runId });
  }

  writeMetadata(metadata: Record<string, unknown>): void {
    fs.writeFileSync(this.paths.metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
  }
}

3.1 open——创建 Run 会话

static open(options: OpenSandboxRunOptions): SandboxRunSession {
  const runId = createUUID();  // 生成唯一 ID
  // 创建 run 目录
  fs.mkdirSync(options.auditDir, { recursive: true });
  fs.mkdirSync(path.join(options.auditDir, runId), { recursive: true });

  const ctx = { auditDir, runId, projectRoot, executionRoot };
  return new SandboxRunSession(auditRecorder, ctx, getRunFilePaths(ctx));
}

3.2 record——写审计事件

async record(partial: SandboxRunRecordInput): Promise<string> {
  return this.auditRecorder.record({ ...partial, runId: this.ctx.runId });
}

自动注入 runId,调用方不需要手动传。

3.3 writeMetadata——写 run 摘要

writeMetadata(metadata: Record<string, unknown>): void {
  fs.writeFileSync(this.paths.metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
}

每次覆盖写入。metadata.json 的内容类似:

{
  "kind": "sandbox.command",
  "command": "ls -la",
  "riskLevel": "L0",
  "approved": true,
  "requestedMode": "workspace-write",
  "effectiveMode": "workspace-write",
  "exitCode": 0,
  "durationMs": 50,
  "stdoutBytes": 1024,
  "stderrBytes": 0
}

4. getRunFilePaths——计算 run 目录内路径

export function getRunFilePaths(ctx: AuditContext): RunFilePaths {
  const runDir = path.join(ctx.auditDir, ctx.runId);
  return {
    stdoutPath: path.join(runDir, 'stdout.txt'),
    stderrPath: path.join(runDir, 'stderr.txt'),
    metadataPath: path.join(runDir, 'metadata.json'),
  };
}

5. audit.ts——审计辅助函数

5.1 createRunId

export function createRunId(): string {
  return createUUID();
}

5.2 createAuditContext(已弃用)

export function createAuditContext(auditDir, projectRoot, executionRoot): AuditContext {
  return SandboxRunSession.open({
    auditRecorder: getSharedAuditRecorder(auditDir),
    auditDir, projectRoot, executionRoot,
  }).ctx;
}

5.3 createSandboxAuditRecorder

export function createSandboxAuditRecorder(auditDir: string): SandboxAuditRecorder {
  return getSharedAuditRecorder(auditDir);
}

内部用 Map 按 auditDir 缓存 recorder,同一个 auditDir 共享同一个 recorder。

5.4 buildCommandAuditMetadata

export function buildCommandAuditMetadata(input: { ... }): Record<string, unknown> {
  return { kind: 'sandbox.command', ...input };
}

把命令执行摘要封装成 metadata.json 的内容。

5.5 normalizeSandboxEvent

export function normalizeSandboxEvent(event: string): `sandbox.${string}` | string {
  return event.startsWith('sandbox.') ? event : `sandbox.${event}`;
}

确保事件名有 sandbox. 前缀。比如 command.started 会变成 sandbox.command.started

6. 审计事件类型

在一次命令执行中,会产生以下事件:

事件名时机
sandbox.command.started命令开始执行
sandbox.command.finished命令正常结束
sandbox.command.failed命令执行出错
sandbox.command.denied审批拒绝
sandbox.command.blocked路径预检拦截
sandbox.command.fallback从安全模式回退到 danger-full-access
sandbox.file.patch.applied文件 Patch 已应用
sandbox.file.patch.denied文件 Patch 被拒绝
sandbox.worktree.promote.startedWorktree 合并开始
sandbox.worktree.promote.finishedWorktree 合并完成
sandbox.worktree.promote.deniedWorktree 合并被拒绝

7. 小结

概念作用
SandboxRunSession一次命令执行的会话(runId + 目录 + IO 路径)
audit.jsonl所有事件的 JSONL 日志
metadata.json每次 run 的摘要(风险等级、退出码、耗时等)
stdout.txt / stderr.txt命令输出落盘
normalizeSandboxEvent确保事件名有 sandbox. 前缀

核心思想:所有操作都有迹可查。 审批通过的、拒绝的、回退的——全记下来。