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:记录"权限系统最初给出什么判断"——是
allow、ask还是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 };
}每一条路径都记录。allow 和 deny 的记录是确定性的,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?因为在实际应用中,确认通常需要异步获取用户输入(比如等待用户在终端输入 y 或 n)。
为什么是回调而不是直接在 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") 意味着 y、Y、yes、Yeah 都会被当作确认。这是终端交互的惯例——默认拒绝,只有明确同意才放行。
拒绝时给出反馈。 " 已拒绝\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 通过可选参数传入。如果不传,所有工具调用直接执行——向后兼容前三章的代码,也方便测试。
在执行之前。 check 在 tool.execute 之前调用。如果被拒绝,工具根本不会执行。拒绝原因作为 tool_result 返回给模型,让模型知道操作失败了、为什么失败,可以尝试其他方案。
不中断循环。 拒绝一个工具调用不会终止整个 agent 循环。模型收到"操作被拒绝"的反馈后,可以换一种方式完成任务。比如被拒绝执行 rm 命令后,模型可能会改用 patch_file 来修改文件内容。
小结
这一节完成了权限系统的最后两个组件:审计日志让每次决策可追溯,确认回调把最终决定权交给用户。下一节用真实任务跑一遍,看权限系统在实际运行中的表现。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。