本文讲 区域 6 · 安全内核 的前两环——权限规则与人工审批:模型发起工具调用时,凭什么被执行、凭什么被拦下、凭什么需要人来点头。
0. 先建立心智模型
最危险的一刻,不是模型"胡说",而是模型"动手"
03 篇讲了能力来源:模型能调用哪些工具。但「能调用」和「该被允许」是两回事。读文件、跑 shell、改代码、访问网络——每一次工具调用都是真实世界的一次物理动作。一个编码 agent 里最危险的一刻,不是模型输出一段错误的话(那只是文本),而是模型发起一个 rm -rf 或读到你的 .ssh/id_rsa。
Grodex 用两道门挡住这个时刻:
| 门 | 类比 | 解决什么 |
|---|---|---|
| ① 权限规则 | 门禁制度 | 模型乱调工具、读敏感文件时,按策略拦下 |
| ② 人工审批 | 警卫 | 高风险副作用没人把关时,要你亲自点头 |
这一层要回答的问题很实际:
- 模型发起工具调用时,凭什么判定「允许 / 拒绝 / 要人审」?
- 审批通过之后、真正执行之前,策略被收紧怎么办?
- 用户想「只批准改这一个文件」,而不是整条命令,怎么表达?
- 审批票能不能活过进程崩溃?
答案不是把权限做成一个 if tool == "exec" { ask() } 的硬编码,而是把「危险动作的每一次放行」都变成一条可审计、可撤销、可恢复的决策链路。
Fail-closed:整条链的灵魂
读完整套权限代码,最先留下的印象不是「它支持什么」,而是「它默认拒绝什么」。安全方向自始至终统一成一句话——不静默放行:
| 场景 | 行为 |
|---|---|
| 用户没配任何规则(空策略) | 一切工具 → Ask(需审批),不是 Allow |
| 审批超时(120 秒没人理) | Deny,不是放行 |
| 数据库不可用 | 降级为纯内存,但 ticket 依然会超时拒绝 |
| 审批通过后、执行前策略被收紧 | LiveRevocationFence 检测到代次变化 → 拒绝执行 |
| 同一张执行许可被重复消费 | PermissionLease 单次使用,第二次返回 false |
这个「默认拒绝」不是口号,而是落在每一个分支、每一个边界条件上的实现。后面每一节你都会看到它的影子。
1. 决策模型:四级风险 × 三值决策
Grodex 把「风险」和「决策」拆成两个正交的东西,这是理解整套机制的第一把钥匙。
风险分级(ticket.rs,只用于展示、不参与判定)分四级:Low / Medium / High / Critical。等级由工具名 + 参数启发式得出:
// manager.rs · assess_risk
read_file → Low
exec 含 sudo 或 rm -rf → Critical
exec(其它) → Medium
write/edit 到 /etc 或 /System → High
其它工具 → Medium
决策(grodex-core 的 PolicyDecision)是独立的三值:Allow / Ask / Deny。
关键点在于:风险等级只影响 UI 怎么展示这张审批卡(红字 [Critical] 还是灰字 [Low]),绝不参与「允不允许」的判定。允不允许,完全由策略规则决定。这样「高风险但用户明确允许」和「低风险但策略明确禁止」都能精确表达,互不打架——一个系统里既不会因为「看着危险」就一刀切,也不会因为「看着安全」就漏放。
2. 权限规则:strictest-merge 策略引擎
策略是 PermissionPolicy { rules: Vec<PolicyRule> }。每条规则带:
tool_pattern(支持*通配,如exec、read_*);- 可选的参数约束(
ArgPattern= JSON pointer + glob,如「只匹配/tmp/*路径」); - 命令 / 资源 / 网络 / MCP 四类维度匹配器(AND 语义,全匹配才命中);
- 一个
decision,以及priority。
规则合并用的是 strictest-merge(最严优先):
严格度:Deny(2) > Ask(1) > Allow(0)
收集所有匹配的规则,取严格度最高者。
priority 字段只用于「同严格度」时的 tie-break,不能盖过更严的规则。
无任何规则匹配 → 默认 Ask。
这带来一个反直觉但正确的性质:一条窄范围的 Deny 永远能压过一条宽范围的 Allow。专门有测试 strictest_merge_narrow_deny_beats_broad_allow 守住这个语义——因为你宁可多问一次,也不能让「某个高危路径」被「全局放开」误伤。
3. check():三道闸,一道比一道快
PermissionManager::check() 是每次工具调用的入口,判断顺序刻意设计成「先看最硬的否决,再看最便宜的放行」:
1. 显式 Deny 规则 → 直接 Denied(session grant 也不能绕过)
2. 会话级授权命中 → Allowed(快速路径,零审批开销)
3. 策略三值判定 → Allow / Deny / Ask
第 1 步为什么在最前:热更新收紧的策略,不能被旧的会话授权绕过——Deny 永远赢。这是把「安全收紧」的优先级拉到最高的硬约束。
第 2 步是 SessionPolicyGrant,也就是「这个 session 内允许所有 exec」。它不往用户/托管配置里塞宽规则,而是独立的一条授权记录,带 max_uses(None = 无限)、expires_at(None = 会话结束)、以及创建时的策略天花板哈希 ceiling_hash。授权在策略判定之前短路放行,唯一例外是上面的显式 Deny。
授权匹配用的是精确相等而非前缀(有回归测试专门防 read 误放 read_file):「允许 read」不等于「允许 read_file」,因为它们是两个不同风险的工具。
4. 人工审批:一条 oneshot 通道串起的全链路
当 check() 落到 Ask,真正的审批流程启动。完整链路横跨四个 crate,是这套系统里最能体现「状态机 + 跨进程通信」的地方:
check() → ApprovalRequired { ticket_id, decision_rx }
→ ApprovalBroker.submit_ticket() // 铸 oneshot 通道
→ 发 ApprovalRequestedEvent(总线)
→ coordinator → supervisor → ACP 协议 → TUI
用户按 a/d/c/A/n,或 ↑↓ + Enter
→ ResolveApproval 命令 → ACP → supervisor → broker.resolve()
→ tx.send(ApprovalResolution) // 唤醒 120 秒前挂起的工具 future
→ Allow/Narrow 执行,Deny/Cancel 拒绝
ApprovalResolution 不是简单的「同意/拒绝」,而是四值:
pub enum ApprovalResolution {
Allow, // 完整范围执行
Narrow { narrowed_args: Value }, // 只允许收窄后的参数范围
Deny, // 阻止
Cancel, // 撤回(语义=Deny,遥测上区分)
}
一个真实 bug 的教训:双 oneshot 陷阱
manager.rs 里有一段注释记录了一个曾经导致「工具永久挂起」的 bug:ApprovalTicket::new() 会返回一个 oneshot::Receiver,但 broker 提交 ticket 时会重新铸一个通道。如果 coordinator 拿错了 receiver,审批结果就永远到不了等待中的工具——模型那边表现为「卡住不动」。
现在的代码强制走 broker.submit_ticket() 返回的 receiver,注释里白纸黑字写着为什么。这类「异步通道所有权」的坑,正是审批系统比权限判定更难写的原因:判定是纯函数,审批是状态机 + 跨进程通信。
5. 审批不是二选一:Narrow 的「替换 + 重校验」
用户面对审批卡,除了 Allow / Deny / Cancel,还有第四种:Narrow(收窄)。比如模型要改 /tmp/*,用户只想批准 /tmp/a.txt 这一个文件。
Narrow 的语义很微妙:它不是「校验原参数是否在收窄范围内」(子集判定),而是直接用 narrowed_args 替换原参数,然后重新过一遍 schema 校验 → 策略判定 → 沙箱检查。
为什么要这么重?因为用户可能收窄出一个更危险的参数(比如从读文件收窄成写 /etc/passwd)。所以收窄后的参数必须当一次全新的工具调用重新审。代码里还有一条 fail-closed 的规则:如果收窄后的参数本身又需要审批,直接报错「不支持嵌套审批」,而不是递归审批——递归只会把审批栈越套越深,不如当场拒绝。
6. 执行前的最后一道防线:Lease + Fence
审批通过不等于可以执行。Grodex 在「批准」和「执行」之间还插了一道防 TOCTOU(time-of-check vs time-of-use)的闸:
PermissionLease:审批通过后铸造的一张单次使用的执行许可,绑定铸造时的revocation_epoch。consume()恰好返回一次 true,重复消费、过期、被撤销都返回 false——防崩溃重放。LiveRevocationFence:执行副作用前,用fence.check(live_epoch)对比「铸造时代次」和「当前代次」。如果期间有人调了revoke_all()或热更新了策略(代次递增),fence 直接返回RevocationAdvanced,拒绝执行。
这是「审批期间策略被收紧」这个场景的兜底:用户点了 Allow 之后、工具真正跑起来之前的那个空档,策略变严了,旧的批准必须作废。 呼应 03 篇说的「安全收紧时,怎么让已经冻结的工具调用立刻失效」——Lease + Fence 就是这个问题的答案。
7. 崩溃恢复:审批票也要能活过重启
审批 ticket 会持久化到 SQLite(TicketStore),字段包括元数据、参数快照、状态。但有个绕不开的限制:oneshot::Sender 无法序列化。
落库时它被标记成 "in-memory-only",崩溃恢复时 broker.recover_pending_from_store() 从库里捞出 pending ticket,重建新的通道再交给 manager reinject_pending_ticket。
这意味着:崩溃后重启,未决的审批还能继续被用户决策——而不是「票丢了,工具永久挂起」。配合 04 篇的日志恢复,整个系统的「可恢复」承诺延伸到了审批这个最容易断链的环节。
写在最后
Grodex 把「默认拒绝」当成第一性原理,然后为每个边界条件补齐 fail-closed 的兜底:
- 空策略 → Ask
- 超时 → Deny
- 热更新 → 作废旧审批
- 崩溃 → 恢复 pending ticket
- 收窄参数 → 当新调用重新审