第 4 章 · Permissions 与安全权限

04 · 审计日志与确认流程

记录每一次权限决策,让 agent 的行为可追溯。用户确认是最后一道防线。

agent 需要审计日志

前面三节搭建了完整的权限系统:三级模型、危险命令拦截。但还有一个问题——如果出了事,你怎么知道 agent 到底做了什么?

"出了事"不一定是灾难。可能只是 agent 修改了一个不该改的文件,或者执行了一个你觉得不应该执行的命令。如果没有记录,你只能凭记忆回忆 agent 当时做了什么选择。

审计日志(Audit Log)就是权限系统的黑匣子。 它记录每一次权限决策——谁在什么时间、对哪个工具调用、做出了什么判断。有了它,你可以:

  • 在任务完成后回顾 agent 的所有行为
  • 在出错时定位是哪个权限决策导致了问题
  • 统计 agent 的权限使用模式,优化规则配置

AuditEntry:每条记录长什么样

先看类型定义:

// src/types.ts

/** 审计日志条目 */
export interface AuditEntry {
  /** 被调用的工具名称 */
  tool: string;
  /** 工具调用参数 */
  args: Record<string, unknown>;
  /** 权限级别:allow / ask / deny */
  level: PermissionLevel;
  /** 最终是否被允许执行 */
  approved: boolean;
  /** 决策原因 */
  reason: string;
}

六个字段,每个都有明确的用途:

  • tool + args:记录"agent 想做什么"。比如 tool: "run_command", args: { command: "npm test" }
  • level:记录"权限系统最初给出什么判断"——是 allowask 还是 deny
  • approved:记录"最终是否执行了"。对于 allow 级别,approved 始终为 true;对于 deny 级别,始终为 false;对于 ask 级别,取决于用户怎么回答。
  • reason:决策原因的文本描述,比如"只读操作,自动放行"或"危险命令被拦截: rm -rf ..."。

getAuditLog:获取完整记录

PermissionGuard 中,审计日志通过一个私有数组维护,通过 getAuditLog 方法对外暴露:

// src/permissions/index.ts

export class PermissionGuard {
  private auditLog: AuditEntry[] = [];

  getAuditLog(): readonly AuditEntry[] {
    return [...this.auditLog];
  }

  /** 每次权限检查时记录 */
  private recordAudit(
    tool: string,
    args: Record<string, unknown>,
    level: PermissionLevel,
    approved: boolean,
    reason: string,
  ): void {
    this.auditLog.push({ tool, args, level, approved, reason });
  }
}

getAuditLog 返回的是数组的浅拷贝[...this.auditLog]),而不是直接暴露内部数组。这样外部代码不能直接修改审计日志。

recordAudit 是一个私有方法,在 check 方法的三个分支中都被调用:

// src/permissions/index.ts(check 方法内部,简化版)

async check(toolName, args) {
  const result = this.resolveLevel(toolName, args);

  if (result.level === "allow") {
    this.recordAudit(toolName, args, "allow", true, result.reason);
    return { approved: true, reason: result.reason };
  }

  if (result.level === "deny") {
    this.recordAudit(toolName, args, "deny", false, result.reason);
    return { approved: false, reason: result.reason };
  }

  /** "ask" 级别:调用确认回调 */
  const approved = await this.confirmCallback(toolName, args, result.reason);
  this.recordAudit(toolName, args, "ask", approved, result.reason);
  return { approved, reason: result.reason };
}

每一条路径都记录。allowdeny 的记录是确定性的,ask 的记录取决于用户的选择。这保证了没有权限决策被遗漏

确认回调:用户是最后一道防线

当权限级别为 ask 时,PermissionGuard 不会自己做决定,而是把决定权交给用户。这个机制通过 ConfirmationCallback 实现:

// src/types.ts

/**
 * 用户确认回调
 *
 * 当权限级别为 "ask" 时,agent 调用此回调请求用户确认。
 * 返回 true 表示允许执行,false 表示拒绝。
 */
export type ConfirmationCallback = (
  tool: string,
  args: Record<string, unknown>,
  reason: string,
) => Promise<boolean>;

ConfirmationCallback 是一个函数类型。它接收工具名称、参数和决策原因,返回一个 Promise<boolean>——true 表示放行,false 表示拒绝。

为什么是 Promise?因为在实际应用中,确认通常需要异步获取用户输入(比如等待用户在终端输入 yn)。

为什么是回调而不是直接在 PermissionGuard 里处理?因为用户界面的实现方式不应该影响权限逻辑。 PermissionGuard 不知道用户是在终端、Web UI 还是 IDE 插件里操作。它只管"有人会告诉我结果"。

main.ts 中的确认实现

在终端应用中,确认回调用 Node.js 的 readline 实现:

// src/main.ts

import * as readline from "node:readline/promises";

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const permissionGuard = new PermissionGuard({
  confirm: async (tool, args, reason) => {
    if (tool === "run_command") {
      console.log(`\n  需要执行命令: ${args.command}`);
    } else if (tool === "write_file" || tool === "patch_file") {
      console.log(`\n  需要修改文件: ${args.path}`);
    } else {
      console.log(`\n  需要执行: ${tool}`);
    }
    console.log(`  原因: ${reason}`);

    const answer = await rl.question("  允许? (y/n) ");
    const approved = answer.toLowerCase().startsWith("y");
    if (!approved) {
      console.log("  已拒绝\n");
    }
    return approved;
  },
});

逐行分析关键设计:

