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 驱动 ← 把前面所有规则合到具体调用上
↓
⑨ 边界、运行、验证与小结 ← 刻意不做的事 + 动手 + 排错 + 自测allow / deny / ask / passthrough。shell-default、terminal-approval、workspace-boundary、default。ask 只能由它收敛成 allow 或 deny。① 工作区硬边界 effect 不是 write,不表态 → 无候选
② Shell 默认 effect 不是 execute,不表态 → 无候选
③ 规则 confirm-file-write 不匹配 → 无候选
④ 合并 一个候选都没有 → passthrough
⑤ 收敛 passthrough → allow(default)
⑥ 审批 不需要
⑦ 审计 [Permission] glob: allow (default) - No ...
⑧ 执行 handler 跑起来
① 工作区硬边界 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 跑起来
① 工作区硬边界 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 执行? | 是 | 看用户输入 | 否 |
| 进审计? | 是 | 是 | 是 |
在 system prompt 里写「不要删除系统文件」只是软约束:模型可能理解错、输入可能与指令冲突、多轮会弱化早期提醒、无法枚举所有危险命令、无法证明检查发生在副作用前。权限策略必须在代码里,位置固定:prepare → decide → invoke。模型只能提出调用请求,是否执行由 Harness 决定。
boolean 表达不出「暂缓」:规则甲想让 write_file「问一下」,返回 false 变成直接拒绝、返回 true 直接执行。规则乙对 write_file 没意见,但「所有规则都 true 才执行」会让弃权变成赞成票。四种行为各有含义。
export const PERMISSION_BEHAVIORS = Object.freeze([
"allow", "deny", "ask", "passthrough",
] as const);
// 每个决定还带 reason(原因)+ source(来源)
| 字段 | 谁在用 | 用来做什么 |
|---|---|---|
| behavior | Agent Loop | 决定执行、拒绝还是弹审批 |
| reason | 用户 + 模型 + 审计 | 审批框的「原因」;被拒时回填给模型的文本 |
| source | 你(排查时) | 回答「到底是谁拒的」——硬边界?某条规则?还是用户? |
toToolResult() 只允许 deny 调用。ask / passthrough 是中间状态,出现在最终结果里一定是 bug——宁可崩掉暴露问题,也不产生一条语义不明的历史。权限规则不重新解析 OpenAI 参数,也不处理未知工具。只有 prepare 成功(定义存在、arguments 是合法 JSON、Zod 已通过、effect 来自受信本地定义)才构造 Request。非法 JSON、多余字段、错误类型在权限系统之前就失败。
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] | deny | deny 赢,连问都不问 |
[deny, allow] | deny | 这是本章最重要的不变量 |
「不能先弹审批框」是安全要求:如果先弹框,用户在赶时间时连着敲了几个 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 逃逸全部继承。
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");
审计在规则合并和审批完成之后、执行之前,记录的是最终结果。格式:[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 ...
Error [permission_evaluation_error]: Permission evaluation failed,消息仍配对。P03 只增加一个 capability:policy。两个 profile 使用相同的五工具注册表和同一个 Agent Loop。组合根根据固定 capability
构建策略,而不是接收调用方拼好的策略;缺少 approvalProvider / auditSink 时启动构建阶段抛错,不允许无边界启动。profileForChapter(profile.chapter) !== profile 的引用相等检查拒绝调用方伪造的同编号 profile。
| 操作 | P02 | P03 |
|---|---|---|
| read_file / glob | 直接允许 | 直接允许并审计 |
| 工作区内 write/edit | 直接允许 | ask + 审计 |
| 工作区外 write | path_escape | 权限硬边界拒绝 + 审计 |
| shell | ask | ask 并审计 |
第 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));
permissionPolicy 分支。这正是第 2 章把 prepare 和 invoke 拆开的回报:当时看似只是「代码整洁」,现在才看出它是为了留出这个插入点。missing tool results——整个会话就废了。「权限系统出故障」和「会话结构损坏」不该是同一种严重程度。这个 catch 也是 fail closed 的:invoke 那一支根本没走到,handler 调用次数为零。查找 + 解析 + Zod 校验,非法输入在此失败。
工作区外写入直接 deny,不询问也不运规则。
结构化参与方依次表态,异常按 fail closed。
按优先级合并第一个决定。
ask 只有拿到 allow 才通过。
审计失败即拒绝。
执行 handler,或把 deny 转成 permission_denied 回填。
Set-Location code + npm ci,.env 三个变量同第 1 章。
npm run test:ch03 预期 10 个文件 61 个测试,不需要 Key。
先 read_file(放行)再 write_file(审批),一条命令看两条路径。
审批框直接回车 = 拒绝,文件不变。这是最值得亲手做的验证。
写入 ../outside.txt → deny (workspace-boundary),不弹审批框。
typecheck / test / lint / format / build。
npm run test:ch03
Test Files 10 passed (10)
Tests 61 passed (61)
# 注意:中间那行「配置错误: Missing required settings: ...」
# 是被测试断言的预期输出(验证缺配置时 CLI 返回退出码 2)
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
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,不看命令内容 |
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) |
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],且文件没被读 | 审计不是「顺便记一下」,它是执行前置条件 |
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 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 需审批 | 操作系统级沙箱隔离 | 不做。审批不等于隔离 |