跳到主要内容

数据截至 (上游 commit afe54827dd65)

02 · 工具体系与权限闸门

本章讲什么: 模型的「手脚」长什么样,以及 Crush 用什么手段保证这双手不乱动。


1. 工具清单:模型能干哪些事

工具在 buildTools 里一次性装配(internal/agent/coordinator.go:679), 然后按 agent 配置里的 AllowedTools 过滤(:763)、按名字排序(:791)。

类别工具要权限吗
文件读viewlsglobgrepview/ls 只在路径落到工作目录之外时才问(view 另放行 skill 文件);glob/grep 从不问
文件写editmultieditwrite
执行bashjob_outputjob_killbash 分级(见 §3);两个 job 工具不问
网络fetchdownloadagentic_fetch
代码搜索sourcegraph
LSPlsp_diagnosticslsp_referenceslsp_definitionlsp_symbolslsp_call_hierarchylsp_renamelsp_replace_symbollsp_restart只有改代码的 lsp_rename/lsp_replace_symbol
MCPlist_mcp_resourcesread_mcp_resource、以及每个 MCP 服务器暴露的工具是(Docker MCP 有白名单例外)
crush_infocrush_logstodosquestionagent(子代理)

工具名就是模型看到的字面标识符,所以 LSP 这一排全都带 lsp_ 前缀,一个不能省。 逐个对得上的常量定义:internal/agent/tools/diagnostics.go:22references.go:25lsp_definition.go:22lsp_symbols.go:18lsp_call_hierarchy.go:20lsp_rename.go:27lsp_replace_symbol.go:26lsp_restart.go:16

四条装配规则值得注意:

  • LSP 工具是有条件加入的:只要用户配了 LSP,或 auto_lsp 没被显式关掉,就全套加上 (internal/agent/coordinator.go:739)。
  • MCP 资源工具也是有条件的:只有配置里真有 MCP 服务器时才装 (internal/agent/coordinator.go:753)。
  • question 工具只在交互模式、且非子代理时存在internal/agent/coordinator.go:735)—— 非交互跑批时问用户是没意义的。
  • web_fetch / web_search 不在主 agent 的工具表里。它们只发给 agentic_fetch 内部那个 子代理用,且那个子代理的工作目录是一个临时目录 (internal/agent/agentic_fetch_tool.go:165-174)。主模型想上网只能走 agentic_fetch, 而 agentic_fetch 自己在入口处就要过一次权限(internal/agent/agentic_fetch_tool.go:83Action: "fetch")。

每个工具的描述文本是单独的 .md 文件(如 internal/agent/tools/edit.md), 部分还是模板(.md.tpl),会按环境注入变量:比如 bash 的描述里会写清当前禁用了哪些命令、 本机有没有 ghrginternal/agent/tools/bash.go:148 bashDescription)。 工具描述随环境变化,这是个容易忽略的细节。


2. 权限闸门:一道门,五道短路 + 一次弹窗

它要解决的小问题

模型要写文件、跑命令。你既不想每次都点同意(烦),也不想它偷偷 rm -rf(危险)。

思路

所有副作用工具调用同一个函数 permissions.Request,它按从便宜到昂贵的顺序依次短路, 五道都没命中,才真的去打扰用户(internal/permission/permission.go:181)。

怎么读这张图:从上往下,命中前五层中任意一层就立即返回、不再往下走; 第 ⑥ 步不是短路,是兜底——只有走到那里才会弹窗。

工具调用 permissions.Request(...)

① 全局 skip(--yolo)? ────────────► 放行
│否
② 在 allowed_tools 白名单? ───────► 放行
("tool" 或 "tool:action" 两种写法)
│否
③ PreToolUse hook 已判 allow? ────► 放行(仍广播一条 granted 通知)
│否
④ 该会话已整体自动批准? ──────────► 放行
│否
⑤ 同会话+同工具+同动作+同路径 已授权过? ─► 放行
│否
⑥ 广播请求事件,阻塞等 UI 回答 ────► 用户点了才放行

几个不显然的点

同一时刻只处理一个请求。 requestMu 把上图第 ③ 步之后的整段串行化 (internal/permission/permission.go:204-205),所以不会同时弹两个窗。 注意加锁点在 hook 判定之后:前三道短路是纯读判断,不必排队。

记忆的粒度是四元组。 「记住这个选择」记的是 {会话, 工具名, 动作, 路径}internal/permission/permission.go:88 PermissionKey), 路径取的是文件所在目录——所以同意一次「在 src/ 下写文件」,同目录的后续写入不再问。

