跳到主要内容

第 3 章 · 工具层与安全边界

这一章讲 agent 的「手脚」:工具怎么被装配、怎么执行、以及最关键的——怎么不让模型把你的机器搞坏

3.1 工具长什么样

每个工具是一个 fantasy.AgentTool,本质是「名字 + 给模型看的描述 + 一个执行函数」。装配发生在 coordinator.buildTools(internal/agent/coordinator.go:613),它把所有工具构造出来,再按当前 agent 的 AllowedTools 白名单过滤、排序。

内置工具一览:

类别工具要权限?
读文件/搜索view ls glob grep references否(只读)
改文件edit multiedit write是(write)
执行bash是(execute,只读命令除外)
联网fetch download web_search sourcegraph视工具
诊断diagnostics lsp_restart
任务todos agent(子 agent)
信息crush_info crush_logs job_output job_kill
外部MCP 工具(动态)视配置

描述模板就放在工具旁边(edit.mdbash.md.tpl 等),用 //go:embed 嵌进二进制——给模型的「使用说明书」与代码同仓同版本。

3.2 权限闸门:串行、可记忆、可 allowlist

所有危险操作的入口都是 permission.Service.Request(internal/permission/permission.go:181)。它的语义层层短路,从最便宜到最贵:

permissions.Request(...) 依次判断:
① 全局 skip(YOLO 模式)? → 直接放行
② tool:action 在 allowlist 里? → 直接放行
③ context 带 hook 预批准? → 放行 + 发通知
④ 会话被 AutoApprove? → 放行 + 发通知
⑤ 这个(会话,工具,动作,路径)已记住授权? → 放行
⑥ 以上都不满足 → 发请求事件给 UI,阻塞等用户:
select { <-ctx.Done(): 取消; <-respCh: 用户的决定 }

几个设计点:

  • 串行化:requestMu 保证一次只处理一个权限请求(internal/permission/permission.go:204)——避免一堆权限弹窗同时炸出来。
  • 多订阅者竞态安全:resolve(internal/permission/permission.go:129)用 Take 原子地取走待决请求,「第一个回应的赢,其余变 no-op」。这样多个 UI 同时点「允许/拒绝」也不会重复结算。
  • 持久授权只在赢得竞态时记:GrantPersistent(internal/permission/permission.go:158)把「记住这个授权」放进 onResolve 回调,只有真正赢了竞态才记——否则一个输给「拒绝」的 grant 会留下一条幽灵 auto-approve 条目,把后续的拒绝悄悄翻成允许。

3.3 改文件的护栏:先读后改 + mod-time 校验

edit 工具(internal/agent/tools/edit.go)按参数分三种操作:OldString 为空 = 创建新文件;NewString 为空 = 删内容;都非空 = 替换。replaceContent(internal/agent/tools/edit.go:340)里有两道关键护栏:

// internal/agent/tools/edit.go:358 —— 必须先读过这个文件
lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}

// internal/agent/tools/edit.go:363 —— 读过之后文件又被外部改动了?拒绝
modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(fmt.Sprintf(
"file %s has been modified since it was last read ...", ...)), nil
}

这两道护栏防的是模型「盲改」:必须先 view(view 工具会调 filetracker.RecordRead,internal/agent/tools/view.go:259),而且读了之后如果文件被人/别的进程动过,就拒绝改、逼模型重读。这正是「乐观锁」思路——用 mod-time 当版本号。

替换匹配本身是精确匹配且要求唯一:strings.Index 找首次出现,strings.LastIndex 找末次,两者不等说明匹配多处,报 old_string appears multiple times,逼模型给更多上下文或用 replace_all(internal/agent/tools/edit.go:385-393)。改完还会写入文件历史(history.Service,支持 diff/回滚)并 RecordRead 刷新读时间戳。

3.4 bash 的双层防护:黑名单 + 只读白名单

bash 是最危险的工具。Crush 用两个名单一堵一放:

黑名单(bannedCommands,internal/agent/tools/bash.go:73) 直接禁掉一批命令:网络下载类(curl wget ssh scp nc…)、提权类(sudo su doas)、系统包管理器(apt brew pacman…)、系统改动(mkfs mount systemctl…)、网络配置(iptables ifconfig…)。禁网络下载类很有意思——是为了强制走 fetch/download 工具(那些会过权限、有审计),而不是让模型用 curl 绕过。

