# Step 19: 权限询问系统

一句话导读:AI 能自主并行调工具,很强大——也意味着它能自己决定跑 rm -rf。这一步在"执行"前插入一道人类闸门:y/Y/n/a 四档授权,并把"拒绝"设计成一条能让 AI 学习的反馈。安全和体验的平衡,全在这四个字母里。


# 一、这一步做了什么(What)

在 step18(并行工具循环)基础上,新增权限系统:

  1. index.ts 新增 checkPerm() 函数:每个工具执行前询问用户;
  2. 四档应答 y / Y / n / a,配两个 Set(sessionApprovals、alwaysAllow)记住"本会话/永久"的授权;
  3. 新增 /permission 命令,在 default(每次问)和 accept(全自动)两种模式间切换;
  4. 关键顺序:先对整轮所有工具逐个问权限,收集"被批准的",再 Promise.all 并行执行批准的那批。

核心约 40 行,却是从"玩具"迈向"能真跑"的分水岭。


# 二、面试官视角:为什么要做?(Why)

面试题:为什么权限检查必须放在"工具执行前",而不能靠工具内部自己判断危不危险?为什么用户拒绝时,不能直接 throw,而要把"被拒绝"作为一条消息回传给 AI?

两个子问题,都在考"权限系统的正确位置和形态"。

第一,为什么必须是执行前的统一闸门? 如果让每个工具自己判断"该不该执行",你会有 N 份不一致的安全逻辑,且新工具很容易忘记加。权限必须是循环层的横切关注点——所有工具执行都得先过同一道关卡。这样:新增工具自动纳入管控、策略集中可审计、模式切换(如自动模式)一处生效。安全不能是工具的自觉,必须是框架的强制。

第二,拒绝为什么要"回传给 AI"而不是"抛异常中断"?

做法 后果
拒绝就 throw,中断整轮 AI 一脸懵,对话卡死,用户还得重新解释
拒绝转成 tool_result(is_error, "Permission denied") AI 看到"这条路被封了",会换方案或问用户

后者才对。"拒绝"是给 AI 的一次反馈,不是给程序的一次崩溃。 AI 看到 Permission denied 后的典型反应是:"既然不让我删,那我列出来让你确认?"——这正是我们想要的协作姿态。把拒绝纳入 tool_result 通道,让 AI 能自适应,是 step17 那条"错误也是可学习反馈"原则的延续。


# 三、原理:它是怎么工作的(How)

# 权限检查函数

