数据截至 (上游 commit 676a0a228882)
安全与权限:规则策略 + LLM 自动审查
30 秒导读: 模型想调一个工具(删文件、跑 shell、写代码),whale 不会直接放行。它在真正执行前设了两道闸:第一道是静态规则策略——一张
allow/ask/deny的规则表,纯本地、零成本、可预测;第二道(可选)是动态 LLM 审查器——把这次工具调用喂给 DeepSeek,让另一个模型判allow/warn/block,借鉴自 Claude Code 的 yolo classifier。两道闸的"失败方向"刻意相反,这是本章最精妙的一点。
本章是 whale 系列的第 4 章。它接在 01-turn-loop(核心回合循环)之后:回合循环拿到模型吐出的工具调用后,就是在这里过闸,再交给 03-tools-and-edit 里的工具实现去执行。
边界(本章不讲):
- 工具本身怎么实现(fuzzy edit、shell 沙箱)→ 见 03-tools-and-edit。
- 子代理如何继承父代理的权限(
childToolPolicy)→ 见 05-subagents-workflows。
1. 这是什么(零基础也能懂)
一句话定义: 权限系统是工具调用的安检门——决定一次工具调用是直接放行、弹窗问用户、还是当场拒绝。
要解决什么问题? LLM 会犯错,也可能被投毒的文件内容诱导。你不能让一个自动跑的 agent 无条件执行 rm -rf /、把 .env 里的密钥 curl 到外网、或 git push --force 到 main。但你也不想每读一个文件、每跑一次 go test 都弹窗打断人。安检门要做的,正是在"全自动"和"每步都问"之间划一条可配置的线。
两道闸,各管一段:
| 闸 | 是什么 | 判什么 | 代价 | 在哪 |
|---|---|---|---|---|
| 第一道:规则策略 | 一张静态规则表 | 工具名 + 目标(路径/命令)按 glob 匹配 | 纯本地、微秒级 | internal/policy/ |
| 第二道:LLM 审查 | 另一个模型当审查员 | 把调用喂给 DeepSeek,读语义判风险 | 一次网络往返、可选 | internal/agent/classifier*.go |
一句话直觉/类比: 把第一道闸想成机场的规则牌("液体不超 100ml")——机械、快、可预测;把第二道闸想成安检员的肉眼复核——能看懂规则牌覆盖不到的可疑组合(比如"下载一段脚本再直接执行")。规则牌先过,安检员再看一眼。
2. 顶层全景(两道闸怎么串起来)
一次工具调用从"模型吐出"到"真正执行",在 dispatchToolCalls 里顺序过这几关。怎么读下图:从上到下是时间顺序,任一步"拦下"就不再往下走:
模型吐出一个 tool call
│
▼
┌───────────────────────────────┐
│ 第一道闸:规则策略 │ sc.Policy.Decide(spec, call)
│ Decide() → PolicyDecision │ stream_dispatch.go:175
└───────────────────────────────┘
│
┌────────┼─────────────┐
Allow=false │ Allow=true
(deny) │ RequiresApproval=true
│ │ │
▼ │ ▼
拒绝并回填 │ 弹窗问人 resolveToolApproval()
ToolResult │ → ToolApprovalRequired 事件
(autoDeny) │ → a.approve(...) 回调等用户
✗ │ │
│ deny / cancel → ✗ allow → ↓
▼
┌───────────────────────────────┐
│ 第二道闸:LLM 自动审查(可选) │ maybeBlockByClassifier()
│ Review() → allow/warn/block │ stream_dispatch.go:209
└───────────────────────────────┘
│
┌────────┼─────────┐
block warn allow
│ │ │
▼ ▼ ▼
回填 blocked 给结果加 放行 → 真正执行工具
ToolResult ⚠ 前缀 (dispatchStandardTool)
→ ToolCallBlocked → 照常执行
✗
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ToolPolicy 接口 | 定义"给我 spec+call,还你 PolicyDecision" | internal/policy/policy.go:77 |
RulePolicy.Decide | 规则表求值的主体 | internal/policy/policy_decide.go:56 |
DefaultToolPolicy | 在用户规则前叠加内置默认规则 | internal/policy/policy_decide.go:141 |
ScopedAllowPolicy / ReadOnlyTurnPolicy | 两个装饰器:白名单前缀 / 回合级只读 | internal/policy/policy_decide.go:11,37 |
resolveToolApproval | 把 ask 变成弹窗 + 缓存已批准 | internal/agent/stream_dispatch.go:537 |
Classifier.Review | 第二道闸:调 DeepSeek 判风险 | internal/agent/classifier.go:98 |
CircuitBreaker | 防审查连环封锁拖垮回合 | internal/agent/classifier_circuit_breaker.go:13 |
3. 第一道闸:规则策略
3.1 三种动作与决策结构
规则系统的词汇表极小,只有三个动作:
| 动作 | 常量 | 含义 |
|---|---|---|
allow | PermissionAllow | 直接放行 |
ask | PermissionAsk | 放行但先弹窗问人 |
deny | PermissionDeny | 当场拒绝,不执行 |
定义见 internal/policy/policy.go:12-16。求值的产物是一个 PolicyDecision(policy.go:61),它不是简单的布尔——Allow 和 RequiresApproval 是两个独立的维度:
deny→Allow=falseask→Allow=true且RequiresApproval=true(能过,但要人点头)allow→Allow=true、RequiresApproval=false
PolicyDecision 还带 Code/Phase/MatchedRule 等审计字段,一路传给遥测(见 §5)。
3.2 权限"种类":工具名先归 类
规则不是按原始工具名写的,而是按权限种类(permission kind)。permissionKind(internal/policy/policy_targets.go:13)把具体工具名映射成一个类别:
| 工具名 | 权限种类 |
|---|---|
read_file / grep / list_dir / load_skill | read |
edit / write / multi_edit | edit |
shell_run | shell |
write_stdin | terminal |
remember / forget | memory |
spawn_subagent | task |
web_search / web_fetch | web_search / web_fetch |
mcp__*(前缀) | mcp |
每个种类有自己的默认规则表(下节)。这样"读文件"和"跑 shell"能各有各的策略。
3.3 内置默认规则:安全的出厂设置
DefaultPermissionConfig(internal/policy/policy_defaults.go:14)是 whale 的"出厂安全底线"。挑几条最能说明设计意图的:
| 种类 | 规则(节选) | 动作 | 为什么 |
|---|---|---|---|
read | * | allow | 读文件默认自由 |
read | *.env / *.env.* | ask | .env 常含密钥,读也要问 |
read | *.env.example | allow | 示例文件无密钥,放行 |
shell | rm * | ask | 删文件要确认 |
shell | rm -rf* / rm -r* | deny | 递归删除直接拒,不给点头机会 |
shell | curl * / wget * | ask | 出网要问 |
shell | git push* | ask | 推远端要问 |
shell | mkfs* / diskutil erase* | deny | 格式化磁盘,拒 |
external_directory | * | ask | 碰工作区外的目录,一律先问 |
mcp / memory | * | ask | 外部工具、写记忆都先问 |
注意 read 里 .env 的处理:* 是 allow,但 *.env 是 ask,而 *.env.example 又回到 allow。三条并存,靠特异性排序 + 最后匹配胜出(下节)选出正确那条。
3.4 求值算法:最后匹配胜出 + 特异性排序
要解决的小问题: 一个目标可能同时命中多条规则(.env.example 同时符合 *、*.env.*、*.env.example),该听哪条?
思路: 规则按"最后匹配胜出"求值(evaluateDetailed,internal/policy/policy_rules.go:13——从后往前遍历,第一个命中的即返回)。但 TOML map 的顺序不确定,所以入表时先排好:RulesFromMap(policy_rules.go:81)按字面字符数(非通配符字符,literalLen)升序排,越具体的规则排越后,从而在"最后匹配胜出"里胜出。
原理演示(示意,非源码):
# 示意,非源码:同一目标命中多条,越具体越靠后,反向扫第一个命中的赢
rules = [
("*", "allow"), # literalLen=0,最泛,排最前
("*.env.*", "ask"), # literalLen≈5
("*.env.example", "allow"),# literalLen≈12,最具体,排最后
]
target = ".env.example"
for pat, action in reversed(rules): # 从后往前
if glob_match(pat, target):
return action # → allow(最具体那条先命中)
通配符匹配本身很朴素:wildcardMatch(internal/policy/policy_wildcard.go:24)把 glob 的 */? 转成正则(* → .*,? → .),加 (?i) 大小写不敏感,编译结果用 sync.Map 缓存,避免每次调用重编译。
3.5 Shell:按"段"匹配,deny 跨段优先
这是规则系统里最需要小心的地方。 一条 shell 命令可能是复合的:ls && rm -rf /。如果整条命令当一个字符串去匹配,rm -rf 的 deny 规则就可能被前半段的 ls 掩盖。
whale 的解法:把命令切成段,每段独立求值,再做跨段合并——deny 优先。 逻辑在 evaluateShell(internal/policy/policy_rules.go:35):
命令: ls && rm -rf /tmp/x | grep foo
│
▼ normalizeShellSegments 按 ; | && & 换行 切段(尊重引号)
┌──────────┬────── ────────┬───────────┐
│ "ls" │ "rm -rf /..." │ "grep foo"│
└──────────┴──────────────┴───────────┘
每段独立求值(仍是最后匹配胜出):
ls → allow
rm -rf … → DENY ← 命中就整体拒
grep foo → allow
│
▼ 跨段合并:deny > ask > allow
整条命令结果 = DENY
切段由 splitShellRuleSegments(internal/policy/policy_shell_segments.go:103)完成。它是一个手写的小状态机,逐字符扫描,在引号内不切,并特判 >&、<&、>| 这类重定向,不把它们当分隔符。切完再 normalizeShellSegmentForRule 去掉引号、折叠空白,得到用于匹配的干净段。
合并时的优先级(evaluateShell 里的循环):任一段是 deny → 立刻返回 deny;否则若有 ask → 返回 ask;否则 allow。注释点破了动机(policy_rules.go:37):"deny precedence across segments so an approval prompt cannot mask a separate denied command"——不能让一个可批准的段替另一个被拒的段打掩护。
3.6 请求拆解:一次调用可能问多个权限
Decide 的主体(RulePolicy.Decide,internal/policy/policy_decide.go:56)不是"一个工具问一个权限"这么简单。它先看 MCP 目录限制,再调 requestsFor(policy_decide.go:175)把一次调用拆成一组 permissionRequest:
- 基础请求(工具名对应的种类 + 目标)。
- 加上从"副作用计划"(effect plan)推出的额外请求——比如一条 shell 命令若会写工作区外的目录,就额外挂一个
external_directory请求。 - 一些特例改写:
grep/search_files把匹配目标改成被搜索的目录而非查询正则(否则搜索.env会被误当成读.env,policy_decide.go:206);spawn_subagent按只读/写改写成readonly/mutating。
decidePermissionRequests(policy_decide.go:77)再对这组请求做和 §3.5 同样的"deny 优先、否则 ask、否则 allow"合并。任一请求 deny → 整体 deny;有 ask → 整体需批准。