第 3 章 复刻 Claude Code 权限系统 · Agent架构实操三 开始测验

深度拆解复刻 Claude Code 权限系统

能力够用了,风险也随之出现。把安全完全委托给概率模型是错的——权限策略必须在代码里,且位置固定:副作用发生前的确定性边界。
⏱ 约 15 分钟 🛡 四态决定 + deny 优先 🔒 fail closed 🧪 61 个离线测试

🎯 本章导读

读完这一章,你应该能用一句话回答下面每个问题。
读完能做到
  1. 说清楚为什么权限判断不能写在 system prompt 里,只能写在代码的固定位置。
  2. 手画出 prepare → 权限 → invoke 的顺序,并解释顺序反过来会失去什么。
  3. 解释 allow / deny / ask / passthrough 四种行为各自的用途,以及为什么 boolean 不够。
  4. 讲清 deny > ask > allow > passthrough 这条优先级,并举出「规则说允许但仍然被拒绝」的例子。
  5. 列出所有会导致 ask 变成 deny 的情况(一共七种),并说明这叫 fail closed。
  6. 判断一次调用会走哪条路径:直接放行、弹审批、还是硬拒绝。
你需要先具备什么
需要说明
读完第 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 只能由它收敛成 allowdeny
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 ...
deny > ask 规则明明说了 ask(问一下就能过),但硬边界的 deny 直接压过它——不给用户「批准一个不该被批准的操作」的机会。
调用 A glob调用 B 区内 write调用 C 区外 write
合并结果passthroughaskdeny
弹审批框?
handler 执行?看用户输入
进审计?
注意 三种情况都进审计。审计记的不是「危险操作」,是「每一个最终决定」。

🧠 核心概念

点击展开。
1 · 为什么 Prompt 不是安全边界

在 system prompt 写「不要删除系统文件」只是软约束:模型可能理解错误、用户输入与系统指令冲突、多轮上下文弱化早期提醒、不可能枚举所有危险命令组合、无法证明副作用前一定检查了。

权限策略必须在代码里,位置固定:

模型返回 tool_calls
      ↓
ToolRegistry.prepare:查找 + JSON 解析 + Zod 校验
      ↓
PermissionPolicy.decide:硬边界 + 规则 + 审批 + 审计  ← 这里
      ↓
ToolRegistry.invoke:执行 handler
      ↓
回填相同 tool_call_id
原则 模型只能提出调用请求。是否执行由 Harness 决定。
2 · 权限不是 boolean,而是四态

只返回 true/false,运行时不知道「需要询问」和「不发表意见」的区别,也无法解释决定来源。本章四种行为:

behavior含义
allow明确允许
deny明确拒绝
ask必须取得一次显式审批
passthrough不表态,交给其他规则或默认值

每个决定携带原因来源。最终拒绝转成稳定工具结果:Error [permission_denied]: ...,不是含混的 Permission denied.

3 · 三道闸门如何合并(deny 优先)

一次权限判断有三层输入:

  1. 系统硬边界:工作区外写入直接 deny。
  2. 结构化参与方:Shell 默认策略、规则、后续 Hook 建议。
  3. 显式审批:只有合并结果为 ask 时才调用。

合并优先级固定:deny > ask > allow > passthrough。核心代码按这个顺序找第一个决定。只有 passthrough 或完全没参与方时才默认 allow(落在没有规则反对的读取上)。

关键 任何层级的 deny 都不能被其他层级的 allow 覆盖。这与 Claude Code 官方权限文档一致。
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、用户直接回车或输入其他内容。真实终端只接受 yyes

fail closed 权限依赖失败时拒绝,不把「无法判断」解释成「可以执行」。规则异常、审批异常、审计异常都按此原则,不降级成放行。
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 差异
操作P02P03
read_file / glob直接允许直接允许并审计
工作区内 write/edit直接允许ask,最终决定审计
工作区外 writehandler 返回 path_escape权限硬边界拒绝并审计
shellaskask 并审计

P03 只增加一个 capability policy。组合根根据固定 profile 自己构造策略,不接收调用方拼好的宽松策略;缺 approvalProvider 或 auditSink 直接启动失败。

▶️ 交互演示:权限决策模拟器

选一个工具调用,看它如何穿过三道闸门,最终是 allow / ask / deny。注意工作区外写入在硬边界就被拦,根本不进审批。

🔑 一句话总结

prepare → 工作区硬边界 → 规则/建议 → deny>ask>allow>passthrough → 显式审批 → 审计 → invoke

这一章建立了一个不会被弱允许覆盖的副作用边界。Prompt 是软约束,权限是硬边界;deny 永远优先,fail closed 永不降级放行。

🚀 运行第 3 章

前置条件和第 1、2 章一样(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(),只接受 yyes
每写一个文件都要批一次本章刻意不做「永久允许」,正常行为
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.tsPermissionDecision 构造校验、规则合并优先级、审批 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 是 approvalask 只能被 allow/deny 收敛,弃权算拒绝
四·让审计器抛异常
TerminalAuditSink.record 直接 throw
Error [permission_evaluation_error],且文件没被读审计不是「顺便记一下」,它是执行前置条件

📝 本章小结

三句话版本、一定要记住的七条、以及本章还没做什么
三句话版本
  1. 权限判断必须在代码的固定位置——prepare 之后、invoke 之前,Prompt 做不到这个保证。
  2. 决定不是 boolean 而是四态,因为「需要问一下」和「我不表态」都必须能表达出来。
  3. deny > ask > allow > passthrough:任何一个参与方的拒绝,都不能被其他人的允许推翻。
一定要记住的七条
#结论出现在哪一节
1顺序是 prepare → permission → handler,反了就只剩日志价值插入 Agent Loop
2硬边界的 deny 出现时,审批框根本不弹工作区边界先于规则
3「没有得到明确的 yes」和「得到明确的 no」等价——七种情况都是 denyask 必须得到显式 allow
4审批器返回值按 unknown 处理,用 instanceof 验证ask 必须得到显式 allow
5审批只授权这一次,不存在「永久允许」ask 必须得到显式 allow
6审计记录最终决定且发生在执行之前;审计失败则不执行审计记录最终决定
7execute 一律 ask,不按命令文本猜风险Shell 为什么一律 ask
本章代码边界(明确「还没做什么」)
已经有还没有在哪一章补
四态决定 + 优先级合并Hook 提出建议(recommendations 通道已预留)第 4 章
一条内置规则配置文件分层(Managed / Project / User)不做
单次调用审批永久允许、规则热更新不做
审批 + 审计独立模型审查、执行后自动验证不做
shell 一律 ask按风险分级的差异化默认值不做(教学取更严格的默认值)
最终决定进审计性能指标、告警、幂等去重部分见第 11 章
execute 需审批操作系统级沙箱隔离不做。审批不等于隔离
检查你是否真的读懂了(不看文章回答)
  1. 一次 glob 调用,四个参与方分别产出什么候选?最终 source 是什么?
  2. 为什么 boolean 表达不了「需要问一下」?举出规则乙那种「不表态」的问题。
  3. 候选集合是 [deny, ask] 时,用户会看到审批框吗?为什么?
  4. 审批器抛异常,最终 behavior 是什么?如果换成「默认允许」会有什么风险?
  5. 审计器抛异常,handler 执行了吗?模型收到什么?
  6. 第 3 章的 Agent Loop 比第 2 章多了几处改动?
  7. toToolResult() 为什么只允许 deny 调用?
提示 答不上来的,回 真实决策过程核心概念 再看一遍,然后做下面的测验。

QA 测验