一个自己开发的 Agent Harness-权限与审批篇

61 阅读8分钟

本文讲 区域 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。

28-permission-decision.png

这带来一个反直觉但正确的性质:一条窄范围的 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,是这套系统里最能体现「状态机 + 跨进程通信」的地方:

29-approval-chain.png

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
  • 收窄参数 → 当新调用重新审