# Step 19: 权限询问系统
一句话导读:AI 能自主并行调工具,很强大——也意味着它能自己决定跑
rm -rf。这一步在"执行"前插入一道人类闸门:y/Y/n/a 四档授权,并把"拒绝"设计成一条能让 AI 学习的反馈。安全和体验的平衡,全在这四个字母里。
# 一、这一步做了什么(What)
在 step18(并行工具循环)基础上,新增权限系统:
index.ts新增checkPerm()函数:每个工具执行前询问用户;- 四档应答 y / Y / n / a,配两个
Set(sessionApprovals、alwaysAllow)记住"本会话/永久"的授权; - 新增
/permission命令,在default(每次问)和accept(全自动)两种模式间切换; - 关键顺序:先对整轮所有工具逐个问权限,收集"被批准的",再
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 或其他 → 拒绝
}
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 });
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 里的文件?"
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 收官——超时保护 + 错误分类 + 指数退避重试,让系统在故障下也不崩。