async function checkPerm(toolName, input, sessionOk, alwaysOk, mode): Promise<boolean> {
  if (mode === 'accept') return true;        // ① 全自动模式,直接放行
  if (alwaysOk.has(toolName)) return true;   // ② 永久白名单(选过 a)
  if (sessionOk.has(toolName)) return true;  // ③ 本会话白名单(选过 Y)

  // ④ 都没命中 → 询问用户,并展示工具名 + 参数
  const display = JSON.stringify(input).slice(0, 120);
  console.log('\n  ? ' + toolName + ' ' + display);
  const answer = await ask('    Allow? (y=once Y=session n=no a=always): ');

  if (answer === 'Y') { sessionOk.add(toolName); return true; }  // 记入会话
  if (answer === 'a') { alwaysOk.add(toolName); return true; }   // 记入永久
  if (answer === 'y') return true;                               // 只此一次,不记
  return false;                                                  // n 或其他 → 拒绝
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

四档的记忆差异是精髓:

应答 返回 副作用 下次还问吗
y 允许一次 true 不记录 问
Y 本会话允许 true 加入 sessionOk 本会话不问
n 拒绝 false 无 问
a 永久允许 true 加入 alwaysOk 永远不问

sessionOk 和 alwaysOk 是两个 Set<string>,按工具名粒度授权——批准过一次 Read,后续所有 Read 都放行。(这是简化:真实系统按"工具+参数"更细粒度授权,见追问。)

# 在循环里的关键位置:先审批、后并行

这是 step18 和 step19 结合处最巧妙的一点——权限询问天然是串行的(要一个个问用户),执行才是并行的:

// 阶段一:串行逐个问权限,把批准的收进 allowedCalls
const resultBlocks: any[] = [];
const allowedCalls: { tc: any; tool: any }[] = [];
for (const tc of result.toolCalls) {
  const tool = registry.get(tc.name);
  if (!tool) { resultBlocks.push({ ...tool_use_id: tc.id, content:'tool not found', is_error:true }); continue; }

  if (await checkPerm(tc.name, tc.input, sessionApprovals, alwaysAllow, permMode)) {
    allowedCalls.push({ tc, tool });                 // ✓ 批准 → 待执行
  } else {
    err('denied: ' + tc.name);
    resultBlocks.push({ type:'tool_result', tool_use_id: tc.id,
                        content:'Permission denied by user', is_error:true });  // ✗ 拒绝 → 直接生成结果
  }
}

// 阶段二:只并行执行"被批准的"
if (allowedCalls.length > 0) {
  const toolResults = await Promise.all(
    allowedCalls.map(async ({ tc, tool }) => { /* execute + 计时 + try/catch */ })
  );
  // ...把执行结果 push 进 resultBlocks
}

// 阶段三:批准的执行结果 + 拒绝的 denied 消息,一起打包回传
if (resultBlocks.length > 0) messages.push({ role:'user', content: resultBlocks });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 数据流

AI 一轮要: Read(package.json) + Bash(rm -rf tmp)
   ↓ 阶段一:逐个问用户(串行,因为要等键盘输入)
     ? Read {...}  → 用户按 Y → 记入 sessionOk,allowedCalls += Read
     ? Bash {...}  → 用户按 n → resultBlocks += "Permission denied"
   ↓ 阶段二:Promise.all 只跑 [Read]
     Read 15ms ✓
   ↓ 阶段三:打包 [Read结果, Bash的denied] 一起回传
AI 看到"Read 成功、Bash 被拒" → 改口:"我不删了,要不要我先列出 tmp 里的文件?"
1
2
3
4
5
6
7
8

注意这个"串行审批 + 并行执行"的两阶段结构:问用户必须一个个来(人只有一双手),但一旦批准,执行仍享受 step18 的并行加速。拒绝的工具不进 Promise.all,直接在阶段一就生成了 denied 结果——被拒的活儿根本不该启动。


# 四、深入追问(面试常见 follow-up)

Q:Y(本会话)和 a(永久)的区别只是存进哪个 Set 吗?真实系统里这俩差在哪? A:在我们这里,sessionApprovals 随进程/​/clear 消失,alwaysAllow 也只是内存 Set——其实两者都活不过重启,区别只是 /clear 会清 session 不清 always。真实系统里 a 会持久化到磁盘配置(如项目/用户级 settings),跨会话生效;Y 只在内存。这就是"信任的持久度"分级:一次性信任、会话级信任、永久信任,对应风险递减的授权成本。

Q:按"工具名"授权够安全吗?批准了 Bash 一次,AI 是不是之后想跑任何命令都畅通无阻了? A:这正是本实现最大的简化和风险点。按工具名授权意味着"你信任 Bash 这个工具"而非"你信任这条具体命令"——批准 ls 却等于放行了之后的 rm -rf。真实 Claude Code 按工具 + 参数模式授权:可以只允许 git * 却仍拦截 rm,甚至对命令做注入分析、路径可信度评估。教学版用工具名粒度是为了讲清机制,生产绝不能这么粗。

Q:/permission 切到 accept 全自动模式,这不就等于把安全关了吗?什么场景用? A:是的,accept 模式下 checkPerm 第一行就 return true,完全跳过询问。它的场景是你已经确信环境安全——比如在一次性容器/沙箱里、或跑一个你完全信任的批量任务,不想被几十次询问打断。这是"安全"与"流畅"的显式权衡开关:默认安全(每次问),需要效率时手动降级。真实版模式更多(plan / dontAsk 等),本质都是这条光谱上的档位。

Q:权限询问是串行阻塞的(等键盘输入),这会不会破坏 step18 好不容易做的并行? A:会让"审批阶段"退化为串行——三个工具得依次问三次。但这是必要且正确的:人只有一双手,不可能同时回答三个问题;而且逐个问才能让用户看清每个工具的参数分别决定。关键是并行只在执行阶段恢复(allowedCalls 一起 Promise.all)。设计上把"需要人参与的串行"和"纯机器的并行"分成两阶段,各得其所。

Q:如果 AI 学会了"换个说法绕过权限"——比如被拒 Bash 后改用 Write 写个脚本再想办法跑——权限系统怎么防? A:这触及权限系统的根本局限:按工具授权防不住能力组合。防御要靠纵深——不只是问不问,还要有工作区边界(Write 只能写工作目录内)、Bash 命令的静态分析、危险模式黑名单。我们这版只有"询问"这一层,真实版的 24 个权限文件大多在做这些纵深防御。面试时能指出"单点询问不足以构成安全,需要沙箱边界配合"是深度信号。


# 五、踩坑 / 设计权衡

  • 拒绝必须回传,不能中断:写成 throw 会让对话卡死;写成 tool_result(denied) 才让 AI 能自适应换方案。
  • 工具名粒度太粗:批准 Bash 一次等于永久放行所有命令,是本实现刻意的简化,也是最该在生产中收紧的地方。
  • 两阶段顺序不能颠倒:必须先审批完整轮、再并行执行批准的。若边问边执行,用户还没拒绝,危险工具可能已经跑了。
  • /clear 清 session 授权:清对话时把 sessionApprovals 一起清是对的——新话题不该继承旧信任。

# 六、与真实源码的对照

特性 真实源码 utils/permissions/(24 文件) 我们的实现(~40 行)
授权粒度 工具 + 参数模式(git * 可放行、rm 仍拦) 仅按工具名
模式 accept / auto / default / plan / dontAsk default / accept
持久化 a 写入项目/用户级配置,跨会话 内存 Set,重启即失
纵深防御 路径可信评估、命令注入分析、网络请求检测、自定义规则 无,只有"询问"一层
拒绝处理 同样作为结果回传给模型 tool_result(is_error, "denied")

三步走"询问 → 获同意 → 执行"的骨架完全一致,真实版多出的 23 个文件几乎都在做"更细的粒度 + 更深的防御"。


# 七、一句话总结

权限系统 = 执行前的统一闸门 + 四档信任分级 + 拒绝作为可学习反馈:它必须是循环层的横切关卡(不能靠工具自觉),用 y/Y/n/a 覆盖"一次/会话/永久"的信任光谱,并把"拒绝"设计成回传给 AI 的 tool_result 而非中断异常,让 AI 能换方案协作。审批串行、执行并行的两阶段结构,让安全与 step18 的性能各得其所。教学版按工具名授权只是骨架,真实的安全在于工具+参数粒度和纵深防御。

# 下一节预告

权限管住了"该不该做",但工具真跑起来还会超时、报错、API 会 429。下一步 step20 是 Phase 2 收官——超时保护 + 错误分类 + 指数退避重试,让系统在故障下也不崩。