第 三 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

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

💡 权限不是 boolean,而是四态:allow / deny / ask / passthrough,优先级 deny > ask > allow。
把结构化权限策略插进工具准备和 handler 执行之间。模型只能提出调用请求,是否执行由 Harness 决定。Shell 一律 ask,工作区外写入是系统硬拒绝;每个最终决定进入审计。本章只加了十几行代码,能力边界就完全变了。
本章进度
0%
1 本章导读与学习目标
  • 说清楚为什么权限判断不能写在 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 驱动 ← 把前面所有规则合到具体调用上 ↓ ⑨ 边界、运行、验证与小结 ← 刻意不做的事 + 动手 + 排错 + 自测
2 术语速查(本章第一次出现的词)
读法先扫一眼,正文里遇到不认识的词回来查即可。不用背,读完正文自然就懂了。
PermissionDecision一个不可变的权限结论,三件事:behavior + reason + source。
behavior结论本身:allow / deny / ask / passthrough
reason人能读懂的原因。会进审计,也会进模型视野。
source谁做的决定。规则名、shell-defaultterminal-approvalworkspace-boundarydefault
PermissionRequest权限系统的输入。只接受已通过 Zod 校验的调用
PermissionRule一条规则 = 名字 + 行为 + 原因 + 匹配函数。
PermissionPolicy合并所有参与方、产出唯一最终决定的地方。
参与方能提出候选决定的角色:硬边界、Shell 默认、规则、Hook 建议。
ApprovalProvider审批边界。ask 只能由它收敛成 allowdeny
AuditSink审计边界。记录最终决定;它失败则不执行。
WorkspaceWriteBoundary系统硬边界。判断写入路径是否在 workspace 内。
fail closed判断依赖出故障时选择拒绝,而不是把「不知道」当成「可以」。
recommendations上游参与方(第 4 章的 Hook)传入的候选决定列表。本章预留不使用。
permission_denied权限拒绝回填给模型的稳定错误码。
permission_evaluation_error权限系统自身出故障时的稳定错误码。
3 先看一眼真实的决策过程
同一次会话三个调用假设模型先后提出三个调用,看它们各走到哪一步。
调用 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
                  参数: {"path":"chapter3-note.txt",...}  允许本次调用? [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 执行?看用户输入
进审计?
最后一行值得单独看三种情况都进审计。审计记的不是「危险操作」,是「每一个最终决定」。
4 核心知识点
为什么 Prompt 不是安全边界

在 system prompt 里写「不要删除系统文件」只是软约束:模型可能理解错、输入可能与指令冲突、多轮会弱化早期提醒、无法枚举所有危险命令、无法证明检查发生在副作用前。权限策略必须在代码里,位置固定:prepare → decide → invoke。模型只能提出调用请求,是否执行由 Harness 决定。

四态决定:为什么不是 boolean

boolean 表达不出「暂缓」:规则甲想让 write_file「问一下」,返回 false 变成直接拒绝、返回 true 直接执行。规则乙对 write_file 没意见,但「所有规则都 true 才执行」会让弃权变成赞成票。四种行为各有含义。

export const PERMISSION_BEHAVIORS = Object.freeze([
  "allow", "deny", "ask", "passthrough",
] as const);
// 每个决定还带 reason(原因)+ source(来源)
字段谁在用用来做什么
behaviorAgent Loop决定执行、拒绝还是弹审批
reason用户 + 模型 + 审计审批框的「原因」;被拒时回填给模型的文本
source你(排查时)回答「到底是谁拒的」——硬边界?某条规则?还是用户?
契约违反要立刻失败toToolResult() 只允许 deny 调用。ask / passthrough 是中间状态,出现在最终结果里一定是 bug——宁可崩掉暴露问题,也不产生一条语义不明的历史。
PermissionRequest 只接收准备完成的调用

权限规则不重新解析 OpenAI 参数,也不处理未知工具。只有 prepare 成功(定义存在、arguments 是合法 JSON、Zod 已通过、effect 来自受信本地定义)才构造 Request。非法 JSON、多余字段、错误类型在权限系统之前就失败。

一条规则长什么样name + behavior + reason + matches 四项放在同一个对象里,reason 和判断条件分开写迟早不一致。匹配时自动生成以规则名为 source 的决定;不匹配返回 undefined(弃权不是赞成)。新增一条规则不需要动 PermissionPolicy、不需要动 Loop。规则 matcher 抛异常 → 按 fail closed 转成 deny(reason 以 Permission rule failed: 开头)。
三道闸门如何合并(本章核心)

一次权限判断有三层输入:① 系统硬边界(工作区外写入直接 deny);② 结构化参与方(Shell 默认、规则、第 4 章 Hook 建议);③ 显式审批(只有合并结果为 ask 才调用)。①–④ 全部跑,不是命中一个就短路,审计才能看到完整冲突,而不是「碰巧第一个匹配的规则说了什么」。

候选如何合并:
找第一个 deny → 有就返回
再找第一个 ask → 有就返回
再找第一个 allow → 有就返回
都没有 → passthrough → 转成 allow(default)

优先级:deny > ask > allow > passthrough
一句话记住它:越保守的越赢。
候选集合最终为什么
[]allow(default)没人反对,默认放行读操作
[ask]走审批需要一次人工确认
[allow, ask]走审批ask 赢。宽松规则不能取消审批
[deny, ask]denydeny 赢,连问都不问
[deny, allow]deny这是本章最重要的不变量
保证只要有任何一个参与方说 deny,无论加多少条 allow、无论 Hook 怎么建议、无论用户敲多少次 y,结果都是拒绝。第 4 章加 Hook、第 19 章接 MCP,这条保证一字不改。Claude Code 官方权限文档用同样的评估顺序:deny、ask、allow 依次判断,任何层级的 deny 都不能被其他层级的 allow 覆盖。
工作区边界为什么必须在规则之前

「不能先弹审批框」是安全要求:如果先弹框,用户在赶时间时连着敲了几个 y,这一个也敲下去了,系统随后还是拒绝——那这个框的存在就只教会用户「批准了也可能不执行」。审批框应该只在「你的回答真的能决定结果」时出现。

const allowed = await this.#writeBoundary.isPathWithinWorkspace(
  request.context.workspace, rawPath);
