第 5 章 · 02 permission 权限批准与 checkpoint 检查点 本节摘要:本节精读主循环上的两道核心闸门—— 权限批准与 checkpoint 检查点。permission 把"是否允许这个工具调用"建模成一个纯函数 Policy(规则评估,零 I/O):输入工具名 + 参数 subject,输出三值 (Allow / Ask / Deny);外层 包一个可选的交互式 。Rule 支持 、 通配、 字面量三种形式。三种批准模式(Ask 逐个问 / Auto 自动批普通项 / Yolo 跳过普通提示)是 UI 层对 Policy 的"模式化"包装。
本节摘要:本节精读主循环上的两道核心闸门——
internal/permission权限批准与 checkpoint 检查点。permission 把"是否允许这个工具调用"建模成一个纯函数 Policy(规则评估,零 I/O):输入工具名 + 参数 subject,输出三值Decision(Allow / Ask / Deny);外层Gate包一个可选的交互式Approver。Rule 支持ToolName、ToolName(glob)通配、ToolName=literal字面量三种形式。三种批准模式(Ask 逐个问 / Auto 自动批普通项 / Yolo 跳过普通提示)是 UI 层对 Policy 的"模式化"包装。checkpoint 则是每 turn 一个文件快照:在 execute_one 执行非只读工具前,用tool.Previewer拿到"改哪个文件",把编辑前内容存进<session-id>.ckpt/,支持回放代码 / 对话 / 两者 / 从此分叉。这是 agent 安全性(权限)与可恢复性(checkpoint)的基石。
内容来源:原项目源码
internal/permission/permission.go、internal/control/checkpoint.go、docs/TOOL_APPROVAL_MODES.md、docs/CHECKPOINTS.md、docs/RECOVERY.md,精读并套用体系化模板。
⚠️ 注意:permission 的 Policy 是纯函数(规则评估无 I/O),把"问用户"这件事推迟到外层 Gate 的 Approver;checkpoint 是文件快照(不是 git),bash 副作用不纳入快照(和 Claude Code 对齐)。
阅读完本节,你应当能够:
Decision 的三个值(Allow / Ask / Deny)各自的语义,以及 Policy 评估的默认兜底。Rule 的三种书写形式:ToolName、ToolName(glob)、ToolName=literal。Gate。FileSnap / Checkpoint / <session-id>.ckpt/。Preview 与 checkpoint 是同一道缝。RewindScope 的三种值(Code / Conversation / Both)与"从此分叉"。internal/permission 的包注释把设计意图讲得很直接:
// Package permission decides, per tool call, whether to allow it, deny it, or // ask the user first. The core is a pure Policy (rule evaluation, no I/O); a // Gate wraps a Policy with an optional interactive Approver and is what the // agent consults at execute time. Keeping rule evaluation pure makes it // trivially testable and keeps the agent independent of how "ask" is resolved.
翻译:permission 包对每个工具调用做判断——允许 / 拒绝 / 先问用户。核心是一个纯 Policy(只评估规则,没有任何 I/O);外层 Gate 在 Policy 外面包一个可选的交互式 Approver,agent 在 execute 时咨询的是 Gate。把规则评估做成纯函数有两个好处:(1) 极易测试(不依赖任何 I/O,纯输入输出);(2) agent 不依赖"问用户"是怎么实现的(终端 TUI 弹卡片、桌面弹窗、headless 直接失败,各前端自己解决 Ask)。
这套"纯内核 + 可插拔外壳"是 Go 里处理"有副作用的决策"的经典套路,和第 4 章 OmsEngine 的"被动订阅 + 字典缓存"异曲同工:把可测试的纯逻辑和不可测试的 I/O 分层。
源码定义了三值:
// Decision is the outcome of evaluating a tool call against a Policy. type Decision int const ( Allow Decision = iota // 直接放行,不问 Ask // 交给交互式 Approver;没有 Approver 时退化为 Allow Deny // 任何模式下都拦截 ) // ParseDecision maps a config string to a Decision. Unknown / empty input // defaults to Ask — the conservative posture for a writer fallback. func ParseDecision(s string) Decision { switch strings.ToLower(strings.TrimSpace(s)) { case "allow": return Allow case "deny": return Deny default: return Ask } }
两个关键细节。
第一,枚举顺序 Allow=0, Ask=1, Deny=2 不是随便排的。 Decision 是 int,默认零值是 Allow(0)。但注意 ParseDecision 对"未知/空"输入返回 Ask,不返回零值——这是写入侧的保守姿态。读侧(配置缺省)和写侧(解析未知)分别选了不同的默认,这是有意的。
第二,"没有 Approver 时 Ask 退化为 Allow"是个微妙的设计。 它的意思是:纯 Policy 说"这个该问用户",但如果没有 Approver(比如某些自动化场景),那就放行而不是卡死。这避免了"配了 ask 规则但没有交互界面 → 整个 agent 卡住"。但这个退化在 headless 模式下会被改写——reasonix run 没有 Approver,默认 Ask 姿态对 writer fallback 和显式 ask 规则失败关闭(fail closed),而不是退化为 Allow。安全优先。
Rule 匹配工具调用:
type Rule struct { Tool string // 工具名 Subject string // 非空时约束调用的 subject Literal bool // true 时按字面量精确匹配,false 时按 glob 通配 }
ParseRule 解析三种写法:
| 写法 | 含义 | 示例 |
|---|---|---|
ToolName |
匹配该工具的所有调用(Subject 空) | bash 匹配所有 bash 调用 |
ToolName(glob) |
按通配符匹配 subject(支持 * ?) |
bash(git push *) 匹配所有 git push |
ToolName=literal |
按字面量精确匹配(把 * 当普通字符) |
bash=git push origin main 精确到这一条 |
注释解释了为什么保留 =literal 旧形式:这是"在 Claude Code 风格的 Tool(specifier) 规则出现之前"的老配置写法,为了兼容已有配置而保留。= 形式(当 = 出现在任何 ( 之前时生效)把剩余字符串原样匹配,不做 glob——这样一条记住的"具体命令"里的 * / ? 不会被当成通配。ok 返回 false 表示格式错误(空工具名),让调用方告警而不是静默装一条匹配不到任何东西的规则。
💡 契约要点:permission 的 Rule 是"工具 + subject"二元组,subject 用 glob 还是 literal 是 Rule 自己的属性(不是全局开关)。这意味着你可以同时有
bash(git *)(通配放行所有 git)和bash=rm -rf /(字面量拒绝这一条),互不干扰。glob 和 literal 的选择绑在每条 Rule 上,设计得很精细。
Policy 是纯函数,但"Ask 怎么落地"需要 I/O。Gate 就是这层外壳:
Gate 在 execute 时被 agent 咨询(上一节 execute_one 流程图里的"permission 检查"那一步就是 Gate)。Gate 持有一个 Policy 和一个可选 Approver,逻辑大致是:调 Policy 拿 Decision → Allow 直接放行 / Deny 直接拦 / Ask 则看有没有 Approver。Approver 是个接口,各前端自己实现:终端 TUI 实现成"弹一个批准卡片等用户按键",桌面实现成"Wails 弹窗",headless 实现成"直接 fail closed"。
"用户的选择能变成新规则" 是这层的另一职责。用户在批准卡片上选"总是允许",前端会把这个工具 + subject 写回配置成为一条 allow 规则——下次同样的调用 Policy 直接 Allow,Gate 都不用问。TOOL_APPROVAL_MODES.md 提到:Ask 模式下"动态 Bash 永不继承更宽的 Bash/前缀/glob 规则",可重用的选择会以 Bash=<literal>(字面量)形式存——正好对应上一节讲的 =literal 形式。
文档 TOOL_APPROVAL_MODES.md 把"批准模式"和 Policy 分开讲,这点很重要:批准模式不是 Policy 的替代,而是对 Policy 决策的一种"模式化"解释。同一套 Policy 规则在三种模式下行为不同:
| 模式 | 普通工具权限 | 仍然拦截 |
|---|---|---|
| Ask | 受控工具(write/bash 等)都先问 | (天然全部走 Approver) |
| Auto | 自动批准普通工具权限 | 显式 deny / 显式 ask 规则、计划确认、全局/偏好/反馈/更新/重复/敏感/超大 remember、嵌套/间接 Bash、MCP 破坏性调用、ask 提问 |
| Yolo | 跳过普通工具权限提示 | 显式 deny 规则、沙箱、计划确认、ask 提问、强制新鲜批准 |
三个关键认知。
第一,模式独立于协作模式(collaboration mode)。 文档专门提醒:协作/运行时模式(lightweight/balanced/delivery-first)决定 agent 怎么推进任务;工具权限模式决定受控工具是否等批准。两者正交,可以自由组合(Plan + Auto、Goal + Yolo 等)。初学者容易把这俩混成一回事。
第二,Auto 不是"无脑全放行"。 Auto 仍然尊重一堆东西:显式 deny、显式 ask、计划模式的"开始执行"确认、各种 remember 的敏感操作、嵌套/间接 Bash、MCP 破坏性调用、ask 提问。文档原文:"Auto is designed as a behavior, not another feature to configure." Auto 是个行为,不是又一个要配置的特性——它执行 Policy 允许的操作,只在出现真正该用户拥有的决策(新产品方案、计划选择)时才问。
第三,Yolo 是唯一能绕过"嵌套/间接 Bash 需人工"要求的模式,但绕不过 deny 和沙箱。 Yolo 最大化连续执行,适合"计划已确认 + 工作树可回滚"的批量机械编辑。文档明确警告:Yolo 不适合生产、敏感文件、delete/publish/push、需求不清的场景。
💡 契约要点:三种模式本质是"对 Approver 的不同实现策略"。Policy 还是那个纯 Policy,规则还是那些规则;模式改变的是"普通工具权限的 Approver 默认怎么应答"。Auto 的 Approver 对普通项自动说"允许",但遇到 ask/deny 规则、计划确认等仍然转发给真实用户;Yolo 的 Approver 对普通项连问都不问,但 deny/沙箱/计划确认挡得住它。把 Policy(纯规则)和 Mode(Approver 策略)分开,是这套设计能同时保证"可测试"和"灵活"的关键。
讲完权限这道"事前闸门",来看"事后可恢复"的 checkpoint。文档 CHECKPOINTS.md 开宗明义:
Let a user rewind a session to a previous point and restore code, conversation, or both — without touching their git history.
核心机制是文件快照,不是 git:
type FileSnap struct { Path string // 工作区相对路径 Content *string // nil → 该文件在此锚点不存在(回放时删除它) } type Checkpoint struct { Turn int // 锚定的用户消息索引(0-based) Time time.Time Prompt string // 用户消息文本 —— picker 的标签 Files []FileSnap // 这一 turn 内动过的不同文件,turn 开始时的状态 }
三个设计要点。
第一,每 turn 一个 checkpoint,在 turn 开始时打开。 文档:"One checkpoint per user turn. A checkpoint opens when a turn starts (Controller.Send / runTurn), labelled with the user prompt." 也就是说,checkpoint 的粒度是"用户消息",不是"工具调用"。一个 turn 里可能改了 5 个文件,这 5 个文件的"turn 开始时内容"都被记进同一个 Checkpoint。
第二,编辑前快照,通过 Previewer 这道缝集中采集。 在 agent.(*Agent).executeOne,执行一个 ReadOnly() 为 false 且实现了 tool.Previewer 的工具前,调 Preview(args) 拿到 diff.Change{Path, Kind, OldText},把该文件的快照记进当前 checkpoint。文档强调:"tool.Previewer already exists and the file-writers implement it, so this is one centralized seam — no per-tool code." Previewer 接口本来就存在(文件类工具已实现),所以采集快照只需要这一处集中缝,不用每个工具各自写。
第三,去重:每 turn 每路径只存第一次。 "Dedup per path per turn: only the first touch is snapshotted." 因为第一次 touch 时的内容就是"turn 开始时的内容",同一文件在这一 turn 内被改多次,只需记最初的版本。Kind == create(文件原本不存在)存 Content = nil,回放时删除该文件;modify/delete 存 OldText。
文档特意强调 checkpoint 与 git 的区别(很多人会以为是 git 回滚):
| 维度 | checkpoint(文件快照) | git |
|---|---|---|
| 是否污染 git | 零污染,从不 commit/stage/碰 .git/ |
必然动 git |
| 非 git 目录 | 能工作 | 不能 |
| 跟踪范围 | 只跟踪可预览的编辑工具(write_file/edit_file/multi_edit) | 跟踪所有文件变化 |
| bash 副作用 | 不跟踪(bash rm 不可回放) |
取决于是否 commit |
| 存储 | 会话边车目录 <session-id>.ckpt/,一个 JSON 一个 checkpoint + 索引 |
.git/ 对象库 |
为什么 bash 不跟踪?文档直说:"no way to know what a shell command touched"(没法知道一条 shell 命令碰了什么文件)。这和 Claude Code 完全对齐——有风险的 bash 已经被 permission 闸门管住了,checkpoint 只管"可预览的编辑工具"这一类确定性的文件改动。
checkpoint 存在会话目录的边车子目录:
<session-dir>/<session-id>.ckpt/ 0001.json # 第 1 个 checkpoint 0002.json # 第 2 个 ... index.json # 小索引
文档解释这个布局:"one JSON per checkpoint plus a small index (v1's layout — cheap delete, a corrupt snapshot only loses itself)." 一个 JSON 一个 checkpoint,删除便宜,单个快照损坏只丢自己。它和会话消息 JSONL(agent.Session.Save)分开存,这样会话格式完全不变。checkpoint 跨会话持久:resume 一个会话会重新加载它的 checkpoint,所以重启后 rewind 照样能用。保留期随会话一起清理(默认约 30 天,可配),用全内容快照所以靠保留期回收来限制磁盘。
回放(rewind)操作挂在 control.Controller 上,和 SetPlanMode / Compact / NewSession 放在一起。文档明确:这样终端 TUI、桌面 webview、HTTP/SSE 服务器三个前端驱动 rewind 完全一样,没有一个重新实现它。
type RewindScope int // Code | Conversation | Both func (c *Controller) Checkpoints() []CheckpointMeta // 给 picker 列表用 func (c *Controller) Rewind(turn int, scope RewindScope) error
三种 scope 的语义:
turn 到最新的每个 checkpoint,取每条路径最早的 FileSnap,把每个文件恢复到那个内容(nil 就删除)——即撤销 turn 及之后的所有编辑。路径穿越会针对实时工作区根重新检查。Session.Messages 截断到 turn 的用户消息之前,重新 Save,并把截断后的历史作为事件发出让前端重渲染。该 turn 的 prompt 会被还原进 composer 供重发/编辑(Claude Code 行为)。还有一个"从此分叉"(fork-from-here)——从某个 turn 复制出一条新的会话分支继续。这和上一节提到的 branch.go 子代理分支是同一套机制在用户层面的暴露。
💡 契约要点:checkpoint 和 permission 是 agent 安全网的"一对":permission 是事前闸门(决定工具能不能跑),checkpoint 是事后安全网(跑完能回退)。两者都尽量做到"集中一道缝、各前端共用":permission 把"问用户"推迟到 Gate 的 Approver,checkpoint 把"改哪个文件"集中在 Previewer 这道缝、把"回放"集中在 Controller.Rewind。这是 Reasonix 反复用的设计范式——把可测试的纯逻辑(Policy / 快照采集)和有副作用的外壳(Approver / UI 回放)分层。
ToolName(全匹配)/ ToolName(glob)(通配)/ ToolName=literal(字面量,glob 与 literal 是 Rule 自身属性)。<session-id>.ckpt/,跨会话持久,随会话清理。下一节,我们看 agent 主循环的"自愈"能力:工具失败后如何自动重读、回显 schema、循环保护;以及
internal/repair包的真实职责(Reasonix 自身升级的事务安全网,不是工具重试)。最后讲贯穿全章的 REASONIX.md 项目记忆——它是第 6 章 Cache-first 契约的"源头"。