多个订阅者可以安全竞争。 Grant / GrantPersistent / Deny 全部走同一个 resolve 辅助函数 (internal/permission/permission.go:129),用「取走 pending 条目」做原子仲裁: 第一个到的赢,其余变 no-op。这对 04 章讲的多客户端场景是必须的。

拒绝会终止本轮。 工具返回 NewPermissionDeniedResponse(),它带 StopTurn=trueinternal/agent/tools/tools.go:66),模型不会拿着「被拒绝」再试一次。


3. bash 工具:三层收紧 + 自动后台化

先把边界说清楚:这三层是命令闸门,不是容器隔离。命令仍然以你本人的身份、 在你本机的真实文件系统上跑;Crush 明确不做沙箱(见 05 章横向对比)。

3.1 安全分级

模型要跑一条命令

含 ; | && $( ` 之类的串联符?
├── 是 ──────────────────────► 一律要权限
└── 否

命中只读白名单前缀?
(ls / pwd / date / ps / git status ...)
├── 是 ──► 免权限直接跑
└── 否 ──► 要权限

白名单在 internal/agent/tools/safe.go:9 safeCommands, 串联符判断在同文件 containsCommandChaining:71)。判定就这十几行:

// internal/agent/tools/bash.go:209-221
isSafeReadOnly := false
cmdLower := strings.ToLower(params.Command)

if !containsCommandChaining(params.Command) {
for _, safe := range safeCommands {
if strings.HasPrefix(cmdLower, safe) {
if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
isSafeReadOnly = true
break
}
}
}
}

那个 len(cmdLower) == len(safe) || ... == ' ' || ... == '-' 是防呆: 只有前缀后面紧跟空格、连字符或直接结尾才算数——否则 lsof 会被 ls 误判成安全。 这只是语法层面的近似,不是语义分析。

3.2 硬禁用清单

和「要不要权限」正交的,是一批用户同意也不给跑的命令,由 blockFuncs 在 shell 层拦截 (internal/agent/tools/bash.go:165,名单本体 bannedCommands:75):

类别例子为什么
网络下载与浏览器curlwgetaria2chttpielynxw3mchrome绕开 Crush 自己的 fetch/download 审计
远程执行与传输sshscpnctelnet把动作甩到本机之外,权限闸门管不到那一头
提权sudosudoas越权
包管理器apt installbrew installnpm install -gcargo installgo install污染全局环境
系统改动systemctlmountmkfscrontabiptables不可逆
特例go test -exec该参数能借测试执行任意命令

两种拦法要分清:CommandsBlocker命令名拦(前五行都是), ArgumentsBlocker命令名 + 参数组合拦(后两行)。 最后一条最见功力:go 本身完全放行,拦的是 go test 带上 -exec 这一种组合。

3.3 用的不是系统 shell

Crush 内嵌了 mvdan.cc/sh/v3 的 POSIX 解释器(go.mod:78), internal/shell/shell.go:82 NewShell 建的是解释器实例而不是 exec.Command("bash")。 带来三个好处:

  1. Windows 上也有 shell,还能按需启用 Go 实现的 coreutils(internal/shell/coreutils.go:11, Windows 默认开,可用 CRUSH_CORE_UTILS 覆盖)。
  2. 拦截点在解释器内部BlockFunc 能看到解析后的 argv,不用做脆弱的字符串匹配。
  3. hooks、crushrc 和 bash 工具共用同一个解释器,行为一致(见 §5 和 03 章)。

环境变量还会被清洗:剥掉 herdr(Charm 家的终端面板复用工具,会用环境变量标记 「这个面板归哪个 agent 管」)的面板归属变量,再加上 Crush 自己的标记, 让子进程能检测到「我是被 Crush 跑起来的」(internal/shell/shell.go:98-103)。 剥掉是为了防止子进程——包括嵌套的 crush——夺走父面板的 agent 权限。

3.4 自动后台化

长命令不会把 agent 卡死。所有命令一律先以后台 shell 启动,然后:

启动后台 shell(detached context)

每 100ms 轮询是否结束

├── 阈值(默认 60 秒)内结束 ──► 同步返回输出
├── 超过阈值仍在跑 ──────────► 转为后台任务,返回 job ID
└── 期间用户取消 ────────────► Kill 掉,返回错误

阈值常量是 DefaultAutoBackgroundAfter = 60internal/agent/tools/bash.go:53,单位秒), 模型也能用调用参数 auto_background_after 逐次覆盖(:318)。 轮询主循环见 internal/agent/tools/bash.go:326-343waitLoop)。返回 job ID 后,模型用 job_output / job_kill 继续管它。显式的 run_in_background 参数则走一条捷径: 启动后只等 1 秒,专门用来捕捉秒挂的失败(语法错误、被 blockFunc 拦住), 否则就直接返回 job ID。


4. edit 工具:怎么保证改对地方

这是整套工具里工程含量最高的一个。难点不是「写文件」,而是 模型给的 old_string 几乎从不和文件内容一字不差

4.1 第一层:必须先读,且读的必须是最新版

loadExistingFile 在做任何事之前检查两件事(internal/agent/tools/edit.go:272):

检查不通过时返回
本会话读过这个文件吗(filetracker 有记录)you must read the file before editing it. Use the View tool first
文件 mtime 晚于最后读取时间?file ... has been modified since it was last read

「读过」是持久化的:读文件事件写进 SQLite 的 read_files 表 (internal/filetracker/service.go:38 RecordRead,迁移 internal/db/migrations/20260127000000_add_read_files_table.sql)。 mtime 比较前先 Truncate(time.Second),避开文件系统时间精度差异。

这层的意义是防止模型基于陈旧内容盲改——它以为文件还是十分钟前那样,其实你手动改过了。

4.2 第二层:精确匹配 + 唯一性

# 示意,非源码:findAndReplace 的决策
idx = content.find(old)
if idx == -1:
... # 落到第三层
elif idx != content.rfind(old):
raise "old_string appears multiple times..." # 歧义,让模型补上下文
else:
return content[:idx] + new + content[idx+len(old):]
# 重点看:多处命中是报错而不是猜,除非显式 replace_all

对应 internal/agent/tools/edit.go:201 findAndReplace

4.3 第三层:空白归一化回退 + 缩进适配

精确匹配失败时不直接放弃,而是把每一行的连续空白压成单个空格再匹配 (internal/agent/tools/edit_whitespace.go:24 findNormalizedMatches)。

两条硬约束让这个回退不至于变成「乱猜」:

  • 只接受整行匹配:匹配区间必须行首开始、行尾结束,否则跳过。 因为替换是按行做的,接受半行匹配会把该行剩下的部分吞掉。
  • 不唯一就放弃replace_all 没开而匹配到多处,直接返回失败,交给模型自己消歧 (internal/agent/tools/edit_whitespace.go:72 normalizedReplace 内的注释: Ambiguous; let the model disambiguate)。

匹配上之后还有一步——new_string 的缩进改造成文件的风格internal/agent/tools/edit_whitespace.go:112 adaptIndentation):

检测文件的缩进单位 fileUnit(tab? 2空格? 4空格?)

推断调用方用的单位 sourceUnit
优先 old_string ──► 没有则 new_string ──► 再没有就用 fileUnit

算深度差 offset = 实际匹配文本的深度 − old_string 的深度

逐行重写 new_string:depth = 原深度 + offset,再乘以 fileUnit

最后,这类「不是精确匹配」的编辑会在工具返回里附一句提醒, 明确告诉模型「我按空白等价的文本改了,请自行核对」 (internal/agent/tools/edit_whitespace.go:12 whitespaceCorrectedNote)。

4.4 匹配失败时的诊断

即使全失败,也不是干巴巴一句 not found。diagnoseMismatchinternal/agent/tools/edit_whitespace.go:219)会尝试找出最接近的行, 把不可见字符可视化(visualizeWS)后给模型看,让它自己纠正。

4.5 写盘之后:版本快照

commitFileChangeinternal/agent/tools/edit.go:246)在写盘后做三件事:

  1. 若历史里没有这个文件,先存一份原始内容。
  2. 若历史里存的内容和当前磁盘内容不同,说明用户手动改过——先存一个中间版本, 不让用户的手改在历史里凭空消失。
  3. 存新版本,并刷新 filetracker 的读取时间(否则下一次 edit 会被 §4.1 的 mtime 检查拦住)。

另外,CRLF 文件被完整照顾:读时转成 LF 处理,写时转回去(fsext.ToUnixLineEndings / ToWindowsLineEndings)。


5. Hooks:让用户在工具执行前插一脚

5.1 形态

目前只有一个事件:PreToolUseinternal/hooks/hooks.go:15)。 配了之后,buildTools 会把每个工具包一层 hookedToolinternal/agent/hooked_tool.go:31 wrapToolsWithHooks),执行前先跑用户脚本。

5.2 协议:用退出码说话

退出码含义效果
0正常可通过 stdout 的 JSON 表达 allow / 改写参数
2拦住这次工具调用stderr 是理由,模型看得到,可以换个做法
49halt 整轮本轮直接结束(StopTurn=true

分派就是一个 switch:

// internal/hooks/runner.go:221-235
exitCode := shell.ExitCode(err)
switch exitCode {
case 2:
// Exit code 2 = block this tool call. Stderr is the reason.
reason := strings.TrimSpace(stderr.String())
if reason == "" {
reason = "blocked by hook"
}
return HookResult{Decision: DecisionDeny, Reason: reason}
case HaltExitCode:
// Exit code 49 = halt the whole turn. Stderr is the reason.

49 这个数字是刻意挑的:避开通用错误区间(1-30)、sysexits 区间(64-78) 和信号区间(128+),保证不会被意外撞上(internal/hooks/hooks.go:19-22 注释)。

5.3 多个 hook 怎么合并

先按命令字符串去重,再每个开一个 goroutine 并发跑,最后按配置顺序聚合 (internal/hooks/runner.go:96-120internal/hooks/hooks.go:94 aggregate):

维度合并规则
决策deny > allow > none
halt粘性,任一个要求 halt 就 halt
理由/上下文按顺序换行拼接
updated_input对原始工具参数做顶层键浅合并,后面的覆盖前面的

5.4 三个安全设计

其一:hook 的 allow 只是「免弹窗」,不是「越过一切」。 它把批准信息盖在 context 上(internal/permission/permission.go:24 WithHookApproval), 权限服务识别后跳过提示,但仍然广播一条 granted 通知,UI 和审计都看得到。

其二:hook 不受 blockFuncs 限制。 注释说得很直白—— hook 是用户自己写的配置,信任级别等同于 shell alias(internal/hooks/runner.go:158-161 注释)。

其三:超时后会「弃用」而不是死等。 超时后再宽限 1 秒 (internal/hooks/runner.go:20 abandonGrace = time.Second);若解释器还不让出, 就放弃这个 goroutine 并明确不再读它的输出缓冲区——因为那个 goroutine 可能还在写, 读了就是数据竞争(internal/hooks/runner.go:190-206,以及 :163-171 那段 「缓冲区所有权严格单 goroutine」的注释)。


6. 其它值得一提的工具

  • question:模型可以反过来问用户结构化问题(单选/多选等), 阻塞等答案(internal/question/question.go:223 Ask)。只在交互模式下存在。
  • todos:模型自己维护一份待办清单,存在会话行上 (迁移 20250812000000_add_todos_to_sessions.sql),摘要时会把待办一起带进 prompt (internal/agent/agent.go:2241 buildSummaryPrompt)。
  • MCP 工具的命名:统一是 mcp_{服务器名}_{工具名}internal/agent/tools/mcp-tools.go:58 Tool.Name),这样模型一眼能看出来源,agent 配置也能按服务器粒度授权。

7. 代码地图

主题文件路径符号名
工具装配与过滤internal/agent/coordinator.gobuildTools
权限闸门internal/permission/permission.gopermissionService.RequestresolvePermissionKey
权限拒绝的语义internal/agent/tools/tools.goNewPermissionDeniedResponse
bash 工具internal/agent/tools/bash.goNewBashToolblockFuncsbannedCommandsDefaultAutoBackgroundAfter
只读命令白名单internal/agent/tools/safe.gosafeCommandscontainsCommandChaining
内嵌 shellinternal/shell/shell.goNewShellCommandsBlockerArgumentsBlocker
后台任务internal/shell/background.goGetBackgroundShellManager
网络子代理internal/agent/agentic_fetch_tool.gocoordinator.agenticFetchTool
子代理专属网络工具internal/agent/tools/web_fetch.goweb_search.goNewWebFetchToolNewWebSearchTool
LSP 工具名常量internal/agent/tools/diagnostics.goDiagnosticsToolNameReferencesToolNameRenameToolNameReplaceSymbolToolName
edit 主流程internal/agent/tools/edit.goreplaceContentfindAndReplaceloadExistingFilecommitFileChange
空白容错匹配internal/agent/tools/edit_whitespace.gofindNormalizedMatchesnormalizedReplaceadaptIndentationdiagnoseMismatch
读取追踪internal/filetracker/service.goRecordReadLastReadTime
文件版本历史internal/history/Service.CreateVersion
hooks 执行internal/hooks/runner.goRunner.RunrunOnematchingHooksabandonGrace
hooks 聚合语义internal/hooks/hooks.goaggregateHaltExitCode
hooks 与工具的缝合internal/agent/hooked_tool.gowrapToolsWithHookshookedTool.Run