return allowed ? undefined
  : new PermissionDecision("deny",
      "Writing outside the workspace is forbidden",
      "workspace-boundary");

core 只依赖接口,不导入 Node adapter。组合根注入的 NodeWorkspaceFileSystem 内部复用第 2 章 safePath——绝对路径、..、Windows 保留名、junction 逃逸全部继承。

Shell 为什么一律 ask

Get-Content 看起来只读,但参数可指向工作区外;普通命令可调用脚本、启动子进程、访问网络。只匹配 Remove-Item、Format-Volume 既不完整也易绕过。只要 effect 是 execute 就产生默认 ask。

if (definition.effect !== "execute") return undefined;
return new PermissionDecision("ask",
  "Shell execution requires approval", "shell-default");
ask 必须得到显式 allow——七种情况全是 deny① 没有审批器 ② 审批器抛异常 ③ 返回普通 object/boolean ④ 再次返回 ask ⑤ 返回 passthrough ⑥ 终端无交互 stdin ⑦ 用户回车或输入其他内容。共同点:都是「没有得到明确的 yes」,而不是「得到了明确的 no」——这就是 fail closed。审批只授权当前这一次调用,不存在「永久允许」。
审计记录最终决定

审计在规则合并和审批完成之后、执行之前,记录的是最终结果。格式:[Permission] 工具名: behavior (source) - reason。审计写的是 stderr,最终答案写的是 stdout——重定向只拿回答,不混日志。

[Permission] write_file: allow (terminal-approval) - User approved this tool call
[Permission] write_file: deny (workspace-boundary) - Writing outside ...
审计失败则不执行审计器本身失败时,运行时不能执行一个未留下要求记录的副作用:Handler 调用次数保持为零,模型收到 Error [permission_evaluation_error]: Permission evaluation failed,消息仍配对。
P02 与 P03 的准确差异

P03 只增加一个 capability:policy。两个 profile 使用相同的五工具注册表和同一个 Agent Loop。组合根根据固定 capability 构建策略,而不是接收调用方拼好的策略;缺少 approvalProvider / auditSink 时启动构建阶段抛错,不允许无边界启动。profileForChapter(profile.chapter) !== profile 的引用相等检查拒绝调用方伪造的同编号 profile。

操作P02P03
read_file / glob直接允许直接允许并审计
工作区内 write/edit直接允许ask + 审计
工作区外 writepath_escape权限硬边界拒绝 + 审计
shellaskask 并审计
能力集为什么不能是裸 SetReadonlySet 只是类型层面只读,运行时仍可 add。CapabilitySet 隐藏私有 Set,使 profile 能力构造后无法被外部修改。
5 插入 Agent Loop:到底改了什么

第 2 章已经分开 prepare 和 invoke。第 3 章只在中间增加权限步骤:

if (prepared.error !== undefined) {
  result = prepared.error;
} else if (this.#permissionPolicy !== undefined) {
  try {
    const decision = await this.#permissionPolicy.decide(
      new PermissionRequest({ prepared, context }));
    result = decision.isAllowed
      ? await tools.invoke(prepared, context)
      : decision.toToolResult();
  } catch {
    result = toolError("permission_evaluation_error",
      "Permission evaluation failed");
  }
} else {
  result = await tools.invoke(prepared, context);
}
this.#history.push(toolMessage(result.content, call.id));
改动小得出奇轮次循环、maxTurns、终止判断、validateToolPairing、snapshot/prepare/invoke、工具回填、handler 实现——一行未改。只加了一个 permissionPolicy 分支。这正是第 2 章把 prepare 和 invoke 拆开的回报:当时看似只是「代码整洁」,现在才看出它是为了留出这个插入点。
那个边界 catch 为什么必须存在如果异常向上抛,这一轮的 tool 消息就少一条,下一轮 validateToolPairing 会直接抛 missing tool results——整个会话就废了。「权限系统出故障」和「会话结构损坏」不该是同一种严重程度。这个 catch 也是 fail closed 的:invoke 那一支根本没走到,handler 调用次数为零。
1
prepare

查找 + 解析 + Zod 校验,非法输入在此失败。

2
系统硬边界

工作区外写入直接 deny,不询问也不运规则。

3
规则 / Hook 建议

结构化参与方依次表态,异常按 fail closed。

4
deny > ask > allow

按优先级合并第一个决定。

5
必要时的显式审批

ask 只有拿到 allow 才通过。

6
审计最终决定

审计失败即拒绝。

7
invoke / 回填

执行 handler,或把 deny 转成 permission_denied 回填。

6 运行第 3 章
0
准备环境

Set-Location code + npm ci,.env 三个变量同第 1 章。

1
先跑离线测试

npm run test:ch03 预期 10 个文件 61 个测试,不需要 Key。

2
跑一次弹审批的任务

先 read_file(放行)再 write_file(审批),一条命令看两条路径。

3
这次故意拒绝

审批框直接回车 = 拒绝,文件不变。这是最值得亲手做的验证。

4
看硬边界拦越界

写入 ../outside.txt → deny (workspace-boundary),不弹审批框

5
(可选)完整离线门禁

typecheck / test / lint / format / build。

第 1 步 · 离线测试输出
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]

# 敲 y  → [Permission] write_file: allow (terminal-approval)
# 直接回车 → [Permission] write_file: deny (terminal-approval)
#         模型收到 Error [permission_denied],文件内容不变
# 确认文件: Get-Content .\chapter3-note.txt
第 4 步 · 硬边界拦越界(不弹框)
npm run ch03 -- --prompt '把 hello 写入 ../outside.txt'
# [Permission] write_file: deny (workspace-boundary) - Writing outside ...
# 注意没有弹审批框——你根本没有机会批准它
# code/ 的上一级目录里不会出现 outside.txt

# 统一入口
npm run agent-tutorial -- run --chapter 3 --prompt '读取 README.md,然后把一句摘要写入 chapter3-note.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,不看命令内容
7 验证与实验

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)
只断言「返回了错误」不够一个实现可能先执行副作用再返回错误,测试照样通过。用三类更硬的断言: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],且文件没被读审计不是「顺便记一下」,它是执行前置条件
8 本章小结
三句话版本
  • 权限判断必须在代码的固定位置——prepare 之后、invoke 之前,Prompt 做不到这个保证。
  • 决定不是 boolean 而是四态,因为「需要问一下」和「我不表态」都必须能表达出来。
  • 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 需审批操作系统级沙箱隔离不做。审批不等于隔离
检查你是否真的读懂了(不看文章回答)
  • 一次 glob 调用,四个参与方分别产出什么候选?最终 source 是什么?
  • 为什么 boolean 表达不了「需要问一下」?举出规则乙那种「不表态」的问题。
  • 候选集合是 [deny, ask] 时,用户会看到审批框吗?为什么?
  • 审批器抛异常,最终 behavior 是什么?如果换成「默认允许」会有什么风险?
  • 审计器抛异常,handler 执行了吗?模型收到什么?
  • 第 3 章的 Agent Loop 比第 2 章多了几处改动?
  • toToolResult() 为什么只允许 deny 调用?
9 QA 测试环节(自测题)
已完成 0 / 8 · 答对 0
Q1. 权限四态决定分别是?
Q2. 合并权限决定时的最高优先级是?
Q3. 为什么 shell 一律 ask,而不是按文本匹配危险命令?
Q4. 工作区外写入(如 ../outside.txt)的正确处理是?
Q5. 下面哪种情况仍默认拒绝(fail closed)?
Q6. 权限拒绝后模型会看到什么?
Q7. 候选集合是 [deny, ask] 时,用户会看到审批框吗?
Q8. 第 3 章的 Agent Loop 比第 2 章多了几处改动?