按工具类型显示不同提示。 run_command 显示要执行的命令,write_file 显示要修改的文件路径。用户一眼就能看到 agent 想做什么,不需要理解工具系统的细节。

rl.question 阻塞等待用户输入。 这会暂停 agent 循环,直到用户回应。在异步环境中,这是最自然的交互方式——agent 不会自作主张往下跑。

宽松的输入解析。 answer.toLowerCase().startsWith("y") 意味着 yYyesYeah 都会被当作确认。这是终端交互的惯例——默认拒绝,只有明确同意才放行。

拒绝时给出反馈。 " 已拒绝\n" 让用户知道操作确实被取消了,而不是系统卡住了。

用户的实际体验

当 agent 运行时,用户的终端会看到类似这样的交互:

> 帮我给 src/utils/math.ts 加一个 subtract 函数

  需要读取文件: src/utils/math.ts
  原因: 只读操作,自动放行     ← allow,不需要用户操作

  需要修改文件: src/utils/math.ts
  原因: patch_file 需要用户确认
  允许? (y/n) y               ← ask,用户确认

  需要执行命令: npm test
  原因: run_command 需要用户确认
  允许? (y/n) y               ← ask,用户确认

我已经在 src/utils/math.ts 中添加了 subtract 函数...

只读操作(搜索、读文件)静默通过,写入和执行操作需要用户逐一确认。如果 agent 尝试执行危险命令:

  需要执行命令: rm -rf node_modules
  ← 这条不会出现,因为危险命令在到达确认回调之前就被拦截了

用户根本不会看到这个提示——它在 resolveLevel 阶段就返回了 deny,根本不会调用 confirmCallback

自定义规则:覆盖 DEFAULT_RULES

DEFAULT_RULES 是给一般场景设计的平衡配置。如果你的环境不同,可以通过构造函数覆盖:

/** 默认规则:只读放行,写入确认 */
const DEFAULT_RULES: PermissionRule[] = [
  { tool: "search", level: "allow" },
  { tool: "glob", level: "allow" },
  { tool: "read_file", level: "allow" },
  { tool: "git_status", level: "allow" },
  { tool: "git_diff", level: "allow" },
  { tool: "create_todos", level: "allow" },
  { tool: "update_todo", level: "allow" },
  { tool: "write_file", level: "ask" },
  { tool: "patch_file", level: "ask" },
  { tool: "run_command", level: "ask" },
];

/** 自定义规则:受信环境中更宽松的配置 */
const trustedRules: PermissionRule[] = [
  { tool: "*", level: "allow" },   // 所有工具直接放行
];

const guard = new PermissionGuard({
  rules: trustedRules,
  confirm: async () => true,       // 确认回调不再被调用,但必须提供
});

几点需要注意:

"*" 通配。 { tool: "*", level: "allow" } 匹配所有工具名称。这等于"全部放行"。在 CI 环境或自动化测试中很有用,但日常使用不推荐。

危险命令仍然会被拦截。 自定义规则只在 resolveLevel 的第二步生效。即使规则写 level: "allow"rm -rf 这样的命令仍会在第一步被拦截。安全检查的优先级高于配置。

confirm 回调仍然必须提供。 即使所有规则都是 allow,构造函数仍然要求 confirm 参数。这是为了避免在切换规则时忘记提供回调。

在 agent 循环中的集成

权限守卫在 agent.ts 中被集成到工具执行之前:

// src/agent.ts(循环内部)

for (const toolCall of response.toolCalls) {
  const tool = allTools.find((t) => t.name === toolCall.name);
  if (!tool) {
    allMessages.push({
      role: "tool_result",
      toolCallId: toolCall.id,
      result: `错误:未知工具 ${toolCall.name}`,
    });
    continue;
  }

  /**
   * 权限检查(第 4 章新增)
   *
   * 如果提供了 permissionGuard,在执行工具前检查权限:
   * - allow: 直接执行
   * - ask: 调用确认回调,由用户决定
   * - deny: 直接拒绝,不执行
   *
   * 如果没有提供 permissionGuard,所有调用自动放行(向后兼容第 1-3 章)。
   */
  if (options?.permissionGuard) {
    const { approved, reason } = await options.permissionGuard.check(
      toolCall.name,
      toolCall.arguments,
    );
    if (!approved) {
      allMessages.push({
        role: "tool_result",
        toolCallId: toolCall.id,
        result: `操作被拒绝: ${reason}`,
      });
      continue;
    }
  }

  const result = await tool.execute(toolCall.arguments, state);
  stats.toolCallCount++;
  allMessages.push({
    role: "tool_result",
    toolCallId: toolCall.id,
    result,
  });
}

集成方式有三个关键点:

可选的。 options?.permissionGuard 通过可选参数传入。如果不传,所有工具调用直接执行——向后兼容前三章的代码,也方便测试。

在执行之前。 checktool.execute 之前调用。如果被拒绝,工具根本不会执行。拒绝原因作为 tool_result 返回给模型,让模型知道操作失败了、为什么失败,可以尝试其他方案。

不中断循环。 拒绝一个工具调用不会终止整个 agent 循环。模型收到"操作被拒绝"的反馈后,可以换一种方式完成任务。比如被拒绝执行 rm 命令后,模型可能会改用 patch_file 来修改文件内容。

小结

这一节完成了权限系统的最后两个组件:审计日志让每次决策可追溯,确认回调把最终决定权交给用户。下一节用真实任务跑一遍,看权限系统在实际运行中的表现。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page