更细的还有「参数级」拦截(blockFuncs,internal/agent/tools/bash.go:163),比如禁 npm install -g、禁 go test -exec(因为 -exec 能跑任意命令)。

只读白名单(safeCommands,internal/agent/tools/safe.go:9) 反过来:ls/pwd/cat/git log/git status 这类纯读命令免权限弹窗,直接跑(internal/agent/tools/bash.go:207-219)。判定时还要求命令不含命令串联(containsCommandChaining),防止 ls; rm -rf / 这种把只读命令当幌子。

此外 bash 支持「自动后台化」:命令跑超过 auto_background_after(默认 60s)就转成后台 job,返回一个 shell ID 让模型后续用 job_output 取结果(internal/agent/tools/bash.go:301-382)——避免长命令把整轮卡死。

3.5 改完文件,把 LSP 报错回灌给模型

Crush 的一个亮点:edit/write 改完文件后,立刻跑 LSP 诊断,把报错塞进工具结果,让模型马上看到自己改出了语法/类型错误。

// internal/agent/tools/edit.go:101 —— 改完之后
notifyLSPs(ctx, lspManager, params.FilePath) // 通知 LSP 文件变了
text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
text += getDiagnostics(params.FilePath, lspManager) // 把诊断拼进结果
response.Content = text

notifyLSPs(internal/agent/tools/diagnostics.go:93)按文件类型找对应的 LSP 客户端,打开文件、通知变更、等最多 5 秒收诊断;getDiagnostics(internal/agent/tools/diagnostics.go:133)把诊断分成「当前文件」和「项目其他文件」两块,附一个错误/警告计数摘要。效果是模型改完一看结果就知道「我引入了 2 个 error」,能立刻自我修正,而不用等用户去跑编译。LSP 客户端是懒启动的(按文件类型,lsp.Manager,internal/lsp/manager.go:24)。

3.6 PreToolUse 钩子:用户可插手每次工具调用

用户可以配 PreToolUse 钩子(外部脚本)在每次工具执行前跑。机制是用 hookedTool 包一层(internal/agent/hooked_tool.go:54):

hookedTool.Run:
跑用户的 PreToolUse 钩子,拿 result:
├─ Deny → 返回错误,只挡这一次工具调用(模型能看到、可改道)
├─ Halt → 返回错误 + StopTurn=true,结束整轮
├─ Allow → 给 ctx 盖「hook 预批准」标记 → 权限闸门直接放行
├─ 改写 Input → 用钩子给的新参数替换
└─ 加 Context → 把钩子输出附到工具结果后面

值得注意:只有顶层 agent 的工具被钩子包裹,子 agent 不包(wrapToolsWithHooksisSubAgent 时原样返回,internal/agent/hooked_tool.go:31)——否则一次委派会把用户的钩子触发 N 次。

3.7 子 agent:派一个只读的「实习生」去搜集上下文

agent 工具(internal/agent/agent_tool.go)让主 agent 委派一个子任务。它用 fantasy.NewParallelAgentTool 注册——可并行调用。关键在于子 agent 的工具集是只读的:

// internal/config/config.go:760 —— task agent 只给只读工具
func resolveReadOnlyTools(tools []string) []string {
readOnlyTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
return filterSlice(tools, readOnlyTools, true) // 只保留这几个
}

默认两个 agent 的差别(Config.SetupAgents,internal/config/config.go:778):coder 拿全部工具;task(子 agent)只拿 glob/grep/ls/sourcegraph/view,且默认无 MCP、无 LSP

子 agent 跑在一个独立的子会话里(runSubAgent,internal/agent/coordinator.go:1269),用 NonInteractive: true 跑,跑完把成本累加回父会话,只把最终文本输出返给主 agent。这样主 agent 的上下文不会被子任务的几十次工具调用塞满——子 agent 是个「上下文压缩器」:它消化一大堆搜索/阅读,只回吐一段结论。

3.8 MCP 工具:同样过权限闸门

外部 MCP server 提供的工具通过 GetMCPTools(internal/agent/tools/mcp-tools.go:24)动态加入。它们也按 agent 的 AllowedMCP 配置过滤(internal/agent/coordinator.go:683 起),并同样走权限闸门。MCP server 的 Instructions 还会在每轮拼进系统提示(internal/agent/agent.go:649-661),让模型知道这些外部工具怎么用。


下一章:04-context-management.md —— 上下文超长怎么办、被打断的工具调用如何修复。