深度拆解复刻 Claude Code 权限系统
🎯 本章导读
- 说清楚为什么权限判断不能写在 system prompt 里,只能写在代码的固定位置。
- 手画出
prepare → 权限 → invoke的顺序,并解释顺序反过来会失去什么。 - 解释
allow/deny/ask/passthrough四种行为各自的用途,以及为什么 boolean 不够。 - 讲清
deny > ask > allow > passthrough这条优先级,并举出「规则说允许但仍然被拒绝」的例子。 - 列出所有会导致
ask变成deny的情况(一共七种),并说明这叫 fail closed。 - 判断一次调用会走哪条路径:直接放行、弹审批、还是硬拒绝。
你需要先具备什么 ▸
| 需要 | 说明 |
|---|---|
| 读完第 1、2 章 | 本章直接建立在 ToolRegistry.prepare() / invoke() 的分离之上 |
理解 effect 标签 | 第 2 章给每个工具贴了 read / write / execute;本章开始真正用它 |
理解 safePath | 本章的工作区硬边界就是复用第 2 章的 safePath |
会跑 npm run ch02 | 本章运行方式完全一样,只是多了审批交互 |
不需要任何安全或密码学背景。本章讲的全是「在哪一行代码上做判断」这种工程问题。
建议的阅读路线 ▸
本章导读(你在这)
↓
① 先看一眼真实的决策过程 ← 三条调用走出三种结果,先有画面
↓
② 验收结果 + 为什么不是 Prompt
↓
③ 四态决定 ← 为什么不是 boolean,三个字段各自的作用
↓
④ 请求与规则 ← 权限层的输入契约、一条规则长什么样
↓
⑤ 三道闸门如何合并 ← 本章最核心的一节:优先级
↓
⑥ 硬边界 / Shell / 审批 / 审计
↓
⑦ 插入 Agent Loop + P02/P03 差异
↓
⑧ 四个场景 + effect 驱动
↓
⑨ 边界、运行、验证与小结
📇 术语速查
| 术语 | 一句话解释 |
|---|---|
| PermissionDecision | 一个不可变的权限结论,三件事:behavior + reason + source |
behavior | 结论本身:allow / deny / ask / passthrough |
reason | 人能读懂的原因。会进审计,也会进模型视野 |
source | 谁做的决定:规则名 / shell-default / terminal-approval / workspace-boundary / default |
| PermissionRequest | 权限系统的输入。只接受已通过 Zod 校验的调用 |
| PermissionRule | 一条规则 = 名字 + 行为 + 原因 + 匹配函数 |
| PermissionPolicy | 合并所有参与方、产出唯一最终决定的地方 |
| 参与方 | 能提出候选决定的角色:硬边界、Shell 默认、规则、Hook 建议 |
| ApprovalProvider | 审批边界。ask 只能由它收敛成 allow 或 deny |
| AuditSink | 审计边界。记录最终决定;它失败则不执行 |
| WorkspaceWriteBoundary | 系统硬边界。判断写入路径是否在 workspace 内 |
| fail closed | 判断依赖出故障时选择拒绝,而不是把「不知道」当成「可以」 |
permission_denied | 权限拒绝回填给模型的稳定错误码 |
permission_evaluation_error | 权限系统自身出故障时的稳定错误码 |
📡 先看一眼真实的决策过程
调用 A · glob(read)→ 默认允许,仍留记录 ▸
① 工作区硬边界 effect 不是 write,不表态 → 无候选
② Shell 默认 effect 不是 execute,不表态 → 无候选
③ 规则 confirm-file-write 不匹配 → 无候选
④ 合并 一个候选都没有 → passthrough
⑤ 收敛 passthrough → allow(default)
⑥ 审批 不需要
⑦ 审计 [Permission] glob: allow (default) - No ...
⑧ 执行 handler 跑起来
默认允许,但仍然留了记录。
调用 B · write_file(工作区内)→ 弹审批 ▸
① 工作区硬边界 path 在 workspace 内 → 不表态
③ 规则 confirm-file-write 匹配 → ask
④ 合并 最强的是 ask
⑤ 收敛 调用 ApprovalProvider
→ 工具调用需要批准: write_file
原因: File writes require explicit approval...
允许本次调用? [y/N] y → allow(terminal-approval)
⑥ 审计 [Permission] write_file: allow (terminal-approval)
⑦ 执行 handler 跑起来
调用 C · write_file(工作区外)→ 硬拒绝,最重要的例子 ▸
① 工作区硬边界 safePath 拒绝 → deny
③ 规则 confirm-file-write 匹配 → ask
④ 合并 deny 和 ask 都在 → deny 优先
⑥ 审批 ❌ 根本没调用,用户看不到任何提示框
⑦ 审计 [Permission] write_file: deny (workspace-boundary)
⑧ 执行 ❌ handler 一次都没跑
⑨ 回填模型 Error [permission_denied]: Writing outside ...
| 调用 A glob | 调用 B 区内 write | 调用 C 区外 write | |
|---|---|---|---|
| 合并结果 | passthrough | ask | deny |
| 弹审批框? | 否 | 是 | 否 |
| handler 执行? | 是 | 看用户输入 | 否 |
| 进审计? | 是 | 是 | 是 |
🧠 核心概念
1 · 为什么 Prompt 不是安全边界 ▸
在 system prompt 写「不要删除系统文件」只是软约束:模型可能理解错误、用户输入与系统指令冲突、多轮上下文弱化早期提醒、不可能枚举所有危险命令组合、无法证明副作用前一定检查了。
权限策略必须在代码里,位置固定:
模型返回 tool_calls
↓
ToolRegistry.prepare:查找 + JSON 解析 + Zod 校验
↓
PermissionPolicy.decide:硬边界 + 规则 + 审批 + 审计 ← 这里
↓
ToolRegistry.invoke:执行 handler
↓
回填相同 tool_call_id
2 · 权限不是 boolean,而是四态 ▸
只返回 true/false,运行时不知道「需要询问」和「不发表意见」的区别,也无法解释决定来源。本章四种行为:
| behavior | 含义 |
|---|---|
allow | 明确允许 |
deny | 明确拒绝 |
ask | 必须取得一次显式审批 |
passthrough | 不表态,交给其他规则或默认值 |
每个决定携带原因和来源。最终拒绝转成稳定工具结果:Error [permission_denied]: ...,不是含混的 Permission denied.
3 · 三道闸门如何合并(deny 优先) ▸
一次权限判断有三层输入:
- 系统硬边界:工作区外写入直接 deny。
- 结构化参与方:Shell 默认策略、规则、后续 Hook 建议。
- 显式审批:只有合并结果为 ask 时才调用。
合并优先级固定:deny > ask > allow > passthrough。核心代码按这个顺序找第一个决定。只有 passthrough 或完全没参与方时才默认 allow(落在没有规则反对的读取上)。
4 · 工作区边界为什么在规则之前 ▸
调用 {"path":"../outside.txt","content":"x"}:项目规则可能允许、未来 Hook 可能建议允许、用户甚至输入 y——但工作区外写入仍必须拒绝,且不能先弹审批框。
PermissionPolicy 先询问 WorkspaceWriteBoundary。它继承第 2 章 safePath 的全部保证:拒绝绝对路径、..、Windows 保留名、junction/符号链接逃逸。真实路径解析抛异常时也得到「Write path could not be resolved safely」,不降级成允许。
5 · Shell 为什么一律 ask ▸
不能用关键词黑名单判断 PowerShell 是否安全。Get-Content 看起来只读,但参数可指向工作区外;普通命令还可调脚本、启动子进程、访问网络。只匹配 Remove-Item 既不完整也容易绕过。
因此只要工具 effect 是 execute,系统就产生默认 ask。审批只授权当前一次调用,不保存「永远允许」。
6 · ask 必须得到显式 allow(fail closed) ▸
以下情况都变成 deny:没有审批器、审批器抛异常、返回普通 object/boolean、再次返回 ask、返回 passthrough、终端无交互 stdin、用户直接回车或输入其他内容。真实终端只接受 y 和 yes。
7 · 审计记录最终决定 + 消息配对 ▸
审计发生在规则合并和审批完成之后,记录的是最终结果:[Permission] write_file: allow (terminal-approval) - User approved。审计器失败时不执行未留记录的副作用,转成 permission_evaluation_error,handler 调用次数为零,消息仍配对。
权限拒绝仍生成与原调用 ID 配对的 permission_denied tool result。validateToolPairing() 每轮检查:assistant 工具调用后必须立即跟对应数量的 tool 消息,ID 严格匹配。
8 · P02 vs P03 差异 ▸
| 操作 | P02 | P03 |
|---|---|---|
| read_file / glob | 直接允许 | 直接允许并审计 |
| 工作区内 write/edit | 直接允许 | ask,最终决定审计 |
| 工作区外 write | handler 返回 path_escape | 权限硬边界拒绝并审计 |
| shell | ask | ask 并审计 |
P03 只增加一个 capability policy。组合根根据固定 profile 自己构造策略,不接收调用方拼好的宽松策略;缺 approvalProvider 或 auditSink 直接启动失败。
▶️ 交互演示:权限决策模拟器
🔑 一句话总结
prepare → 工作区硬边界 → 规则/建议 → deny>ask>allow>passthrough → 显式审批 → 审计 → invoke这一章建立了一个不会被弱允许覆盖的副作用边界。Prompt 是软约束,权限是硬边界;deny 永远优先,fail closed 永不降级放行。
🚀 运行第 3 章
npm ci + .env 三变量)。下面四步,每步都给出预期结果。第 0–1 步 · 环境与离线测试 ▸
Set-Location 'F:\笔记\Agent实操\code'
npm ci
npm run test:ch03
Test Files 10 passed (10)
Tests 61 passed (61)
跑测试时你会看到一行 配置错误: Missing required settings: ...——这不是失败,是被测试断言的预期输出(验证缺配置时 CLI 返回退出码 2)。
第 2–3 步 · 跑一个会弹审批的任务,再故意拒绝 ▸
npm run ch03 -- --prompt '读取 README.md,然后把一句摘要写入 chapter3-note.txt'
[Permission] read_file: allow (default) - No permission rule blocked the request
工具调用需要批准: write_file
原因: File writes require explicit approval from chapter 3 onward
参数: {"path":"chapter3-note.txt","content":"..."}
允许本次调用? [y/N]
这条 prompt 是专门挑的:先 read_file(默认放行)再 write_file(必须审批),一次跑完看两条路径。敲 y 后看到:
[Permission] write_file: allow (terminal-approval) - User approved this tool call
第 3 步:再跑一次,在审批框直接回车。预期先 [Permission] write_file: deny ...,模型随后收到 Error [permission_denied]: User denied this tool call,且文件内容没有任何变化。[y/N] 里大写的 N 是默认值,回车等于拒绝——这是本章最值得亲手做的验证。
第 4 步 · 看硬边界拦下一次越界写入 ▸
npm run ch03 -- --prompt '把 hello 写入 ../outside.txt'
# [Permission] write_file: deny (workspace-boundary) - Writing outside ...
# 统一入口
npm run agent-tutorial -- run --chapter 3 --prompt '读取 README.md,然后把一句摘要写入 chapter3-note.txt'
注意这次没有弹审批框——你根本没有机会批准它。code/ 的上一级目录里不会出现 outside.txt。
常见报错排查表 ▸
| 现象 | 原因 / 处理 |
|---|---|
| 审批框一闪而过,出现「无交互输入,默认拒绝。」 | 非交互终端(CI/管道/某些 IDE 面板)。在真实 PowerShell 窗口跑。这是 fail closed 的正确行为,不是 bug |
| 输入 Y(大写)/ yes | 都行,代码做了 toLowerCase(),只接受 y 和 yes |
| 每写一个文件都要批一次 | 本章刻意不做「永久允许」,正常行为 |
| read_file 也进审计,很吵 | 审计记录的是每一个最终决定;输出在 stderr,2>$null 可临时屏蔽 |
Error [permission_evaluation_error] | 权限系统自身故障(规则实现 bug、审计写失败)。fail closed 兜底,handler 没执行 |
| 启动报 approvalProvider is required | 自己调 buildAgent() 时没注入审批器/审计器。P03 不允许无边界启动 |
| 只读命令 shell 也要批准 | effect === "execute" 一律 ask,不看命令内容 |
🧪 验证与实验
npm run test:ch03 执行 10 个测试文件、61 个测试。本章新增两个,其余 8 个是前两章累计测试,保证加权限没改坏旧行为。| 测试文件 | 验证内容 |
|---|---|
permissions.test.ts | PermissionDecision 构造校验、规则合并优先级、审批 fail closed 全部分支、工作区硬边界、审计调用时机 |
ch03-permissions.test.ts | 端到端:审批器有没有被调用、副作用有没有发生、消息是否配对、回填内容是否精确 |
| 其余 8 个 | 前两章累计测试(loop/files/shell/messages/profiles/config/openai-chat) |
ch03-permissions.test.ts 用三类更硬的断言:expect(approval.requests).toEqual([])(审批器没被调用)+ readFile(outside).rejects(文件没被创建)+ validateToolPairing(result.history)(消息仍完整)。测试断言的回填文本是逐字精确的——改任何一条 reason 文案都会让测试变红,这是有意的。四个建议动手做的小实验 ▸
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一·让规则 matcher 抛异常 matches 改成 () => { throw new Error("boom"); } | 得到 deny,source 是 confirm-file-write,reason 是 Permission rule failed: ... | 规则故障导向拒绝,而不是「这条规则不算」 |
| 二·加一条 allow 规则 追加 match 恒 true 的 allow-everything | 写 ../outside.txt 仍是 deny (workspace-boundary) | allow 加多少条都赢不了 deny——亲手确认最重要的不变量 |
| 三·审批器返回 passthrough 假审批器返回弃权 | 最终 deny,source 是 approval | ask 只能被 allow/deny 收敛,弃权算拒绝 |
| 四·让审计器抛异常 TerminalAuditSink.record 直接 throw | Error [permission_evaluation_error],且文件没被读 | 审计不是「顺便记一下」,它是执行前置条件 |
📝 本章小结
- 权限判断必须在代码的固定位置——prepare 之后、invoke 之前,Prompt 做不到这个保证。
- 决定不是 boolean 而是四态,因为「需要问一下」和「我不表态」都必须能表达出来。
- deny > ask > allow > passthrough:任何一个参与方的拒绝,都不能被其他人的允许推翻。
一定要记住的七条 ▸
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 顺序是 prepare → permission → handler,反了就只剩日志价值 | 插入 Agent Loop |
| 2 | 硬边界的 deny 出现时,审批框根本不弹 | 工作区边界先于规则 |
| 3 | 「没有得到明确的 yes」和「得到明确的 no」等价——七种情况都是 deny | ask 必须得到显式 allow |
| 4 | 审批器返回值按 unknown 处理,用 instanceof 验证 | ask 必须得到显式 allow |
| 5 | 审批只授权这一次,不存在「永久允许」 | ask 必须得到显式 allow |
| 6 | 审计记录最终决定且发生在执行之前;审计失败则不执行 | 审计记录最终决定 |
| 7 | execute 一律 ask,不按命令文本猜风险 | Shell 为什么一律 ask |
本章代码边界(明确「还没做什么」) ▸
| 已经有 | 还没有 | 在哪一章补 |
|---|---|---|
| 四态决定 + 优先级合并 | Hook 提出建议(recommendations 通道已预留) | 第 4 章 |
| 一条内置规则 | 配置文件分层(Managed / Project / User) | 不做 |
| 单次调用审批 | 永久允许、规则热更新 | 不做 |
| 审批 + 审计 | 独立模型审查、执行后自动验证 | 不做 |
| shell 一律 ask | 按风险分级的差异化默认值 | 不做(教学取更严格的默认值) |
| 最终决定进审计 | 性能指标、告警、幂等去重 | 部分见第 11 章 |
| execute 需审批 | 操作系统级沙箱隔离 | 不做。审批不等于隔离 |